Add `ToolAvailabilityDeltaPart` with native `tool_addition` and `additional_tools` rendering

Merged
DO
3mo ago
+9871-3095

Closes #6997

When a deferred capability loads, we append a synthetic ToolSearchCallPart / ToolSearchReturnPart
pair to history — a tool search the model never performed, with a blake2s-derived fake tool_call_id.
It fabricates assistant activity, welds application-driven reveals to tool search, and makes one provider's
wire format the canonical representation.

This adds a first-class representation instead:

class ToolAvailabilityDeltaPart:
    added: list[str]
    tool_call_id: str | None

Names, not definitions — definitions live in tools[], and duplicating them into history would be a
second source of truth that drifts. No provider_name, no wire form: the part is portable by
construction, which is what makes the matrix below tractable.

Rendering, per model

targetrendering
Anthropic with the mid-conversation-tool-changes-2026-07-01 beta (Opus 5, Opus 4.8, Fable 5)tool_addition block on a {'role': 'system'} message
OpenAI Responses on models that honour it (5.6, 5.6-sol, 5.5, 5.4-mini, 5.1, 4.1)additional_tools input item
a model that can withhold a tool's schemathe tool-search exchange, because that exchange is the mechanism that unhides it
everything elsea SystemPromptPart stating what became available

Both model lists were verified live per model, N=10 with controls, and are not derivable from a
version cutoff — claude-sonnet-5 rejects tool_addition while the older claude-opus-4-8 accepts it,
and gpt-5.4-mini honours additional_tools while gpt-5.4 silently ignores it. OpenAI gives no
error
on models that ignore the item, so its profile flag defaults to off and opts in only measured
models.

The last two rows are the same question asked twice. Where a model withholds a schema behind a wire
defer_loading flag, the tool-search exchange is what takes the flag off — Anthropic renders the return
as the tool_reference block that unhides it — so removing the exchange would leave the tool hidden for
good. Where the tool is plainly in tools from the turn it's revealed, the exchange carries no mechanism,
only news, and stating the news beats fabricating a call the model never made. "Can withhold a schema" is
narrower than "has native tool search": OpenAI has tool search but rejects defer_loading without a
tool_search tool on the wire, and a capability-only corpus has nothing to put there.

An empty search corpus no longer advertises a search

Splitting "member of the searchable corpus" from "hidden until revealed" also settles #6997, reported
independently while this was in review: where every deferred tool is capability-owned, the corpus is
empty at every point in the run — the tools are excluded before their capability opens, and drop out via
revealed_tool_names after — yet search_tools was still offered, and could only ever answer "no
matching tools found". At least one model (MiniMax M3) called it on every turn.

Measured on the reporter's scenario, tools reaching the wire:

adaptermainthis PR
Google gemini-3-flash-preview['load_capability', 'search_tools']['load_capability']
OpenAI Chat gpt-5['load_capability', 'search_tools']['load_capability']
OpenAI Responses gpt-5['load_capability', 'search_tools']['load_capability']
Anthropic claude-opus-4-8[…, 'lookup_refund_policy', 'search_tools'][…, 'lookup_refund_policy']

The guard sits at the shared resolution layer rather than at each adapter, which is why all four agree —
and matters for the reported case, since MiniMax goes through the OpenAI-compatible path that has no
tests of its own.

Removals are out of scope

removed is not on the part at all. Anthropic's tool_removal requires shrinking the tools section,
which breaks the very cached prefix this part exists to protect, so there is no cache-safe rendering to
offer. Shipping the field unsupported would only advertise something we can't do. A cache-safe design is
tracked in #6985.

Standing system prompts: a behaviour change worth reading

prepare_messages decides which SystemPromptParts are the run's standing system prompt — the ones a
provider hoists into its top-level system parameter — and which are mid-conversation instructions that stay
where they were authored. That decision used to be "everything in the first ModelRequest". It is now
"the opening contiguous run of SystemPromptParts in that request".

This is a bug fix first. _clean_message_history merges adjacent requests that no assistant turn
separates, so the announcement this PR adds landed inside the leading request and was hoisted — on
Google, into systemInstruction:

"systemInstruction": {"parts": [{"text": "The following tools have become available to you: `lookup_exchange_rate`."}], "role": "user"}

Adding a tool was rewriting the first cache section of every later request. Anthropic and Bedrock had the
same defect by the same route; all three now have a regression test. The seven adapters that emit a
SystemPromptPart inline at its position were never affected.

It reaches beyond the delta, though, which is why it's called out here. A SystemPromptPart authored
after a user prompt or a tool return in the first request — from a history processor, from
RunContext.enqueue, or from a hand-built message_history — is now treated as mid-conversation: an
inline system message where the provider serves one, <system>-tagged text where it doesn't, rather than
being hoisted to the front. The ordinary [SystemPromptPart, UserPromptPart, ...] shape is unaffected.

Two existing behaviours changed with it, both visible in the diff: history processors
(tests/test_history_processor.py) and MCP sampling, where such a part no longer becomes the sampling
systemPrompt. I believe the new rule is right — the agent never produces that shape for a standing
prompt, so anything that does placed it deliberately, and hoisting silently moved it to the front and into
the cached prefix — but it's a judgement call on the version policy rather than a pure fix, so it should
be read as such.

Portability

Every combination of history representation × target is pinned by a test with its own recorded cassette —
30 cells, none shared, none mocked (10 Anthropic, 15 OpenAI, 5 Google), plus five A→B→A replays for
the "user switched models mid-conversation" case. A decision table in the docs states what each cell
renders and why.

One rule the matrix enforces: a genuine model-driven search is never rewritten as an application-driven
addition.
Search is discovery; a delta is control. Upgrading one into the other would misrepresent what
happened, and a stored history can't tell you which it was.

All 30 recordings were re-made against the current code. They had drifted — every one predated a second
tool being added to the test — and two of them, once refreshed, went red on the systemInstruction bug
above. Because VCR matches on method/path/host only, a cassette keeps replaying after the rendering
changes and the assertions go on describing a request we stopped sending; #6993 has the repo-wide
measurement.

The tools cache section

A delta exists so availability can change without the prefix moving, and tools is the first cache
section — ahead of system/instructions and every message — so a difference there invalidates all of it.
Two consecutive requests differing only in the trailing delta, compared as bytes:

  • Anthropic holds. It used to drop the native tool-search tool as soon as any delta appeared in
    history. (An earlier revision of this description called that a fix; it was the opposite.) There was
    nothing to gain: a tool_addition block alongside tool_search_tool_bm25 returns 200 and the model
    calls the revealed tool.
  • OpenAI Responses holds too. It used to promote the revealed definition out of tools into the
    additional_tools item and drop tool_search behind it, taking two of three entries with it — worse
    than Anthropic, on the one turn that was supposed to be free. A still-deferred entry plus tool_search
    plus an item naming the same tool returns 200 and is also cheaper, because the model then calls the
    tool directly instead of burning a tool_search_call round-trip first.

test_tool_availability_delta_and_the_tools_cache_section asserts both. It's mocked deliberately: VCR
matches method/path/host, so a cassette assertion reads the file rather than the live request, and one
cassette holds one request where the question is about two.

Follow-up

Several existing features change the tool set mid-run and still invalidate the cached prefix —
FunctionToolset.add_function(), dynamic toolsets, MCP tools/list_changed, prepare_tools. They should
move onto this primitive; tracked separately.

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. — see "Standing system prompts" above; this needs a maintainer call.
  • PR title is fit for the release changelog.