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,89 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 interrupted = (id: string): Block => ({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: "tool_result", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tool_use_id: id, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content: "Tool execution was interrupted before completion.", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| is_error: true, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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"), interrupted("B"), text("next")]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it("preserves string content in the user turn after a tool_use", () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const out = normalizeToolResultPairing([assistant(use("A")), { role: "user", content: "hi" }]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(out[1].content).toEqual([interrupted("A"), text("hi")]) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+38
to
+48
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. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '1,125p' src/core/task/normalizeToolResultPairing.ts
sed -n '1,145p' src/core/task/__tests__/normalizeToolResultPairing.spec.ts
rg -n 'content: "hi"|content: "[^"]+"|normalizeToolResultPairing' src/core/task/__tests__ src/core/task/Task.tsRepository: Zoo-Code-Org/Zoo-Code Length of output: 21231 🏁 Script executed: set -e
printf '%s\n' '--- ApiMessage and merge definitions ---'
rg -n -F --glob '*.ts' -e 'export type ApiMessage' -e 'type ApiMessage' -e 'interface ApiMessage' -e 'mergeConsecutiveApiMessages' src/core/task src | head -80
printf '%s\n' '--- relevant declarations/callers ---'
rg -n -F --glob '*.ts' -e 'normalizeToolResultPairing(' -e 'mergeConsecutiveApiMessages(' src/core/task
printf '%s\n' '--- caller context ---'
sed -n '5815,5870p' src/core/task/Task.ts
printf '%s\n' '--- ApiMessage definition context ---'
api_file=$(rg -l -F --glob '*.ts' -e 'export type ApiMessage' -e 'type ApiMessage' -e 'interface ApiMessage' src/core/task src | head -1)
if [ -n "$api_file" ]; then
echo "$api_file"
rg -n -F -C 12 --glob '*.ts' -e 'export type ApiMessage' -e 'type ApiMessage' -e 'interface ApiMessage' "$api_file"
fi
printf '%s\n' '--- focused tests for string content and tool-use adjacency ---'
rg -n -F --glob '*.{spec,test}.ts' -e 'assistant(use(' -e 'tool_use' -e 'normalizeToolResultPairing' -e 'content: "hi"' src/core/task/__tests__ || test "$?" -eq 1Repository: Zoo-Code-Org/Zoo-Code Length of output: 44413 🏁 Script executed: set -e
printf '%s\n' '--- ApiMessage declaration ---'
sed -n '1,38p' src/core/task-persistence/apiMessages.ts
printf '%s\n' '--- merge implementation ---'
sed -n '1,110p' src/core/task/mergeConsecutiveApiMessages.ts
printf '%s\n' '--- merge tests ---'
sed -n '1,85p' src/core/task/__tests__/mergeConsecutiveApiMessages.spec.tsRepository: 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 🧪 Suggested test+ it("preserves string content in the user turn after a tool_use", () => {
+ const out = normalizeToolResultPairing([assistant(use("A")), { role: "user", content: "hi" }])
+
+ expect(out[1].content).toEqual([
+ { type: "tool_result", tool_use_id: "A", content: "Tool execution was interrupted before completion." },
+ text("hi"),
+ ])
+ })📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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,83 @@ | ||
| 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 interruptedResult(toolUseId: string): ToolResultBlock { | ||
| return { type: "tool_result", tool_use_id: toolUseId, content: INTERRUPTED_RESULT, is_error: true } | ||
| } | ||
|
|
||
| function toBlocks(content: ApiMessage["content"]): Block[] { | ||
| if (Array.isArray(content)) return content as Block[] | ||
| if (content === undefined || content === null || content === "") return [] | ||
|
Check warning on line 16 in src/core/task/normalizeToolResultPairing.ts
|
||
| return [{ type: "text", text: String(content) }] | ||
| } | ||
|
|
||
| function toolUseIdsOf(message: ApiMessage): string[] { | ||
| if (message.role !== "assistant" || !Array.isArray(message.content)) return [] | ||
|
Check warning on line 21 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 50 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 53 in src/core/task/normalizeToolResultPairing.ts
|
||
| resultsById.set(block.tool_use_id, block) | ||
| } | ||
| } | ||
|
|
||
| // is_error keeps an interrupted call (e.g. attempt_completion) from reading as a success, | ||
| // matching the synthetic results written by Task.resumeTaskFromHistory. | ||
| const results: ToolResultBlock[] = toolUseIds.map((id) => resultsById.get(id) ?? interruptedResult(id)) | ||
|
|
||
| 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")) { | ||
|
Check warning on line 74 in src/core/task/normalizeToolResultPairing.ts
|
||
| out.push(message) | ||
| continue | ||
| } | ||
| const kept = (message.content as Block[]).filter((block) => block.type !== "tool_result") | ||
|
Check warning on line 78 in src/core/task/normalizeToolResultPairing.ts
|
||
| if (kept.length > 0) out.push({ ...message, content: kept }) | ||
| } | ||
|
|
||
| return out | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.