diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d28fd83..c60276e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.clawd.bot ### Fixes - Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x. +- Agents: retry compaction reset once, then surface a user-facing error on repeat failure. (#1187) — thanks @fayrose. - macOS: load menu session previews asynchronously so items populate while the menu is open. - macOS: use label colors for session preview text so previews render in menu subviews. diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 8c6fbc174..06570f6ff 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -94,7 +94,7 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("- Read: Read file contents"); expect(prompt).toContain("- Exec: Run shell commands"); expect(prompt).toContain( - "Use `Read` to load the SKILL.md at the location listed for that skill.", + "- If exactly one skill clearly applies: read its SKILL.md at with `Read`, then follow it.", ); expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs"); expect(prompt).toContain( @@ -188,7 +188,7 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("## Skills"); expect(prompt).toContain( - "Use `read` to load the SKILL.md at the location listed for that skill.", + "- If exactly one skill clearly applies: read its SKILL.md at with `read`, then follow it.", ); }); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 166b6289a..c775f5ceb 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -441,19 +441,22 @@ export async function runAgentTurnWithFallback(params: { // Some embedded runs surface context overflow as an error payload instead of throwing. // Treat those as a session-level failure and auto-recover by starting a fresh session. const embeddedError = runResult.meta?.error; - if ( - embeddedError && - isContextOverflowError(embeddedError.message) && - !didResetAfterCompactionFailure && - (await params.resetSessionAfterCompactionFailure(embeddedError.message)) - ) { - didResetAfterCompactionFailure = true; - return { - kind: "final", - payload: { - text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.", - }, - }; + if (embeddedError && isContextOverflowError(embeddedError.message)) { + if ( + !didResetAfterCompactionFailure && + (await params.resetSessionAfterCompactionFailure(embeddedError.message)) + ) { + didResetAfterCompactionFailure = true; + continue; + } + if (didResetAfterCompactionFailure) { + return { + kind: "final", + payload: { + text: "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.", + }, + }; + } } if (embeddedError?.kind === "role_ordering") { const didReset = await params.resetSessionAfterRoleOrderingConflict( @@ -487,10 +490,13 @@ export async function runAgentTurnWithFallback(params: { (await params.resetSessionAfterCompactionFailure(message)) ) { didResetAfterCompactionFailure = true; + continue; + } + if (isCompactionFailure && didResetAfterCompactionFailure) { return { kind: "final", payload: { - text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 4000 or higher in your config.", + text: "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again.\n\nTo prevent this, increase your compaction buffer by setting `agents.defaults.compaction.reserveTokensFloor` to 20000 or higher in your config.", }, }; } diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts index f583daf6a..5b32146cb 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts @@ -172,6 +172,53 @@ describe("runReplyAgent typing (heartbeat)", () => { } } }); + it("returns a user-facing error after repeated compaction failure", async () => { + const prevStateDir = process.env.CLAWDBOT_STATE_DIR; + const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-compaction-repeat-")); + process.env.CLAWDBOT_STATE_DIR = stateDir; + try { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId); + const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + await fs.mkdir(path.dirname(transcriptPath), { recursive: true }); + await fs.writeFile(transcriptPath, "ok", "utf-8"); + + runEmbeddedPiAgentMock.mockImplementation(async () => { + throw new Error( + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + ); + }); + + const callsBefore = runEmbeddedPiAgentMock.mock.calls.length; + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + expect(runEmbeddedPiAgentMock.mock.calls.length - callsBefore).toBe(2); + const payload = Array.isArray(res) ? res[0] : res; + expect(payload.text).toContain("Context limit exceeded"); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + } finally { + if (prevStateDir) { + process.env.CLAWDBOT_STATE_DIR = prevStateDir; + } else { + delete process.env.CLAWDBOT_STATE_DIR; + } + } + }); it("retries after context overflow payload by resetting the session", async () => { const prevStateDir = process.env.CLAWDBOT_STATE_DIR; const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-overflow-reset-"));