From 6bf5e76be6669d8ad14144a36816a650e3a52a39 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 21 Feb 2026 23:47:06 -0800 Subject: [PATCH] Agents: drop stale pre-compaction usage snapshots --- CHANGELOG.md | 1 + ...ed-runner.sanitize-session-history.test.ts | 96 +++++++++++++++++++ src/agents/pi-embedded-runner/google.ts | 35 ++++++- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8712622dca..38235499463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. - Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry. +- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson. - Agents/Diagnostics: include resolved lifecycle error text in `embedded run agent end` warnings so UI/TUI “Connection error” runs expose actionable provider failure reasons in gateway logs. (#23054) Thanks @Raize. - Plugins/Hooks: run legacy `before_agent_start` once per agent turn and reuse that result across model-resolve and prompt-build compatibility paths, preventing duplicate hook side effects (for example duplicate external API calls). (#23289) Thanks @ksato8710. - Models/Config: default missing Anthropic provider/model `api` fields to `anthropic-messages` during config validation so custom relay model entries are preserved instead of being dropped by runtime model registry validation. (#23332) Thanks @bigbigmonkey123. diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 44b1ef0b11e..d2acc54fba5 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -158,6 +158,102 @@ describe("sanitizeSessionHistory", () => { expect(first.content as string).toContain("sourceSession=agent:main:req"); }); + it("drops stale assistant usage snapshots kept before latest compaction summary", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const messages = [ + { role: "user", content: "old context" }, + { + role: "assistant", + content: [{ type: "text", text: "old answer" }], + stopReason: "stop", + usage: { + input: 191_919, + output: 2_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 193_919, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + { + role: "compactionSummary", + summary: "compressed", + tokensBefore: 191_919, + timestamp: new Date().toISOString(), + }, + ] as unknown as AgentMessage[]; + + const result = await sanitizeSessionHistory({ + messages, + modelApi: "openai-responses", + provider: "openai", + sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, + }); + + const staleAssistant = result.find((message) => message.role === "assistant") as + | (AgentMessage & { usage?: unknown }) + | undefined; + expect(staleAssistant).toBeDefined(); + expect(staleAssistant?.usage).toBeUndefined(); + }); + + it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => { + vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + + const messages = [ + { + role: "assistant", + content: [{ type: "text", text: "pre-compaction answer" }], + stopReason: "stop", + usage: { + input: 120_000, + output: 3_000, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 123_000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + { + role: "compactionSummary", + summary: "compressed", + tokensBefore: 123_000, + timestamp: new Date().toISOString(), + }, + { role: "user", content: "new question" }, + { + role: "assistant", + content: [{ type: "text", text: "fresh answer" }], + stopReason: "stop", + usage: { + input: 1_000, + output: 250, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 1_250, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + ] as unknown as AgentMessage[]; + + const result = await sanitizeSessionHistory({ + messages, + modelApi: "openai-responses", + provider: "openai", + sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, + }); + + const assistants = result.filter((message) => message.role === "assistant") as Array< + AgentMessage & { usage?: unknown } + >; + expect(assistants).toHaveLength(2); + expect(assistants[0]?.usage).toBeUndefined(); + expect(assistants[1]?.usage).toBeDefined(); + }); + it("keeps reasoning-only assistant messages for openai-responses", async () => { setNonGoogleModelApi(); diff --git a/src/agents/pi-embedded-runner/google.ts b/src/agents/pi-embedded-runner/google.ts index 544d45f291a..231c55de34d 100644 --- a/src/agents/pi-embedded-runner/google.ts +++ b/src/agents/pi-embedded-runner/google.ts @@ -214,6 +214,35 @@ function annotateInterSessionUserMessages(messages: AgentMessage[]): AgentMessag return touched ? out : messages; } +function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]): AgentMessage[] { + let latestCompactionSummaryIndex = -1; + for (let i = 0; i < messages.length; i += 1) { + if (messages[i]?.role === "compactionSummary") { + latestCompactionSummaryIndex = i; + } + } + if (latestCompactionSummaryIndex <= 0) { + return messages; + } + + const out = [...messages]; + let touched = false; + for (let i = 0; i < latestCompactionSummaryIndex; i += 1) { + const candidate = out[i] as (AgentMessage & { usage?: unknown }) | undefined; + if (!candidate || candidate.role !== "assistant") { + continue; + } + if (!candidate.usage || typeof candidate.usage !== "object") { + continue; + } + const candidateRecord = candidate as unknown as Record; + const { usage: _droppedUsage, ...rest } = candidateRecord; + out[i] = rest as unknown as AgentMessage; + touched = true; + } + return touched ? out : messages; +} + function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] { if (!schema || typeof schema !== "object") { return []; @@ -466,6 +495,8 @@ export async function sanitizeSessionHistory(params: { ? sanitizeToolUseResultPairing(sanitizedToolCalls) : sanitizedToolCalls; const sanitizedToolResults = stripToolResultDetails(repairedTools); + const sanitizedCompactionUsage = + stripStaleAssistantUsageBeforeLatestCompaction(sanitizedToolResults); const isOpenAIResponsesApi = params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"; @@ -480,8 +511,8 @@ export async function sanitizeSessionHistory(params: { }) : false; const sanitizedOpenAI = isOpenAIResponsesApi - ? downgradeOpenAIReasoningBlocks(sanitizedToolResults) - : sanitizedToolResults; + ? downgradeOpenAIReasoningBlocks(sanitizedCompactionUsage) + : sanitizedCompactionUsage; if (hasSnapshot && (!priorSnapshot || modelChanged)) { appendModelSnapshot(params.sessionManager, {