From d3bf3d91fbc8b33f3f0d5ed68f4a0e554d2772f0 Mon Sep 17 00:00:00 2001 From: Luca Barbato Date: Fri, 11 Sep 2026 12:51:25 +0200 Subject: [PATCH] fix(ai): round-trip Anthropic tool_search_tool_result blocks The native Anthropic protocol dropped `tool_search_tool_result` content blocks at stream-decode time and refused to lower them back onto the wire. Both gaps keyed off the same hardcoded three-entry server-tool list, which predates Anthropic's tool-search server tools. A tool search puts signed `thinking`, `server_tool_use`, `tool_search_tool_result` and `tool_use` in a single assistant turn. Dropping the result block means the replayed turn no longer matches the original response, and Anthropic rejects the signed thinking block: "`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified." Also records `server_tool_use` id -> name in the parser state, because `tool_search_tool_bm25` and `tool_search_tool_regex` both report through one `tool_search_tool_result` block type and the result block carries no name of its own. Port of the same fix made against `dev` in anomalyco/opencode#48466, where the file lives at `packages/llm/src/protocols/anthropic-messages.ts`. Co-Authored-By: Claude Opus 5 --- .../ai/src/protocols/anthropic-messages.ts | 44 ++++++++-- .../test/provider/anthropic-messages.test.ts | 84 +++++++++++++++++++ 2 files changed, 120 insertions(+), 8 deletions(-) diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index 1c4c32da3b80..2c203d89a90e 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -227,15 +227,22 @@ const AnthropicServerToolUseBlock = Schema.Struct({ }) type AnthropicServerToolUseBlock = Schema.Schema.Type -// Server tool result blocks: web_search_tool_result, code_execution_tool_result, -// and web_fetch_tool_result. The provider executes the tool and inlines the +// Server tool result blocks. The provider executes the tool and inlines the // structured result into the assistant turn — there is no client tool_result // round-trip. We round-trip the structured `content` payload as opaque JSON so // the next request can echo it back when continuing the conversation. +// +// Echoing them back is mandatory, not an optimisation: when the assistant turn +// also contains a signed `thinking` block, Anthropic rejects the next request if +// any sibling block was dropped, reordered or edited. `tool_search_tool_result` +// is emitted by the tool-search server tools that back `defer_loading`, which +// put thinking + server_tool_use + tool_search_tool_result + tool_use in one +// turn — so dropping it breaks every following request in the session. const AnthropicServerToolResultType = Schema.Literals([ "web_search_tool_result", "code_execution_tool_result", "web_fetch_tool_result", + "tool_search_tool_result", ]) type AnthropicServerToolResultType = Schema.Schema.Type @@ -473,6 +480,12 @@ interface ParserState { readonly compactions: Readonly> readonly providerMetadataKey: string readonly tools: ToolStream.State + // `server_tool_use` id -> tool name. The matching `*_tool_result` block + // carries only `tool_use_id`, and the pending-tool entry is already gone by + // then (`content_block_stop` finishes it), so the name is recorded here. + // Needed because one block type can answer several tool names — both + // tool-search variants report through `tool_search_tool_result`. + readonly serverTools: Readonly> readonly reasoningSignatures: Readonly> readonly usage?: Usage readonly pendingFinish?: { @@ -567,13 +580,15 @@ const lowerServerToolCall = (part: ToolCallPart): AnthropicServerToolUseBlock => input: part.input, }) -// Server tool result blocks are typed by name. Anthropic ships three today; -// extend this list when new server tools land. The block content is the -// structured payload returned by the provider, which we round-trip as-is. +// Server tool result blocks are typed by name; extend this list when new server +// tools land. The block content is the structured payload returned by the +// provider, which we round-trip as-is. Both tool-search variants (BM25 and +// regex) report their results in a single `tool_search_tool_result` block type. const serverToolResultType = (name: string): AnthropicServerToolResultType | undefined => { if (name === "web_search") return "web_search_tool_result" if (name === "code_execution") return "code_execution_tool_result" if (name === "web_fetch") return "web_fetch_tool_result" + if (name === "tool_search_tool_bm25" || name === "tool_search_tool_regex") return "tool_search_tool_result" return undefined } @@ -1258,15 +1273,23 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM // `providerExecuted: true`. The runtime appends it to the assistant message // for round-trip; downstream consumers can inspect `result.value` for the // structured payload. +// +// These are fallback names, used only when the originating `server_tool_use` was not seen +// on this stream. `serverTools` in the parser state is the accurate source. const SERVER_TOOL_RESULT_NAMES: Record = { web_search_tool_result: "web_search", code_execution_tool_result: "code_execution", web_fetch_tool_result: "web_fetch", + tool_search_tool_result: "tool_search_tool_bm25", } const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES -const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: string): LLMEvent | undefined => { +const serverToolResultEvent = ( + block: AnthropicStreamBlock, + providerMetadataKey: string, + serverTools: ParserState["serverTools"], +): LLMEvent | undefined => { if (!block.type || !isServerToolResultType(block.type)) return undefined const errorPayload = typeof block.content === "object" && block.content !== null && "type" in block.content @@ -1275,7 +1298,7 @@ const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey: const isError = errorPayload.endsWith("_tool_result_error") return LLMEvent.toolResult({ id: block.tool_use_id ?? "", - name: SERVER_TOOL_RESULT_NAMES[block.type], + name: (block.tool_use_id ? serverTools[block.tool_use_id] : undefined) ?? SERVER_TOOL_RESULT_NAMES[block.type], result: isError ? { type: "error", value: block.content } : { type: "json", value: block.content }, providerExecuted: true, // The complete payload is irreducible provider replay state: subsequent @@ -1307,6 +1330,10 @@ const onContentBlockStart = ( { ...state, lifecycle, + serverTools: + block.type === "server_tool_use" + ? { ...state.serverTools, [block.id]: block.name ?? "" } + : state.serverTools, tools: ToolStream.start(state.tools, event.index, { id: block.id, name: block.name ?? "", @@ -1380,7 +1407,7 @@ const onContentBlockStart = ( ] } - const result = serverToolResultEvent(block, state.providerMetadataKey) + const result = serverToolResultEvent(block, state.providerMetadataKey, state.serverTools) if (!result) return [state, NO_EVENTS] const events: LLMEvent[] = [] return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]] @@ -1672,6 +1699,7 @@ export const protocol = Protocol.make({ compactions: {}, providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider), tools: ToolStream.empty(), + serverTools: {}, reasoningSignatures: {}, lifecycle: Lifecycle.initial(), }), diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index b5696462f81f..9b9ab56a3f60 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -945,6 +945,90 @@ describe("Anthropic Messages route", () => { }), ) + // Anthropic's tool-search server tools (`tool_search_tool_bm25_20251119` / + // `tool_search_tool_regex_20251119`) let most tool schemas be sent with + // `defer_loading: true`. A search puts signed `thinking`, `server_tool_use`, + // `tool_search_tool_result` and `tool_use` blocks in ONE assistant turn. + // Anthropic rejects the next request if any sibling of a signed thinking + // block was dropped, so the search result has to survive the round trip. + it.effect("round-trips a tool_search_tool_result beside a signed thinking block", () => + Effect.gen(function* () { + const result = [{ type: "tool_search_result", name: "read" }] + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { type: "message_start", message: { usage: { input_tokens: 5 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } }, + { + type: "content_block_delta", + index: 0, + delta: { type: "thinking_delta", thinking: "Need the read tool." }, + }, + { type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_search" } }, + { type: "content_block_stop", index: 0 }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "server_tool_use", + id: "srvtoolu_s1", + name: "tool_search_tool_bm25", + input: { query: "read file" }, + }, + }, + { type: "content_block_stop", index: 1 }, + { + type: "content_block_start", + index: 2, + content_block: { type: "tool_search_tool_result", tool_use_id: "srvtoolu_s1", content: result }, + }, + { type: "content_block_stop", index: 2 }, + { + type: "content_block_start", + index: 3, + content_block: { type: "tool_use", id: "toolu_1", name: "read", input: { filePath: "/tmp/a" } }, + }, + { type: "content_block_stop", index: 3 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 9 } }, + { type: "message_stop" }, + ), + ), + ), + ) + + // The result block carries only `tool_use_id`; the name comes from the + // originating `server_tool_use`, since both tool-search variants report + // through this one block type. + expect(response.events.find((event) => event.type === "tool-result")).toMatchObject({ + type: "tool-result", + id: "srvtoolu_s1", + name: "tool_search_tool_bm25", + result: { type: "json", value: result }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "tool_search_tool_result", result } }, + }) + + const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" })) + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Need the read tool.", signature: "sig_search" }, + { + type: "server_tool_use", + id: "srvtoolu_s1", + name: "tool_search_tool_bm25", + input: { query: "read file" }, + }, + { type: "tool_search_tool_result", tool_use_id: "srvtoolu_s1", content: result }, + { type: "tool_use", id: "toolu_1", name: "read", input: { filePath: "/tmp/a" } }, + ], + }, + ]) + }), + ) + it.effect("preserves a reasoning signature when message_stop closes the block", () => Effect.gen(function* () { const compatible = Route.make({