feat(FN-1877): add auto-compaction on context window errors to promptWithFallback

- Detect context-window-limit errors from AI sessions and automatically compact
  the session conversation history before retrying (one attempt per session)
- Centralize auto-compaction in promptWithFallback (pi.ts) so executor, merger,
  and step-session-executor all benefit from the same mechanism
- Remove scattered context-limit error handling from executor.ts, merger.ts, and
  step-session-executor.ts in favor of the centralized approach
- Add comprehensive tests for auto-compaction retry behavior in pi.test.ts
- Remove unused compactSessionContext import from step-session-executor.ts
- Add memory note documenting the centralized auto-compaction design
This commit is contained in:
Fusion
2026-04-15 10:42:20 -07:00
committed by gsxdsm
parent bd7ada7533
commit ddf0fc7303
7 changed files with 335 additions and 244 deletions

View File

@@ -3,6 +3,7 @@
## Architecture
- `TaskExecutor` terminates active agent sessions (single and step) when tasks are moved away from `in-progress` via the `task:moved` event handler. This prevents zombie sessions when users manually send tasks back to todo/triage from the board UI.
- **Centralized Context-Window Auto-Compaction (FN-1877)**: The `promptWithFallback()` function in `packages/engine/src/pi.ts` automatically catches context-window overflow errors, runs `compactSessionContext()`, and retries once. This centralizes recovery for ALL agent types (executor, step-session, merger, triage, heartbeat, reviewer, mission-execution-loop). Callers that previously had duplicate compact-and-resume logic (executor, step-session-executor, merger) have been simplified to use `promptWithFallback`'s auto-compaction as first-level recovery, with their own reduced-prompt fallbacks as second-level recovery. This eliminates code duplication and ensures consistent recovery behavior.
- **Workflow Step Revision Loop (FN-1499)**: Workflow steps can request implementation revisions via "REQUEST REVISION" output. The flow:
1. Workflow step agent outputs "REQUEST REVISION\n\n[feedback]" to signal that code changes are needed
2. `executeWorkflowStep()` detects this pattern and returns `WorkflowStepOutcome` with `revisionRequested: true`

View File

@@ -1880,97 +1880,46 @@ export class TaskExecutor {
this.stuckAborted.delete(task.id);
executorLog.log(`${task.id} terminated by stuck task detector — will ${stuckRequeue ? "retry" : "not retry (budget exhausted)"}`);
} else {
// Check if the error is a context-limit error and attempt bounded recovery
// before falling through to the normal failure path. Recovery strategy:
// 1. Try compact-and-resume (compacts session history, then resumes with same prompt)
// 2. If compaction fails, try reduced-prompt retry (simpler, shorter prompt)
// 3. If reduced prompt succeeds, return (task continues)
// 4. If all recovery fails, fall through to mark task as failed
// Context-limit error reached the executor after promptWithFallback's auto-compaction
// already attempted to recover. Try reduced-prompt retry as a second-level fallback.
// This is bounded to 1 attempt to prevent infinite retry loops.
const loopState = this.loopRecoveryState.get(task.id);
const loopAttempts = loopState?.attempts ?? 0;
if (isContextLimitError(errorMessage) && loopAttempts < 1) {
const activeEntry = this.activeSessions.get(task.id);
if (activeEntry) {
executorLog.log(`${task.id} context limit error — attempting compact-and-resume`);
await this.store.logEntry(task.id, `Context limit error — attempting compact-and-resume: ${errorMessage}`, undefined, this.currentRunContext);
executorLog.log(`${task.id} context limit error after auto-compaction — attempting reduced-prompt retry`);
await this.store.logEntry(task.id, `Context limit error after auto-compaction — attempting reduced-prompt retry: ${errorMessage}`, undefined, this.currentRunContext);
const compactResult = await compactSessionContext(activeEntry.session);
if (compactResult) {
// Compaction succeeded — try to resume with original prompt
this.loopRecoveryState.set(task.id, { attempts: loopAttempts + 1, pending: true });
executorLog.log(`${task.id} context compaction succeeded — resuming`);
await this.store.logEntry(task.id, "Context compaction succeeded — resuming execution", undefined, this.currentRunContext);
this.loopRecoveryState.set(task.id, { attempts: loopAttempts + 1, pending: false });
try {
this.options.stuckTaskDetector?.recordProgress(task.id);
const resumePrompt = [
"Your conversation hit the context window limit and has been compacted.",
"Review the current state of the worktree and continue from where you left off.",
"Check git log and current files to understand what's already been done.",
"Take a different, more efficient approach if needed.",
"",
"Continue the task.",
].join("\n");
await promptWithFallback(activeEntry.session, resumePrompt);
checkSessionError(activeEntry.session);
try {
this.options.stuckTaskDetector?.recordProgress(task.id);
// Build a reduced prompt that's simpler and shorter to avoid context overflow
const reducedPrompt = [
"Your previous attempt hit the context window limit.",
"Focus on completing the task efficiently with minimal context:",
"1. Review git status and git log to see what's been done",
"2. Identify the most critical remaining work",
"3. Complete it with a simpler, more focused approach",
"",
"Do not repeat what's already been done. Just complete the task and call task_done.",
].join("\n");
// Check for loop recovery pending from the compact-and-resume
const updatedState = this.loopRecoveryState.get(task.id);
if (updatedState?.pending) {
updatedState.pending = false;
await promptWithFallback(activeEntry.session, "Continue working on the remaining steps.");
checkSessionError(activeEntry.session);
}
// Compact-and-resume succeeded — return to let the finally block clean up
// without marking the task as failed. The agent will continue execution
// and call task_done or complete implicitly.
return;
} catch (resumeErr: unknown) {
// Resume after context compaction failed — fall through to reduced-prompt retry
const resumeErrorMessage = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
executorLog.error(`${task.id} resume after context compaction failed: ${resumeErrorMessage}`);
await this.store.logEntry(task.id, `Resume after context compaction failed: ${resumeErrorMessage}`, undefined, this.currentRunContext);
// Fall through to reduced-prompt retry below
}
}
await promptWithFallback(activeEntry.session, reducedPrompt);
checkSessionError(activeEntry.session);
// Compact returned null (no history to compact) OR compact succeeded but resume failed.
// Try reduced-prompt recovery: a simpler prompt that doesn't include full history.
// This is bounded to 1 attempt to prevent infinite retry loops.
if (this.loopRecoveryState.get(task.id)?.attempts ?? 0) {
// Already tried compact-and-resume, skip reduced-prompt to prevent loops
} else {
executorLog.log(`${task.id} attempting reduced-prompt retry for context limit`);
await this.store.logEntry(task.id, "Context compaction unavailable — attempting reduced-prompt recovery", undefined, this.currentRunContext);
try {
this.options.stuckTaskDetector?.recordProgress(task.id);
// Build a reduced prompt that's simpler and shorter to avoid context overflow
const reducedPrompt = [
"Your previous attempt hit the context window limit.",
"Focus on completing the task efficiently with minimal context:",
"1. Review git status and git log to see what's been done",
"2. Identify the most critical remaining work",
"3. Complete it with a simpler, more focused approach",
"",
"Do not repeat what's already been done. Just complete the task and call task_done.",
].join("\n");
await promptWithFallback(activeEntry.session, reducedPrompt);
checkSessionError(activeEntry.session);
// Reduced-prompt retry succeeded — return to let the finally block clean up
// without marking the task as failed.
executorLog.log(`${task.id} reduced-prompt recovery succeeded — continuing`);
await this.store.logEntry(task.id, "Reduced-prompt recovery succeeded — continuing execution", undefined, this.currentRunContext);
return;
} catch (reducedErr: unknown) {
const reducedErrorMessage = reducedErr instanceof Error ? reducedErr.message : String(reducedErr);
executorLog.error(`${task.id} reduced-prompt recovery also failed: ${reducedErrorMessage}`);
await this.store.logEntry(task.id, `Reduced-prompt recovery failed: ${reducedErrorMessage}`, undefined, this.currentRunContext);
// Fall through to mark task as failed
}
// Reduced-prompt retry succeeded — return to let the finally block clean up
// without marking the task as failed.
executorLog.log(`${task.id} reduced-prompt recovery succeeded — continuing`);
await this.store.logEntry(task.id, "Reduced-prompt recovery succeeded — continuing execution", undefined, this.currentRunContext);
return;
} catch (reducedErr: unknown) {
const reducedErrorMessage = reducedErr instanceof Error ? reducedErr.message : String(reducedErr);
executorLog.error(`${task.id} reduced-prompt recovery also failed: ${reducedErrorMessage}`);
await this.store.logEntry(task.id, `Reduced-prompt recovery failed: ${reducedErrorMessage}`, undefined, this.currentRunContext);
// Fall through to mark task as failed
}
}
} else if (this.options.usageLimitPauser && isUsageLimitError(errorMessage)) {

View File

@@ -3416,12 +3416,9 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
});
}
it("retries with minimal prompt when context limit hit and compaction returns null", async () => {
const { compactSessionContext } = await import("./pi.js");
it("retries with minimal prompt when context limit hit after auto-compaction", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
// Mock compaction to return null (fresh session)
vi.mocked(compactSessionContext).mockResolvedValue(null);
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls
@@ -3464,16 +3461,12 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
// Second call should have the minimal placeholder
expect(promptCalls[1]).toContain("(see git log)");
// Compaction was attempted
expect(vi.mocked(compactSessionContext)).toHaveBeenCalled();
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("throws when truncated retry also fails with context limit", async () => {
const { compactSessionContext } = await import("./pi.js");
const { isContextLimitError } = await import("./context-limit-detector.js");
// Mock compaction to return null (fresh session)
vi.mocked(compactSessionContext).mockResolvedValue(null);
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls to verify both original and truncated prompts were tried
@@ -3530,16 +3523,12 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
// With 3 merge attempts, this means we should have at least 6 prompt calls total
expect(promptCalls.length).toBeGreaterThan(0);
// Compaction was attempted
expect(vi.mocked(compactSessionContext)).toHaveBeenCalled();
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("uses normal flow when compaction succeeds (regression test)", async () => {
const { compactSessionContext } = await import("./pi.js");
it("succeeds when prompt succeeds on retry after context error", async () => {
const { isContextLimitError } = await import("./context-limit-detector.js");
// Mock compaction to succeed
vi.mocked(compactSessionContext).mockResolvedValue({ summary: "compacted", tokensBefore: 10000 });
vi.mocked(isContextLimitError).mockReturnValue(true);
// Track prompt calls
@@ -3569,14 +3558,13 @@ describe("aiMergeTask — context limit recovery with truncation", () => {
const result = await aiMergeTask(store, "/tmp/root", "FN-050");
// Merge should succeed after compaction retry
// Merge should succeed after retry
expect(result.merged).toBe(true);
// Should have made 2 prompt calls
expect(promptCalls).toHaveLength(2);
// Compaction was attempted and succeeded
expect(vi.mocked(compactSessionContext)).toHaveBeenCalled();
// Note: Compaction is now handled by promptWithFallback, not by the merger directly
});
it("does not attempt truncation retry for non-context errors", async () => {

View File

@@ -2358,71 +2358,45 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
},
});
} catch (err: unknown) {
// Context-limit error: try compact-and-retry recovery.
// Context-limit error after promptWithFallback's auto-compaction already attempted recovery.
// Try truncated prompt retry as second-level fallback.
// This detects when the LLM rejects the prompt due to context-window overflow.
const errorMessage = err instanceof Error ? err.message : String(err);
if (isContextLimitError(errorMessage)) {
mergerLog.warn(`${taskId}: context limit hit in merge agent — attempting compaction recovery`);
await store.logEntry(taskId, "Context limit reached during merge — compacting session and retrying");
mergerLog.warn(`${taskId}: context limit hit after auto-compaction — retrying with minimal merge prompt`);
await store.logEntry(taskId, "Context limit reached during merge after auto-compaction — retrying with reduced prompt");
// Attempt to compress conversation history to free context space.
const compactResult = await compactSessionContext(session);
if (compactResult) {
// Compaction succeeded: retry with the compressed session.
mergerLog.log(`${taskId}: context compacted at ${compactResult.tokensBefore} tokens — retrying`);
await store.logEntry(taskId, `Session compacted at ${compactResult.tokensBefore} tokens — retrying merge`);
// Build minimal prompt: omit diff stat, use placeholder for commit log
const truncatedPrompt = buildMergePrompt({
taskId,
branch,
commitLog: "(see git log)", // Minimal placeholder instead of full commit log
diffStat: "", // Omit diff stat entirely
hasConflicts,
simplifiedContext: true, // Also skip detailed context
testCommand,
buildCommand,
authorArg,
});
// Retry the prompting after compaction
try {
await withRateLimitRetry(async () => {
await promptWithFallback(session, prompt);
await promptWithFallback(session, truncatedPrompt);
checkSessionError(session);
}, {
onRetry: (attempt, delayMs, error) => {
const delaySec = Math.round(delayMs / 1000);
mergerLog.warn(`${taskId} rate limited after compaction — retry ${attempt} in ${delaySec}s: ${error.message}`);
mergerLog.warn(`${taskId} rate limited during truncated retry — retry ${attempt} in ${delaySec}s: ${error.message}`);
},
});
} else {
// Compaction unavailable or failed (fresh session has nothing to compact):
// Retry with a minimal prompt that omits diff stat and uses placeholder commit log.
// Use truncatedRetryAttempted flag to prevent infinite loops.
let truncatedRetryAttempted = false;
mergerLog.warn(`${taskId}: compaction unavailable — retrying with minimal merge prompt`);
await store.logEntry(taskId, "Context limit reached — retrying with reduced prompt");
// Build minimal prompt: omit diff stat, use placeholder for commit log
const truncatedPrompt = buildMergePrompt({
taskId,
branch,
commitLog: "(see git log)", // Minimal placeholder instead of full commit log
diffStat: "", // Omit diff stat entirely
hasConflicts,
simplifiedContext: true, // Also skip detailed context
testCommand,
buildCommand,
authorArg,
});
try {
await withRateLimitRetry(async () => {
await promptWithFallback(session, truncatedPrompt);
checkSessionError(session);
truncatedRetryAttempted = true;
}, {
onRetry: (attempt, delayMs, error) => {
const delaySec = Math.round(delayMs / 1000);
mergerLog.warn(`${taskId} rate limited during truncated retry — retry ${attempt} in ${delaySec}s: ${error.message}`);
},
});
} catch (retryErr: unknown) {
// Truncated retry also failed: propagate original error
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
if (isContextLimitError(retryErrorMessage) && truncatedRetryAttempted) {
mergerLog.error(`${taskId}: truncated retry also hit context limit — propagating original error`);
throw err; // Throw original error with original context
}
throw retryErr; // Non-context error or other failure
} catch (retryErr: unknown) {
// Truncated retry also failed: propagate original error
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
if (isContextLimitError(retryErrorMessage)) {
mergerLog.error(`${taskId}: truncated retry also hit context limit — propagating original error`);
throw err; // Throw original error with original context
}
throw retryErr; // Non-context error or other failure
}
} else {
// Non-context error (network, rate limit, build failure): propagate immediately.

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createKbAgent, type AgentOptions } from "./pi.js";
import { describeModel, compactSessionContext, COMPACTION_FALLBACK_INSTRUCTIONS, createKbAgent, promptWithFallback, type AgentOptions } from "./pi.js";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
// Mock skill resolver functions - define inside factory to avoid hoisting issues
@@ -310,3 +310,150 @@ describe("createKbAgent skills parameter", () => {
);
});
});
describe("promptWithFallback auto-compaction", () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
vi.clearAllMocks();
});
it("auto-compacts on context error, then retries successfully", async () => {
// Mock session that throws context error on first prompt, succeeds on retry
const mockPrompt = vi.fn()
.mockRejectedValueOnce(new Error("prompt is too long: 210000 tokens > 200000 maximum"))
.mockResolvedValueOnce(undefined);
const mockCompact = vi.fn().mockResolvedValue({ summary: "compacted", tokensBefore: 210000 });
const session = { prompt: mockPrompt, compact: mockCompact } as unknown as AgentSession;
await promptWithFallback(session, "test prompt");
// Verify compact was called once
expect(mockCompact).toHaveBeenCalledTimes(1);
// Verify prompt was called twice (first throw, second success)
expect(mockPrompt).toHaveBeenCalledTimes(2);
expect(mockPrompt.mock.calls[0]).toEqual(["test prompt"]);
expect(mockPrompt.mock.calls[1]).toEqual(["test prompt"]);
});
it("auto-compacts when compact returns null (session doesn't support it)", async () => {
// Mock session that throws context error, compact not available
const mockPrompt = vi.fn().mockRejectedValue(new Error("prompt is too long: 210000 tokens > 200000 maximum"));
const session = { prompt: mockPrompt } as unknown as AgentSession; // No compact method
await expect(promptWithFallback(session, "test prompt")).rejects.toThrow("prompt is too long: 210000 tokens > 200000 maximum");
// Verify prompt was called only once (no retry since compaction unavailable)
expect(mockPrompt).toHaveBeenCalledTimes(1);
});
it("propagates original error when retry after compaction also fails", async () => {
// Mock session that always throws context error
const mockPrompt = vi.fn().mockRejectedValue(new Error("prompt is too long: 210000 tokens > 200000 maximum"));
const mockCompact = vi.fn().mockResolvedValue({ summary: "compacted", tokensBefore: 200000 });
const session = { prompt: mockPrompt, compact: mockCompact } as unknown as AgentSession;
await expect(promptWithFallback(session, "test prompt")).rejects.toThrow("prompt is too long: 210000 tokens > 200000 maximum");
// Verify prompt was called exactly twice (original + 1 retry)
expect(mockPrompt).toHaveBeenCalledTimes(2);
// Verify compact was called once
expect(mockCompact).toHaveBeenCalledTimes(1);
});
it("propagates non-context errors without attempting compaction", async () => {
// Mock session that throws non-context error
const mockPrompt = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
const mockCompact = vi.fn();
const session = { prompt: mockPrompt, compact: mockCompact } as unknown as AgentSession;
await expect(promptWithFallback(session, "test prompt")).rejects.toThrow("ECONNREFUSED");
// Verify compact was NOT called
expect(mockCompact).not.toHaveBeenCalled();
// Verify prompt was called only once
expect(mockPrompt).toHaveBeenCalledTimes(1);
});
it("does not compact when prompt succeeds on first try", async () => {
// Mock session that succeeds on first prompt
const mockPrompt = vi.fn().mockResolvedValue(undefined);
const mockCompact = vi.fn();
const session = { prompt: mockPrompt, compact: mockCompact } as unknown as AgentSession;
await promptWithFallback(session, "test prompt");
// Verify compact was NOT called
expect(mockCompact).not.toHaveBeenCalled();
// Verify prompt was called once
expect(mockPrompt).toHaveBeenCalledTimes(1);
});
it("auto-compacts with options parameter and passes options to retry", async () => {
// Mock session that throws context error on first prompt, succeeds on retry
const mockPrompt = vi.fn()
.mockRejectedValueOnce(new Error("prompt is too long: 210000 tokens > 200000 maximum"))
.mockResolvedValueOnce(undefined);
const mockCompact = vi.fn().mockResolvedValue({ summary: "compacted", tokensBefore: 210000 });
const session = { prompt: mockPrompt, compact: mockCompact } as unknown as AgentSession;
// Use a simple options object (AbortSignal cannot be constructed in test env)
const options = { timeout: 60000 };
await promptWithFallback(session, "test prompt", options);
// Verify compact was called once
expect(mockCompact).toHaveBeenCalledTimes(1);
// Verify prompt was called twice with options
expect(mockPrompt).toHaveBeenCalledTimes(2);
expect(mockPrompt.mock.calls[0]).toEqual(["test prompt", options]);
expect(mockPrompt.mock.calls[1]).toEqual(["test prompt", options]);
});
it("delegates to session.promptWithFallback when available", async () => {
// Mock session with promptWithFallback method
const mockSessionPromptWithFallback = vi.fn().mockResolvedValue(undefined);
const mockPrompt = vi.fn();
const mockCompact = vi.fn();
const session = {
prompt: mockPrompt,
compact: mockCompact,
promptWithFallback: mockSessionPromptWithFallback,
} as unknown as AgentSession;
await promptWithFallback(session, "test prompt");
// Verify session.promptWithFallback was called (auto-compaction handled by session)
expect(mockSessionPromptWithFallback).toHaveBeenCalledTimes(1);
// Verify direct prompt was NOT called
expect(mockPrompt).not.toHaveBeenCalled();
});
it("handles context error patterns from various providers", async () => {
const contextErrorPatterns = [
"prompt is too long: 210000 tokens > 200000 maximum", // Anthropic
"exceeds the context window", // OpenAI
"input token count exceeds the maximum", // Google Gemini
"maximum prompt length is 100000 but request contains 150000", // xAI
"reduce the length of the messages", // Groq
"too many tokens", // Generic
];
for (const errorMessage of contextErrorPatterns) {
const mockPrompt = vi.fn()
.mockRejectedValueOnce(new Error(errorMessage))
.mockResolvedValueOnce(undefined);
const mockCompact = vi.fn().mockResolvedValue({ summary: "compacted", tokensBefore: 150000 });
const session = { prompt: mockPrompt, compact: mockCompact } as unknown as AgentSession;
await promptWithFallback(session, "test prompt");
// Verify compaction was triggered for each error pattern
expect(mockCompact).toHaveBeenCalled();
}
});
});

View File

@@ -33,6 +33,7 @@ import {
createSkillsOverrideFromSelection,
type SkillSelectionContext,
} from "./skill-resolver.js";
import { isContextLimitError } from "./context-limit-detector.js";
export interface AgentResult {
session: AgentSession;
@@ -54,12 +55,42 @@ export async function promptWithFallback(session: AgentSession, prompt: string,
}
console.error(`[pi] promptWithFallback: calling session.prompt (prompt length=${prompt.length})`);
if (options === undefined) {
await session.prompt(prompt);
} else {
await (session.prompt as any)(prompt, options);
try {
if (options === undefined) {
await session.prompt(prompt);
} else {
await (session.prompt as any)(prompt, options);
}
console.error(`[pi] promptWithFallback: prompt completed`);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
if (!isContextLimitError(errorMessage)) {
console.error(`[pi] promptWithFallback: non-context error — propagating: ${errorMessage}`);
throw err;
}
// Context limit error — attempt auto-compaction and retry once
console.error(`[pi] promptWithFallback: context limit error — attempting auto-compaction`);
const compactResult = await compactSessionContext(session);
if (!compactResult) {
console.error(`[pi] promptWithFallback: compaction unavailable — propagating original error`);
throw err;
}
console.error(`[pi] promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
if (options === undefined) {
await session.prompt(prompt);
} else {
await (session.prompt as any)(prompt, options);
}
console.error(`[pi] promptWithFallback: prompt completed after auto-compaction`);
} catch (retryErr: unknown) {
const retryErrorMessage = retryErr instanceof Error ? retryErr.message : String(retryErr);
console.error(`[pi] promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
throw err; // Throw original error to preserve original context
}
}
console.error(`[pi] promptWithFallback: prompt completed`);
}
/**
@@ -564,7 +595,33 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
}
return;
} catch (err: any) {
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(err?.message || "")) {
const errorMessage = err?.message || "";
if (isContextLimitError(errorMessage)) {
// Context limit error — attempt auto-compaction and retry once
console.error(`[pi] promptWithFallback: context limit error — attempting auto-compaction`);
const compactResult = await compactSessionContext(session);
if (compactResult) {
console.error(`[pi] promptWithFallback: compaction succeeded (${compactResult.tokensBefore} tokens) — retrying prompt`);
try {
if (promptOptions === undefined) {
await session.prompt(prompt);
} else {
await (session.prompt as any)(prompt, promptOptions);
}
return;
} catch (retryErr: any) {
const retryErrorMessage = retryErr?.message || "";
console.error(`[pi] promptWithFallback: retry after auto-compaction failed: ${retryErrorMessage}`);
// Throw original error to preserve original context
throw err;
}
} else {
console.error(`[pi] promptWithFallback: compaction unavailable — propagating original error`);
throw err;
}
}
if (!fallbackModel || usingFallback || !isRetryableModelSelectionError(errorMessage)) {
throw err;
}
@@ -603,10 +660,39 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
Object.assign(promptableSession, fallbackSession);
promptableSession.promptWithFallback = fallbackSession.promptWithFallback ?? promptableSession.promptWithFallback;
if (promptOptions === undefined) {
await fallbackSession.prompt(prompt);
} else {
await (fallbackSession.prompt as any)(prompt, promptOptions);
// Retry with fallback model, also with auto-compaction support
try {
if (promptOptions === undefined) {
await fallbackSession.prompt(prompt);
} else {
await (fallbackSession.prompt as any)(prompt, promptOptions);
}
return;
} catch (fallbackErr: any) {
const fallbackErrorMessage = fallbackErr?.message || "";
if (isContextLimitError(fallbackErrorMessage)) {
console.error(`[pi] promptWithFallback: fallback session context limit error — attempting auto-compaction`);
const compactResult = await compactSessionContext(fallbackSession);
if (compactResult) {
console.error(`[pi] promptWithFallback: fallback compaction succeeded (${compactResult.tokensBefore} tokens) — retrying`);
try {
if (promptOptions === undefined) {
await fallbackSession.prompt(prompt);
} else {
await (fallbackSession.prompt as any)(prompt, promptOptions);
}
return;
} catch (retryErr: any) {
const retryErrorMessage = retryErr?.message || "";
console.error(`[pi] promptWithFallback: fallback retry after auto-compaction failed: ${retryErrorMessage}`);
throw fallbackErr; // Throw original fallback error
}
} else {
console.error(`[pi] promptWithFallback: fallback compaction unavailable — propagating original error`);
throw fallbackErr;
}
}
throw fallbackErr;
}
}
};

View File

@@ -19,7 +19,7 @@ import { join } from "node:path";
import type { AgentSession } from "@mariozechner/pi-coding-agent";
import type { TaskDetail, Settings, TaskStore } from "@fusion/core";
import { createKbAgent, promptWithFallback, describeModel, compactSessionContext } from "./pi.js";
import { createKbAgent, promptWithFallback, describeModel } from "./pi.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import { generateWorktreeName } from "./worktree-names.js";
import { AgentSemaphore } from "./concurrency.js";
@@ -868,101 +868,47 @@ export class StepSessionExecutor {
`Step ${stepIndex} attempt ${attempt + 1} failed: ${errorMessage}`,
);
// Check for context-limit error and attempt bounded recovery
// Context-limit error after promptWithFallback's auto-compaction already attempted recovery.
// Try reduced-prompt retry as second-level fallback.
// Recovery is bounded to prevent infinite loops (MAX_STEP_RETRIES recovery)
if (isContextLimitError(errorMessage) && recoveryAttempts < MAX_STEP_RETRIES && session) {
stepExecLog.log(
`Step ${stepIndex} context limit error — attempting bounded recovery ` +
`Step ${stepIndex} context limit error after auto-compaction — attempting reduced-prompt retry ` +
`(recoveryAttempt=${recoveryAttempts + 1}/${MAX_STEP_RETRIES})`,
);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Context limit error on step ${stepIndex}: ${errorMessage}`,
`[step-exec] Context limit error after auto-compaction on step ${stepIndex}: ${errorMessage}`,
"tool_error",
);
// Try compact-and-resume first
const compactResult = await compactSessionContext(session);
if (compactResult) {
stepExecLog.log(
`Step ${stepIndex} context compaction succeeded (${compactResult.tokensBefore} tokens) — resuming`,
recoveryAttempts++;
try {
stuckTaskDetector?.recordActivity(trackingKey);
await promptWithFallback(session, reducedStepPrompt);
checkSessionError(session);
stepExecLog.log(`Step ${stepIndex} reduced-prompt recovery succeeded`);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Reduced-prompt recovery succeeded for step ${stepIndex}`,
"text",
);
const result: StepResult = { stepIndex, success: true, retries };
this.options.onStepComplete?.(stepIndex, result);
return result;
} catch (reducedErr: unknown) {
const reducedErrorMessage = typeof reducedErr === "string"
? reducedErr
: (reducedErr as { message?: string })?.message ?? String(reducedErr);
stepExecLog.warn(
`Step ${stepIndex} reduced-prompt retry also failed: ${reducedErrorMessage}`,
);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Context compaction succeeded at ${compactResult.tokensBefore} tokens — resuming step ${stepIndex}`,
"text",
`[step-exec] Reduced-prompt recovery failed for step ${stepIndex}: ${reducedErrorMessage}`,
"tool_error",
);
recoveryAttempts++;
// Attempt resume with original prompt
try {
stuckTaskDetector?.recordActivity(trackingKey);
await promptWithFallback(session, stepPrompt);
checkSessionError(session);
stepExecLog.log(`Step ${stepIndex} compact-and-resume succeeded`);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Compact-and-resume succeeded for step ${stepIndex}`,
"text",
);
const result: StepResult = { stepIndex, success: true, retries };
this.options.onStepComplete?.(stepIndex, result);
return result;
} catch (resumeErr: unknown) {
const resumeErrorMessage = typeof resumeErr === "string"
? resumeErr
: (resumeErr as { message?: string })?.message ?? String(resumeErr);
stepExecLog.warn(
`Step ${stepIndex} resume after compaction failed: ${resumeErrorMessage}`,
);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Resume after compaction failed for step ${stepIndex}: ${resumeErrorMessage}`,
"tool_error",
);
// Fall through to reduced-prompt retry
}
} else {
stepExecLog.log(
`Step ${stepIndex} context compaction unavailable — attempting reduced-prompt retry`,
);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Context compaction unavailable for step ${stepIndex} — attempting reduced-prompt recovery`,
"text",
);
}
// Compact returned null OR resume failed — try reduced-prompt retry
if (recoveryAttempts < MAX_STEP_RETRIES) {
recoveryAttempts++;
try {
stuckTaskDetector?.recordActivity(trackingKey);
await promptWithFallback(session, reducedStepPrompt);
checkSessionError(session);
stepExecLog.log(`Step ${stepIndex} reduced-prompt recovery succeeded`);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Reduced-prompt recovery succeeded for step ${stepIndex}`,
"text",
);
const result: StepResult = { stepIndex, success: true, retries };
this.options.onStepComplete?.(stepIndex, result);
return result;
} catch (reducedErr: unknown) {
const reducedErrorMessage = typeof reducedErr === "string"
? reducedErr
: (reducedErr as { message?: string })?.message ?? String(reducedErr);
stepExecLog.warn(
`Step ${stepIndex} reduced-prompt retry also failed: ${reducedErrorMessage}`,
);
await this.store.appendAgentLog(
taskDetail.id,
`[step-exec] Reduced-prompt recovery failed for step ${stepIndex}: ${reducedErrorMessage}`,
"tool_error",
);
// Fall through to normal retry or failure
}
// Fall through to normal retry or failure
}
}