From cd1cae5be9ba76763e696784ff2effa534e00052 Mon Sep 17 00:00:00 2001 From: "Jason (Json)" <263060202+fuller-stack-dev@users.noreply.github.com> Date: Thu, 21 May 2026 23:04:41 -0600 Subject: [PATCH] fix(auto-reply): preserve sessions after compaction failures (#70479) Summary: - The PR removes the auto-reply compaction-failure session reset hook, adds preserved-session recovery guidance for overflow/compaction failure paths, and updates focused tests, docs, and the changelog. - Reproducibility: yes. at source level with high confidence. Current main routes both embedded overflow paylo ... resetSessionAfterCompactionFailure, and the PR body includes before/after terminal proof of those branches. Automerge notes: - PR branch already contained follow-up commit before automerge: fix(auto-reply): drop dead compaction reset hook - PR branch already contained follow-up commit before automerge: fix(auto-reply): preserve sessions after compaction failures Validation: - ClawSweeper review passed for head 193d3c0fdd5a42cc5291f4f279e80f872fdd1051. - Required merge gates passed before the squash merge. Prepared head SHA: 193d3c0fdd5a42cc5291f4f279e80f872fdd1051 Review: https://github.com/openclaw/openclaw/pull/70479#issuecomment-4325128777 Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com> --- CHANGELOG.md | 3 +- .../session-management-compaction.md | 5 + .../reply/agent-runner-execution.test.ts | 186 ++++++++++++------ .../reply/agent-runner-execution.ts | 37 ++-- src/auto-reply/reply/agent-runner.ts | 7 - 5 files changed, 147 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 010babd1d30..ea89d6d0df4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4929,7 +4929,8 @@ Docs: https://docs.openclaw.ai - Providers/SDK retry: cap long `Retry-After` sleeps in Stainless-based Anthropic/OpenAI model SDKs so 60s+ retry windows surface immediately for OpenClaw failover instead of blocking the run. (#68474) Thanks @jetd1. - Agents/TTS: preserve spoken text in TTS tool results while defusing reply directives in transcript content, so future turns remember voice replies without treating spoken `MEDIA:` or voice tags as delivery metadata. (#68869) Thanks @zqchris. - Providers/OpenAI: harden Voice Call realtime transcription against OpenAI Realtime session-update drift, forward language and prompt hints, and add live coverage for realtime STT. -- Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana. +- Agents/Pi embedded runs: suppress the "⚠️ Agent couldn't generate a response" warning when the assistant already delivered user-visible content through a messaging tool and the turn ended cleanly (`stopReason=stop`). Real failure modes (tool errors, provider `stopReason=error`, interrupted tool use) still surface the existing "verify before retrying" warning. Fixes #70396. (#70425) Thanks @neeravmakwana. +- Auto-reply/WebChat: preserve the active session mapping when context-overflow recovery or auto-compaction fails, and return retry, `/compact`, and `/new` guidance instead of silently rotating to a fresh session. Fixes #70472. (#70479) Thanks @fuller-stack-dev. - Gateway/Linux: wrap gateway-managed supervisor, PTY, MCP stdio, and browser child processes in a tiny `/bin/sh` shim that raises the child's own `oom_score_adj` on Linux, so under cgroup memory pressure the kernel prefers transient workers over the long-lived gateway. Opt out with `OPENCLAW_CHILD_OOM_SCORE_ADJ=0`. Fixes #70404. (#70419) Thanks @neeravmakwana. - Providers/Moonshot: stop strict-sanitizing Kimi's native tool_call IDs (shaped like `functions.:`) on the OpenAI-compatible transport, so multi-turn agentic flows through Kimi K2.6 no longer break after 2-3 tool-calling rounds when the serving layer fails to match mangled IDs against the original tool definitions. Adds a `sanitizeToolCallIds` opt-out to the shared `openai-compatible` replay family helper and wires Moonshot to it. Fixes #62319. (#70030) Thanks @LeoDu0314. - Dependencies/security: override transitive `uuid` to `14.0.0`, clearing the runtime advisory across dependencies. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index a51d0e8167b..4408f898b39 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -274,6 +274,11 @@ In the embedded Pi agent, auto-compaction triggers in two cases: number of tokens`, `input token count exceeds the maximum number of input tokens`, `input is too long for the model`, `ollama error: context length exceeded`, and similar provider-shaped variants) → compact → retry. + If overflow recovery still fails, OpenClaw surfaces explicit guidance to the + user and preserves the current session mapping instead of silently rotating + the session key to a fresh session id. The next step is operator-controlled: + retry the message, run `/compact`, or run `/new` when a fresh session is + preferred. 2. **Threshold maintenance**: after a successful turn, when: `contextTokens > contextWindow - reserveTokens` diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index a350531f1d5..6ffa2be3578 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -25,6 +25,10 @@ const state = vi.hoisted(() => ({ isCliProviderMock: vi.fn((_: unknown) => false), isInternalMessageChannelMock: vi.fn((_: unknown) => false), createBlockReplyDeliveryHandlerMock: vi.fn(), + isCompactionFailureErrorMock: vi.fn((_: string | undefined) => false), + isContextOverflowErrorMock: vi.fn((_: string | undefined) => false), + isLikelyContextOverflowErrorMock: vi.fn((_: string | undefined) => false), + updateSessionStoreMock: vi.fn(), })); const GENERIC_RUN_FAILURE_TEXT = @@ -87,10 +91,11 @@ vi.mock("../../agents/pi-embedded-helpers.js", () => ({ } return undefined; }, - isCompactionFailureError: () => false, - isContextOverflowError: () => false, + isCompactionFailureError: (message?: string) => state.isCompactionFailureErrorMock(message), + isContextOverflowError: (message?: string) => state.isContextOverflowErrorMock(message), isBillingErrorMessage: () => false, - isLikelyContextOverflowError: () => false, + isLikelyContextOverflowError: (message?: string) => + state.isLikelyContextOverflowErrorMock(message), isOverloadedErrorMessage: (message: string) => /overloaded|capacity/i.test(message), isRateLimitErrorMessage: (message: string) => /rate.limit|too many requests|429|usage limit/i.test(message), @@ -101,7 +106,7 @@ vi.mock("../../agents/pi-embedded-helpers.js", () => ({ vi.mock("../../config/sessions.js", () => ({ resolveGroupSessionKey: vi.fn(() => null), resolveSessionTranscriptPath: vi.fn(), - updateSessionStore: vi.fn(), + updateSessionStore: state.updateSessionStoreMock, })); vi.mock("../../globals.js", () => ({ @@ -270,10 +275,13 @@ function createFollowupRun(): FollowupRun { function createMockReplyOperation(): { replyOperation: ReplyOperation; failMock: ReturnType; + updateSessionIdMock: ReturnType; } { const failMock = vi.fn(); + const updateSessionIdMock = vi.fn(); return { failMock, + updateSessionIdMock, replyOperation: { key: "main", sessionId: "session", @@ -282,7 +290,7 @@ function createMockReplyOperation(): { phase: "running", result: null, setPhase: vi.fn(), - updateSessionId: vi.fn(), + updateSessionId: updateSessionIdMock, attachBackend: vi.fn(), detachBackend: vi.fn(), complete: vi.fn(), @@ -389,7 +397,6 @@ function createMinimalRunAgentTurnParams(overrides?: { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set>(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -410,6 +417,19 @@ describe("buildContextOverflowRecoveryText", () => { expect(text).not.toContain("heartbeat model bleed"); }); + it("keeps the preserved-session copy with the existing overflow hint", () => { + const text = buildContextOverflowRecoveryText({ + preserveSessionMapping: true, + cfg: {}, + primaryProvider: "openrouter", + primaryModel: "qwen3.6-plus", + }); + + expect(text).toContain("kept this conversation mapped to the current session"); + expect(text).toContain("reserveTokensFloor"); + expect(text).not.toContain("reset our conversation"); + }); + it("points to heartbeat model bleed when the last runtime model matches configured heartbeat.model", () => { const text = buildContextOverflowRecoveryText({ cfg: { @@ -593,6 +613,13 @@ describe("runAgentTurnWithFallback", () => { state.isInternalMessageChannelMock.mockReturnValue(false); state.createBlockReplyDeliveryHandlerMock.mockReset(); state.createBlockReplyDeliveryHandlerMock.mockReturnValue(undefined); + state.isCompactionFailureErrorMock.mockReset(); + state.isCompactionFailureErrorMock.mockReturnValue(false); + state.isContextOverflowErrorMock.mockReset(); + state.isContextOverflowErrorMock.mockReturnValue(false); + state.isLikelyContextOverflowErrorMock.mockReset(); + state.isLikelyContextOverflowErrorMock.mockReturnValue(false); + state.updateSessionStoreMock.mockReset(); state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => ({ result: await params.run("anthropic", "claude"), provider: "anthropic", @@ -1250,7 +1277,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1367,7 +1393,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1443,7 +1468,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1508,7 +1532,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1569,7 +1592,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1628,7 +1650,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1681,7 +1702,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1738,7 +1758,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1785,7 +1804,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -1952,7 +1970,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks, - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2004,7 +2021,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2049,7 +2065,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2338,7 +2353,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks, - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2385,7 +2399,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks, - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2431,7 +2444,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks, - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2484,7 +2496,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks, - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2715,7 +2726,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2761,7 +2771,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2826,7 +2835,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2883,7 +2891,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -2944,7 +2951,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3032,7 +3038,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks, - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3167,7 +3172,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3251,7 +3255,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3298,7 +3301,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3344,7 +3346,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3393,7 +3394,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3450,7 +3450,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3515,7 +3514,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3579,7 +3577,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3637,7 +3634,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3690,7 +3686,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3742,7 +3737,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3782,7 +3776,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3843,7 +3836,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3892,7 +3884,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3946,7 +3937,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -3993,7 +3983,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4032,7 +4021,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4137,7 +4125,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4367,7 +4354,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4383,6 +4369,95 @@ describe("runAgentTurnWithFallback", () => { } }); + it("preserves the active session when embedded overflow recovery fails", async () => { + state.isContextOverflowErrorMock.mockReturnValue(true); + state.runEmbeddedPiAgentMock.mockResolvedValueOnce({ + payloads: [], + meta: { + error: { + message: "400 The prompt is too long: 203557, model maximum context length: 196607", + }, + }, + }); + + const activeSessionEntry = { sessionId: "session", updatedAt: 1 } as SessionEntry; + const activeSessionStore = { "agent:main:main": activeSessionEntry }; + const { replyOperation, failMock, updateSessionIdMock } = createMockReplyOperation(); + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + ...createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "webchat", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + replyOperation, + sessionKey: "agent:main:main", + getActiveSessionEntry: () => activeSessionEntry, + activeSessionStore, + storePath: "/tmp/sessions.json", + }); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toContain("kept this conversation mapped to the current session"); + expect(result.payload.text).toContain("reserveTokensFloor"); + expectRecordFields(requireRecord(getReplyPayloadMetadata(result.payload), "reply metadata"), { + deliverDespiteSourceReplySuppression: true, + }); + } + expect(failMock).toHaveBeenCalledWith( + "run_failed", + expect.objectContaining({ + message: "400 The prompt is too long: 203557, model maximum context length: 196607", + }), + ); + expect(activeSessionStore["agent:main:main"]?.sessionId).toBe("session"); + expect(updateSessionIdMock).not.toHaveBeenCalled(); + expect(state.updateSessionStoreMock).not.toHaveBeenCalled(); + }); + + it("preserves the active session when compaction failure is thrown before reply", async () => { + state.isCompactionFailureErrorMock.mockReturnValue(true); + state.runEmbeddedPiAgentMock.mockRejectedValueOnce( + new Error("Auto-compaction failed: nothing to compact"), + ); + + const activeSessionEntry = { sessionId: "session", updatedAt: 1 } as SessionEntry; + const activeSessionStore = { "agent:main:main": activeSessionEntry }; + const { replyOperation, failMock, updateSessionIdMock } = createMockReplyOperation(); + const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const result = await runAgentTurnWithFallback({ + ...createMinimalRunAgentTurnParams({ + sessionCtx: { + Provider: "webchat", + MessageSid: "msg", + } as unknown as TemplateContext, + }), + replyOperation, + sessionKey: "agent:main:main", + getActiveSessionEntry: () => activeSessionEntry, + activeSessionStore, + storePath: "/tmp/sessions.json", + }); + + expect(result.kind).toBe("final"); + if (result.kind === "final") { + expect(result.payload.text).toContain("kept this conversation mapped to the current session"); + expect(result.payload.text).toContain("reserveTokensFloor"); + expectRecordFields(requireRecord(getReplyPayloadMetadata(result.payload), "reply metadata"), { + deliverDespiteSourceReplySuppression: true, + }); + } + expect(failMock).toHaveBeenCalledWith( + "run_failed", + expect.objectContaining({ message: "Auto-compaction failed: nothing to compact" }), + ); + expect(activeSessionStore["agent:main:main"]?.sessionId).toBe("session"); + expect(updateSessionIdMock).not.toHaveBeenCalled(); + expect(state.updateSessionStoreMock).not.toHaveBeenCalled(); + }); + it("surfaces gateway reauth guidance for known OAuth refresh failures", async () => { state.runEmbeddedPiAgentMock.mockRejectedValueOnce( new Error( @@ -4407,7 +4482,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4447,7 +4521,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4485,7 +4558,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4525,7 +4597,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4565,7 +4636,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4601,7 +4671,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4636,7 +4705,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict, isHeartbeat: false, sessionKey: "main", @@ -4675,7 +4743,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4742,7 +4809,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4797,7 +4863,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4882,7 +4947,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -4940,7 +5004,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -5003,7 +5066,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -5072,7 +5134,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -5143,7 +5204,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -5207,7 +5267,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", @@ -5274,7 +5333,6 @@ describe("runAgentTurnWithFallback", () => { shouldEmitToolResult: () => true, shouldEmitToolOutput: () => false, pendingToolTasks: new Set(), - resetSessionAfterCompactionFailure: async () => false, resetSessionAfterRoleOrderingConflict: async () => false, isHeartbeat: false, sessionKey: "main", diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 33c988ab587..924129e4b45 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -875,15 +875,18 @@ function resolveHeartbeatBleedHint(params: { export function buildContextOverflowRecoveryText(params: { duringCompaction?: boolean; + preserveSessionMapping?: boolean; cfg: FollowupRun["run"]["config"]; agentId?: string; primaryProvider?: string; primaryModel?: string; activeSessionEntry?: SessionEntry; }): string { - const prefix = params.duringCompaction - ? "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again." - : "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again."; + const prefix = params.preserveSessionMapping + ? "⚠️ Auto-compaction could not recover this turn. I kept this conversation mapped to the current session. Please try again, use /compact, or use /new to start a fresh session." + : params.duringCompaction + ? "⚠️ Context limit exceeded during compaction. I've reset our conversation to start fresh - please try again." + : "⚠️ Context limit exceeded. I've reset our conversation to start fresh - please try again."; return ( prefix + (resolveHeartbeatBleedHint({ @@ -1134,7 +1137,6 @@ export async function runAgentTurnWithFallback(params: { shouldEmitToolResult: () => boolean; shouldEmitToolOutput: () => boolean; pendingToolTasks: Set>; - resetSessionAfterCompactionFailure: (reason: string) => Promise; resetSessionAfterRoleOrderingConflict: (reason: string) => Promise; isHeartbeat: boolean; sessionKey?: string; @@ -1315,7 +1317,6 @@ export async function runAgentTurnWithFallback(params: { let fallbackProvider = params.followupRun.run.provider; let fallbackModel = params.followupRun.run.model; let fallbackAttempts: RuntimeFallbackAttempt[] = []; - let didResetAfterCompactionFailure = false; let didRetryTransientHttpError = false; let liveModelSwitchRetries = 0; let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( @@ -2231,20 +2232,19 @@ 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. + // Preserve the active session mapping and surface explicit guidance instead + // of silently rotating the session key to a new session id. const embeddedError = runResult.meta?.error; - if ( - embeddedError && - isContextOverflowError(embeddedError.message) && - !didResetAfterCompactionFailure && - (await params.resetSessionAfterCompactionFailure(embeddedError.message)) - ) { - didResetAfterCompactionFailure = true; + if (embeddedError && isContextOverflowError(embeddedError.message)) { + defaultRuntime.error( + `Auto-compaction failed (${embeddedError.message}). Preserving existing session mapping for ${params.sessionKey ?? params.followupRun.run.sessionId}.`, + ); params.replyOperation?.fail("run_failed", embeddedError); return { kind: "final", payload: markAgentRunFailureReplyPayload({ text: buildContextOverflowRecoveryText({ + preserveSessionMapping: true, cfg: runtimeConfig, agentId: params.followupRun.run.agentId, primaryProvider: params.followupRun.run.provider, @@ -2364,18 +2364,17 @@ export async function runAgentTurnWithFallback(params: { }; } - if ( - isCompactionFailure && - !didResetAfterCompactionFailure && - (await params.resetSessionAfterCompactionFailure(message)) - ) { - didResetAfterCompactionFailure = true; + if (isCompactionFailure) { + defaultRuntime.error( + `Auto-compaction failed (${message}). Preserving existing session mapping for ${params.sessionKey ?? params.followupRun.run.sessionId}.`, + ); params.replyOperation?.fail("run_failed", err); return { kind: "final", payload: markAgentRunFailureReplyPayload({ text: buildContextOverflowRecoveryText({ duringCompaction: true, + preserveSessionMapping: true, cfg: runtimeConfig, agentId: params.followupRun.run.agentId, primaryProvider: params.followupRun.run.provider, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index d08df8dff1d..fdec6991177 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -1437,12 +1437,6 @@ export async function runReplyAgent(params: { activeIsNewSession = true; }, }); - const resetSessionAfterCompactionFailure = async (reason: string): Promise => - resetSession({ - failureLabel: "compaction failure", - buildLogMessage: (nextSessionId) => - `Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`, - }); const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise => resetSession({ failureLabel: "role ordering conflict", @@ -1471,7 +1465,6 @@ export async function runReplyAgent(params: { shouldEmitToolResult, shouldEmitToolOutput, pendingToolTasks, - resetSessionAfterCompactionFailure, resetSessionAfterRoleOrderingConflict, isHeartbeat, sessionKey,