diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9c1efc235..6b0876596fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,7 @@ Docs: https://docs.openclaw.ai - Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk. - Plugins/subagents: preserve gateway-owned plugin subagent access across runtime, tool, and embedded-runner load paths so gateway plugin tools and context engines can still spawn and manage subagents after the loader cache split. (#46648) Thanks @jalehman. - Control UI/overview: keep the language dropdown aligned with the persisted locale during dashboard startup so refreshing the page does not fall back to English before locale hydration completes. (#48019) Thanks @git-jxj. +- Agents/compaction: rerun transcript repair after `session.compact()` so orphaned `tool_result` blocks cannot survive compaction and break later Anthropic requests. (#16095) thanks @claw-sylphx. ## 2026.3.13 diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ba001a6746a..ea6bc0c5299 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -64,7 +64,10 @@ import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { repairSessionFileIfNeeded } from "../session-file-repair.js"; import { guardSessionManager } from "../session-tool-result-guard-wrapper.js"; -import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js"; +import { + repairToolUseResultPairing, + sanitizeToolUseResultPairing, +} from "../session-transcript-repair.js"; import { acquireSessionWriteLock, resolveSessionLockMaxHoldFromTimeout, @@ -954,6 +957,22 @@ export async function compactEmbeddedPiSessionDirect( }, }, ); + // Re-run tool_use/tool_result pairing repair after compaction. + // Compaction can remove assistant messages containing tool_use blocks + // while leaving orphaned tool_result blocks behind, which causes + // Anthropic API 400 errors: "unexpected tool_use_id found in tool_result blocks". + // See: https://github.com/openclaw/openclaw/issues/15691 + if (transcriptPolicy.repairToolUseResultPairing) { + const postCompactRepair = repairToolUseResultPairing(session.messages); + if (postCompactRepair.droppedOrphanCount > 0 || postCompactRepair.moved) { + session.agent.replaceMessages(postCompactRepair.messages); + log.info( + `[compaction] post-compact repair: dropped ${postCompactRepair.droppedOrphanCount} orphaned tool_result(s), ` + + `${postCompactRepair.droppedDuplicateCount} duplicate(s) ` + + `(sessionKey=${params.sessionKey ?? params.sessionId})`, + ); + } + } await runPostCompactionSideEffects({ config: params.config, sessionKey: params.sessionKey, diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index eea82268d7d..6ed5fdedb73 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -488,3 +488,143 @@ describe("stripToolResultDetails", () => { expect(out).toBe(input); }); }); + +describe("post-compaction orphaned tool_result removal (#15691)", () => { + it("drops orphaned tool_result blocks left after compaction removes tool_use messages", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "text", text: "Here is a summary of our earlier conversation..." }], + }, + { + role: "toolResult", + toolCallId: "toolu_compacted_1", + toolName: "Read", + content: [{ type: "text", text: "file contents" }], + isError: false, + }, + { + role: "toolResult", + toolCallId: "toolu_compacted_2", + toolName: "exec", + content: [{ type: "text", text: "command output" }], + isError: false, + }, + { role: "user", content: "now do something else" }, + { + role: "assistant", + content: [ + { type: "text", text: "I'll read that file" }, + { type: "toolCall", id: "toolu_active_1", name: "Read", arguments: { path: "foo.ts" } }, + ], + }, + { + role: "toolResult", + toolCallId: "toolu_active_1", + toolName: "Read", + content: [{ type: "text", text: "actual content" }], + isError: false, + }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(2); + const toolResults = result.messages.filter((message) => message.role === "toolResult"); + expect(toolResults).toHaveLength(1); + expect((toolResults[0] as { toolCallId?: string }).toolCallId).toBe("toolu_active_1"); + expect(result.messages.map((message) => message.role)).toEqual([ + "assistant", + "user", + "assistant", + "toolResult", + ]); + }); + + it("handles synthetic tool_result from interrupted request after compaction", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "text", text: "Compaction summary of previous conversation." }], + }, + { + role: "toolResult", + toolCallId: "toolu_interrupted", + toolName: "unknown", + content: [ + { + type: "text", + text: "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.", + }, + ], + isError: true, + }, + { role: "user", content: "continue please" }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(1); + expect(result.messages.some((message) => message.role === "toolResult")).toBe(false); + expect(result.messages.map((message) => message.role)).toEqual(["assistant", "user"]); + }); + + it("preserves valid tool_use/tool_result pairs while removing orphans", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [ + { type: "toolCall", id: "toolu_valid", name: "Read", arguments: { path: "a.ts" } }, + ], + }, + { + role: "toolResult", + toolCallId: "toolu_valid", + toolName: "Read", + content: [{ type: "text", text: "content of a.ts" }], + isError: false, + }, + { role: "user", content: "thanks, what about b.ts?" }, + { + role: "toolResult", + toolCallId: "toolu_gone", + toolName: "Read", + content: [{ type: "text", text: "content of old file" }], + isError: false, + }, + { + role: "assistant", + content: [{ type: "text", text: "Let me check b.ts" }], + }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(1); + const toolResults = result.messages.filter((message) => message.role === "toolResult"); + expect(toolResults).toHaveLength(1); + expect((toolResults[0] as { toolCallId?: string }).toolCallId).toBe("toolu_valid"); + }); + + it("returns original array when no orphans exist", () => { + const input = castAgentMessages([ + { + role: "assistant", + content: [{ type: "toolCall", id: "toolu_1", name: "Read", arguments: { path: "x.ts" } }], + }, + { + role: "toolResult", + toolCallId: "toolu_1", + toolName: "Read", + content: [{ type: "text", text: "ok" }], + isError: false, + }, + { role: "user", content: "good" }, + ]); + + const result = repairToolUseResultPairing(input); + + expect(result.droppedOrphanCount).toBe(0); + expect(result.messages).toStrictEqual(input); + }); +});