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 193d3c0fdd.
- Required merge gates passed before the squash merge.

Prepared head SHA: 193d3c0fdd
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>
This commit is contained in:
Jason (Json)
2026-05-21 23:04:41 -06:00
committed by GitHub
parent 93c613cec4
commit cd1cae5be9
5 changed files with 147 additions and 91 deletions

View File

@@ -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.<name>:<index>`) 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.

View File

@@ -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`

View File

@@ -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<typeof vi.fn>;
updateSessionIdMock: ReturnType<typeof vi.fn>;
} {
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<Promise<void>>(),
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",

View File

@@ -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<Promise<void>>;
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
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,

View File

@@ -1437,12 +1437,6 @@ export async function runReplyAgent(params: {
activeIsNewSession = true;
},
});
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> =>
resetSession({
failureLabel: "compaction failure",
buildLogMessage: (nextSessionId) =>
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
});
const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise<boolean> =>
resetSession({
failureLabel: "role ordering conflict",
@@ -1471,7 +1465,6 @@ export async function runReplyAgent(params: {
shouldEmitToolResult,
shouldEmitToolOutput,
pendingToolTasks,
resetSessionAfterCompactionFailure,
resetSessionAfterRoleOrderingConflict,
isHeartbeat,
sessionKey,