Repository navigation
[Fix] Anthropic tool_use / tool_result pairing errors (400) #2113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import type { Anthropic } from "@anthropic-ai/sdk" | ||
|
|
||
| import type { ApiMessage } from "../../task-persistence" | ||
| import { normalizeToolResultPairing } from "../normalizeToolResultPairing" | ||
|
|
||
| type Block = Anthropic.Messages.ContentBlockParam | ||
|
|
||
| const use = (id: string): Block => ({ type: "tool_use", id, name: "read_file", input: {} }) | ||
| const result = (id: string, content = `result ${id}`): Block => ({ type: "tool_result", tool_use_id: id, content }) | ||
| const text = (value: string): Block => ({ type: "text", text: value }) | ||
| const image: Block = { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } } | ||
| const assistant = (...content: Block[]): ApiMessage => ({ role: "assistant", content }) | ||
| const user = (...content: Block[]): ApiMessage => ({ role: "user", content }) | ||
|
|
||
| describe("normalizeToolResultPairing", () => { | ||
| it("moves interleaved tool results ahead of other content", () => { | ||
| const input = [ | ||
| user(text("go")), | ||
| assistant(text("ok"), use("A"), use("B")), | ||
| user(result("A"), image, result("B")), | ||
| ] | ||
|
|
||
| expect(normalizeToolResultPairing(input)[2].content).toEqual([result("A"), result("B"), image]) | ||
| }) | ||
|
|
||
| it("orders results by tool_use order and drops duplicates", () => { | ||
| const input = [assistant(use("A"), use("B")), user(result("B"), result("A"), result("A", "dup"))] | ||
|
|
||
| expect(normalizeToolResultPairing(input)[1].content).toEqual([result("A"), result("B")]) | ||
| }) | ||
|
|
||
| it("adds a synthetic error result for an interrupted tool_use", () => { | ||
| const input = [assistant(use("A"), use("B")), user(result("A"), text("next"))] | ||
|
|
||
| expect(normalizeToolResultPairing(input)[1].content).toEqual([ | ||
| result("A"), | ||
| { type: "tool_result", tool_use_id: "B", content: "Tool execution was interrupted before completion." }, | ||
| text("next"), | ||
| ]) | ||
| }) | ||
|
|
||
| it("inserts a user turn when an assistant turn is followed by another assistant turn", () => { | ||
| const out = normalizeToolResultPairing([assistant(use("A")), assistant(text("again"))]) | ||
|
|
||
| expect(out.map((m) => m.role)).toEqual(["assistant", "user", "assistant"]) | ||
| expect(out[1].content).toMatchObject([{ type: "tool_result", tool_use_id: "A" }]) | ||
| }) | ||
|
|
||
| it.each([ | ||
| { name: "result for an unknown id", messages: [assistant(use("A")), user(result("A"), result("X"))] }, | ||
| { | ||
| name: "result after a text-only assistant turn", | ||
| messages: [assistant(text("hi")), user(result("X"), text("q"))], | ||
| }, | ||
| ])("drops orphan tool results: $name", ({ messages }) => { | ||
| const ids = normalizeToolResultPairing(messages) | ||
| .flatMap((m) => (Array.isArray(m.content) ? m.content : [])) | ||
| .flatMap((b) => (b.type === "tool_result" ? [b.tool_use_id] : [])) | ||
|
|
||
| expect(ids).not.toContain("X") | ||
| }) | ||
|
|
||
| it("removes a user message that only held orphan results", () => { | ||
| expect(normalizeToolResultPairing([assistant(text("hi")), user(result("X"))])).toHaveLength(1) | ||
| }) | ||
|
|
||
| it("leaves a trailing assistant tool_use and well-formed history unchanged", () => { | ||
| const wellFormed = [user(text("go")), assistant(use("A")), user(result("A"), text("done")), assistant(use("B"))] | ||
|
|
||
| expect(normalizeToolResultPairing(wellFormed)).toEqual(wellFormed) | ||
| }) | ||
|
|
||
| it("does not mutate its input", () => { | ||
| const input = [assistant(use("A")), user(image, result("A"))] | ||
| const snapshot = structuredClone(input) | ||
|
|
||
| normalizeToolResultPairing(input) | ||
|
|
||
| expect(input).toEqual(snapshot) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,79 @@ | ||||||||||||||||||||||||||
| import { Anthropic } from "@anthropic-ai/sdk" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import type { ApiMessage } from "../task-persistence" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| type Block = Anthropic.Messages.ContentBlockParam | ||||||||||||||||||||||||||
| type ToolResultBlock = Anthropic.Messages.ToolResultBlockParam | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const INTERRUPTED_RESULT = "Tool execution was interrupted before completion." | ||||||||||||||||||||||||||
|
Check warning on line 8 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function toBlocks(content: ApiMessage["content"]): Block[] { | ||||||||||||||||||||||||||
| if (Array.isArray(content)) return content as Block[] | ||||||||||||||||||||||||||
|
Check warning on line 11 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
| if (content === undefined || content === null || content === "") return [] | ||||||||||||||||||||||||||
|
Check warning on line 12 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
| return [{ type: "text", text: String(content) }] | ||||||||||||||||||||||||||
|
Check warning on line 13 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function toolUseIdsOf(message: ApiMessage): string[] { | ||||||||||||||||||||||||||
| if (message.role !== "assistant" || !Array.isArray(message.content)) return [] | ||||||||||||||||||||||||||
|
Check warning on line 17 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
| return (message.content as Block[]).flatMap((block) => (block.type === "tool_use" ? [block.id] : [])) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Shapes outgoing history so tool results satisfy Anthropic's adjacency rule: every | ||||||||||||||||||||||||||
| * `tool_use` in an assistant message is answered by a `tool_result` in the very next | ||||||||||||||||||||||||||
| * user message, `tool_result` blocks come first, and no `tool_result` refers to a | ||||||||||||||||||||||||||
| * `tool_use` outside the immediately preceding assistant message. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * - Results are moved ahead of other content, in `tool_use` order (duplicates dropped). | ||||||||||||||||||||||||||
| * - Results without a matching `tool_use` are dropped. | ||||||||||||||||||||||||||
| * - Interrupted calls get a synthetic error result. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Used for API request shaping only; stored history is not mutated. A trailing assistant | ||||||||||||||||||||||||||
| * message is left alone because its results have not been produced yet. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export function normalizeToolResultPairing(messages: ApiMessage[]): ApiMessage[] { | ||||||||||||||||||||||||||
| const out: ApiMessage[] = [] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| for (let i = 0; i < messages.length; i++) { | ||||||||||||||||||||||||||
| const message = messages[i] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (message.role === "assistant") { | ||||||||||||||||||||||||||
| out.push(message) | ||||||||||||||||||||||||||
| const toolUseIds = toolUseIdsOf(message) | ||||||||||||||||||||||||||
| if (toolUseIds.length === 0 || i === messages.length - 1) continue | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const next = messages[i + 1] | ||||||||||||||||||||||||||
| const nextBlocks = next.role === "user" ? toBlocks(next.content) : [] | ||||||||||||||||||||||||||
|
Check warning on line 46 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
| const resultsById = new Map<string, ToolResultBlock>() | ||||||||||||||||||||||||||
| for (const block of nextBlocks) { | ||||||||||||||||||||||||||
| if (block.type === "tool_result" && !resultsById.has(block.tool_use_id)) { | ||||||||||||||||||||||||||
|
Check warning on line 49 in src/core/task/normalizeToolResultPairing.ts
|
||||||||||||||||||||||||||
| resultsById.set(block.tool_use_id, block) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const results: ToolResultBlock[] = toolUseIds.map( | ||||||||||||||||||||||||||
| (id) => resultsById.get(id) ?? { type: "tool_result", tool_use_id: id, content: INTERRUPTED_RESULT }, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
Comment on lines
+54
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Mark synthetic interrupted results with The synthetic Trigger: an assistant turn calls Add 🐛 Proposed fix--- "a/src/core/task/normalizeToolResultPairing.ts"
+++ "b/src/core/task/normalizeToolResultPairing.ts"
@@ -51,9 +51,15 @@
}
}
const results: ToolResultBlock[] = toolUseIds.map(
- (id) => resultsById.get(id) ?? { type: "tool_result", tool_use_id: id, content: INTERRUPTED_RESULT },
+ (id) =>
+ resultsById.get(id) ?? {
+ type: "tool_result",
+ tool_use_id: id,
+ content: INTERRUPTED_RESULT,
+ is_error: true,
+ },
)
if (next.role === "user") {
const rest = nextBlocks.filter((block) => block.type !== "tool_result")📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (next.role === "user") { | ||||||||||||||||||||||||||
| const rest = nextBlocks.filter((block) => block.type !== "tool_result") | ||||||||||||||||||||||||||
| out.push({ ...next, content: [...results, ...rest] }) | ||||||||||||||||||||||||||
| i++ | ||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||
| // Another assistant message follows: answer the calls with a user turn of their own. | ||||||||||||||||||||||||||
| out.push({ role: "user", content: results }) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // A user message not directly after a tool_use turn cannot carry tool results. | ||||||||||||||||||||||||||
| if (!Array.isArray(message.content) || !message.content.some((block) => block.type === "tool_result")) { | ||||||||||||||||||||||||||
| out.push(message) | ||||||||||||||||||||||||||
| continue | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| const kept = (message.content as Block[]).filter((block) => block.type !== "tool_result") | ||||||||||||||||||||||||||
| if (kept.length > 0) out.push({ ...message, content: kept }) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return out | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 21231
🏁 Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 44413
🏁 Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 5341
Add the required boundary regression test for string-content user turns.
A string-content user turn can follow an assistant
tool_use.toBlocksconverts it to a text block before inserting the synthetic result. The current test uses array content only, so it does not protect this branch. Add the focused test below. Do not addis_erroruntil the production implementation adds that field.🧪 Suggested test
📝 Committable suggestion
🤖 Prompt for AI Agents