fix: remove orphaned tool_result blocks during compaction (#15691) (#16095)

Merged via squash.

Prepared head SHA: b772432c1f
Co-authored-by: claw-sylphx <260243939+claw-sylphx@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
Clayton Shaw
2026-03-16 22:57:45 +00:00
committed by GitHub
parent 313e5bb58b
commit 6ba4d0ddc3
3 changed files with 161 additions and 1 deletions

View File

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

View File

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

View File

@@ -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);
});
});