Drop native tool calls the replayed payload has no result block for

Merged
DS
2mo ago
+1500-123

This pull request was posted by Codex Desktop using gpt-5.6-sol on behalf of David.

Found while working on #6692; split out because it stands on its own and #6692 stacks on top of it.

A stored or locally transformed conversation that has continued past an unresolved native tool call becomes permanently unsendable. Anthropic rejects the whole request:

web_search tool use with id srvtoolu_01 was found without a corresponding web_search_tool_result block

Anthropic's current contract deliberately allows an unpaired server or MCP call while the request ends at the client-tool result turns answering the same generation. The server runs the pending native tool and returns its result at the start of the next response. This PR preserves that valid in-flight shape.

The failure begins when a replayed payload has moved beyond that point without a rendered result: for example, stale history created under older provider behavior, the Bedrock tool-search path reported in #5143, or a history processor that made a native result unrenderable. Every later request built from that history is rejected.

Measured

claude-sonnet-4-5, histories differing only in what follows the call:

shapeverdict
request ends at the tool-result turnACCEPTED
two tool_result-only user turns followACCEPTED
a turn with any other content followsREJECTED
control: same, server_tool_use removedACCEPTED

No reasoning, tool output, or other content varies across those arms — the unpaired call alone decides it.

The fix

_collect_orphan_tool_search_call_ids already dropped this shape, but only for tool search and only by pairing message parts. Pairing now reads the blocks the mapper actually built, across the whole payload. That also covers a second way a call goes unpaired: the result arrived but didn't render. A history processor that trims a large search payload to a string leaves a NativeToolReturnPart whose content has no block shape here, so the result is skipped and the call it answered is left dangling.

Three behaviors are deliberate:

  1. In-flight calls are kept. A dangling advisor call on a pause-turn resume has to replay — dropping it would break the resume. test_anthropic_advisor_dangling_call_replayed_when_active pins this and caught an earlier, broader version of this change.
  2. Tool search drops even in flight, preserving the behavior introduced in #5143 after a Bedrock user reported the orphan 400. The direct API tolerates the shape and a search is cheap for the model to repeat.
  3. A cache breakpoint that landed on the dropped block walks back rather than vanishing. A leading CachePoint attaches to the end of the previous message, which can be the assistant turn holding the unpaired call; losing it would silently re-process the tail on every later request. The search runs over every block ahead of the dropped one, since the nearest is often neither the block before it nor even in the same turn — thinking takes no cache_control, and a replayed MCP result is a model rather than a mapping. A payload holding no carrier at all gives the breakpoint up instead of failing the request.

Dropping beats synthesizing an empty result block, which would tell the model the tool ran and returned nothing.

Behavior change worth a look

For non-tool-search server tools, an unpaired call that is not in flight was previously sent and is now dropped. That request was rejected by Anthropic, so no working call changes — but if a reviewer knows a provider path where such a call was meaningful, that's the thing to flag.

Tests

tests/models/anthropic/test_unpaired_native_tool_calls.py — ten mapper-level cases (result absent, unrenderable result, paired kept, result in a later response, turn emptied, in-flight kept, in-flight kept behind two tool-result turns, in-flight tool search dropped, and the mcp_tool_use drop and in-flight pair), five cache-boundary tests, the tool-search drop through an agent run, and three VCR tests.

Each recording pins a claim about what the API accepts: that the previously rejected history is answered once the call is dropped, and that keeping an in-flight server_tool_use and an in-flight mcp_tool_use are both safe. Everything else is asserted on the mapper's own output, because Anthropic cassettes match on method and URI, so a recording replays green whether or not the call was dropped.

Removed test_collect_orphan_tool_search_call_ids_pairs_across_responses, a unit test on the deleted private helper; its cross-response pairing invariant is now a case in the new file.

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • No breaking changes in accordance with the version policy.
  • PR title is fit for the release changelog.