fix(agents): skip tool extraction for aborted/errored assistant messages (#4598)

Fixes tool call/tool_result pairing issues that cause permanent session corruption when assistant messages have stopReason "error" or "aborted". Includes 4 unit tests.
main
Aisling Cahill 2026-02-06 01:08:46 +01:00 committed by GitHub
parent de7b2ba7d5
commit 861725fba1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 108 additions and 0 deletions

View File

@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
sanitizeToolCallInputs,
sanitizeToolUseResultPairing,
repairToolUseResultPairing,
} from "./session-transcript-repair.js";
describe("sanitizeToolUseResultPairing", () => {
@ -112,6 +113,100 @@ describe("sanitizeToolUseResultPairing", () => {
expect(out.some((m) => m.role === "toolResult")).toBe(false);
expect(out.map((m) => m.role)).toEqual(["user", "assistant"]);
});
it("skips tool call extraction for assistant messages with stopReason 'error'", () => {
// When an assistant message has stopReason: "error", its tool_use blocks may be
// incomplete/malformed. We should NOT create synthetic tool_results for them,
// as this causes API 400 errors: "unexpected tool_use_id found in tool_result blocks"
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_error", name: "exec", arguments: {} }],
stopReason: "error",
},
{ role: "user", content: "something went wrong" },
] as AgentMessage[];
const result = repairToolUseResultPairing(input);
// Should NOT add synthetic tool results for errored messages
expect(result.added).toHaveLength(0);
// The assistant message should be passed through unchanged
expect(result.messages[0]?.role).toBe("assistant");
expect(result.messages[1]?.role).toBe("user");
expect(result.messages).toHaveLength(2);
});
it("skips tool call extraction for assistant messages with stopReason 'aborted'", () => {
// When a request is aborted mid-stream, the assistant message may have incomplete
// tool_use blocks (with partialJson). We should NOT create synthetic tool_results.
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "Bash", arguments: {} }],
stopReason: "aborted",
},
{ role: "user", content: "retrying after abort" },
] as AgentMessage[];
const result = repairToolUseResultPairing(input);
// Should NOT add synthetic tool results for aborted messages
expect(result.added).toHaveLength(0);
// Messages should be passed through without synthetic insertions
expect(result.messages).toHaveLength(2);
expect(result.messages[0]?.role).toBe("assistant");
expect(result.messages[1]?.role).toBe("user");
});
it("still repairs tool results for normal assistant messages with stopReason 'toolUse'", () => {
// Normal tool calls (stopReason: "toolUse" or "stop") should still be repaired
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_normal", name: "read", arguments: {} }],
stopReason: "toolUse",
},
{ role: "user", content: "user message" },
] as AgentMessage[];
const result = repairToolUseResultPairing(input);
// Should add a synthetic tool result for the missing result
expect(result.added).toHaveLength(1);
expect(result.added[0]?.toolCallId).toBe("call_normal");
});
it("drops orphan tool results that follow an aborted assistant message", () => {
// When an assistant message is aborted, any tool results that follow should be
// dropped as orphans (since we skip extracting tool calls from aborted messages).
// This addresses the edge case where a partial tool result was persisted before abort.
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: "call_aborted", name: "exec", arguments: {} }],
stopReason: "aborted",
},
{
role: "toolResult",
toolCallId: "call_aborted",
toolName: "exec",
content: [{ type: "text", text: "partial result" }],
isError: false,
},
{ role: "user", content: "retrying" },
] as AgentMessage[];
const result = repairToolUseResultPairing(input);
// The orphan tool result should be dropped
expect(result.droppedOrphanCount).toBe(1);
expect(result.messages).toHaveLength(2);
expect(result.messages[0]?.role).toBe("assistant");
expect(result.messages[1]?.role).toBe("user");
// No synthetic results should be added
expect(result.added).toHaveLength(0);
});
});
describe("sanitizeToolCallInputs", () => {

View File

@ -213,6 +213,19 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
}
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
// Skip tool call extraction for aborted or errored assistant messages.
// When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
// (e.g., partialJson: true) and should not have synthetic tool_results created.
// Creating synthetic results for incomplete tool calls causes API 400 errors:
// "unexpected tool_use_id found in tool_result blocks"
// See: https://github.com/openclaw/openclaw/issues/4597
const stopReason = (assistant as { stopReason?: string }).stopReason;
if (stopReason === "error" || stopReason === "aborted") {
out.push(msg);
continue;
}
const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) {
out.push(msg);