diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 5366155182..6eb8085be0 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1,4 +1,5 @@ import type { IncomingMeta, ProviderAdapter } from "./base"; +import { createToolCallIdAllocator, type ToolCallIdAllocator } from "./tool-call-id"; import { debugDroppedFrame } from "../lib/debug"; import type { AdapterEvent, @@ -559,7 +560,7 @@ function buildToolNameTransforms(provider: OcxProviderConfig): { toWire: (name: return { toWire: (name) => name, fromWire: (name) => name }; } -function toAnthropicToolResult(msg: OcxToolResultMessage): Record { +function toAnthropicToolResult(msg: OcxToolResultMessage, wireCallId: string): Record { // Anthropic tool_result accepts a string OR content blocks — render images natively // (e.g. Codex view_image output) instead of dropping them. let content: string | unknown[]; @@ -574,12 +575,17 @@ function toAnthropicToolResult(msg: OcxToolResultMessage): Record string }, ): { system: string | undefined; messages: unknown[] } { + // One allocator for the whole request: a tool_result must resolve to the SAME wire id its + // call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed + // first so a rewritten id can never squat on an id another call legitimately owns. + const callIds = createToolCallIdAllocator(); + for (const message of parsed.context.messages) { + if (message.role === "assistant") { + for (const part of (message as OcxAssistantMessage).content) { + if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id); + } + } else if (message.role === "toolResult") { + callIds.reserve((message as OcxToolResultMessage).toolCallId); + } + } const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools( parsed.context.tools, parsed.options.toolChoice, @@ -643,8 +662,17 @@ function messagesToAnthropicFormat( } else if (part.type === "toolCall") { const tc = part as OcxToolCall; const flatName = namespacedToolName(tc.namespace, tc.name); - toolUseIds.push(tc.id); - toolUses.push({ type: "tool_use", id: tc.id, name: toolNames.toWire(flatName), input: tc.arguments }); + // Normalized here, and identically for the matching tool_result above, so a history + // replayed from another provider path keeps its call/result pairing (#1767). + // No raw fallback: restoring an empty/unusable id puts a value on the wire Anthropic + // rejects. An unrepresentable call becomes text instead, and its result follows it there. + const wireCallId = callIds.allocate(tc.id); + if (wireCallId === undefined) { + preface.push({ type: "text", text: unrepresentableToolCallText(tc, toolNames.toWire(flatName)) }); + continue; + } + toolUseIds.push(wireCallId); + toolUses.push({ type: "tool_use", id: wireCallId, name: toolNames.toWire(flatName), input: tc.arguments }); } } // Anthropic treats text/thinking after tool_use as ending the tool turn, which makes @@ -660,9 +688,13 @@ function messagesToAnthropicFormat( let j = i + 1; while (j < parsed.context.messages.length && parsed.context.messages[j].role === "toolResult") { const tr = parsed.context.messages[j] as OcxToolResultMessage; - if (requiredIds.has(tr.toolCallId) && !seen.has(tr.toolCallId)) { - resultBlocks.push(toAnthropicToolResult(tr)); - seen.add(tr.toolCallId); + // Match on the WIRE id. requiredIds holds normalized ids, so comparing the raw result id + // made every rewritten pair lose its result to orphan text and gain a synthetic + // missing-result block. lookup() never mints an id: a result with no call stays orphan. + const wireResultId = callIds.lookup(tr.toolCallId); + if (wireResultId !== undefined && requiredIds.has(wireResultId) && !seen.has(wireResultId)) { + resultBlocks.push(toAnthropicToolResult(tr, wireResultId)); + seen.add(wireResultId); } else { orphanBlocks.push({ type: "text", text: orphanToolResultText(tr) }); } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 764e4ce031..ab27089e7d 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,6 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; -import { createHash } from "node:crypto"; +import { createToolCallIdAllocator } from "./tool-call-id"; import { createImageBudget, materializeInlineImage, MAX_ENCODED_BYTES_PER_IMAGE, artifactHttpUrl } from "../images/artifacts"; import type { AdapterEvent, @@ -11,6 +11,7 @@ import type { OcxProviderOpaqueToolCallMetadata, OcxTextContent, OcxToolCall, + OcxToolResultMessage, OcxUsage, } from "../types"; import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types"; @@ -95,15 +96,9 @@ function vertexReplaySessionId(parsed: OcxParsedRequest): string { * the call/response pairing is preserved. Returns `undefined` for an empty id so the caller omits the * field entirely rather than inventing a non-matching one. */ -function geminiToolCallId(rawId: string | undefined): string | undefined { - const raw = rawId ?? ""; - if (raw.length === 0) return undefined; - const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); - if (cleaned === raw) return cleaned; - // Lossy rewrite happened: disambiguate with a deterministic suffix derived from the raw id. - const suffix = createHash("sha256").update(raw).digest("hex").slice(0, 8); - return `${cleaned}_${suffix}`; -} +// Aliasing the stateless transform here would reintroduce the collision it cannot prevent: +// a rewritten id can equal a distinct raw id that already conforms. Use a request-scoped +// allocator, exactly as the Anthropic adapter does, so call/response pairing stays injective. /** * Inline image parts (Gemini `inline_data`) extracted from tool-result content. Only base64 data URLs @@ -166,6 +161,16 @@ function messagesToGeminiFormat( const contents: unknown[] = []; + const callIds = createToolCallIdAllocator(); + for (const msg of parsed.context.messages) { + if (msg.role === "assistant") { + for (const part of (msg as OcxAssistantMessage).content) { + if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id); + } + } else if (msg.role === "toolResult") { + callIds.reserve((msg as OcxToolResultMessage).toolCallId); + } + } for (const msg of parsed.context.messages) { switch (msg.role) { case "user": @@ -204,7 +209,7 @@ function messagesToGeminiFormat( // streaming covered by the replay cache. Only forward a REAL upstream signature — the // Responses parser also stashes synthetic item ids (`fc_...`) on this field, and sending // those as a thoughtSignature breaks continuity (the replay cache supplies the real one). - const callId = geminiToolCallId(tc.id); + const callId = callIds.allocate(tc.id); const functionCall: Record = { name: namespacedToolName(tc.namespace, tc.name), args: tc.arguments }; // Claude-on-Antigravity maps this id to Anthropic `tool_use.id`; without it the upstream // conversion 400s. Gemini accepts the optional id and pairs call/response by it. @@ -229,7 +234,8 @@ function messagesToGeminiFormat( // functionResponse, but it does accept sibling inline_data parts in the same user turn, so // tool-result screenshots (e.g. Computer Use) ride along as inline_data instead of being // flattened to a "[image]" marker the model can't actually see. - const responseId = geminiToolCallId(msg.toolCallId); + // lookup(), not allocate(): a response must reuse its call's id and must never mint a new one. + const responseId = callIds.lookup(msg.toolCallId); const functionResponse: Record = { name: namespacedToolName(msg.toolNamespace, msg.toolName), response: { result: geminiToolResultText(msg.content) } }; // Mirror the matching functionCall id so Claude-on-Antigravity can pair this result with its // `tool_use` block (-> Anthropic `tool_result.tool_use_id`). diff --git a/src/adapters/tool-call-id.ts b/src/adapters/tool-call-id.ts new file mode 100644 index 0000000000..b48d043c8e --- /dev/null +++ b/src/adapters/tool-call-id.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; + +/** + * Anthropic rejects a `tool_use.id` longer than this. Collision disambiguation has to fit + * inside it too, which is why candidates are assembled from parts instead of sliced at the end. + */ +export const MAX_TOOL_CALL_ID_LENGTH = 64; + +/** Hex characters of the deterministic tail. Fixed width: it is a discriminator, not a payload. */ +const TOOL_CALL_ID_HASH_WIDTH = 8; + +const CONFORMING_TOOL_CALL_ID = /^[a-zA-Z0-9_-]+$/; + +/** An id Anthropic accepts as-is: right character set AND within the length bound. */ +export function isConformingToolCallId(rawId: string): boolean { + return rawId.length > 0 + && rawId.length <= MAX_TOOL_CALL_ID_LENGTH + && CONFORMING_TOOL_CALL_ID.test(rawId); +} + +/** + * The parts a rewritten id is built from: the sanitized prefix and a deterministic tail + * derived from the raw id. Kept separate so collision handling can truncate the prefix + * without destroying the discriminator. + */ +function toolCallIdComponents(rawId: string | undefined): { cleaned: string; hash: string } | undefined { + const raw = rawId ?? ""; + if (raw.length === 0) return undefined; + const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, "_"); + const hash = createHash("sha256").update(raw).digest("hex").slice(0, TOOL_CALL_ID_HASH_WIDTH); + return { cleaned, hash }; +} + +/** Assemble `prefix_hash`, truncating only the prefix, leaving `reserve` characters spare. */ +function fitToolCallId(cleaned: string, hash: string, reserve = 0): string { + const tail = `_${hash}`; + const room = MAX_TOOL_CALL_ID_LENGTH - reserve - tail.length; + return cleaned.slice(0, Math.max(1, room)) + tail; +} + +/** + * Normalize a tool call id into the character set and length Anthropic accepts. + * + * This is the stateless view, kept for callers that only need the shape of one id. It is NOT + * injective on its own: `anthropicToolCallId("call:a")` returns something like `call_a_1f2e3d4c`, + * and a raw id that already equals that value is returned unchanged — two distinct sources, one + * wire id. Anything building a whole request must use {@link createToolCallIdAllocator}, which + * reserves the conforming ids first and resolves collisions. + * + * Returns `undefined` for an empty id. Callers must handle that rather than falling back to the + * raw value: restoring `""` puts an id on the wire that Anthropic rejects (#1767). + */ +export function anthropicToolCallId(rawId: string | undefined): string | undefined { + const raw = rawId ?? ""; + if (raw.length === 0) return undefined; + if (isConformingToolCallId(raw)) return raw; + const parts = toolCallIdComponents(raw); + if (!parts) return undefined; + return fitToolCallId(parts.cleaned, parts.hash); +} + +export type ToolCallIdAllocator = { + /** Claim an already-conforming source id so no rewrite can be handed the same value. */ + reserve(rawId: string | undefined): void; + /** Wire id for a raw id, stable within the request. `undefined` means "not representable". */ + allocate(rawId: string | undefined): string | undefined; + /** Wire id previously allocated for this raw id, without creating one. */ + lookup(rawId: string | undefined): string | undefined; +}; + +/** + * Request-scoped raw-id to wire-id mapping. + * + * Two properties the stateless transform cannot provide: + * + * - **Injective.** Reserve every already-conforming id first, then allocate rewrites around them, + * appending a numeric suffix when a candidate is taken. Two distinct raw ids never share a wire id, + * including the case where one raw id already looks like another's normalized form, and including + * an ordinary 32-bit hash collision. + * - **Stable.** A tool result asks for the same raw id its call used and gets the same wire id, so + * call/result pairing survives normalization. + */ +export function createToolCallIdAllocator(): ToolCallIdAllocator { + const rawToWire = new Map(); + const occupied = new Set(); + + return { + reserve(rawId) { + if (!rawId || rawToWire.has(rawId)) return; + if (!isConformingToolCallId(rawId)) return; + rawToWire.set(rawId, rawId); + occupied.add(rawId); + }, + allocate(rawId) { + if (!rawId) return undefined; + const existing = rawToWire.get(rawId); + if (existing) return existing; + if (isConformingToolCallId(rawId) && !occupied.has(rawId)) { + rawToWire.set(rawId, rawId); + occupied.add(rawId); + return rawId; + } + const parts = toolCallIdComponents(rawId); + if (!parts) return undefined; + let candidate = fitToolCallId(parts.cleaned, parts.hash); + for (let n = 2; occupied.has(candidate); n++) { + const suffix = `_${n}`; + candidate = fitToolCallId(parts.cleaned, parts.hash, suffix.length) + suffix; + } + rawToWire.set(rawId, candidate); + occupied.add(candidate); + return candidate; + }, + lookup(rawId) { + if (!rawId) return undefined; + return rawToWire.get(rawId); + }, + }; +} diff --git a/tests/adapter-usage.test.ts b/tests/adapter-usage.test.ts index c9a34ca67d..5fd93b9d7c 100644 --- a/tests/adapter-usage.test.ts +++ b/tests/adapter-usage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { anthropicToolCallId, MAX_TOOL_CALL_ID_LENGTH } from "../src/adapters/tool-call-id"; import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; import { createOpenAIChatAdapter as createOpenAIChatAdapterProduction } from "../src/adapters/openai-chat"; @@ -435,6 +436,126 @@ describe("openai-chat tool history repair", () => { }); describe("anthropic tool result history repair", () => { + /** Build one Anthropic request from a call/result history and return its parsed body. */ + async function replay(messages: any[]): Promise<{ messages: Array<{ role: string; content: any }> }> { + const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" }); + const request = await adapter.buildRequest({ + modelId: "claude-sonnet", + context: { messages }, + stream: true, + options: {}, + }); + return JSON.parse(request.body); + } + + function callThenResult(callId: string, resultId = callId): any[] { + return [ + { role: "user", content: "start", timestamp: 0 }, + { + role: "assistant", + content: [{ type: "toolCall", id: callId, name: "read_file", arguments: {} }], + model: "claude-sonnet", + timestamp: 0, + }, + { role: "toolResult", toolCallId: resultId, toolName: "read_file", content: "ok", isError: false, timestamp: 0 }, + { role: "user", content: "continue", timestamp: 0 }, + ]; + } + + test("a rewritten call id keeps its result paired (#1767)", async () => { + // requiredIds holds NORMALIZED ids. Matching the raw result id against them meant every + // rewritten pair lost its real result to orphan text and gained a synthetic missing-result. + const body = await replay(callThenResult("call:a")); + + const toolUse = (body.messages[1].content as any[]).find(b => b.type === "tool_use"); + expect(toolUse).toBeDefined(); + const results = body.messages[2].content as any[]; + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ type: "tool_result", tool_use_id: toolUse.id, content: "ok" }); + expect(JSON.stringify(results)).not.toContain("missing tool_result"); + expect(JSON.stringify(results)).not.toContain("tool_result without adjacent tool_use"); + }); + + test("an empty id never reaches the wire", async () => { + // `anthropicToolCallId("")` returns undefined, but the old `?? rawId` fallback restored the + // empty string -- an id Anthropic rejects. The call becomes text instead. + const body = await replay(callThenResult("")); + + const serialized = JSON.stringify(body); + expect(serialized).not.toContain('"id":""'); + expect(serialized).not.toContain('"tool_use_id":""'); + expect(serialized).toContain("tool_use without a usable id"); + }); + + test("a rewritten id does not collide with a conforming id that already looks like it", async () => { + // The stateless transform is not injective: `call:a` normalizes to `call_a_`, and a raw + // id already equal to that value passes through untouched. Two sources, one wire id. + const normalized = anthropicToolCallId("call:a")!; + expect(normalized).not.toBe("call:a"); + + const body = await replay([ + { role: "user", content: "start", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "toolCall", id: "call:a", name: "first", arguments: {} }, + { type: "toolCall", id: normalized, name: "second", arguments: {} }, + ], + model: "claude-sonnet", + timestamp: 0, + }, + { role: "toolResult", toolCallId: "call:a", toolName: "first", content: "one", isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: normalized, toolName: "second", content: "two", isError: false, timestamp: 0 }, + { role: "user", content: "continue", timestamp: 0 }, + ]); + + const uses = (body.messages[1].content as any[]).filter(b => b.type === "tool_use"); + expect(uses).toHaveLength(2); + expect(uses[0].id).not.toBe(uses[1].id); + + // Each result pairs with its own call, and nothing is orphaned. + const results = (body.messages[2].content as any[]).filter(b => b.type === "tool_result"); + expect(results.map(r => r.tool_use_id).sort()).toEqual(uses.map(u => u.id).sort()); + expect(JSON.stringify(results)).not.toContain("missing tool_result"); + }); + + test("a result with no matching call does not mint a tool_use identity", async () => { + const body = await replay(callThenResult("call_1", "call_other")); + + const uses = (body.messages[1].content as any[]).filter(b => b.type === "tool_use"); + expect(uses).toHaveLength(1); + expect(uses[0].id).toBe("call_1"); + + // The unmatched result stays text; the real call gets the synthetic missing-result block. + const followUp = JSON.stringify(body.messages[2].content); + expect(followUp).toContain("tool_result without adjacent tool_use"); + expect(followUp).toContain("missing tool_result"); + }); + + test("an over-length id is rewritten to fit, not passed through", async () => { + // Character-valid but too long: Anthropic rejects it, so `isConformingToolCallId` has to + // include the length bound or reserve() would hand it back verbatim. + const longId = "c".repeat(MAX_TOOL_CALL_ID_LENGTH + 20); + const body = await replay(callThenResult(longId)); + + const toolUse = (body.messages[1].content as any[]).find(b => b.type === "tool_use"); + expect(toolUse.id.length).toBeLessThanOrEqual(MAX_TOOL_CALL_ID_LENGTH); + expect(toolUse.id).not.toBe(longId); + + const results = (body.messages[2].content as any[]).filter(b => b.type === "tool_result"); + expect(results).toHaveLength(1); + expect(results[0].tool_use_id).toBe(toolUse.id); + }); + + test("already-conforming pairs pass through byte-identical", async () => { + const body = await replay(callThenResult("call_ok_1")); + + const toolUse = (body.messages[1].content as any[]).find(b => b.type === "tool_use"); + expect(toolUse.id).toBe("call_ok_1"); + const results = (body.messages[2].content as any[]).filter(b => b.type === "tool_result"); + expect(results[0].tool_use_id).toBe("call_ok_1"); + }); + test("merges adjacent tool results after multiple tool uses into one user message", async () => { const adapter = createAnthropicAdapter({ ...provider, adapter: "anthropic" }); const request = await adapter.buildRequest({ diff --git a/tests/anthropic-tool-call-id.test.ts b/tests/anthropic-tool-call-id.test.ts new file mode 100644 index 0000000000..8164182ffe --- /dev/null +++ b/tests/anthropic-tool-call-id.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicToolCallId, createToolCallIdAllocator, MAX_TOOL_CALL_ID_LENGTH } from "../src/adapters/tool-call-id"; + +// #1767: Anthropic validates tool_use.id against [a-zA-Z0-9_-]. A history replayed from another +// provider path can carry ids that do not conform, and one of them anywhere in the transcript +// fails the whole request with a 400. +describe("anthropicToolCallId", () => { + test("leaves a conforming id untouched", () => { + expect(anthropicToolCallId("call_5sNzuhhhfcuN91ysezpcwXjp")).toBe("call_5sNzuhhhfcuN91ysezpcwXjp"); + expect(anthropicToolCallId("call-9f1c2d3e-4")).toBe("call-9f1c2d3e-4"); + expect(anthropicToolCallId("toolu_01A2b3C4")).toBe("toolu_01A2b3C4"); + }); + + test("rewrites the composite id from the report", () => { + const raw = "call_5sNzuhhhfcuN91ysezpcwXjp\nfc_0c71abbccafaad67016a803ba3007487d2afa509a7ca8c9687"; + const out = anthropicToolCallId(raw)!; + + expect(out).toMatch(/^[a-zA-Z0-9_-]+$/); + expect(out).not.toContain("\n"); + expect(out.startsWith("call_5sNzuhhhfcuN91ysezpcwXjp_fc_")).toBe(true); + }); + + test("is deterministic, so a call and its result still pair up", () => { + const raw = "call:a/b c"; + expect(anthropicToolCallId(raw)).toBe(anthropicToolCallId(raw)); + }); + + test("keeps distinct raw ids distinct after rewriting", () => { + // Without the hash suffix both of these would collapse to "call_a". + const a = anthropicToolCallId("call:a"); + const b = anthropicToolCallId("call/a"); + + expect(a).not.toBe(b); + expect(a).toMatch(/^[a-zA-Z0-9_-]+$/); + expect(b).toMatch(/^[a-zA-Z0-9_-]+$/); + }); + + test("returns undefined for an empty id so the caller can omit the field", () => { + expect(anthropicToolCallId("")).toBeUndefined(); + expect(anthropicToolCallId(undefined)).toBeUndefined(); + }); +}); + +describe("createToolCallIdAllocator", () => { + test("is injective where the stateless transform is not", () => { + // `call:a` normalizes to `call_a_`. A raw id already equal to that value conforms and + // passes through untouched, so the stateless helper maps two distinct sources onto one id. + const collidingId = anthropicToolCallId("call:a")!; + expect(anthropicToolCallId(collidingId)).toBe(collidingId); + + const allocator = createToolCallIdAllocator(); + allocator.reserve(collidingId); + const rewritten = allocator.allocate("call:a"); + const conforming = allocator.allocate(collidingId); + + expect(rewritten).toBeDefined(); + expect(conforming).toBe(collidingId); + expect(rewritten).not.toBe(conforming); + }); + + test("is stable, so a result reuses its call's wire id", () => { + const allocator = createToolCallIdAllocator(); + const first = allocator.allocate("call:a"); + expect(allocator.allocate("call:a")).toBe(first); + expect(allocator.lookup("call:a")).toBe(first); + }); + + test("lookup never mints an id", () => { + const allocator = createToolCallIdAllocator(); + expect(allocator.lookup("call:a")).toBeUndefined(); + }); + + test("returns undefined for an empty id instead of an unusable value", () => { + const allocator = createToolCallIdAllocator(); + expect(allocator.allocate("")).toBeUndefined(); + expect(allocator.allocate(undefined)).toBeUndefined(); + }); + + test("keeps every candidate within the Anthropic length bound", () => { + const allocator = createToolCallIdAllocator(); + const long = "x".repeat(MAX_TOOL_CALL_ID_LENGTH * 2) + ":"; + const first = allocator.allocate(long)!; + expect(first.length).toBeLessThanOrEqual(MAX_TOOL_CALL_ID_LENGTH); + + // Force the collision branch: the disambiguating suffix must fit inside the bound too. + const second = allocator.allocate(long.slice(0, -1) + "/")!; + expect(second.length).toBeLessThanOrEqual(MAX_TOOL_CALL_ID_LENGTH); + expect(second).not.toBe(first); + }); + + test("an over-length character-valid id is rewritten, not reserved verbatim", () => { + const allocator = createToolCallIdAllocator(); + const long = "c".repeat(MAX_TOOL_CALL_ID_LENGTH + 10); + allocator.reserve(long); + const wire = allocator.allocate(long)!; + expect(wire).not.toBe(long); + expect(wire.length).toBeLessThanOrEqual(MAX_TOOL_CALL_ID_LENGTH); + }); +});