From 9353ce95d9365481dbf4683317b8f67f744150fb Mon Sep 17 00:00:00 2001 From: chesterbella <39539841+chesterbella@users.noreply.github.com> Date: Fri, 30 Jan 2026 10:06:15 +0100 Subject: [PATCH] fix: drop errored assistant tool calls and their orphan tool_results When an assistant message has stopReason='error' (e.g. JSON parse failure mid-stream) and contains tool_use blocks, the provider-level transform (pi-ai) drops the entire assistant message. However, the matching tool_results survive in the transcript, creating orphan references that cause Anthropic API rejections: 'unexpected tool_use_id found in tool_result blocks: . Each tool_result block must have a corresponding tool_use block in the previous message.' This fix adds defence-in-depth to repairToolUseResultPairing(): when an assistant message has stopReason='error' or 'aborted' and contains tool calls, both the assistant and its matching tool_results are dropped from the sanitised output. Note: the upstream pi-ai transform-messages.ts has the same gap - when it skips errored assistants it should also skip their tool_results. That fix should be contributed separately to @mariozechner/pi-ai. Closes #TBD --- src/agents/session-transcript-repair.test.ts | 42 ++++++++++++++++++++ src/agents/session-transcript-repair.ts | 41 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index ccc63ec7f..34210bd57 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -89,6 +89,48 @@ describe("sanitizeToolUseResultPairing", () => { expect(results[0]?.toolCallId).toBe("call_1"); }); + it("drops errored assistant with tool calls and their matching tool results", () => { + const input = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_err", name: "cron", arguments: {} }], + stopReason: "error", + errorMessage: "JSON parse error", + }, + { + role: "toolResult", + toolCallId: "call_err", + toolName: "cron", + content: [{ type: "text", text: "synthetic repair" }], + isError: true, + }, + { role: "user", content: "fix this" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + // The errored assistant and its tool_result should both be dropped + expect(out.map((m) => m.role)).toEqual(["user", "user"]); + expect(out).toHaveLength(2); + }); + + it("keeps errored assistant without tool calls", () => { + const input = [ + { role: "user", content: "do something" }, + { + role: "assistant", + content: [{ type: "text", text: "partial response" }], + stopReason: "error", + errorMessage: "timeout", + }, + { role: "user", content: "try again" }, + ] satisfies AgentMessage[]; + + const out = sanitizeToolUseResultPairing(input); + expect(out).toHaveLength(3); + expect(out[1]?.role).toBe("assistant"); + }); + it("drops orphan tool results that do not match any tool call", () => { const input = [ { role: "user", content: "hello" }, diff --git a/src/agents/session-transcript-repair.ts b/src/agents/session-transcript-repair.ts index 2728bb89c..780e54237 100644 --- a/src/agents/session-transcript-repair.ts +++ b/src/agents/session-transcript-repair.ts @@ -116,6 +116,47 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep } const assistant = msg as Extract; + + // Drop errored/aborted assistant messages that contain tool calls, along with + // their matching tool_results. The provider-level transform (pi-ai) also drops + // these assistants, but if the tool_results survive they become orphans that + // cause "unexpected tool_use_id" API rejections. Defence-in-depth: strip them + // here so downstream transforms never see the mismatch. + if ( + (assistant as { stopReason?: unknown }).stopReason === "error" || + (assistant as { stopReason?: unknown }).stopReason === "aborted" + ) { + const erroredToolCalls = extractToolCallsFromAssistant(assistant); + if (erroredToolCalls.length > 0) { + const erroredIds = new Set(erroredToolCalls.map((t) => t.id)); + // Skip ahead past any matching tool_results for this errored assistant + let j = i + 1; + for (; j < messages.length; j += 1) { + const next = messages[j] as AgentMessage; + if (!next || typeof next !== "object") continue; + const nextRole = (next as { role?: unknown }).role; + if (nextRole === "assistant") break; + if (nextRole === "toolResult") { + const id = extractToolResultId( + next as Extract, + ); + if (id && erroredIds.has(id)) { + // Drop the orphan tool_result that matched the errored assistant + changed = true; + continue; + } + } + out.push(next); + } + i = j - 1; + changed = true; + continue; + } + // Errored assistant without tool calls - keep it + out.push(msg); + continue; + } + const toolCalls = extractToolCallsFromAssistant(assistant); if (toolCalls.length === 0) { out.push(msg);