Support hiding function tools until revealed — via tool search, `load_capability`, or `ToolReturn.tools` — using each provider's native deferral/addition channel

Merged
DO
3mo ago
+9504-882
  • Closes #7087
  • Closes #7239 (already handled by the prepare_messages translation layer; pinned here by the record/replay transition matrix)
  • Closes #7187 (fixed here: the synthesis guard now seeds uniqueness with history call IDs)
  • Closes #7186 (reveal stream events + Vercel AI / AG-UI chunks land here; state round-trip was
    already in)
  • Supersedes #7015, #7088, and #6712 (their behavior and tests are absorbed here)
  • Builds on #6793; related: #6977 (Kimi K3 follow-up), #6985 (removals follow-up)

From #6712, this PR ports the catalog-prefix steering: in runs that have a search surface (a
searchable standalone deferred tool), the deferred-capability catalog tells the model to load a
capability rather than search for its tools; capability-only runs get wording with no mention of
searching, since no search tool exists there to name. The variant is picked from authored
definitions only, so it is byte-stable across a run. #6712's search_tools description rewrite
was already on main in equivalent form, and its empty-search routing hint was deliberately not
kept: the catalog and the search tool description already separate searching from capability
loading, and the hint could only fire on locally-executed search paths.

Maintainer review round (2026-08-05)

All rulings from the maintainer's read of the deep-review round are implemented:

  • ToolDefinition stays pure authored intent. Resolved wire placement moved off the
    definition onto ModelRequestParameters.tool_visibility, a tool-name → visibility dict
    populated once by prepare_request; adapters take the looked-up value as an explicit mapper
    parameter instead of reading mutated definition copies.
  • Naming finalized: profile flags are tool_deferral_mode / tool_addition_mode, and the
    reveal field is ToolReturn.tools (a tool return that returns tools makes them available; the
    name leaves room for a later upgrade to a TypedDict with add/remove keys). All renamed surfaces
    are unreleased, so no deprecation shims.
  • #7187 fixed: a client-authored delta whose tool_call_id matches a call still present in
    history no longer clones that ID into the synthesized search exchange; the collapsed-exchange
    reuse shape (R4) still passes through. Regression tests cover the live collision, the
    passthrough, and a client-authored lookalike of a fabricated ID.
  • AnthropicModel over Bedrock/Vertex SDK clients keeps both channel claims: accepted on
    Anthropic's own platform docs (only Azure Foundry is listed as unsupported); not live-verified
    here.

Round 5: release compatibility + bot findings (2026-08-06)

  • CI Review caught a real error in this PR's compat claim: v2.23.0/v2.24.0 shipped #6793
    while this branch was in flight, so ModelProfile.tool_additions,
    ModelProfile.deferred_tools_require_tool_search, and
    ModelRequestParameters.deferred_capability_ids are released surface after all (my earlier
    tag check ran against a stale local tag list — git fetch --tags matters). All three now have
    deprecation shims: the profile keys are re-declared and translated to their new spellings
    (with PydanticAIDeprecationWarning) wherever profiles merge or resolve, including the
    callable profile= form (True → tool_deferral_mode='with_tool_search'; False carried no
    signal and is dropped; an explicit new-spelling key in the same profile wins), and
    deferred_capability_ids survives as a deprecated property deriving the capability-ownership
    set adapters actually used it for, and (per a Macroscope follow-up) as an accepted constructor
    argument that warns instead of raising — via a wrapped __init__ rather than an InitVar,
    because dataclasses.replace() on Python 3.13+ round-trips init-only variables through
    getattr and would fire the deprecation warnings on every internal replace() call.
    v2.23-era serialized parameters (Temporal) still validate.
  • Macroscope: declared_tool_defs applied the name-keyed visibility filter to output tools,
    so a hidden function tool sharing a name with an output tool could shadow it off the wire —
    now built from filtered function tools plus output tools unconditionally, matching the
    documented invariant. A follow-up round also caught the OpenAI additional_tools renderer
    declaring a tool twice when one part's list repeats a name (the dedupe set was only updated
    after the filter ran) — now marked per accepted name, matching the Anthropic renderer.
  • Veria: ToolReturn.tools naming a capability-owned tool while its capability is unloaded
    would have surfaced the tool with the capability's hooks and instructions inactive — now
    rejected with a UserError pointing at load_capability (which itself passes: the loader
    marks the capability loaded before returning its names).
  • Veria follow-up (Low): flagged that a reveal name absent from the current tool map skips
    that guard. Gating reveal state on history load markers was implemented, then reverted on the
    suite's evidence: test_delta_in_history_reveals_a_capability_tool_without_a_load pins that
    history is the trust boundary (a fabricated reveal implies a fabricable load exchange), and
    eager capabilities' search-gated tools have no load marker at all. What did land: the OpenAI
    additional_tools renderer never carries a 'withheld' definition, so a history delta can't
    smuggle onto the wire a schema the request deliberately withheld, on the direct Model.request
    path. ('deferred' names still render — the prefix-stability test caught that the item is the
    reveal mechanism for a schema-withheld tools entry, not a duplicate declaration.)

Round 4: structure over convention + reveal stream events (2026-08-06)

  • Channel claims are now enforced by the base Model, not by per-adapter convention.
    Mirroring supported_native_tools: adapters declare supported_tool_deferral_modes /
    supported_tool_addition_modes (class-level, default empty), and model.tool_deferral_mode /
    model.tool_addition_mode — the profile claim intersected with the declaration — are the only
    request-path reads. The three per-model clearing overrides (OpenAIChatModel,
    OpenAIResponsesModel, BedrockConverseModel) are deleted; profiles are pure family facts
    again; a third-party Model subclass that declares nothing is safe by construction (structural
    test included). TestModel/FunctionModel declare every mode: a test double has no wire, so
    the profile handed to it is the whole simulation.
  • Reveals now stream (closes #7186): ToolAvailabilityDeltaEvent joins HandleResponseEvent,
    emitted after assembly-time dedupe in both executors, so event_stream_handlers and durable
    execution see reveals live. Vercel AI emits a persistent data-tool-availability-delta chunk
    and AG-UI (≥0.1.19) an ActivitySnapshotEvent, both byte-matching the existing state-side
    encodings — a client that accumulates the stream ends up with exactly the state the round-trip
    decoder already accepts.
  • Naming polish per maintainer review: tool_visibility (wire is implied on
    ModelRequestParameters), declared_tool_defs/declared_function_tools (declared to the
    provider, schema possibly withheld), _resolve_request_tools (the decision table's real scope),
    ToolVisibility defined in one place and in the API docs.
  • Round-3 review fixes: four docs corrections (portability-table T4 column now says what
    actually ships; adapter-projection paragraph regains the declared/undeclared split; discovery
    tracking described as typed parts; ToolSearch injection described as unconditional), a real
    catalog-steering bug for named-native strategies (bm25/regex) fixed with a regression test,
    tool_choice × hidden-tools documented, and the plain-path visibility stamp pinned.
  • Temporal: cross-model re-preparation is now covered — a reveal serialized under one model's
    channel is re-projected onto a different model by _reprepare_messages (mutation-verified), on
    top of the earlier availability-snapshot carry.
  • Live FallbackModel failover recording: OpenAI serves the failover turn and loads the
    deferred capability; the recovered Anthropic primary projects that foreign-authored reveal onto
    tool_addition + the lazily appended deferred entry and calls the revealed tool, prefix-stable
    across both recovered requests. Only the transient failure is synthetic; every non-failed
    request is live.

Portability-matrix hardening (2026-08-05)

An adversarial audit of the matrix's blind spots drove a coverage batch, all mocked byte/dict
assertions against current code (recorded cassettes can only assert bodies frozen at record
time):

  • FallbackModel × reveals — the one path where a provider switch happens mid-run: each
    inner model projects the same stored delta onto its own channel (Anthropic fails → OpenAI
    announcement; OpenAI fails → Anthropic tool_addition + beta + deferred entry).
  • google→anthropic cross-provider capability replay recorded live (the previously missing
    matrix cell): the Sonnet-tier resume dispatches the revealed tool directly off Google-authored
    delta history.
  • Delta three-hop anthropic→google→anthropic wire proof, including reveal-order survival
    through the channel-less detour.
  • UI round-trips (Vercel AI, AG-UI) landing on OpenAI Responses and Google render the delta
    exactly once (additional_tools dedupe / single announcement).
  • Legacy-fabrication upgrade pins the full additional_tools payload on Responses (R1/R5 ×
    gpt-5.6/gpt-5); output tools stay plainly declared and forceable alongside an
    additional_tools reveal; truncated reveal evidence renders no channel on Anthropic;
    the announcement fallback pins SystemPromptPart vs <system>-tagged part per
    supports_inline_system_prompts; Bedrock renders the announcement + plain toolSpec with no
    deferral metadata; streaming smoke tests pin that both native channels share the non-streaming
    request assembly; the projection matrix gains the OpenAI-compatible Responses column
    (unit-only — no recordable first-party provider exists for it).
  • Three announcement cassettes had drifted (recorded before the announcement rewording; one even
    pinned a pre-rules announcement for a non-deferred tool). Re-recorded against current code;
    _wire_facts now counts only the one wording that exists, via the exported constant.

Deliberately not covered here: wire cassettes for the OpenAI-compatible Responses column (no
recordable first-party provider). Temporal cross-provider re-prepare, initially deferred, landed
in round 4 above.

Deep-review round (2026-08-04)

Two independent deep reviews (Codex/gpt-5.6-sol and a second Claude Fable agent) audited the
test↔guarantee mapping, cassette strength, and feature interactions; every confirmed finding is
fixed in e2a8bae2d. Highlights: parallel ToolReturn.tools dedupe is now deterministic
(assembly-time in history order); reveal-state derivation runs after history cleanup so
processor-orphaned evidence can't count; OpenAIResponsesModel sanitized vendor-profile channel
claims it can't render (the same class of leak as the Bedrock fix — both since superseded by
round 4's base-class enforcement); the cassette cache-prefix
checker now enforces the Anthropic deferred tail append-only; per-adapter hidden-tool wire tests
cover groq/mistral/cohere/huggingface/xai; and the five flagship live cassettes were re-recorded
against final code (they had drifted on the catalog wording), confirming the real models still
drive the full reveal flows.

One rule instead of per-provider special cases

A tool that is hidden and not a member of an on-wire searchable corpus is never on the wire.
It reaches the model only when revealed, through the best channel the profile advertises — and
native tool search stays fully native everywhere, because there is nothing on the wire for it to
leak.

Two profile facts state each provider's abilities directly, replacing the previous inference
chains (supports ToolSearchTool ⇔ can defer, capability-ID plumbing, client-side forcing):

  • tool_deferral_mode: 'standalone' | 'with_tool_search' | None — can a tools[] entry withhold
    its schema, and under what condition (Anthropic 4.5+: standalone; first-party OpenAI Responses
    gpt-5.4+: only alongside tool_search; everyone else: no).
  • tool_addition_mode: 'by_reference' | 'with_definitions' | None (from #6793) — how a
    mid-conversation reveal travels.

Model.prepare_request resolves every tool in one decision table into
ModelRequestParameters.tool_visibility ('visible' | 'deferred' | 'withheld' | 'via_channel' per tool name); adapters and prepare_messages read the dict instead of
re-deriving policy, and ToolDefinition itself is never mutated on its way into a model.

Per provider class, the reveal now is:

  • First-party OpenAI Responses ('with_definitions'): a hidden tool never appears in
    tools[] at any point; its full definition travels in the additional_tools input item at
    reveal. Mixed runs keep tool_search server-side (live-verified 2026-08-03 on gpt-5.6 +
    gpt-5.4-mini with controls: the hidden tool is invisible to server search, the item is
    cache-safe — 2427/2430 tokens read from cache vs 0 when tools[] is touched).
  • Anthropic delta tier ('by_reference', Opus 4.8/5): lazy advertisement — in a run with
    a search surface, the hidden tool is withheld until its reveal, then the same request appends
    its defer_loading: true entry AND renders the tool_addition block referencing it. Deferred
    entries are entirely outside Anthropic's cache key (measured 2026-07-27, re-verified on all
    three tiers 2026-08-03: mid-run deferred appends are full cache HITs), so the cached prefix
    survives the reveal. The entry stays deferred forever — graduating it would mutate the hashed
    prefix.
  • Anthropic sub-delta tier (Sonnet 5/4.6, Haiku 4.5): same lazy append; the reveal mechanism
    is the synthesized search exchange instead of tool_addition.
  • Capability-only runs on Anthropic (no search surface on the wire): unchanged — hidden tools
    stay pre-advertised as deferred entries from turn 1. Nothing can leak them, and the stable
    declaration means no configuration ever pays a reveal-time cache transition. No dummy tools.
  • Channel-less models (Google, OpenAI Chat, third-party Responses): revealed tools join
    tools[] visibly with a prose announcement — the one-time bust that has no alternative there.

Any tool can reveal tools: ToolReturn.tools

Capability loads stop being special-cased. A tool that makes deferred tools available names them
on its rich return; the executor records a ToolAvailabilityDeltaPart immediately after that
call's ToolReturnPart and load_capability is now a plain consumer of the same public
mechanism. Reveals are durable history facts from any source (capability load, tool return, UI
client), and RunContext.is_tool_available reduces to two arms — always-visible, or named in
history — with no capability knowledge at all.

Deleted

  • ModelRequestParameters.deferred_capability_ids (and every capability_id read under models/)
  • ModelProfile.deferred_tools_require_tool_search (both unreleased, post-#6793)
  • The client-side tool-search forcing (_resolve_tool_search_native_for_capability_gated_tools)
    and its plumbing — with never-advertise-while-hidden there is nothing for server search to
    leak, on any model.

Behavior changes to note

  • ToolSearch(strategy='bm25'|'regex') alongside a deferred capability no longer raises —
    the incompatibility was an artifact of pre-advertising. Named strategies still raise where the
    model lacks them.
  • Anthropic mixed runs (searchable corpus + deferred capability) now use native server-side
    search instead of being forced client-side.
  • The Anthropic request builder raises a UserError (instead of an opaque API 400) when a
    hand-built request's tools[] would be entirely deferred — the API requires ≥1 non-deferred
    entry.

Latest-model live coverage (2026-08-03)

Both reveal channels and the channel-less fallback are pinned by live cassettes on the newest
tier: claude-fable-5 (lazy advertisement, appended entry + tool_addition, model calls the
revealed tool), gpt-5.6-sol (byte-identical tools[] across four interactions, reveal via
additional_tools, server-side search), and gemini-3.6-flash (announcement path, marked as the
one intentional prefix move). Cache measurements: Opus 4.8 reads the full warmed prefix across a
reveal, and the mid-conversation-tool-changes beta header is measured to be outside the
cache key, so the header-at-reveal gating is cache-free; gpt-5.6-sol preserves 6415/6418 cached
tokens across the item append (touching tools[] reads 0). claude-fable-5's cache-read path was
intermittently unavailable service-side during verification (writes report, reads don't
materialize, in every request shape — including ones that read fine an hour earlier); its wire is
byte-stable per the checker, so this is provider service state, not a property of this change.

Interaction hardening (from two adversarial audits)

  • Reveal state is derived from the outgoing message list, not raw run state, so the wire is
    self-consistent by construction when history processors or summarizers rewrite history: a
    processor that drops a ToolAvailabilityDeltaPart (and the load_capability pair the backfill
    heals from) reverts the tool to hidden instead of leaving a dangling declaration or a
    never-revealed deferred entry. The history-processor docs state the preservation contract.
  • Forcing tool_choice onto a hidden (withheld/via_channel) tool raises a UserError naming
    the reveal mechanisms, instead of a silent drop or a provider 400. Deferred-declared corpus
    entries stay forceable.
  • A ToolReturn.tools/delta naming an always-visible tool is now the documented no-op on both
    channels; duplicate reveals are deduped at authoring and rendering.
  • TestModel(call_tools=[...]) naming a hidden tool gets a clear UserError (was a bare
    KeyError).
  • RunContext.is_tool_available(name) now answers correctly inside Temporal activities (falls
    back to available_tool_names when live tool state is unavailable), and Temporal gained an
    end-to-end workflow test for capability and activity-authored reveals.

Test notes

  • Mocked two-request byte comparisons pin the append-at-reveal shape on both Anthropic tiers and
    OpenAI mixed mode (a cassette can't see a two-request property); live cassettes on
    claude-opus-4-8 and gpt-5.6 put both reveal channels under the repo-wide cache-prefix
    checker, which now (correctly) treats Anthropic defer_loading: true entries as outside the
    canonical prefix while still flagging any visible-entry change.
  • Two #6793 review findings about intra-request ordering are resolved as wontfix by the
    maintainer's ordering principle (parts within one request all precede the same assistant
    response; rendering is deterministic and append-only) — documented at the two rendering sites.

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 — the three #6793 surfaces that shipped in v2.23/v2.24 (tool_additions, deferred_tools_require_tool_search, deferred_capability_ids) have deprecation shims (see round 5); everything else renamed or deleted is unreleased, and defer_loading keeps its released, authored meaning.
  • PR title is fit for the release changelog.