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
18 changes: 18 additions & 0 deletions .changeset/anthropic-server-tool-roundtrip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@tanstack/ai-anthropic': patch
'@tanstack/ai': patch
---

Preserve Anthropic server-tool results (`web_search` / `web_fetch`) across turns.

Previously the Anthropic adapter dropped `server_tool_use` and
`web_search_tool_result` / `web_fetch_tool_result` blocks while streaming, so the
evidence never round-tripped — a follow-up turn could no longer see the prior
web-search sources (issue #839). These now stream as a **provider-executed**
tool call carrying the raw result, which the agent loop skips (never executed
client-side) and the adapter replays verbatim into the next request. Adds the
`ProviderExecutedToolMetadata` convention plus `isProviderExecutedToolCall` /
`getProviderExecutedMetadata` helpers to `@tanstack/ai`.

(No e2e: aimock cannot synthesize `server_tool_use` blocks; covered by unit
tests and verified live against the Anthropic API.)
3 changes: 2 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
{
"label": "Provider Tools",
"to": "tools/provider-tools",
"addedAt": "2026-04-21"
"addedAt": "2026-04-21",
"updatedAt": "2026-06-26"
},
{
"label": "Provider Skills",
Expand Down
41 changes: 41 additions & 0 deletions docs/tools/provider-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,47 @@ const stream = chat({
})
```

## Multi-turn persistence

Provider tools run on the provider's own infrastructure, so their results
(e.g. Anthropic `web_search` sources, `web_fetch` page contents) come back
embedded in the assistant turn rather than as a separate tool message. TanStack
AI preserves those results on the assistant message, so when you feed the prior
conversation back into the next `chat()` call the model still sees the earlier
evidence — no special handling required:

```typescript
import { chat, StreamProcessor } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'
import { webSearchTool } from '@tanstack/ai-anthropic/tools'

const adapter = anthropicText('claude-opus-4-6')
const tools = [webSearchTool({ name: 'web_search', type: 'web_search_20250305' })]

const processor = new StreamProcessor()
for await (const chunk of chat({
adapter,
tools,
messages: [{ role: 'user', content: 'Find two sources on the drone market.' }],
})) {
processor.processChunk(chunk)
}
processor.finalizeStream()

// The follow-up turn can still cite the previous search results.
const followUp = chat({
adapter,
tools,
messages: [
...processor.getMessages(),
{ role: 'user', content: 'List the exact sources you used.' },
],
})
Comment on lines +65 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Seed StreamProcessor with the first-turn messages before replaying turn 2.

As written, processor.getMessages() will only contain the streamed assistant turn, so the follow-up call drops the original user prompt. Reuse a UIMessage-shaped firstTurnMessages array for both chat() and new StreamProcessor({ initialMessages: ... }) so the example actually replays the full conversation.

Suggested doc fix
-const processor = new StreamProcessor()
+const firstTurnMessages = [
+  {
+    id: 'user-1',
+    role: 'user',
+    parts: [{ type: 'text', content: 'Find two sources on the drone market.' }],
+  },
+]
+
+const processor = new StreamProcessor({ initialMessages: firstTurnMessages })
 for await (const chunk of chat({
   adapter,
   tools,
-  messages: [{ role: 'user', content: 'Find two sources on the drone market.' }],
+  messages: firstTurnMessages,
 })) {
   processor.processChunk(chunk)
 }
 processor.finalizeStream()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const processor = new StreamProcessor()
for await (const chunk of chat({
adapter,
tools,
messages: [{ role: 'user', content: 'Find two sources on the drone market.' }],
})) {
processor.processChunk(chunk)
}
processor.finalizeStream()
// The follow-up turn can still cite the previous search results.
const followUp = chat({
adapter,
tools,
messages: [
...processor.getMessages(),
{ role: 'user', content: 'List the exact sources you used.' },
],
})
const firstTurnMessages = [
{
id: 'user-1',
role: 'user',
parts: [{ type: 'text', content: 'Find two sources on the drone market.' }],
},
]
const processor = new StreamProcessor({ initialMessages: firstTurnMessages })
for await (const chunk of chat({
adapter,
tools,
messages: firstTurnMessages,
})) {
processor.processChunk(chunk)
}
processor.finalizeStream()
// The follow-up turn can still cite the previous search results.
const followUp = chat({
adapter,
tools,
messages: [
...processor.getMessages(),
{ role: 'user', content: 'List the exact sources you used.' },
],
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tools/provider-tools.md` around lines 65 - 83, The provider-tools
example is replaying turn 2 with only the streamed assistant output, so the
original user prompt is lost. Update the StreamProcessor usage in the documented
chat/StreamProcessor flow to seed it with the full first-turn UIMessage array
via initialMessages, and reuse that same firstTurnMessages value for both the
initial chat() call and the follow-up messages spread so the conversation is
replayed end-to-end.

```

The search/fetch call surfaces as a provider-executed `tool-call` part on the
assistant message; the agent loop never tries to run it client-side.

## Type-level guard

Every provider-specific tool factory (e.g. `webSearchTool`, `computerUseTool`)
Expand Down
165 changes: 165 additions & 0 deletions packages/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ import type {
ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
ServerToolUseBlockParam,
TextBlockParam,
ThinkingBlockParam,
ToolUseBlockParam,
URLImageSource,
URLPDFSource,
WebFetchToolResultBlockParam,
WebSearchToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/messages'
import type Anthropic_SDK from '@anthropic-ai/sdk'
import type { AnthropicBeta } from '@anthropic-ai/sdk/resources/beta/beta'
Expand All @@ -57,6 +60,83 @@ import type {
} from '../message-types'
import type { AnthropicClientConfig } from '../utils'

/**
* The block type carried by an Anthropic provider-executed (server) tool's
* stored result. Mirrors the `*_tool_result` block emitted by the streaming
* API so it can be replayed verbatim into a later turn.
*/
type AnthropicServerToolResultBlockType =
| 'web_search_tool_result'
| 'web_fetch_tool_result'

/**
* Anthropic payload stashed on a provider-executed tool call's `metadata`
* (under the `anthropic` key, alongside `providerExecuted: true`). Holds enough
* to reconstruct the original `server_tool_use` + `*_tool_result` blocks so the
* model still sees prior `web_search` / `web_fetch` evidence on the next turn.
*/
interface AnthropicServerToolMetadata {
serverToolType: ServerToolUseBlockParam['name']
resultBlockType: AnthropicServerToolResultBlockType
/** Raw result block content, preserved verbatim from the stream. */
result: unknown
}

/**
* Narrow an opaque tool-call `metadata` to {@link AnthropicServerToolMetadata}
* when it follows the provider-executed convention, else `null`.
*/
function readAnthropicServerToolMetadata(
metadata: unknown,
): AnthropicServerToolMetadata | null {
if (typeof metadata !== 'object' || metadata === null) return null
const outer = metadata as { providerExecuted?: unknown; anthropic?: unknown }
if (outer.providerExecuted !== true) return null
const inner = outer.anthropic
if (typeof inner !== 'object' || inner === null) return null
const { serverToolType, resultBlockType, result } = inner as {
serverToolType?: unknown
resultBlockType?: unknown
result?: unknown
}
if (
typeof serverToolType !== 'string' ||
(resultBlockType !== 'web_search_tool_result' &&
resultBlockType !== 'web_fetch_tool_result')
Comment on lines +104 to +105

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think IIRC llms can return other tool results as well like code exectuion etc, this will be a problem on openai as well i'm 100% sure as I remember they returned these chunks as well so lets fix it on openai as well and lets make sure all hte provider tool chunks are covered

) {
return null
}
return {
// Validated as a string above; widen back to the SDK's tool-name union.
serverToolType: serverToolType as ServerToolUseBlockParam['name'],
resultBlockType,
result,
}
Comment on lines +89 to +114

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reject mismatched Anthropic server-tool metadata pairs.

readAnthropicServerToolMetadata() currently accepts any string serverToolType as long as resultBlockType is one of the two web_* values. That means a persisted message with a mismatched pair will pass validation and formatMessages() will replay an impossible server_tool_use/*_tool_result combination back to Anthropic on the next turn.

Suggested hardening
-  if (
-    typeof serverToolType !== 'string' ||
-    (resultBlockType !== 'web_search_tool_result' &&
-      resultBlockType !== 'web_fetch_tool_result')
-  ) {
+  const isWebSearchPair =
+    serverToolType === 'web_search' &&
+    resultBlockType === 'web_search_tool_result'
+  const isWebFetchPair =
+    serverToolType === 'web_fetch' &&
+    resultBlockType === 'web_fetch_tool_result'
+  if (!isWebSearchPair && !isWebFetchPair) {
     return null
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function readAnthropicServerToolMetadata(
metadata: unknown,
): AnthropicServerToolMetadata | null {
if (typeof metadata !== 'object' || metadata === null) return null
const outer = metadata as { providerExecuted?: unknown; anthropic?: unknown }
if (outer.providerExecuted !== true) return null
const inner = outer.anthropic
if (typeof inner !== 'object' || inner === null) return null
const { serverToolType, resultBlockType, result } = inner as {
serverToolType?: unknown
resultBlockType?: unknown
result?: unknown
}
if (
typeof serverToolType !== 'string' ||
(resultBlockType !== 'web_search_tool_result' &&
resultBlockType !== 'web_fetch_tool_result')
) {
return null
}
return {
// Validated as a string above; widen back to the SDK's tool-name union.
serverToolType: serverToolType as ServerToolUseBlockParam['name'],
resultBlockType,
result,
}
function readAnthropicServerToolMetadata(
metadata: unknown,
): AnthropicServerToolMetadata | null {
if (typeof metadata !== 'object' || metadata === null) return null
const outer = metadata as { providerExecuted?: unknown; anthropic?: unknown }
if (outer.providerExecuted !== true) return null
const inner = outer.anthropic
if (typeof inner !== 'object' || inner === null) return null
const { serverToolType, resultBlockType, result } = inner as {
serverToolType?: unknown
resultBlockType?: unknown
result?: unknown
}
const isWebSearchPair =
serverToolType === 'web_search' &&
resultBlockType === 'web_search_tool_result'
const isWebFetchPair =
serverToolType === 'web_fetch' &&
resultBlockType === 'web_fetch_tool_result'
if (!isWebSearchPair && !isWebFetchPair) {
return null
}
return {
// Validated as a string above; widen back to the SDK's tool-name union.
serverToolType: serverToolType as ServerToolUseBlockParam['name'],
resultBlockType,
result,
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-anthropic/src/adapters/text.ts` around lines 89 - 114,
readAnthropicServerToolMetadata currently validates serverToolType and
resultBlockType independently, which allows mismatched Anthropic server-tool
pairs to slip through. Tighten the checks in readAnthropicServerToolMetadata and
the downstream formatMessages path so only known valid combinations of
serverToolType and resultBlockType are accepted, and return null for any
persisted metadata where the pair does not match an allowed server-tool/result
pairing.

}

/**
* Reconstruct the `*_tool_result` block param from stored server-tool metadata.
* The `result` content is opaque round-trip data, asserted to the SDK's param
* content type at this single boundary.
*/
function buildServerToolResultBlock(
toolUseId: string,
meta: AnthropicServerToolMetadata,
): WebSearchToolResultBlockParam | WebFetchToolResultBlockParam {
if (meta.resultBlockType === 'web_search_tool_result') {
return {
type: 'web_search_tool_result',
tool_use_id: toolUseId,
content: meta.result as WebSearchToolResultBlockParam['content'],
}
}
return {
type: 'web_fetch_tool_result',
tool_use_id: toolUseId,
content: meta.result as WebFetchToolResultBlockParam['content'],
}
}

/**
* Computes the `betas` array for a Messages request. Unions:
* - `interleaved-thinking-2025-05-14` when interleaved thinking is enabled,
Expand Down Expand Up @@ -636,6 +716,25 @@ export class AnthropicTextAdapter<
parsedInput = toolCall.function.arguments
}

// Provider-executed server tools (e.g. web_search) replay as the
// original `server_tool_use` + result blocks so the model still sees
// the prior evidence. Their result was captured verbatim during
// streaming (see processAnthropicStream).
const serverMeta = readAnthropicServerToolMetadata(toolCall.metadata)
if (serverMeta) {
const serverToolUseBlock: ServerToolUseBlockParam = {
type: 'server_tool_use',
id: toolCall.id,
name: serverMeta.serverToolType,
input: parsedInput,
}
contentBlocks.push(serverToolUseBlock)
contentBlocks.push(
buildServerToolResultBlock(toolCall.id, serverMeta),
)
continue
}

const toolUseBlock: ToolUseBlockParam = {
type: 'tool_use',
id: toolCall.id,
Expand Down Expand Up @@ -806,6 +905,14 @@ export class AnthropicTextAdapter<
// input.
let currentServerTool: { id: string; name: string; input: string } | null =
null
// Completed server tools awaiting their matching result block. Anthropic
// emits `server_tool_use` then a separate `*_tool_result` block; we hold
// the call here (keyed by id) until the result arrives so we can emit a
// single provider-executed tool call carrying the raw result for round-trip.
const completedServerTools = new Map<
string,
{ id: string; name: string; input: string }
>()

// AG-UI lifecycle tracking
const runId = options.runId ?? genId()
Expand Down Expand Up @@ -881,6 +988,61 @@ export class AnthropicTextAdapter<
},
)
}

// Emit the server tool as a single provider-executed tool call,
// carrying its raw result so the evidence (e.g. web_search sources)
// round-trips into the next turn's request. The agent loop skips
// provider-executed calls, so this never triggers client execution.
const serverTool = completedServerTools.get(
event.content_block.tool_use_id,
)
if (serverTool) {
completedServerTools.delete(serverTool.id)

let parsedInput: unknown = {}
try {
const parsed = serverTool.input
? JSON.parse(serverTool.input)
: {}
parsedInput = parsed && typeof parsed === 'object' ? parsed : {}
} catch {
parsedInput = {}
}

const serverToolMetadata = {
providerExecuted: true,
anthropic: {
serverToolType: serverTool.name,
resultBlockType: event.content_block.type,
result: content,
},
}

currentToolIndex++
yield {
type: EventType.TOOL_CALL_START,
toolCallId: serverTool.id,
toolCallName: serverTool.name,
toolName: serverTool.name,
parentMessageId: messageId,
model,
timestamp: Date.now(),
index: currentToolIndex,
metadata: serverToolMetadata,
}
yield {
type: EventType.TOOL_CALL_END,
toolCallId: serverTool.id,
toolCallName: serverTool.name,
toolName: serverTool.name,
model,
timestamp: Date.now(),
input: parsedInput,
}

// Text after the server tool starts a fresh message segment.
hasEmittedTextMessageStart = false
}
} else if (event.content_block.type === 'thinking') {
accumulatedThinking = ''
accumulatedSignature = ''
Expand Down Expand Up @@ -1095,6 +1257,9 @@ export class AnthropicTextAdapter<
input: currentServerTool.input,
},
)
// Hold the call until its result block arrives so we can emit
// both together as one provider-executed tool call.
completedServerTools.set(currentServerTool.id, currentServerTool)
}
currentServerTool = null
} else if (
Expand Down
Loading
Loading