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:
@@ -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`
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user