feat: Improving the plan revisions (#465)

* feat(plans): Suggested plan for plan revisions.

* fix: add planCommentId to reduce GitHub API calls.

* Revert "fix: add planCommentId to reduce GitHub API calls."

This reverts commit ef9c24811fa291b12ac3601cc4cd3edb7c9a0fca.

* Improving plan revision: the implementation draft.

* fix schema composition order.

* fix: reusing existing retry helper (action) for reportPlanCommentToRun.

* mv: updatePlanCommentId.

* fix: higher severity for logging error.

* fix: add error handling when calling findExistingPlanCommentIdForIssue.

* feat: improving the revisit plan request detection by adding PLAN_REVISION_VERBS.

* Updating the plan with alternative non-determenistic solution.

* add more verbs to PLAN_REVISION_VERBS.

* fix: supply the previous plan in the event context as previousPlanBody, updating Plan mode instructions.

* fix: adjusting the way PLAN_REVISION_VERBS are used in sentences.

* fix: using GraphQL approach with NodeId to find commentId in findExistingPlanCommentIdForIssue.

* fix: use double word boundaries (both sides).

* fix condition in findExistingPlanCommentIdForIssue.

* fix: rm unused args from findExistingPlanCommentIdForIssue.

* bump the action version.

* fix(plan): rm everything related to approach A.

* fix(plan): No limit for progress comments.

* feat(plan): the new plan.

* Revert: changed to webhook (no longer involved).

* feat: mv plan comment lookup into a new API endpoint.

* Revert: changes to select_mode tool.

* FEAT: The new implementation.

* fix arktype issues.

* fix plan diagram.

* fix(selectMode): e2e type constraints for fetchExistingPlanComment.

* Revert "fix(selectMode): e2e type constraints for fetchExistingPlanComment."

This reverts commit 53f3b6650a9928e3080700faa9eead0052e94333.

* fix(selectMode): type constraints (copy) for fetchExistingPlanComment.

* feat: improving isHttpError helper and reusing it consistently instead of casting.

* address review: remove unconditional retry, add plan comment warning, dedupe type, remove dead guard, fix GraphQL types

* Fix: tightening the PlanEdit guidance.

* fix(select_mode): Providing the agent with existingPlanCommentId as well.

* fix(instructions): Adjusting the primary guidance to prefer Plan mode for issue-related ambiguous requests.

* fix(wiki): updating the delegation docs according to the current instructions.

* fix(instructions): rm implication to call for issue details.

* fix(select_mode): tweak for PlanEdit.

* fix(select_mode): more tweaks to PlanEdit.

* fix(select_mode): tweaks for the order of instructions and context.

* revert: to the state of 5c400efce1f1fec0a0855eeacad2bc3b721fd1bf.

* fix(select_mode): Correcting the guideline.

* rm the plan from the branch (impletemented).

---------

Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
Anna Bocharova
2026-03-10 20:34:54 +00:00
committed by pullfrog[bot]
parent 6c9747585f
commit 9c99bcbbac
6 changed files with 319 additions and 31 deletions
+104 -10
View File
@@ -1,11 +1,43 @@
import { type } from "arktype";
import type { Agent } from "../agents/index.ts";
import { apiFetch } from "../utils/apiFetch.ts";
import { getApiUrl } from "../utils/apiUrl.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { retry } from "../utils/retry.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
/** PATCH workflow-run with plan comment node_id so plan revisions can update that comment in place. */
async function updatePlanCommentId(ctx: ToolContext, planCommentNodeId: string): Promise<void> {
if (ctx.runId === undefined || !ctx.apiToken) return;
try {
await retry(
async () => {
const response = await apiFetch({
path: `/api/workflow-run/${ctx.runId}`,
method: "PATCH",
headers: {
authorization: `Bearer ${ctx.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({ planCommentNodeId }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`);
},
{
maxAttempts: 3,
delayMs: 2000,
label: "updatePlanCommentId",
}
);
} catch (error) {
log.warning(`updatePlanCommentId exhausted retries: ${error}`);
}
}
/**
* The prefix text for the initial "leaping into action" comment.
* This is used to identify if a comment is still in its initial state
@@ -86,15 +118,21 @@ export async function addFooter(ctx: AddFooterCtx, body: string): Promise<string
export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"),
type: type
.enumerated("Plan", "Comment")
.describe(
"Plan: record this comment as the plan for this run (use report_progress for progress/plan updates on the current run). Comment: regular comment (default)."
)
.optional(),
});
export function CreateCommentTool(ctx: ToolContext) {
return tool({
name: "create_issue_comment",
description:
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
"Create a comment on a GitHub issue. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' only when creating a standalone plan comment to record as this run's plan.",
parameters: Comment,
execute: execute(async ({ issueNumber, body }) => {
execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({
@@ -104,6 +142,10 @@ export function CreateCommentTool(ctx: ToolContext) {
body: bodyWithFooter,
});
if (commentType === "Plan" && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
success: true,
commentId: result.data.id,
@@ -147,6 +189,9 @@ export function EditCommentTool(ctx: ToolContext) {
export const ReportProgress = type({
body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe(
"when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan"
),
});
/**
@@ -161,13 +206,14 @@ export const ReportProgress = type({
*/
export async function reportProgress(
ctx: ToolContext,
{ body }: { body: string }
params: { body: string; target_plan_comment?: boolean }
): Promise<{
commentId?: number;
url?: string;
body: string;
action: "created" | "updated" | "skipped";
}> {
const { body, target_plan_comment } = params;
// always track the body for job summary
ctx.toolState.lastProgressBody = body;
@@ -177,14 +223,50 @@ export async function reportProgress(
return { body, action: "skipped" };
}
const existingCommentId = ctx.toolState.progressCommentId;
// Explicit issue_number from the payload wins here
// This is especially important for manually triggered workflows from trigger links.
// Without this, the created "Implement plan" would be created for the wrong issue
// if the run happens to research other issues (and thus overwrite the toolState.issueNumber).
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
// when editing existing plan: update the plan comment from tool state (set by select_mode)
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) {
log.warning("target_plan_comment requested but no existingPlanCommentId in tool state");
}
if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) {
const commentId = ctx.toolState.existingPlanCommentId;
const customParts =
isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, commentId)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
customParts,
});
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: commentId,
body: bodyWithFooter,
});
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
const existingCommentId = ctx.toolState.progressCommentId;
// if we already have a progress comment, update it
if (existingCommentId) {
const customParts =
@@ -209,6 +291,10 @@ export async function reportProgress(
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updatePlanCommentId(ctx, result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
@@ -264,6 +350,10 @@ export async function reportProgress(
body: bodyWithPlanLink,
});
if (updateResult.data.node_id) {
await updatePlanCommentId(ctx, updateResult.data.node_id);
}
return {
commentId: updateResult.data.id,
url: updateResult.data.html_url,
@@ -286,8 +376,12 @@ export function ReportProgressTool(ctx: ToolContext) {
description:
"Share progress on the associated GitHub issue/PR. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress,
execute: execute(async ({ body }) => {
const result = await reportProgress(ctx, { body });
execute: execute(async (params) => {
const reportParams: { body: string; target_plan_comment?: boolean } = { body: params.body };
if (params.target_plan_comment !== undefined) {
reportParams.target_plan_comment = params.target_plan_comment;
}
const result = await reportProgress(ctx, reportParams);
if (result.action === "skipped") {
// no-op: no comment target, but progress is still tracked for job summary