feat(FN-4082): compact reviewer prompts and retry on context limits

Adds reviewer prompt compaction and automatic retry logic when the reviewer hits context-window limits, with matching test coverage and a changeset for `@runfusion/fusion`.

Fusion-Task-Id: FN-4082
This commit is contained in:
Fusion
2026-05-11 23:57:52 -07:00
committed by gsxdsm
parent 9c80988e29
commit f3a39753b3
5 changed files with 390 additions and 90 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
FN-4082: retry oversized code reviews with a compacted request after provider context-limit errors like Kimi's `exceeded model token limit` failure.

View File

@@ -47,6 +47,20 @@ describe("compactLargePromptSections", () => {
expect(out).toContain("existing specification middle trimmed"); expect(out).toContain("existing specification middle trimmed");
}); });
it("shortens Task PROMPT.md while preserving mission, file scope, and steps outline", () => {
const verboseSection = Array.from({ length: 120 }, (_, i) => `- verbose requirement ${i}: ${"x".repeat(80)}`).join("\n");
const prompt = `## Task PROMPT.md\n\n\`\`\`markdown\n# Task: FN-4082\n\n## Mission\nShip a focused reviewer fallback.\n\n## Context to Read First\n${verboseSection}\n\n## File Scope\n- packages/engine/src/reviewer.ts\n- packages/engine/src/pi.ts\n\n## Steps\n### Step 0: Preflight\n- [ ] Confirm behavior\n### Step 1: Compact prompt\n- [ ] Trim verbose sections\n### Step 2: Retry reviewer\n- [ ] Retry once on context limit\n\n## Do NOT\n${verboseSection}\n\`\`\``;
const out = __testOnlyPromptCompaction.compactLargePromptSections(prompt);
expect(out).toContain("## Task PROMPT.md");
expect(out).toContain("```markdown");
expect(out).toContain("## Mission");
expect(out).toContain("## File Scope");
expect(out).toContain("### Step 1: Compact prompt");
expect(out).toContain("step checklist details trimmed");
expect(out).toContain("remaining PROMPT.md sections trimmed");
expect(out?.length).toBeLessThan(prompt.length);
});
it("collapses older User Comments while keeping latest", () => { it("collapses older User Comments while keeping latest", () => {
const comments = Array.from({ length: 40 }, (_, i) => `- **[2026-05-${String(i + 1).padStart(2, "0")}]** ${"x".repeat(120)}`).join("\n"); const comments = Array.from({ length: 40 }, (_, i) => `- **[2026-05-${String(i + 1).padStart(2, "0")}]** ${"x".repeat(120)}`).join("\n");
const prompt = `## User Comments\n\n${comments}`; const prompt = `## User Comments\n\n${comments}`;

View File

@@ -13,9 +13,11 @@ vi.mock("../pi.js", () => ({
})); }));
import { reviewStep, REVIEWER_SYSTEM_PROMPT } from "../reviewer.js"; import { reviewStep, REVIEWER_SYSTEM_PROMPT } from "../reviewer.js";
import { createFnAgent } from "../pi.js"; import { createFnAgent, promptWithFallback } from "../pi.js";
const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedCreateFnAgent = vi.mocked(createFnAgent);
const mockedPromptWithFallback = vi.mocked(promptWithFallback);
const CONTEXT_LIMIT_ERROR = "exceeded model token limit: 262144 (requested: 262879)";
function createMockSession(reviewText: string) { function createMockSession(reviewText: string) {
return { return {
@@ -33,6 +35,17 @@ function createMockSession(reviewText: string) {
} as any; } as any;
} }
beforeEach(() => {
vi.clearAllMocks();
mockedPromptWithFallback.mockImplementation(async (session, prompt, options) => {
if (options == null) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
});
});
describe("reviewStep — model settings threading", () => { describe("reviewStep — model settings threading", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -277,6 +290,107 @@ describe("reviewStep — spec review type", () => {
}); });
}); });
describe("reviewStep — context-limit retry", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries with a compacted request when the first prompt hits a context limit", async () => {
const subscribers: Array<(event: any) => void> = [];
mockedCreateFnAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockImplementation(async () => {
for (const subscriber of subscribers) {
subscriber({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: "### Verdict: APPROVE\n### Summary\nCompacted retry worked." },
});
}
}),
subscribe: vi.fn().mockImplementation((cb: any) => {
subscribers.push(cb);
}),
dispose: vi.fn(),
},
} as any);
const store = {
getSettings: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
appendAgentLog: vi.fn().mockResolvedValue(undefined),
};
mockedPromptWithFallback
.mockImplementationOnce(async () => {
throw new Error(CONTEXT_LIMIT_ERROR);
})
.mockImplementationOnce(async (session, prompt, options) => {
if (options == null) {
await session.prompt(prompt);
} else {
await session.prompt(prompt, options);
}
});
const verboseSection = Array.from({ length: 120 }, (_, i) => `- verbose requirement ${i}: ${"x".repeat(80)}`).join("\n");
const promptContent = `# Task: FN-4082\n\n## Mission\nShip the reviewer retry.\n\n## Context to Read First\n${verboseSection}\n\n## Dependencies\n- None\n\n## File Scope\n- packages/engine/src/reviewer.ts\n- packages/engine/src/pi.ts\n\n## Steps\n### Step 0: Preflight\n- [ ] Confirm existing behavior\n### Step 1: Compact prompt\n- [ ] Trim the request\n### Step 2: Retry review\n- [ ] Retry once\n\n## Do NOT\n${verboseSection}`;
const result = await reviewStep(
"/tmp/worktree",
"FN-4082",
2,
"Retry review",
"code",
promptContent,
"abc123",
{ store: store as any, taskId: "FN-4082" },
);
expect(result.verdict).toBe("APPROVE");
expect(mockedPromptWithFallback).toHaveBeenCalledTimes(2);
const firstRequest = mockedPromptWithFallback.mock.calls[0]?.[1] as string;
const secondRequest = mockedPromptWithFallback.mock.calls[1]?.[1] as string;
expect(secondRequest.length).toBeLessThan(firstRequest.length);
expect(secondRequest).toContain("## Task PROMPT.md");
expect(secondRequest).toContain("## Mission");
expect(secondRequest).toContain("## File Scope");
expect(secondRequest).toContain("### Step 1: Compact prompt");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-4082",
"code review hit context limit — retrying with compacted request",
);
});
it("returns UNAVAILABLE when both attempts hit the context limit", async () => {
mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nCompacted retry worked."),
);
mockedPromptWithFallback.mockImplementation(async () => {
throw new Error(CONTEXT_LIMIT_ERROR);
});
const runReview = async () => {
try {
return await reviewStep(
"/tmp/worktree",
"FN-4082",
2,
"Retry review",
"code",
"# Task: FN-4082\n\n## Mission\nShip the reviewer retry.",
"abc123",
);
} catch {
return { verdict: "UNAVAILABLE" as const };
}
};
await expect(runReview()).resolves.toEqual({ verdict: "UNAVAILABLE" });
expect(mockedPromptWithFallback).toHaveBeenCalledTimes(2);
});
});
describe("reviewStep — exhausted-retry error detection", () => { describe("reviewStep — exhausted-retry error detection", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();

View File

@@ -373,6 +373,7 @@ const MAX_COMPACTED_PROMPT_MEMORY_CHARS = 8_000;
const MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS = 1_200; const MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS = 1_200;
const MAX_COMPACTED_ATTACHMENTS_CHARS = 4_000; const MAX_COMPACTED_ATTACHMENTS_CHARS = 4_000;
const MAX_COMPACTED_EXISTING_SPEC_CHARS = 4_000; const MAX_COMPACTED_EXISTING_SPEC_CHARS = 4_000;
const MAX_COMPACTED_TASK_PROMPT_CHARS = MAX_COMPACTED_EXISTING_SPEC_CHARS;
const MAX_COMPACTED_USER_COMMENTS_CHARS = 2_000; const MAX_COMPACTED_USER_COMMENTS_CHARS = 2_000;
function compactMarkdownMemorySection(sectionBody: string): string { function compactMarkdownMemorySection(sectionBody: string): string {
@@ -500,6 +501,83 @@ function compactExistingSpecificationSectionBody(body: string): string {
return `${head}\n\n_... existing specification middle trimmed ..._\n\n${tail}`; return `${head}\n\n_... existing specification middle trimmed ..._\n\n${tail}`;
} }
function extractMarkdownSection(document: string, headingName: string): string {
const heading = `## ${headingName}`;
const start = document.indexOf(heading);
if (start === -1) {
return "";
}
const afterHeading = start + heading.length;
const nextH2 = document.indexOf("\n## ", afterHeading);
const nextH1 = document.indexOf("\n# ", afterHeading);
const endCandidates = [nextH2, nextH1].filter((value) => value !== -1);
const end = endCandidates.length > 0 ? Math.min(...endCandidates) : document.length;
return document.slice(start, end).trim();
}
function compactTaskPromptStepsSection(section: string): string {
const stepTitles = Array.from(section.matchAll(/^### Step \d+:.*$/gm), (match) => match[0].trim());
if (stepTitles.length === 0) {
return section.trim();
}
return [
"## Steps",
...stepTitles,
"",
"_... step checklist details trimmed for context limits ..._",
].join("\n").trim();
}
function truncateCompactedSection(section: string, maxChars: number, label: string): string {
const trimmed = section.trim();
if (!trimmed || trimmed.length <= maxChars) {
return trimmed;
}
const marker = `_... ${label} trimmed for context limits ..._`;
const headBudget = Math.max(200, maxChars - marker.length - 2);
return [
`${trimmed.slice(0, headBudget).trimEnd()}`,
"",
marker,
].join("\n").trim();
}
function compactTaskPromptSectionBody(body: string): string {
const trimmed = body.trim();
if (trimmed.length <= MAX_COMPACTED_TASK_PROMPT_CHARS) {
return trimmed;
}
const fencedMatch = /^```markdown\s*\n([\s\S]*?)\n```$/m.exec(trimmed);
const promptContent = fencedMatch ? fencedMatch[1].trim() : trimmed;
const firstSectionIndex = promptContent.indexOf("\n## ");
const preamble = (firstSectionIndex === -1 ? promptContent : promptContent.slice(0, firstSectionIndex)).trim();
const missionSection = extractMarkdownSection(promptContent, "Mission");
const dependenciesSection = extractMarkdownSection(promptContent, "Dependencies");
const fileScopeSection = extractMarkdownSection(promptContent, "File Scope");
const stepsSection = compactTaskPromptStepsSection(extractMarkdownSection(promptContent, "Steps"));
const compactedContent = [
truncateCompactedSection(preamble, 400, "task header"),
truncateCompactedSection(missionSection, 900, "mission"),
truncateCompactedSection(dependenciesSection, 500, "dependencies"),
truncateCompactedSection(fileScopeSection, 1_000, "file scope"),
truncateCompactedSection(stepsSection, 1_200, "steps outline"),
"_... remaining PROMPT.md sections trimmed for context limits ..._",
].filter(Boolean).join("\n\n").trim();
const narrowedContent = compactedContent.length <= MAX_COMPACTED_TASK_PROMPT_CHARS
? compactedContent
: compactExistingSpecificationSectionBody(compactedContent);
const finalContent = fencedMatch ? `\`\`\`markdown\n${narrowedContent}\n\`\`\`` : narrowedContent;
return finalContent.length < trimmed.length ? finalContent : compactExistingSpecificationSectionBody(trimmed);
}
function compactUserCommentsSectionBody(body: string): string { function compactUserCommentsSectionBody(body: string): string {
const trimmed = body.trim(); const trimmed = body.trim();
if (trimmed.length <= MAX_COMPACTED_USER_COMMENTS_CHARS) { if (trimmed.length <= MAX_COMPACTED_USER_COMMENTS_CHARS) {
@@ -536,7 +614,7 @@ function compactUserCommentsSectionBody(body: string): string {
} }
function compactLargePromptSections(prompt: string): string | null { function compactLargePromptSections(prompt: string): string | null {
const sectionPattern = /(^|\n)(## (?:Subtask Consideration|Subtask Breakdown Requested|Attachments|Existing Specification|User Comments)\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g; const sectionPattern = /(^|\n)(## (?:Subtask Consideration|Subtask Breakdown Requested|Attachments|Existing Specification|Task PROMPT\.md|User Comments)\n)((?:\n*```markdown[\s\S]*?\n```|[\s\S]*?))(?=\n## [^#]|\n# [^#]|$)/g;
let changed = false; let changed = false;
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix: string, heading: string, body: string) => { const compactedPrompt = prompt.replace(sectionPattern, (match, prefix: string, heading: string, body: string) => {
@@ -548,6 +626,7 @@ function compactLargePromptSections(prompt: string): string | null {
"Subtask Breakdown Requested": MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS, "Subtask Breakdown Requested": MAX_COMPACTED_SUBTASK_GUIDANCE_CHARS,
Attachments: MAX_COMPACTED_ATTACHMENTS_CHARS, Attachments: MAX_COMPACTED_ATTACHMENTS_CHARS,
"Existing Specification": MAX_COMPACTED_EXISTING_SPEC_CHARS, "Existing Specification": MAX_COMPACTED_EXISTING_SPEC_CHARS,
"Task PROMPT.md": MAX_COMPACTED_TASK_PROMPT_CHARS,
"User Comments": MAX_COMPACTED_USER_COMMENTS_CHARS, "User Comments": MAX_COMPACTED_USER_COMMENTS_CHARS,
}; };
@@ -563,6 +642,8 @@ function compactLargePromptSections(prompt: string): string | null {
compactedBody = compactAttachmentSectionBody(trimmedBody); compactedBody = compactAttachmentSectionBody(trimmedBody);
} else if (headingName === "Existing Specification") { } else if (headingName === "Existing Specification") {
compactedBody = compactExistingSpecificationSectionBody(trimmedBody); compactedBody = compactExistingSpecificationSectionBody(trimmedBody);
} else if (headingName === "Task PROMPT.md") {
compactedBody = compactTaskPromptSectionBody(trimmedBody);
} else if (headingName === "User Comments") { } else if (headingName === "User Comments") {
compactedBody = compactUserCommentsSectionBody(trimmedBody); compactedBody = compactUserCommentsSectionBody(trimmedBody);
} }

View File

@@ -11,6 +11,7 @@
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core"; import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core"; import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js"; import { describeModel, promptWithFallback } from "./pi.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js"; import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
import { buildSessionSkillContext } from "./session-skill-context.js"; import { buildSessionSkillContext } from "./session-skill-context.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
@@ -336,12 +337,10 @@ export async function reviewStep(
}; };
} }
// Build the review request
const request = buildReviewRequest( const request = buildReviewRequest(
taskId, stepNumber, stepName, reviewType, promptContent, cwd, baseline, options.userComments, taskId, stepNumber, stepName, reviewType, promptContent, cwd, baseline, options.userComments,
); );
// Create AgentLogger for reviewer if store is available
const agentLogger = options.store && options.taskId const agentLogger = options.store && options.taskId
? new AgentLogger({ ? new AgentLogger({
store: options.store, store: options.store,
@@ -355,12 +354,6 @@ export async function reviewStep(
}) })
: null; : null;
// Resolve validator model settings using canonical lane hierarchy:
// 1. Task-level validator override pair (taskValidatorProvider + taskValidatorModelId)
// 2. Project-level validator override pair (projectValidatorProvider + projectValidatorModelId)
// 3. Global validator lane pair (globalValidatorProvider + globalValidatorModelId)
// 4. Project default override pair (projectDefaultOverrideProvider + projectDefaultOverrideModelId)
// 5. Execution default pair (defaultProvider + defaultModelId)
const validatorProvider = options.taskValidatorProvider && options.taskValidatorModelId const validatorProvider = options.taskValidatorProvider && options.taskValidatorModelId
? options.taskValidatorProvider ? options.taskValidatorProvider
: (options.projectValidatorProvider && options.projectValidatorModelId : (options.projectValidatorProvider && options.projectValidatorModelId
@@ -380,9 +373,6 @@ export async function reviewStep(
? options.projectDefaultOverrideModelId ? options.projectDefaultOverrideModelId
: options.defaultModelId))); : options.defaultModelId)));
// Resolve validator fallback using lane hierarchy:
// 1. Project-level validator fallback (projectValidatorFallbackProvider + projectValidatorFallbackModelId)
// 2. Execution fallback (fallbackProvider + fallbackModelId)
const validatorFallbackProvider = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId const validatorFallbackProvider = options.projectValidatorFallbackProvider && options.projectValidatorFallbackModelId
? options.projectValidatorFallbackProvider ? options.projectValidatorFallbackProvider
: options.fallbackProvider; : options.fallbackProvider;
@@ -390,7 +380,6 @@ export async function reviewStep(
? options.projectValidatorFallbackModelId ? options.projectValidatorFallbackModelId
: options.fallbackModelId; : options.fallbackModelId;
// Resolve per-agent custom instructions for the reviewer role
let reviewerInstructions = ""; let reviewerInstructions = "";
if (options.agentStore && options.rootDir) { if (options.agentStore && options.rootDir) {
try { try {
@@ -406,18 +395,10 @@ export async function reviewStep(
} }
} }
const reviewerBasePrompt = resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT; const reviewerBasePrompt = resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT;
// Memory goes in the dynamic layer (not concatenated onto basePrompt) so the
// stable prefix is byte-identical across sessions even if memory changes.
// The leading "\n" separator is no longer needed — buildPromptLayers handles
// section joining with "\n\n".
const memorySection = options.rootDir && options.settings?.memoryEnabled !== false const memorySection = options.rootDir && options.settings?.memoryEnabled !== false
? buildReviewerMemoryInstructions(options.rootDir, options.settings) ? buildReviewerMemoryInstructions(options.rootDir, options.settings)
: ""; : "";
// Build structured layers for cross-session prompt caching.
// The stable layer (base prompt only) is byte-identical across all
// reviewer sessions in this task, enabling cache hits. Memory goes
// into the dynamic layer because it can change between sessions.
const reviewerPluginContributions = buildPluginPromptSection( const reviewerPluginContributions = buildPluginPromptSection(
"reviewer", "reviewer",
options.pluginRunner, options.pluginRunner,
@@ -432,12 +413,8 @@ export async function reviewStep(
memorySection, memorySection,
pluginContributions: reviewerPluginContributions, pluginContributions: reviewerPluginContributions,
}); });
// Collapsed string for backward compatibility with runtimes that don't
// support layers (plugin runtimes, older pi versions).
const reviewerSystemPromptFinal = collapsePromptLayers(layers); const reviewerSystemPromptFinal = collapsePromptLayers(layers);
// Build skill selection context (assigned agent skills take precedence over role fallback)
let skillContext = undefined; let skillContext = undefined;
if (options.agentStore && options.rootDir) { if (options.agentStore && options.rootDir) {
try { try {
@@ -453,7 +430,6 @@ export async function reviewStep(
} }
} }
// Spawn a reviewer agent with read-only tools
const assignedAgentId = options.task?.assignedAgentId ?? null; const assignedAgentId = options.task?.assignedAgentId ?? null;
const agentStore = options.agentStore; const agentStore = options.agentStore;
const memoryAgent = const memoryAgent =
@@ -481,26 +457,42 @@ export async function reviewStep(
} : undefined), } : undefined),
] ]
: undefined; : undefined;
// Sentinel error used by the beforeCreateSession hook to cancel session
// creation when a pause is detected after runtime resolution but before
// the LLM session is actually spawned. Caught locally and converted to an
// UNAVAILABLE verdict.
class ReviewerPauseAbortError extends Error { class ReviewerPauseAbortError extends Error {
constructor(public readonly reason: string) { constructor(public readonly reason: string) {
super(`reviewer aborted: ${reason}`); super(`reviewer aborted: ${reason}`);
} }
} }
// Reviewers run within the parent agent's slot accounting via const activeSessions = new Set<import("@mariozechner/pi-coding-agent").AgentSession>();
// semaphore.runNested at the call site. The session spawn itself includes let reviewText = "";
// a last-chance pause check (beforeSpawnSession) that the runtime fires
// immediately before the underlying LLM session is instantiated — past const endSession = (session: import("@mariozechner/pi-coding-agent").AgentSession) => {
// every awaited setup step inside the runtime (provider registration, if (!activeSessions.delete(session)) {
// resource loading, etc.). This closes the TOCTOU window where a pause return;
// flipped during the setup chain. }
let session: import("@mariozechner/pi-coding-agent").AgentSession; session.dispose();
try { options.onSessionEnded?.(session);
({ session } = await createResolvedAgentSession({ };
const buildPauseUnavailableResult = async (reason: string): Promise<ReviewResult> => {
reviewerLog.log(
`${taskId}: ${reviewType} review for Step ${stepNumber} aborted before spawn — ${reason} active`,
);
if (options.store && options.taskId) {
await options.store.logEntry(
options.taskId,
`${reviewType} review aborted before spawn — ${reason} active`,
).catch(() => undefined);
}
return {
verdict: "UNAVAILABLE",
review: `${reason} active — reviewer not spawned. Stop calling fn_review_* and exit cleanly; the parent task will resume after unpause.`,
summary: `Skipped: ${reason}`,
};
};
const createReviewerSession = async (): Promise<import("@mariozechner/pi-coding-agent").AgentSession> => {
const { session } = await createResolvedAgentSession({
sessionPurpose: "reviewer", sessionPurpose: "reviewer",
runtimeHint: extractRuntimeHint(memoryAgent?.runtimeConfig), runtimeHint: extractRuntimeHint(memoryAgent?.runtimeConfig),
pluginRunner: options.pluginRunner, pluginRunner: options.pluginRunner,
@@ -518,7 +510,6 @@ export async function reviewStep(
fallbackProvider: validatorFallbackProvider, fallbackProvider: validatorFallbackProvider,
fallbackModelId: validatorFallbackModelId, fallbackModelId: validatorFallbackModelId,
defaultThinkingLevel: options.defaultThinkingLevel, defaultThinkingLevel: options.defaultThinkingLevel,
// Skill selection: use assigned agent skills if available, otherwise role fallback
...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}),
taskId: options.taskId, taskId: options.taskId,
taskTitle: options.taskTitle, taskTitle: options.taskTitle,
@@ -535,8 +526,6 @@ export async function reviewStep(
try { try {
finalSettings = await options.store.getSettings(); finalSettings = await options.store.getSettings();
} catch { } catch {
// Treat a transient store failure as "not paused" — better to
// proceed than to block reviews on a flaky read.
return; return;
} }
if (finalSettings?.globalPause || finalSettings?.enginePaused) { if (finalSettings?.globalPause || finalSettings?.enginePaused) {
@@ -544,26 +533,7 @@ export async function reviewStep(
throw new ReviewerPauseAbortError(reason); throw new ReviewerPauseAbortError(reason);
} }
}, },
})); });
} catch (err) {
if (err instanceof ReviewerPauseAbortError) {
reviewerLog.log(
`${taskId}: ${reviewType} review for Step ${stepNumber} aborted before spawn — ${err.reason} active`,
);
if (options.store && options.taskId) {
await options.store.logEntry(
options.taskId,
`${reviewType} review aborted before spawn — ${err.reason} active`,
).catch(() => undefined);
}
return {
verdict: "UNAVAILABLE",
review: `${err.reason} active — reviewer not spawned. Stop calling fn_review_* and exit cleanly; the parent task will resume after unpause.`,
summary: `Skipped: ${err.reason}`,
};
}
throw err;
}
const reviewerModelDesc = describeModel(session); const reviewerModelDesc = describeModel(session);
const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`; const reviewerModelMarker = `Reviewer using model: ${reviewerModelDesc}`;
@@ -573,32 +543,83 @@ export async function reviewStep(
await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined); await options.store.appendAgentLog(options.taskId, reviewerModelMarker, "text", undefined, "reviewer").catch(() => undefined);
} }
// Notify the caller so it can track this session in a per-task subagent map. activeSessions.add(session);
// If the parent task is later moved out of in-progress, paused, or the engine
// is globally paused, the caller will dispose this session — preventing the
// reviewer from outliving its parent task.
options.onSessionCreated?.(session); options.onSessionCreated?.(session);
let reviewText = "";
// Capture the reviewer's full text output (still needed for verdict extraction)
session.subscribe((event) => { session.subscribe((event) => {
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
reviewText += event.assistantMessageEvent.delta; reviewText += event.assistantMessageEvent.delta;
} }
}); });
try { return session;
await promptWithFallback(session, request); };
// Re-raise errors that pi-coding-agent swallowed after exhausting retries. const runReviewPrompt = async (
// The caller (executor's createReviewStepTool) catches errors and returns session: import("@mariozechner/pi-coding-agent").AgentSession,
// UNAVAILABLE, so the thrown error will be handled there. prompt: string,
): Promise<void> => {
await promptWithFallback(session, prompt);
checkSessionError(session); checkSessionError(session);
};
let session: import("@mariozechner/pi-coding-agent").AgentSession;
try {
session = await createReviewerSession();
} catch (err) {
if (err instanceof ReviewerPauseAbortError) {
return buildPauseUnavailableResult(err.reason);
}
throw err;
}
try {
try {
await runReviewPrompt(session, request);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (!isContextLimitError(errorMessage)) {
throw err;
}
const retryLogMessage = reviewType === "code"
? "code review hit context limit — retrying with compacted request"
: `${reviewType} review hit context limit — retrying with compacted request`;
reviewerLog.warn(`${taskId}: ${retryLogMessage}`);
if (options.store && options.taskId) {
await options.store.logEntry(options.taskId, retryLogMessage).catch(() => undefined);
}
reviewText = "";
const reducedRequest = buildReducedReviewRequest(
taskId, stepNumber, stepName, reviewType, promptContent, cwd, baseline,
);
try {
await runReviewPrompt(session, reducedRequest);
} catch (retryErr: unknown) {
if (!isReviewerSessionReuseError(retryErr)) {
throw retryErr;
}
endSession(session);
try {
session = await createReviewerSession();
} catch (recreateErr) {
if (recreateErr instanceof ReviewerPauseAbortError) {
return buildPauseUnavailableResult(recreateErr.reason);
}
throw recreateErr;
}
await runReviewPrompt(session, reducedRequest);
}
}
} finally { } finally {
if (agentLogger) await agentLogger.flush(); if (agentLogger) {
session.dispose(); await agentLogger.flush();
options.onSessionEnded?.(session); }
for (const activeSession of [...activeSessions]) {
endSession(activeSession);
}
} }
const verdict = extractVerdict(reviewText); const verdict = extractVerdict(reviewText);
@@ -606,6 +627,71 @@ export async function reviewStep(
return { verdict, review: reviewText, summary }; return { verdict, review: reviewText, summary };
} }
function isReviewerSessionReuseError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /prompt is in progress|session (?:is )?(?:closed|disposed|ended)|conversation already active/i.test(message);
}
function extractPromptSection(promptContent: string, sectionName: string): string {
const heading = `## ${sectionName}`;
const start = promptContent.indexOf(heading);
if (start === -1) {
return "";
}
const afterHeading = start + heading.length;
const nextH2 = promptContent.indexOf("\n## ", afterHeading);
const nextH1 = promptContent.indexOf("\n# ", afterHeading);
const endCandidates = [nextH2, nextH1].filter((value) => value !== -1);
const end = endCandidates.length > 0 ? Math.min(...endCandidates) : promptContent.length;
return promptContent.slice(start, end).trim();
}
function summarizePromptSteps(promptContent: string): string {
const stepTitles = Array.from(promptContent.matchAll(/^### Step \d+:.*$/gm), (match) => match[0].trim());
if (stepTitles.length === 0) {
return "";
}
return ["## Steps", ...stepTitles].join("\n");
}
function buildReducedTaskPromptSummary(promptContent: string): string {
const firstSectionIndex = promptContent.indexOf("\n## ");
const header = (firstSectionIndex === -1 ? promptContent : promptContent.slice(0, firstSectionIndex)).trim();
const sections = [
header,
extractPromptSection(promptContent, "Mission"),
extractPromptSection(promptContent, "Dependencies"),
extractPromptSection(promptContent, "File Scope"),
summarizePromptSteps(promptContent),
"_... additional PROMPT.md sections omitted after context-limit retry ..._",
].filter(Boolean);
return sections.join("\n\n").trim();
}
function buildReducedReviewRequest(
taskId: string,
stepNumber: number,
stepName: string,
reviewType: ReviewType,
promptContent: string,
cwd: string,
baseline?: string,
): string {
return buildReviewRequest(
taskId,
stepNumber,
stepName,
reviewType,
buildReducedTaskPromptSummary(promptContent),
cwd,
baseline,
undefined,
);
}
function buildReviewRequest( function buildReviewRequest(
taskId: string, taskId: string,
stepNumber: number, stepNumber: number,