fix(ai-anthropic): preserve web_search/web_fetch results across turns (#839)#855
Conversation
Anthropic server-tool blocks (server_tool_use + *_tool_result) were dropped during streaming, so a follow-up turn could no longer see prior web-search evidence (#839). They now stream as a provider-executed tool call carrying the raw result, which the agent loop skips and the adapter replays verbatim into the next request. - core: add ProviderExecutedToolMetadata convention + isProviderExecutedToolCall / getProviderExecutedMetadata helpers; skip provider-executed calls in the agent loop; treat them as complete in StreamProcessor; guard isToolCallPartErrored against parts-less seeded messages - anthropic: emit server tools as provider-executed tool calls during streaming; replay server_tool_use + result blocks in formatMessages - docs: provider-tools multi-turn persistence section - tests: round-trip coverage + inverted server-tool assertions Closes #839 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds provider-executed tool metadata and Anthropic adapter support so ChangesProvider-executed Anthropic tools
Sequence Diagram(s)sequenceDiagram
participant chat
participant anthropicText
participant processAnthropicStream
participant StreamProcessor
chat->>anthropicText: formatMessages() with providerExecuted metadata
anthropicText->>processAnthropicStream: emit server_tool_use and web_*_tool_result blocks
processAnthropicStream->>StreamProcessor: TOOL_CALL_START / TOOL_CALL_END
StreamProcessor->>chat: getMessages() for the next turn
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
testing/e2e/tests/anthropic-server-tool.spec.tsParsing error: "parserOptions.project" has been provided for Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚀 Changeset Version Preview9 package(s) bumped directly, 15 bumped as dependents. 🟥 Major bumps
🟨 Minor bumps
🟩 Patch bumps
|
|
View your CI Pipeline Execution ↗ for commit 6deab10
☁️ Nx Cloud last updated this comment at |
|
View your CI Pipeline Execution ↗ for commit c964f0f
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-devtools-core
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-openrouter
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with 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.
Inline comments:
In `@docs/tools/provider-tools.md`:
- Around line 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.
In `@packages/ai-anthropic/src/adapters/text.ts`:
- Around line 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.
In `@packages/ai-anthropic/tests/anthropic-adapter.test.ts`:
- Around line 1166-1170: The `chunks.find(...)` usage in
`anthropic-adapter.test.ts` is relying on redundant casts for the
`TOOL_CALL_START` event, even though `ToolCallStartEvent` already includes
`toolCallId` and `metadata`. Update the `serverStart` lookup to use a type
predicate in the `find()` callback, following the pattern already used elsewhere
in this test file, so the result narrows naturally without any `as (StreamChunk
& { metadata?: Record<string, unknown> }) | undefined` assertion.
In `@packages/ai/src/activities/chat/stream/processor.ts`:
- Around line 405-413: areAllToolsComplete() still assumes lastAssistant has
parts and can crash on seeded ModelMessage-shaped assistant messages. Update
this helper to guard against a missing parts array before iterating or
dereferencing it, similar to the existing handling in isToolCallPartErrored(),
and make sure the provider-executed tool-call check in processor.ts remains
reachable after the normalization/guard.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 6313ca57-5134-43ac-9b83-7eb2fd3c47af
📒 Files selected for processing (11)
.changeset/anthropic-server-tool-roundtrip.mddocs/config.jsondocs/tools/provider-tools.mdpackages/ai-anthropic/src/adapters/text.tspackages/ai-anthropic/tests/anthropic-adapter.test.tspackages/ai/src/activities/chat/index.tspackages/ai/src/activities/chat/stream/processor.tspackages/ai/src/index.tspackages/ai/src/types.tspackages/ai/src/utilities/provider-executed.tspackages/ai/tests/messages.test.ts
| 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.' }, | ||
| ], | ||
| }) |
There was a problem hiding this comment.
🎯 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.
| 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.
| 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, | ||
| } |
There was a problem hiding this comment.
🗄️ 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.
| 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.
| const serverStart = chunks.find( | ||
| (c) => | ||
| c.type === 'TOOL_CALL_START' && | ||
| (c as { toolCallId: string }).toolCallId === 'srv_fetch', | ||
| ) as (StreamChunk & { metadata?: Record<string, unknown> }) | undefined |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor
Remove redundant type assertions and replace find() calls with type predicates.
The casts to (StreamChunk & { metadata?: Record<string, unknown> }) | undefined are unnecessary because ToolCallStartEvent already defines metadata and toolCallId. Using type predicates as shown elsewhere in the file (lines 1643-1644) properly narrows the type without assertions, resolving the @typescript-eslint/no-unnecessary-type-assertion warnings.
Apply fix
- const serverStart = chunks.find(
- (c) =>
- c.type === 'TOOL_CALL_START' &&
- (c as { toolCallId: string }).toolCallId === 'srv_fetch',
- ) as (StreamChunk & { metadata?: Record<string, unknown> }) | undefined
+ const serverStart = chunks.find((c): c is Extract<StreamChunk, { type: 'TOOL_CALL_START' }> =>
- c.type === 'TOOL_CALL_START' && c.toolCallId === 'srv_fetch'
- )- const start = chunks.find((c) => c.type === 'TOOL_CALL_START') as
- | (StreamChunk & { metadata?: Record<string, unknown> })
- | undefined
+ const start = chunks.find((c): c is Extract<StreamChunk, { type: 'TOOL_CALL_START' }> =>
- c.type === 'TOOL_CALL_START'
- )🧰 Tools
🪛 ESLint
[error] 1166-1170: This assertion is unnecessary since it does not change the type of the expression.
(@typescript-eslint/no-unnecessary-type-assertion)
🤖 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/tests/anthropic-adapter.test.ts` around lines 1166 -
1170, The `chunks.find(...)` usage in `anthropic-adapter.test.ts` is relying on
redundant casts for the `TOOL_CALL_START` event, even though
`ToolCallStartEvent` already includes `toolCallId` and `metadata`. Update the
`serverStart` lookup to use a type predicate in the `find()` callback, following
the pattern already used elsewhere in this test file, so the result narrows
naturally without any `as (StreamChunk & { metadata?: Record<string, unknown> })
| undefined` assertion.
| // 4. It is provider-executed (e.g. Anthropic web_search) — already run by | ||
| // the provider, so there is no client result to wait for. | ||
| return toolParts.every( | ||
| (part) => | ||
| part.state === 'complete' || | ||
| part.state === 'approval-responded' || | ||
| (part.output !== undefined && !part.approval) || | ||
| toolResultIds.has(part.id), | ||
| toolResultIds.has(part.id) || | ||
| isProviderExecutedToolCall(part), |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
areAllToolsComplete() can still throw on seeded ModelMessage-shaped messages.
This PR already treats parts-less initialMessages as a supported runtime shape in isToolCallPartErrored(), but areAllToolsComplete() still dereferences lastAssistant.parts unconditionally earlier in the same method. A seeded assistant message without parts will still crash this helper before the new provider-executed clause is reached. Normalizing initialMessages once would be ideal; short-term, this method needs the same guard.
🤖 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/src/activities/chat/stream/processor.ts` around lines 405 - 413,
areAllToolsComplete() still assumes lastAssistant has parts and can crash on
seeded ModelMessage-shaped assistant messages. Update this helper to guard
against a missing parts array before iterating or dereferencing it, similar to
the existing handling in isToolCallPartErrored(), and make sure the
provider-executed tool-call check in processor.ts remains reachable after the
normalization/guard.
| (resultBlockType !== 'web_search_tool_result' && | ||
| resultBlockType !== 'web_fetch_tool_result') |
There was a problem hiding this comment.
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
…b_fetch The #604 regression spec still asserted web_fetch was never surfaced as a tool call. After #839, provider-executed server-tool results stream as a provider-executed TOOL_CALL_START/END pair so they persist across turns. Invert the assertions: expect two tool calls, the web_fetch flagged metadata.providerExecuted with its raw result, both with clean parsed input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Anthropic
web_search/web_fetchresults were not preserved across turns when usingchat()+StreamProcessor(#839). The adapter accumulated theserver_tool_useand*_tool_resultblocks during streaming but emitted nothing for them, so the evidence never became part of the assistant message and never round-tripped — a follow-up turn could no longer cite the prior sources. The official Anthropic SDK preserves them because you push the assistant turn's fullcontent(including those blocks) back into the next request.These blocks were deliberately dropped because routing a server-executed tool through the normal
TOOL_CALL_*path makes the agent loop try to executeweb_searchclient-side (it's aProviderToolwith noexecute) and hang.Fix — provider-executed tool calls
Server tools now stream as a provider-executed tool call carrying the raw result, which the agent loop skips and the adapter replays verbatim.
Core (
@tanstack/ai)ProviderExecutedToolMetadataconvention +isProviderExecutedToolCall/getProviderExecutedMetadatahelpers (exported).getPendingToolCallsFromMessagesskips provider-executed calls so the loop never runs them.StreamProcessor.areAllToolsCompletetreats them as complete;isToolCallPartErroredhardened against parts-less seededinitialMessages(the latent crash this change exposed — exactly the issue'snew StreamProcessor({ initialMessages: messages })pattern).Anthropic adapter
server_tool_useand, when its matching result block arrives, emits oneTOOL_CALL_START/ENDpair tagged{ providerExecuted: true, anthropic: { serverToolType, resultBlockType, result } }.formatMessages: replays those as the originalserver_tool_use+*_tool_resultblocks in the assistant content.Test plan
StreamProcessor→ second turn preserves the blocks), and a core converter test. 125 anthropic + 1107 core tests pass.pnpm test:pr: green across 33 projects (sherif, knip, docs, kiira, eslint, lib, types, build).docs/tools/provider-tools.md(per the source↔docs coupling rule).No e2e added — aimock can't synthesize
server_tool_useblocks, so unit + live coverage stands in (noted in the changeset).Closes #839
🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
New Features
Documentation