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:
parent
6af205a13a
commit
9353ce95d9
@ -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" },
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user