Document that capability hooks read the activity set as of request preparation, and that `prepare_tools` is the tool gate

Author: DouweMCreated Sep 17, 2026Updated Sep 18, 2026
Labelsdocumentation

Disposition: document the hook contract; the current behavior is correct

Reframed after review. The original framing — "a capability activated during request preparation has its own before_model_request skipped" — described the mechanism accurately but mislabelled it as a defect. It is the intended semantics, and the rule is worth writing down.

A capability cannot activate another capability mid-request-preparation. There is no side channel for it. A ProcessHistory capability edits request_context.messages, and that result is written back to the run's durable history unconditionally (_agent_graph.py:1651) — after which the injected load_capability exchange is a real load by every definition the framework has. The hook dispatch loop belongs to the activity epoch before that write-back; the new epoch begins at it. So a capability that becomes active through the write-back correctly does not receive hooks that already ran for the prior epoch.

What follows, and what should be documented:

  • prepare_tools is where a capability gates its own tools. It is dispatched at tool resolution, and since #8071 it re-runs whenever capability availability moved, so it governs every tool the availability gate admits.
  • before_model_request is for messages, settings and request parameters — which is what its docstring already says. It is not an authorization hook, and a permission check placed there will not run on the request where a processor-injected load first makes a capability's tools callable.
  • The same epoch reasoning applies to after_model_request and the other hooks dispatched through _ctx_for_active_cap (36 sites in capabilities/combined.py).

Why the stronger rule is not enforceable today

Making processor-injected additions defer to the next request — rather than relying on the epoch boundary to make it moot — would need one of:

  1. not writing before_model_request output into the run's durable history (that list is the one capture_run_messages observes), or
  2. provenance on framework-authored history parts, so dispatch-time evidence could distinguish an injected load from one the model actually made.

(2) is the same missing primitive #8188 needs to let an operator-authored instruction delta survive a UI round trip. If it is ever built, both become enforceable; until then the epoch boundary is the operative rule.

Note the conservative direction already behaves correctly and is pinned: a processor that removes a load pair leaves advertisement and gate in agreement, because _with_outgoing_reveal_state reads the same processed messages (test_processor_removed_load_leaves_advertisement_and_gate_in_agreement, #8071).

Remaining work

Docs only:

  • pydantic_ai_slim/pydantic_ai/capabilities/AGENTS.md and the capability docs: state that tool gating belongs in prepare_tools, and that hook dispatch reads the activity set as of request preparation.
  • Consider a line in AbstractCapability.before_model_request's docstring saying it is not an authorization hook.

Original report

Reproduced on main — a deferred capability whose gate lives in before_model_request rather than prepare_tools, plus a ProcessHistory capability injecting a LoadCapabilityCallPart/LoadCapabilityReturnPart pair, with a scripted model calling the tool on step 1:

before_model_request ran at steps : [2]          # skipped at step 1
prepare_tools calls               : [['secret_op'], ['secret_op']]
secret_op returns                 : ['EXECUTED']

Not remotely reachable: a load exchange arriving in client-submitted history is picked up by _refresh_loaded_capability_ids at step start, before tool resolution, so both hooks run normally. Verified separately.

References

  • #8071 — fixes the prepare_tools half; this was found by automated review of that PR
  • #7305 — the original fail-open
  • #8188 — wants the same history-part provenance primitive