Require a deferred tool to be revealed, and its capability loaded, before it can be called

Merged
DO
3mo ago
+875-217

A deferred tool could be called at any time. Hiding a tool kept its schema out of the prompt but never gated execution, so a model that named a hidden tool — remembered from a summary after compaction, or simply guessed — got it executed, skipping the search or the capability load that was supposed to come first. Availability is now the gate: a tool that is not currently available cannot be called.

The ordering a run holds to is load, then reveal, then call. A capability's instructions, hooks, and model settings arrive as a bundle, so its tools should not reach the model ahead of the runbook for using them; and a tool the model has never been shown should not be callable by name. Concretely, a deferred tool is available once it has been revealed, and — if a capability owns it — once that capability is available too.

This is not a trust boundary and is not meant as one. Any history the model could plausibly have produced is honoured — fabricating a coherent load_capability exchange is equivalent to the model having called it, and history integrity remains the deployment's job. What is rejected is a history no legitimate run could have produced: a capability tool revealed with no load behind it describes a world that never existed, and honouring it would put the run in a state its own rules forbid.

The refusal is a ModelRetry saying the tool is not available yet rather than "unknown tool", so the model searches or loads instead of concluding the tool does not exist — and the resulting exchange regenerates the history that justifies the call, which is what keeps a compacted history coherent.

One definition of "available"

RunContext.is_tool_available is the single predicate. ToolManager consults it and only chooses which way to point the model, so introspection and execution cannot disagree — previously is_tool_available could return True for a tool the manager would refuse.

discovered_tool_names stays raw history evidence, answering only the middle step of the ordering; available_tool_names is the subset that passes the whole rule.

Same response: search-then-call yes, load-then-call no

Reveals are re-derived from history at tool-dispatch time, once the response is in it:

Same-response patternWhat reached the model firstVerdict
provider-side tool search → callthe tool's schema, in-band in the search resultallowed
load_capability → callonly a confirmation; the capability's instructions arrive on the next requestrefused

Native tool search keeps working unchanged (its six OpenAI/Anthropic provider tests were the oracle and pass untouched), while a model that loads a capability and calls its tool in one breath is asked to wait a turn — it has not read the runbook yet.

Availability also gates visibility

A reveal naming a tool whose deferred capability is not loaded is dropped from the request, so such a tool is never advertised-but-uncallable. This closes #7252 across every history source rather than only the UI channel its sanitize_messages proposal covered.

That required making ModelRequestParameters.deferred_capability_ids real again rather than derived. It was deprecated in favour of reading ToolDefinition.capability_id + defer_loading — but those cannot express it: capability_id records which capability contributed a tool, and defer_loading is set both by a deferred capability and by a search-gated tool inside an always-on one. It is now populated from the capability instances, which know their own defer_loading.

Behaviour changes worth calling out

  • After compaction, a tool whose reveal evidence the boundary hid is no longer callable until rediscovered. Previously the reset hid schemas but never revoked execution. The re-search lands on the near side of the boundary, so the retained history accounts for the tool it goes on to call.
  • ctx.loaded_capability_ids no longer updates mid-step. It is derived from history before each request, so a capability loaded during a step appears from the next one — the same step that first carries its instructions to the model.

Also fixed

ToolReturn.tools reveals were gated on loaded_capability_ids, so an always-on capability revealing its own search-gated tool hit a UserError telling it to load_capability a capability that cannot be loaded. Now gated on availability, plus the capability a load_capability call is activating — scoped to that one id, so the loader cannot reveal any other capability's tools.

_ctx_for_tool_hook (#7225), this PR's original prepare_tools patch, and the loader's mid-step loaded_capability_ids mutation are all removed: they existed only to defend the callable-while-unloaded state that no longer exists.

Not a security advisory

Deferred-capability hooks are documented as not running until the capability loads (on-demand.md:66, :125, :227; hooks.md:102, :391), and we explicitly steer enforcement away from deferred capabilities (on-demand.md:254). We had also documented both rules 19 lines apart in the agent skill (ON-DEMAND-CAPABILITIES.md:108 says not callable, :127 says callable), and git blame traces the behaviour to #5230's registry/wire split rather than any decision. This introduces an expectation rather than repairing a violated promise.

Note for the release notes

ModelRequestParameters.deferred_capability_ids becomes a real framework-populated field again. It was briefly a deprecated derived property — that shipped in v2.26.0 and is withdrawn here, so a deprecation warning that existed for one release goes away.

Nothing that worked breaks: reading it loses a spurious warning, and passing it now retains the value instead of warning and silently discarding it. Worth a changelog mention only so anyone who saw the warning knows it was withdrawn rather than missed.

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.