From d158f2cd39e667f9be0cf117d12aa03e8428354f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 21 Aug 2026 00:54:31 -0500 Subject: [PATCH] fix(ai): scrub Anthropic tool call IDs (#43796) --- .../ai/src/protocols/anthropic-messages.ts | 10 ++++--- .../test/provider/anthropic-messages.test.ts | 27 +++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 14c6bc1b16..ed5087f516 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -374,16 +374,18 @@ const lowerToolChoice = (toolChoice: NonNullable) => tool: (name) => ({ type: "tool" as const, name }), }) +const scrubToolCallID = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") + const lowerToolCall = (part: ToolCallPart): AnthropicToolUseBlock => ({ type: "tool_use", - id: part.id, + id: scrubToolCallID(part.id), name: part.name, input: part.input, }) const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => ({ type: "server_tool_use", - id: part.id, + id: scrubToolCallID(part.id), name: part.name, input: part.input, }) @@ -405,7 +407,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult // Prefer the provider-owned replay payload; fall back to the result value for // histories constructed directly from provider events. const payload = part.providerMetadata?.anthropic?.["result"] ?? part.result.value - return { type: wireType, tool_use_id: part.id, content: payload } satisfies AnthropicServerToolResultBlock + return { type: wireType, tool_use_id: scrubToolCallID(part.id), content: payload } satisfies AnthropicServerToolResultBlock }) const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) { @@ -587,7 +589,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( return yield* ProviderShared.unsupportedContent("Anthropic Messages", "tool", ["tool-result"]) content.push({ type: "tool_result", - tool_use_id: part.id, + tool_use_id: scrubToolCallID(part.id), content: yield* lowerToolResultContent(part), is_error: part.result.type === "error" ? true : undefined, cache_control: cacheControl(breakpoints, part.cache), diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 9a03840225..a2447aea90 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -327,6 +327,29 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("scrubs outbound tool call IDs without truncating them", () => + Effect.gen(function* () { + const id = `functions.lookup:1|${"x".repeat(64)}` + const scrubbed = `functions_lookup_1_${"x".repeat(64)}` + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [ + Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]), + Message.tool({ id, name: "lookup", result: "done" }), + ], + cache: "none", + }), + ) + + expect(prepared.body.messages).toMatchObject([ + { role: "assistant", content: [{ type: "tool_use", id: scrubbed, name: "lookup", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: scrubbed }] }, + ]) + expect(scrubbed.length).toBeGreaterThan(64) + }), + ) + it.effect("batches parallel tool results into one Anthropic user message", () => Effect.gen(function* () { const prepared = yield* compileRequest( @@ -1393,14 +1416,14 @@ describe("Anthropic Messages route", () => { Message.assistant([ { type: "tool-call", - id: "srvtoolu_abc", + id: "srvtoolu.abc", name: "web_search", input: { query: "effect 4" }, providerExecuted: true, }, { type: "tool-result", - id: "srvtoolu_abc", + id: "srvtoolu.abc", name: "web_search", result: { type: "json", value: [{ url: "https://example.com" }] }, providerExecuted: true,