diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 1c0dcd32a433..4aa07dcca6c9 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -80,15 +80,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 @@ -224,6 +231,12 @@ interface ParserState { readonly tools: ToolStream.State readonly usage?: Usage readonly lifecycle: Lifecycle.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> } const invalid = ProviderShared.invalidRequest @@ -287,13 +300,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 } @@ -621,15 +636,22 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => { // `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: NonNullable): LLMEvent | undefined => { +const serverToolResultEvent = ( + block: NonNullable, + 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 @@ -638,7 +660,7 @@ const serverToolResultEvent = (block: NonNullable ({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial() }), + initial: () => ({ tools: ToolStream.empty(), lifecycle: Lifecycle.initial(), serverTools: {} }), step, }, }) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 898931295849..c05d61e459c9 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -715,6 +715,128 @@ 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("decodes tool_search_tool_result as a provider-executed result", () => + Effect.gen(function* () { + const body = 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" }, + }, + { + type: "content_block_delta", + index: 1, + delta: { type: "input_json_delta", partial_json: '{"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: [{ type: "tool_search_result", name: "read" }], + }, + }, + { type: "content_block_stop", index: 2 }, + { + type: "content_block_start", + index: 3, + content_block: { type: "tool_use", id: "toolu_1", name: "read" }, + }, + { + type: "content_block_delta", + index: 3, + delta: { type: "input_json_delta", partial_json: '{"filePath":"/tmp/a"}' }, + }, + { type: "content_block_stop", index: 3 }, + { type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 9 } }, + { type: "message_stop" }, + ) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events.find((event) => event.type === "tool-result")).toEqual({ + type: "tool-result", + id: "srvtoolu_s1", + name: "tool_search_tool_bm25", + result: { type: "json", value: [{ type: "tool_search_result", name: "read" }] }, + providerExecuted: true, + providerMetadata: { anthropic: { blockType: "tool_search_tool_result" } }, + }) + }), + ) + + it.effect("replays a tool-search assistant turn without dropping a signed thinking sibling", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_search_replay", + model, + messages: [ + Message.user("Read /tmp/a."), + Message.assistant([ + { + type: "reasoning", + text: "Need the read tool.", + providerMetadata: { anthropic: { signature: "sig_search" } }, + }, + { + type: "tool-call", + id: "srvtoolu_s1", + name: "tool_search_tool_bm25", + input: { query: "read file" }, + providerExecuted: true, + }, + { + type: "tool-result", + id: "srvtoolu_s1", + name: "tool_search_tool_bm25", + result: { type: "json", value: [{ type: "tool_search_result", name: "read" }] }, + providerExecuted: true, + }, + { type: "tool-call", id: "toolu_1", name: "read", input: { filePath: "/tmp/a" } }, + ]), + ], + }), + ) + + expect(prepared.body).toMatchObject({ + messages: [ + { role: "user", content: [{ type: "text", text: "Read /tmp/a." }] }, + { + 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: [{ type: "tool_search_result", name: "read" }], + }, + { type: "tool_use", id: "toolu_1", name: "read", input: { filePath: "/tmp/a" } }, + ], + }, + ], + }) + }), + ) + it.effect("continues a conversation with user image content", () => Effect.gen(function* () { const response = yield* LLMClient.generate(