mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-18 21:40:53 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user