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: <id>.
   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
This commit is contained in:
chesterbella 2026-01-30 10:06:15 +01:00
parent 6af205a13a
commit 9353ce95d9
2 changed files with 83 additions and 0 deletions

View File

@ -89,6 +89,48 @@ describe("sanitizeToolUseResultPairing", () => {
expect(results[0]?.toolCallId).toBe("call_1"); 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", () => { it("drops orphan tool results that do not match any tool call", () => {
const input = [ const input = [
{ role: "user", content: "hello" }, { role: "user", content: "hello" },

View File

@ -116,6 +116,47 @@ export function repairToolUseResultPairing(messages: AgentMessage[]): ToolUseRep
} }
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>; const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
// 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<AgentMessage, { role: "toolResult" }>,
);
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); const toolCalls = extractToolCallsFromAssistant(assistant);
if (toolCalls.length === 0) { if (toolCalls.length === 0) {
out.push(msg); out.push(msg);