refactor instructions to return object with full/system/user/event/runtime properties, fix duplicate modes and json prompt extraction (#110)

This commit is contained in:
Colin McDonnell
2026-01-16 21:43:54 +00:00
committed by pullfrog[bot]
parent 410b11db71
commit cb925556e8
16 changed files with 1843 additions and 1952 deletions
+42 -95
View File
@@ -1,11 +1,10 @@
import { type } from "arktype";
import type { Agent } from "../agents/index.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { writeSummary } from "../utils/cli.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { getGitHubInstallationToken } from "../utils/token.ts";
import { fetchWorkflowRunInfo } from "../utils/workflowRun.ts";
import type { ToolContext } from "./server.ts";
import type { ToolContext, ToolState } from "./server.ts";
import { execute, tool } from "./shared.ts";
/**
@@ -153,42 +152,6 @@ export function EditCommentTool(ctx: ToolContext) {
});
}
/**
* Get progress comment ID from environment variable.
* This allows the webhook handler to pre-create a "leaping into action" comment
* and pass the ID to the action for updates.
*/
function getProgressCommentIdFromEnv(): number | null {
const envCommentId = process.env.PULLFROG_PROGRESS_COMMENT_ID;
if (envCommentId) {
const parsed = parseInt(envCommentId, 10);
if (!Number.isNaN(parsed)) {
return parsed;
}
}
return null;
}
// progress comment state - initialized lazily on first use to allow env var to be set after module load
const progressComment = {
id: null as number | null,
idInitialized: false,
wasUpdated: false,
};
function getProgressCommentId(): number | null {
if (!progressComment.idInitialized) {
progressComment.id = getProgressCommentIdFromEnv();
progressComment.idInitialized = true;
}
return progressComment.id;
}
function setProgressCommentId(id: number): void {
progressComment.id = id;
progressComment.idInitialized = true;
}
export const ReportProgress = type({
body: type.string.describe("the progress update content to share"),
});
@@ -196,21 +159,22 @@ export const ReportProgress = type({
/**
* Standalone function to report progress to GitHub comment.
* Can be called directly without going through the MCP tool interface.
* Returns result data if successful, undefined if comment cannot be created.
* Returns result data if successful.
* When there's no comment target (no progressCommentId and no issueNumber), returns a "skipped" result.
*/
export async function reportProgress(
ctx: ToolContext,
{ body }: { body: string }
): Promise<
| {
commentId: number;
url: string;
body: string;
action: "created" | "updated";
}
| undefined
> {
const existingCommentId = getProgressCommentId();
): Promise<{
commentId?: number;
url?: string;
body: string;
action: "created" | "updated" | "skipped";
}> {
// always track the body for job summary
ctx.toolState.lastProgressBody = body;
const existingCommentId = ctx.toolState.progressComment.id;
const issueNumber =
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
@@ -237,9 +201,7 @@ export async function reportProgress(
body: bodyWithFooter,
});
progressComment.wasUpdated = true;
writeSummary(bodyWithFooter);
ctx.toolState.progressComment.wasUpdated = true;
return {
commentId: result.data.id,
@@ -249,11 +211,12 @@ export async function reportProgress(
};
}
// no existing comment - create one
// no existing comment - need an issue/PR to create one on
// use fallback chain: dynamically set context > event payload
if (issueNumber === undefined) {
// cannot create comment without issue_number (e.g., workflow_dispatch events)
return undefined;
// no-op: no comment target (e.g., workflow_dispatch events)
// body is already tracked for job summary
return { body, action: "skipped" };
}
// for new comments, we need to create first, then update with Plan link if in Plan mode
@@ -267,8 +230,10 @@ export async function reportProgress(
});
// store the comment ID for future updates
setProgressCommentId(result.data.id);
progressComment.wasUpdated = true;
ctx.toolState.progressComment = {
id: result.data.id,
wasUpdated: true,
};
// if Plan mode, update the comment to add the "Implement plan" link
if (isPlanMode) {
@@ -290,8 +255,6 @@ export async function reportProgress(
body: bodyWithPlanLink,
});
writeSummary(bodyWithPlanLink);
return {
commentId: updateResult.data.id,
url: updateResult.data.html_url,
@@ -300,8 +263,6 @@ export async function reportProgress(
};
}
writeSummary(initialBody);
return {
commentId: result.data.id,
url: result.data.html_url,
@@ -319,13 +280,12 @@ export function ReportProgressTool(ctx: ToolContext) {
execute: execute(async ({ body }) => {
const result = await reportProgress(ctx, { body });
if (!result) {
// gracefully handle case where no comment can be created
// this happens for workflow_dispatch events or when there's no associated issue/PR
if (result.action === "skipped") {
// no-op: no comment target, but progress is still tracked for job summary
return {
success: false,
success: true,
message:
"cannot create progress comment: no issue_number found in the payload event. this may occur for workflow_dispatch events or when there is no associated issue/PR. if you need to comment on a specific issue or PR, use create_issue_comment with an explicit issueNumber.",
"progress recorded (no GitHub comment created - this may occur for workflow_dispatch events or when there is no associated issue/PR)",
};
}
@@ -337,19 +297,12 @@ export function ReportProgressTool(ctx: ToolContext) {
});
}
/**
* Check if the progress comment was updated during execution
*/
export function wasProgressCommentUpdated(): boolean {
return progressComment.wasUpdated;
}
/**
* Delete the progress comment if it exists.
* Used after submitting a PR review since the review body contains all necessary info.
*/
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = getProgressCommentId();
const existingCommentId = ctx.toolState.progressComment.id;
if (!existingCommentId) {
return false;
}
@@ -370,42 +323,37 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
}
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
progressComment.id = null;
progressComment.idInitialized = true; // keep initialized so we don't re-fetch from env
progressComment.wasUpdated = true; // mark as handled so ensureProgressCommentUpdated skips
ctx.toolState.progressComment = {
id: null,
wasUpdated: true,
};
return true;
}
interface EnsureProgressCommentUpdatedParams {
hasProgressComment: boolean;
}
/**
* Ensure the progress comment is updated with a generic error message if it was never updated.
* This should be called after agent execution completes to handle cases where the agent
* exited without ever calling reportProgress.
*
* Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts).
* Will fetch comment ID from database if not available in environment variable.
* Will fetch comment ID from database if not available in toolState.
*/
export async function ensureProgressCommentUpdated(
params: EnsureProgressCommentUpdatedParams
): Promise<void> {
export async function ensureProgressCommentUpdated(toolState: ToolState): Promise<void> {
// skip if comment was already updated during execution
if (progressComment.wasUpdated) {
if (toolState.progressComment.wasUpdated) {
return;
}
// skip if no progress comment was created for this run
if (!params.hasProgressComment) {
// skip if there's already a progress body recorded (agent called report_progress)
if (toolState.lastProgressBody) {
return;
}
// try to get comment ID from env var first, then from database if needed
let existingCommentId = getProgressCommentId();
// try to get comment ID from toolState first, then from database if needed
let existingCommentId = toolState.progressComment.id;
// if not in env var, try fetching from database using run ID
// if not in toolState, try fetching from database using run ID
if (!existingCommentId) {
const runId = process.env.GITHUB_RUN_ID;
if (runId) {
@@ -413,9 +361,8 @@ export async function ensureProgressCommentUpdated(
const workflowRunInfo = await fetchWorkflowRunInfo(runId);
if (workflowRunInfo.progressCommentId) {
existingCommentId = parseInt(workflowRunInfo.progressCommentId, 10);
// cache it in env var for future use
if (!Number.isNaN(existingCommentId)) {
process.env.PULLFROG_PROGRESS_COMMENT_ID = workflowRunInfo.progressCommentId;
if (Number.isNaN(existingCommentId)) {
existingCommentId = null;
}
}
} catch {
@@ -496,7 +443,7 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
});
// mark progress as updated so ensureProgressCommentUpdated doesn't think the run failed
progressComment.wasUpdated = true;
ctx.toolState.progressComment.wasUpdated = true;
return {
success: true,
+24 -5
View File
@@ -23,6 +23,29 @@ export interface ToolState {
promise: Promise<PrepResult[]> | undefined;
results: PrepResult[] | undefined;
};
progressComment: {
id: number | null;
wasUpdated: boolean;
};
lastProgressBody?: string;
}
import type { ResolveRunResult } from "../utils/workflow.ts";
interface InitToolStateParams {
runInfo: ResolveRunResult;
}
export function initToolState(ctx: InitToolStateParams): ToolState {
const progressCommentIdStr = ctx.runInfo.workflowRunInfo.progressCommentId;
const progressCommentId = progressCommentIdStr ? parseInt(progressCommentIdStr, 10) : null;
return {
progressComment: {
id: Number.isNaN(progressCommentId) ? null : progressCommentId,
wasUpdated: false,
},
};
}
export interface ToolContext {
@@ -35,8 +58,6 @@ export interface ToolContext {
toolState: ToolState;
runId: string;
jobId: string | undefined;
/** true if a progress comment was pre-created for this run */
hasProgressComment: boolean;
}
import { BashTool } from "./bash.ts";
@@ -141,9 +162,7 @@ export async function startMcpHttpServer(
tools.push(BashTool(ctx));
}
if (ctx.hasProgressComment) {
tools.push(ReportProgressTool(ctx));
}
tools.push(ReportProgressTool(ctx));
addTools(ctx, server, tools);