mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 11:47:41 +00:00
fix: auto-compact on context overflow promptError before returning error (#1627)
* fix: detect Anthropic 'Request size exceeds model context window' as context overflow Anthropic now returns 'Request size exceeds model context window' instead of the previously detected 'prompt is too long' format. This new error message was not recognized by isContextOverflowError(), causing auto-compaction to NOT trigger. Users would see the raw error twice without any recovery attempt. Changes: - Add 'exceeds model context window' and 'request size exceeds' to isContextOverflowError() detection patterns - Add tests that fail without the fix, verifying both the raw error string and the JSON-wrapped format from Anthropic's API - Add test for formatAssistantErrorText to ensure the friendly 'Context overflow' message is shown instead of the raw error Note: The upstream pi-ai package (@mariozechner/pi-ai) also needs a fix in its OVERFLOW_PATTERNS regex: /exceeds the context window/i should be changed to /exceeds.*context window/i to match both 'the' and 'model' variants for triggering auto-compaction retry. * fix(tests): remove unused imports and helper from test files Remove WorkspaceBootstrapFile references and _makeFile helper that were incorrectly copied from another test file. These caused type errors and were unrelated to the context overflow detection tests. * fix: trigger auto-compaction on context overflow promptError When the LLM rejects a request with a context overflow error that surfaces as a promptError (thrown exception rather than streamed error), the existing auto-compaction in pi-coding-agent never triggers. This happens because the error bypasses the agent's message_end → agent_end → _checkCompaction path. This fix adds a fallback compaction attempt directly in the run loop: - Detects context overflow in promptError (excluding compaction_failure) - Calls compactEmbeddedPiSessionDirect (bypassing lane queues since already in-lane) - Retries the prompt after successful compaction - Limits to one compaction attempt per run to prevent infinite loops Fixes: context overflow errors shown to user without auto-compaction attempt * style: format compact.ts and run.ts with oxfmt * fix: tighten context overflow match (#1627) (thanks @rodrigouroz) --------- Co-authored-by: Claude <claude@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
281
src/agents/pi-embedded-runner/run.overflow-compaction.test.ts
Normal file
281
src/agents/pi-embedded-runner/run.overflow-compaction.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("./run/attempt.js", () => ({
|
||||
runEmbeddedAttempt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./compact.js", () => ({
|
||||
compactEmbeddedPiSessionDirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./model.js", () => ({
|
||||
resolveModel: vi.fn(() => ({
|
||||
model: {
|
||||
id: "test-model",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200000,
|
||||
api: "messages",
|
||||
},
|
||||
error: null,
|
||||
authStorage: {
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
},
|
||||
modelRegistry: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../model-auth.js", () => ({
|
||||
ensureAuthProfileStore: vi.fn(() => ({})),
|
||||
getApiKeyForModel: vi.fn(async () => ({
|
||||
apiKey: "test-key",
|
||||
source: "test",
|
||||
})),
|
||||
resolveAuthProfileOrder: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../models-config.js", () => ({
|
||||
ensureClawdbotModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../context-window-guard.js", () => ({
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
|
||||
evaluateContextWindowGuard: vi.fn(() => ({
|
||||
shouldWarn: false,
|
||||
shouldBlock: false,
|
||||
tokens: 200000,
|
||||
source: "model",
|
||||
})),
|
||||
resolveContextWindowInfo: vi.fn(() => ({
|
||||
tokens: 200000,
|
||||
source: "model",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../process/command-queue.js", () => ({
|
||||
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils.js", () => ({
|
||||
resolveUserPath: vi.fn((p: string) => p),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/message-channel.js", () => ({
|
||||
isMarkdownCapableMessageChannel: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("../agent-paths.js", () => ({
|
||||
resolveClawdbotAgentDir: vi.fn(() => "/tmp/agent-dir"),
|
||||
}));
|
||||
|
||||
vi.mock("../auth-profiles.js", () => ({
|
||||
markAuthProfileFailure: vi.fn(async () => {}),
|
||||
markAuthProfileGood: vi.fn(async () => {}),
|
||||
markAuthProfileUsed: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../defaults.js", () => ({
|
||||
DEFAULT_CONTEXT_TOKENS: 200000,
|
||||
DEFAULT_MODEL: "test-model",
|
||||
DEFAULT_PROVIDER: "anthropic",
|
||||
}));
|
||||
|
||||
vi.mock("../failover-error.js", () => ({
|
||||
FailoverError: class extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
},
|
||||
resolveFailoverStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../usage.js", () => ({
|
||||
normalizeUsage: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./lanes.js", () => ({
|
||||
resolveSessionLane: vi.fn(() => "session-lane"),
|
||||
resolveGlobalLane: vi.fn(() => "global-lane"),
|
||||
}));
|
||||
|
||||
vi.mock("./logger.js", () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./run/payloads.js", () => ({
|
||||
buildEmbeddedRunPayloads: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("./utils.js", () => ({
|
||||
describeUnknownError: vi.fn((err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-embedded-helpers.js", async () => {
|
||||
return {
|
||||
isCompactionFailureError: (msg?: string) => {
|
||||
if (!msg) return false;
|
||||
const lower = msg.toLowerCase();
|
||||
return lower.includes("request_too_large") && lower.includes("summarization failed");
|
||||
},
|
||||
isContextOverflowError: (msg?: string) => {
|
||||
if (!msg) return false;
|
||||
const lower = msg.toLowerCase();
|
||||
return lower.includes("request_too_large") || lower.includes("request size exceeds");
|
||||
},
|
||||
isFailoverAssistantError: vi.fn(() => false),
|
||||
isFailoverErrorMessage: vi.fn(() => false),
|
||||
isAuthAssistantError: vi.fn(() => false),
|
||||
isRateLimitAssistantError: vi.fn(() => false),
|
||||
classifyFailoverReason: vi.fn(() => null),
|
||||
formatAssistantErrorText: vi.fn(() => ""),
|
||||
pickFallbackThinkingLevel: vi.fn(() => null),
|
||||
isTimeoutErrorMessage: vi.fn(() => false),
|
||||
parseImageDimensionError: vi.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
import { runEmbeddedPiAgent } from "./run.js";
|
||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
||||
import { log } from "./logger.js";
|
||||
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
|
||||
const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
|
||||
|
||||
function makeAttemptResult(
|
||||
overrides: Partial<EmbeddedRunAttemptResult> = {},
|
||||
): EmbeddedRunAttemptResult {
|
||||
return {
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
promptError: null,
|
||||
sessionIdUsed: "test-session",
|
||||
assistantTexts: ["Hello!"],
|
||||
toolMetas: [],
|
||||
lastAssistant: undefined,
|
||||
messagesSnapshot: [],
|
||||
didSendViaMessagingTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTargets: [],
|
||||
cloudCodeAssistFormatError: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "hello",
|
||||
timeoutMs: 30000,
|
||||
runId: "run-1",
|
||||
};
|
||||
|
||||
describe("overflow compaction in run loop", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retries after successful compaction on context overflow promptError", async () => {
|
||||
const overflowError = new Error("request_too_large: Request size exceeds model context window");
|
||||
|
||||
mockedRunEmbeddedAttempt
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
|
||||
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "Compacted session",
|
||||
firstKeptEntryId: "entry-5",
|
||||
tokensBefore: 150000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("context overflow detected; attempting auto-compaction"),
|
||||
);
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded"));
|
||||
// Should not be an error result
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error if compaction fails", async () => {
|
||||
const overflowError = new Error("request_too_large: Request size exceeds model context window");
|
||||
|
||||
mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError: overflowError }));
|
||||
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "nothing to compact",
|
||||
});
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(result.meta.error?.kind).toBe("context_overflow");
|
||||
expect(result.payloads?.[0]?.isError).toBe(true);
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed"));
|
||||
});
|
||||
|
||||
it("returns error if overflow happens again after compaction", async () => {
|
||||
const overflowError = new Error("request_too_large: Request size exceeds model context window");
|
||||
|
||||
mockedRunEmbeddedAttempt
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }));
|
||||
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "Compacted",
|
||||
firstKeptEntryId: "entry-3",
|
||||
tokensBefore: 180000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
// Compaction attempted only once
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
// Two attempts: first overflow -> compact -> retry -> second overflow -> return error
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(result.meta.error?.kind).toBe("context_overflow");
|
||||
expect(result.payloads?.[0]?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attempt compaction for compaction_failure errors", async () => {
|
||||
const compactionFailureError = new Error(
|
||||
"request_too_large: summarization failed - Request size exceeds model context window",
|
||||
);
|
||||
|
||||
mockedRunEmbeddedAttempt.mockResolvedValue(
|
||||
makeAttemptResult({ promptError: compactionFailureError }),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).not.toHaveBeenCalled();
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(result.meta.error?.kind).toBe("compaction_failure");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user