Support hiding function tools until revealed — via tool search, `load_capability`, or `ToolReturn.tools` — using each provider's native deferral/addition channel
- Closes #7087
- Closes #7239 (already handled by the
prepare_messagestranslation 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:
ToolDefinitionstays pure authored intent. Resolved wire placement moved off the
definition ontoModelRequestParameters.tool_visibility, a tool-name → visibility dict
populated once byprepare_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 isToolReturn.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_idmatches 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. AnthropicModelover 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, soModelProfile.tool_additions,
ModelProfile.deferred_tools_require_tool_search, and
ModelRequestParameters.deferred_capability_idsare released surface after all (my earlier
tag check ran against a stale local tag list —git fetch --tagsmatters). All three now have
deprecation shims: the profile keys are re-declared and translated to their new spellings
(withPydanticAIDeprecationWarning) wherever profiles merge or resolve, including the
callableprofile=form (True→tool_deferral_mode='with_tool_search';Falsecarried no
signal and is dropped; an explicit new-spelling key in the same profile wins), and
deferred_capability_idssurvives 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 anInitVar,
becausedataclasses.replace()on Python 3.13+ round-trips init-only variables through
getattrand would fire the deprecation warnings on every internalreplace()call.
v2.23-era serialized parameters (Temporal) still validate. - Macroscope:
declared_tool_defsapplied 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 OpenAIadditional_toolsrenderer
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.toolsnaming 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 aUserErrorpointing atload_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_loadpins 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_toolsrenderer never carries a'withheld'definition, so a history delta can't
smuggle onto the wire a schema the request deliberately withheld, on the directModel.request
path. ('deferred'names still render — the prefix-stability test caught that the item is the
reveal mechanism for a schema-withheldtoolsentry, 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.
Mirroringsupported_native_tools: adapters declaresupported_tool_deferral_modes/
supported_tool_addition_modes(class-level, default empty), andmodel.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-partyModelsubclass that declares nothing is safe by construction (structural
test included).TestModel/FunctionModeldeclare every mode: a test double has no wire, so
the profile handed to it is the whole simulation. - Reveals now stream (closes #7186):
ToolAvailabilityDeltaEventjoinsHandleResponseEvent,
emitted after assembly-time dedupe in both executors, soevent_stream_handlers and durable
execution see reveals live. Vercel AI emits a persistentdata-tool-availability-deltachunk
and AG-UI (≥0.1.19) anActivitySnapshotEvent, 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),
ToolVisibilitydefined 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
FallbackModelfailover 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 → Anthropictool_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_toolsdedupe / single announcement). - Legacy-fabrication upgrade pins the full
additional_toolspayload on Responses (R1/R5 ×
gpt-5.6/gpt-5); output tools stay plainly declared and forceable alongside an
additional_toolsreveal; truncated reveal evidence renders no channel on Anthropic;
the announcement fallback pinsSystemPromptPartvs<system>-tagged part per
supports_inline_system_prompts; Bedrock renders the announcement + plaintoolSpecwith 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_factsnow 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 atools[]entry withhold
its schema, and under what condition (Anthropic 4.5+: standalone; first-party OpenAI Responses
gpt-5.4+: only alongsidetool_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 theadditional_toolsinput item at
reveal. Mixed runs keeptool_searchserver-side (live-verified 2026-08-03 ongpt-5.6+
gpt-5.4-miniwith controls: the hidden tool is invisible to server search, the item is
cache-safe — 2427/2430 tokens read from cache vs 0 whentools[]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
itsdefer_loading: trueentry AND renders thetool_additionblock 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 oftool_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 undermodels/)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'stools[]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 aToolAvailabilityDeltaPart(and theload_capabilitypair 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_choiceonto a hidden (withheld/via_channel) tool raises aUserErrornaming
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 clearUserError(was a bare
KeyError).RunContext.is_tool_available(name)now answers correctly inside Temporal activities (falls
back toavailable_tool_nameswhen 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-8andgpt-5.6put both reveal channels under the repo-wide cache-prefix
checker, which now (correctly) treats Anthropicdefer_loading: trueentries 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, anddefer_loadingkeeps its released, authored meaning. - PR title is fit for the release changelog.