Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 36 additions & 8 deletions packages/ai/src/protocols/anthropic-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,15 +227,22 @@ const AnthropicServerToolUseBlock = Schema.Struct({
})
type AnthropicServerToolUseBlock = Schema.Schema.Type<typeof AnthropicServerToolUseBlock>

// 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<typeof AnthropicServerToolResultType>

Expand Down Expand Up @@ -473,6 +480,12 @@ interface ParserState {
readonly compactions: Readonly<Record<number, string | null>>
readonly providerMetadataKey: string
readonly tools: ToolStream.State<number>
// `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<Record<string, string>>
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly usage?: Usage
readonly pendingFinish?: {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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<AnthropicServerToolResultType, string> = {
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
Expand All @@ -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
Expand Down Expand Up @@ -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 ?? "",
Expand Down Expand Up @@ -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]]
Expand Down Expand Up @@ -1672,6 +1699,7 @@ export const protocol = Protocol.make({
compactions: {},
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
serverTools: {},
reasoningSignatures: {},
lifecycle: Lifecycle.initial(),
}),
Expand Down
84 changes: 84 additions & 0 deletions packages/ai/test/provider/anthropic-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading