Document that capability hooks read the activity set as of request preparation, and that `prepare_tools` is the tool gate
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_toolsis 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_requestis 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_requestand the other hooks dispatched through_ctx_for_active_cap(36 sites incapabilities/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:
- not writing
before_model_requestoutput into the run's durable history (that list is the onecapture_run_messagesobserves), or - 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.mdand the capability docs: state that tool gating belongs inprepare_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_toolshalf; this was found by automated review of that PR - #7305 — the original fail-open
- #8188 — wants the same history-part provenance primitive
Source: pydantic/pydantic-ai