Skip to content

Replay Anthropic web search and web fetch turns as server tool blocks - #178

Merged
shibayan merged 13 commits into
masterfrom
replay-anthropic-server-tool-calls
Oct 20, 2026
Merged

shibayan merged 13 commits into
masterfrom
replay-anthropic-server-tool-calls

Conversation

@shibayan

Copy link
Copy Markdown
Member

What this changes

Stacked on #176 (it reuses that PR's open-turn rule for unanswered calls). Merge #176 first; this PR then needs a rebase onto master.

A turn that used the web search or web fetch server tool now replays as the blocks the API sent. The receive side types such a call as an informational function_call and its result as a function_result, and the conversion sent them back as a local tool_use / tool_result pair for a tool the request does not define. This happened both in memory and after a restore.

They now go back as server_tool_use and web_search_tool_result / web_fetch_tool_result on the assistant turn:

  • In memory: the raw blocks are replayed.
  • After a restore: the blocks are rebuilt from the typed contents. The result's block type comes from the name of the call it answers, and its content is the result as the wire spelled it.

How unanswered calls and other providers are handled:

  • Recognizing a server call: a call counts as a server call if it holds its raw server_tool_use block or, after a restore, if its id has the srvtoolu_ prefix the API gives every server call. An informational call reported by another provider keeps the local mapping.
  • Unanswered server calls follow Rebuild restored code-execution turns from what the wire said #176's rule for code-execution calls. They are kept on the open turn, which a pause_turn continuation or a server call waiting on a local tool result needs; previously such a call was dropped by the local unanswered-call filter. Once the turn has closed, they are omitted.

Parity

  • Reference checked: Python agent_framework_anthropic/_chat_client.py, _prepare_message_for_anthropic. Python sends an informational function_call as tool_use and its function_result as tool_result, which is what this package did. This deliberately diverges. The Messages API server-tools documentation describes server calls as server_tool_use blocks paired with their own result blocks on the assistant turn, re-sent as-is for a pause_turn continuation. A server call whose turn closed without its result is refused with … tool use with id … was found without a corresponding …_tool_result block. The receive-side mapping is unchanged and still matches Python.
  • Wire format affected: no (request shape only; nothing serialized changes)
  • Public API affected: no
  • Breaking change: no

Checklist

  • pnpm check passes (lint, typecheck, build, test)
  • Behaviour changes are covered by a test that fails without the change (7 of the 10 new cases fail with the fix reverted; the other 3 pin existing behaviour: a closed-turn call is dropped, and another provider's informational call stays local)
  • Public API changes are reflected in the package README and CHANGELOG.md

🤖 Generated with Claude Code

shibayan and others added 6 commits October 20, 2026 15:58
A restored transcript rebuilds the code-execution result blocks from the
typed contents, but two payload shapes were rebuilt wrongly:

- A payload variant this build does not model is preserved as unknown
  content on receive, yet the rebuild ignored it and invented an empty
  success payload (return_code 0) for code execution, bash and the text
  editor. It is now sent back exactly as it arrived.
- An encrypted_code_execution_result surfaces as plain text, so the
  rebuild sent the ciphertext back as plaintext stdout. The text output
  now carries additionalProperties.encrypted, and the rebuild restores
  the encrypted_code_execution_result / encrypted_stdout shape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A malformed code-execution file entry and an unmodelled payload variant
both arrive as unknown content among the result's outputs, so a lone
entry of an unexpected type was replayed after a restore as if it were
the whole payload. The receive side now marks such entries with
additionalProperties.fileEntry; the rebuild keeps them inside the
rebuilt result and strips the marker from the wire block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unknown content carries the wire entry's fields, so a malformed entry
that already had additionalProperties lost them when the file-entry
marker replaced the record. The marker now joins the existing record,
and the rebuild strips only the marker. An entry whose
additionalProperties is not a non-empty record stays unmarked, since
merging into it could not be undone exactly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The file-entry marker lived in the entry's own additionalProperties,
which unknown content shares with the wire's fields, so an entry whose
wire metadata used the same key lost it after a save and restore. The
receive side now marks the code_interpreter_tool_result itself, which
the framework owns, as additionalProperties.unmodelledPayload when its
payload is a variant this build does not model; every other unknown
output is a file entry and is replayed verbatim inside the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The typed outputs of a code-execution, bash or text-editor result keep
the outcome but not everything the wire said, so a restored transcript
rebuilt some blocks wrongly: a failed run went back with return_code 0,
an encrypted result with empty stdout went back as a plain one, a bash
error with an unknown code went back as a successful run, and a
text-editor view reading like a create result, or a str_replace with
only the replaced span, went back as another variant.

The receive side now records the wire payload's type (payloadType) and a
code-execution run's non-zero return code (returnCode) on the result
content, which the framework owns. The rebuild reads the variant from
that record, falling back to the typed outputs for a transcript stored
before it existed; this subsumes the separate encrypted-stdout and
unmodelled-payload markers. Unknown bash error codes now degrade to
unavailable, as on the code-execution path.

A code-execution call that no result answers is now omitted once its
turn has closed, in memory and after a restore, since the API refuses
such a request; it is kept on the open turn, where a pause_turn
continuation or a pending local tool result still needs it. The
rebuilt server_tool_use helper also takes the call content type it is
only ever given, dropping its dead empty-block branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The receive side types a web search or web fetch call as an
informational function_call and its result as a function_result, and
the conversion sent them back as a local tool_use / tool_result pair for
a tool the request does not define. They now go back as server_tool_use
and web_search_tool_result / web_fetch_tool_result on the assistant
turn: the raw blocks while held, and blocks rebuilt from the typed
contents after a restore, with the result type taken from the name of
the call it answers.

A server call is recognized by its raw server_tool_use block or, after a
restore, by the srvtoolu_ id prefix, so an informational call reported
by another provider keeps the local mapping. An unanswered server call
follows the same open-turn rule as a code-execution call: kept on the
open turn for a pause_turn continuation or a pending local tool result,
omitted once the turn has closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Usage: [Issues, PRs], Target: documentation changes anthropic Usage: [Issues, PRs], Target: packages/anthropic labels Oct 20, 2026
shibayan and others added 6 commits October 20, 2026 16:41
The open-turn rule took the last assistant-role message as the open
turn, so a local tool result carried in an assistant-role message, which
goes to the user side as a tool_result, was mistaken for a later
assistant turn and dropped the pending server call. The open turn is now
the last message carrying anything but local tool results; a result
answering a provider-executed call still counts as assistant content.

A str_replace span with no replacement lines to quote carries no
snippet, so a replaced-only span was rebuilt as an inserted one. Each
str_replace line-span citation now records which span it is under
additionalProperties.lineSpan, and the rebuild uses that record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes the open-turn rule's move to emitted roles: a function_result
answering a server tool call is part of the assistant turn, so it is not
counted as a local result when deciding which turn is still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The open-turn rule read turns off framework messages, but a framework
message can split into several request turns and one that sends nothing
is not a turn at all. A usage-only message after a paused call closed
the turn and dropped the pending call, and a message bundling a server
call, a local call, its result and final text exempted the whole
message, keeping a call whose request turn had closed.

The rule now runs on the converted request messages: the open turn is
the trailing run of assistant messages followed only by local
tool_result messages, and a server_tool_use outside it that no result
block answers is omitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The updated restored-replay branch decides abandoned server calls from
the request messages rather than framework messages; that pass already
covers web search and web fetch server_tool_use blocks, so the
message-level server-call handling here goes away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The abandoned-call pass recognized a provider call only as a
server_tool_use block, so a code-execution call the receive side read
from a tool_use block, replayed raw with that spelling, stayed in a
closed turn in memory while its restored form was dropped. The blocks
emitted for provider-executed calls are now collected during conversion
and the pass filters by that identity, leaving local tool_use blocks
alone.

The synthetic Continue turn checked only the last request message, so an
open turn split across assistant messages, with its pending call in an
earlier one, was closed on that call. A pending call anywhere on the open
turn now keeps Continue out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The abandoned-call pass now filters by the blocks emitted for
provider-executed calls rather than by block type, so the server_tool_use
blocks emitted for web search and web fetch calls are registered as
provider calls when they are converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from replay-unmodelled-code-execution-payloads to master October 20, 2026 08:01
Master now carries the squash-merged restored-replay change this branch
was stacked on, plus the OTLP/gRPC peer range entry. The restored-replay
code here already matches master's, so the conflicts resolve to this
branch's version with both changelog entries kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 20, 2026 08:02
@shibayan
shibayan merged commit fba296e into master Oct 20, 2026
9 checks passed
@shibayan
shibayan deleted the replay-anthropic-server-tool-calls branch October 20, 2026 08:04

Copilot AI left a comment

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.

🔵 Needs a closer look

Split-message result routing needs correction, and the deliberate reference divergence requires maintainer validation.

1 open issue
What changed in this PR

Updates Anthropic transcript replay to preserve web search and web fetch server-tool blocks in memory and after restoration.

Changes:

  • Recognizes server calls and rebuilds their matching results.
  • Preserves unanswered calls on open turns.
  • Adds replay regression tests and documents the behavior change.
File Description
packages/​anthropic/​src/​to-anthropic.ts Recognizes and reconstructs web server-tool exchanges.
packages/​anthropic/​src/​server-tool-replay.test.ts Tests replay, restoration, and unanswered-call handling.
CHANGELOG.md Documents replay changes and deliberate reference divergence.

Review effort: Balanced

// A server tool's result, reached without its raw block — a restored transcript. The
// receive side kept the block's `content` as the result, as the wire spelled it, so the
// block goes back around it on the assistant turn that produced it.
blocks.push({ type: serverResultType, tool_use_id: content.callId, content: content.result });
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

anthropic Usage: [Issues, PRs], Target: packages/anthropic documentation Usage: [Issues, PRs], Target: documentation changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants