Policy¶
Audit, the destructive-tool gate and the tool-failure policy all ride the Pydantic-AI capability seam. The first two are off by default; the third is on. See Policy for the narrative.
Audit¶
AuditLogger¶
AuditLogger ¶
Bases: Protocol
Sink for tool-invocation records.
An implementation may drop, sample or forward events. The package ships
NullAuditLogger and LoggingAuditLogger; a project passes its own to
its transport's audit_logger=.
AuditEvent¶
AuditEvent
dataclass
¶
A single tool invocation as seen by the audit logger.
Arguments are stored as a string, typically JSON, to keep records cheap to serialize and to discourage retaining sensitive raw values.
One run-level record rides this shape: a client disconnecting mid-run is
recorded as tool_name="agent.run", success=False and an error
starting "cancelled:", so a sink can tell cancelled runs apart without
widening the AuditLogger protocol.
organization_id
class-attribute
instance-attribute
¶
Multi-tenant scope of the acting user. None at this layer; a custom
AuditLogger fills it from its own
tenancy model.
target_type
class-attribute
instance-attribute
¶
Kind of domain object the call acted on. None at this layer, where
tool arguments are domain-opaque; a sink that knows its tools can
classify them.
target_id
class-attribute
instance-attribute
¶
Identifier of the acted-on object, paired with target_type.
ip_address
class-attribute
instance-attribute
¶
Client IP of the request that drove the run, when the view knows it.
AuditCapability¶
AuditCapability ¶
Bases: AbstractCapability[Any]
Records every tool execution to an
AuditLogger sink.
A Pydantic-AI capability on the tool-execution hooks, so it records every tool the agent runs: registry tools, the drf-mcp and spec bridges, attachment and skill tools alike.
One record per execution, describing the tool's own execution, whatever
other capabilities sort ahead of it and on every supported pydantic-ai.
That is everything composed through AgentConfig.capabilities unless
its own ordering places it inside audit. A capability that sorts after
audit is the exception, below.
arguments_reprholds the arguments the tool received, after every other capability'sbefore_tool_executehas rewritten them.successanderrordescribe what the tool did.errorisType: messageof the exception the tool raised, before any capability converts it (asToolFailurePolicydoes) or recovers from it with anon_tool_execute_errorthat returns a value. A recovered failure is still recorded as the failure: recovering is a decision about the run, not about the tool. On an ordinary call the exceptions pydantic-ai routes past every error hook are recorded as they arrive: a tool's ownToolFailedas theToolFailedErrorpydantic-ai converts it into, with the same message, andModelRetryasToolRetryError. From a code-mode sandbox, whose nested tool manager converts neither, they are recorded as raised:ToolFailed: <message>andModelRetry: <message>. AToolFailedkeeps its own message either way; its cause and context are never read. AModelRetryon an ordinary call with the tool's retry budget spent is different: pydantic-ai raisesUnexpectedModelBehaviorin its place, which does reach the error hooks, and that is what is recorded.result_sizeis the length of the tool's own result, before anyafter_tool_executerewrites it.duration_msspans the tool alone: from the last moment audit sees before the tool runs, which is after every other capability'sbefore_tool_execute, to the first moment it sees the outcome, which is before every other capability'son_tool_execute_errororafter_tool_execute. Other capabilities' hooks are not in it.
A call that never runs the tool produces no record. That is a call
stopped before audit's before_tool_execute, by any exception, and one
whose outcome is what pydantic-ai treats as not executed:
SkipToolExecution, CallDeferred or ApprovalRequired, from
wherever it is raised. So a veto such as pydantic-ai-harness's guardrails
and tool-call judge raise, a destructive call the
ToolGuard holds for approval, a call a
tool or a toolset such as pydantic-ai's approval_required() defers for
approval, and a call deferred to external execution are not executions. A
call held for approval is recorded when it is resumed and runs, like any
other; a call deferred to external execution never runs in this process,
so it has no record here.
How it gets the same record on every release. pydantic-ai 2.54 moved
every capability's before_tool_execute, on_tool_execute_error and
after_tool_execute inside the wrap_tool_execute chain, where
earlier releases ran them around it, so no wrapper position sees the raw
outcome on both. Audit is pinned innermost, which makes its
before_tool_execute the last to run and its on_tool_execute_error
and after_tool_execute the first, and observes the tool from all four
hooks. Its wrapper is the only one that writes the record, once, as it
exits, preferring what the hooks captured over what it saw itself. Before
2.54 the innermost wrapper encloses the tool alone and what it sees is
already the raw outcome; from 2.54 it encloses every hook, and for what
sorts ahead of audit the captures are what keep the record the tool's.
A capability that sorts after audit runs between audit and the tool.
One does when it is innermost and passed to a single run, since
pydantic-ai sorts those after the agent's own capabilities whatever
build_agent did; when it is composed by hand after audit in the
innermost tier; and wherever it is composed, when its own ordering places
it inside audit, as an innermost CapabilityOrdering with
wrapped_by=[AuditCapability] does. What such a capability does can
reach the record, on every pydantic-ai and through any of its hooks, and
the record is then not the tool's own. These are measured examples, not
a list of the only ways:
- From 2.54, through its
before_tool_execute,on_tool_execute_errorandafter_tool_execute: a record misses its argument rewrite, records aModelRetryitsbefore_tool_executeraises as a failure though the tool never ran, records its recovery as a success and an exception it raises in place of the tool's, records aModelRetryitsafter_tool_executeraises as a failure though the tool succeeded, measures its result rewrite, and times its hooks with the tool. - From 2.54, through its
wrap_tool_execute, when the tool raisedModelRetryorToolFailed, which pydantic-ai routes past every error and result hook, audit's included: a record carries its rewrite of the message, as pydantic-ai-harness'sresult_guarddoes, records its recovery as a success, and records an exception it raises in their place. If it runs the tool again, the record describes the later run alone and the failed run has none; with that rerun refused ahead of audit, the record pairs the first run's arguments with the refusal. Run again after a failure audit's error hook saw, or after a success, the tool gets one record, of its first run. Reruns it makes concurrently share one record, which can pair one run's arguments with another's result. - Before 2.54, through its
wrap_tool_execute: a record misses an argument rewrite there, records a recovery as a success, an exception of its own in place of the tool's and aModelRetryraised before the tool runs as a failure, measures a result rewrite, carries a rewrite of a failed tool's message, and times the wrapper with the tool. If it runs the tool again, the one record holds the last run's outcome, with the arguments audit passed on and the time of every run.
A SkipToolExecution veto from it is still not recorded. Everything
composed through AgentConfig.capabilities is unaffected unless its own
ordering places it inside audit: build_agent appends audit after all
of it, so audit sorts last among the innermost capabilities there.
pydantic-ai-harness's tool guardrail and tool-call judge are innermost,
so passed to a single run they sort after audit like any other.
Recording is non-raising. A sink that throws is caught and logged to the
django_pydantic_agent.audit Python logger, so a broken audit backend
costs audit records rather than the run. Audit's own error hook re-raises
what it is handed, and the exception the run sees is never changed by it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
logger
|
AuditLogger
|
The sink each |
required |
ip_address
|
str | None
|
Fallback client IP, used only when the run's deps carry no
|
None
|
organization_id
|
str | None
|
Org scope stamped onto every event, for a multi-tenant host. |
None
|
get_ordering ¶
Pin audit as an innermost capability.
pydantic-ai runs before_tool_execute hooks outermost first and
on_tool_execute_error / after_tool_execute hooks innermost
first, so innermost is the one position whose hooks sit directly either
side of the tool: after every other capability's argument rewrite and
veto, and before any other capability converts, recovers or rewrites
the outcome, unless a capability sorts after audit even so (see the
class). Declared here rather than left to list order at the
build_agent call site, since pydantic-ai sorts by these
constraints.
wrap_tool_execute
async
¶
wrap_tool_execute(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
handler: WrapToolExecuteHandler,
) -> Any
Write the call's one record on the way out, and hand the result back as is.
What this wrapper sees depends on the release: the tool alone before
2.54, every capability's hooks around it from 2.54. Either way the
hooks below have recorded the tool's side first where they could, and
settle keeps the first account it is given, so what is written is
the tool's whenever nothing sorts between audit and the tool (see the
class). A call whose before_tool_execute never ran here never
reached the tool, and is not recorded.
Every entry starts a fresh execution, because pydantic-ai lets an outer
wrapper call its handler more than once and each call runs the tool
again. Before 2.54 before_tool_execute runs once, ahead of every
wrapper, so whether the call reached the tool is carried over from what
it left in this context. From 2.54 it runs again inside each entry and
says so itself, unless a capability ahead of audit stops that entry
first. The reset on the way out is what keeps the previous entry's
"reached" from carrying into one stopped that way, which would record
a call that never ran. Coverage cannot see it go missing, since the
line runs either way; from 2.54
test_a_rerun_refused_ahead_of_audit_is_not_recorded fails without
it. Before 2.54 there is no such entry, because audit's
before_tool_execute has run ahead of every one.
before_tool_execute
async
¶
before_tool_execute(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
) -> dict[str, Any]
Note that the call reached the tool, with the arguments it gets.
Innermost, so this runs after every other capability's
before_tool_execute, and nothing else stands between it and the
tool, unless a capability sorts after audit (see the class). Bound to
the context here as well as in the wrapper because before 2.54 this
hook runs first, and the wrapper reads it from there.
on_tool_execute_error
async
¶
on_tool_execute_error(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: Any,
error: Exception,
) -> Any
Keep the tool's own exception, then pass it on unchanged.
Innermost, so this is the first error hook to run, before any other capability converts or recovers, unless one sorts after audit (see the class). Re-raising is what keeps the chain going: the next capability's hook is handed the same exception.
after_tool_execute
async
¶
after_tool_execute(
ctx: RunContext[Any],
*,
call: ToolCallPart,
tool_def: ToolDefinition,
args: dict[str, Any],
result: Any,
) -> Any
Keep the tool's own result, then pass it on unchanged.
Innermost, so this is the first result hook to run, before any other
capability rewrites the result, unless one sorts after audit (see the
class). After a recovery it is handed the recovered value instead,
which settle ignores: the error hook settled the call first.
NullAuditLogger¶
NullAuditLogger ¶
Discards every event. The default when no audit logger is configured.
LoggingAuditLogger¶
LoggingAuditLogger ¶
Writes audit events to the Python logging framework.
Successful invocations log at INFO; failures log at WARNING
with the error message included.
Tool guard¶
ToolGuardConfig¶
ToolGuardConfig
dataclass
¶
Resolved policy for the server-side tool-approval gate.
Turns the ToolGuard
capability on and tunes which tools it flips to require human approval. Off
by default, so the gate never surprises a project that has not opted in.
When enabled, a tool is gated if it is destructive or its name is in
require_approval; exempt overrides both. A tool counts as destructive
when it is a registry @tool(destructive=True), or when its definition
says so — the drf-mcp bridge's metadata key, an MCP readOnlyHint of
False (a ServiceSpec through SpecToolset, among
others), or an x-destructive stamp at the root of its parameter schema.
Nothing else is gated. A tool from a source that declares none of those
is invisible to the gate however dangerous it is, and require_approval
is the only answer for it. Off by default, so on stock settings a
server-side destructive tool runs with no approval interrupt at all.
enabled
class-attribute
instance-attribute
¶
Whether the ToolGuard capability is composed into the agent.
exempt
class-attribute
instance-attribute
¶
Tool names never gated, even if flagged destructive: the escape hatch for a mutation a project has decided is safe to auto-run.
require_approval
class-attribute
instance-attribute
¶
Tool names always gated, even if not flagged destructive, for a read
tool a project treats as sensitive. exempt wins on a name in both.
ToolGuard¶
ToolGuard ¶
Bases: AbstractCapability[Any]
Gates destructive server-side tools behind the AG-UI approval loop.
pydantic-ai supplies the mechanism — a tool whose definition is
kind="unapproved" defers to an interrupt the client approves or denies —
and this supplies the policy, flipping a plain function tool to
unapproved at prepare_tools time when it is destructive. Server-side
tools thereby get the confirmation gate the web component already applies to
client-registered ones.
Destructiveness is read from every vocabulary a toolset might declare it in, so one hook covers every tool the agent sees wherever it came from:
- Registry
@tool(destructive=True), collected at construction: the flag lives on the spec and never reaches pydantic-ai, which sees a bare callable, so the capability reads it directly. - drf-mcp bridged tools, through the
DESTRUCTIVE_METADATA_KEYthe bridge stamps intoToolDefinition.metadata. - MCP tool annotations —
metadata["annotations"]["readOnlyHint"] is False. A toolset that speaks MCP's own vocabulary rather than this package's key declares a mutation this way,SpecToolsetover aServiceSpecamong them. Reading it gates a spec attached in process the way the bridge's key gates the same spec over drf-mcp, so a transport swap cannot silently remove the gate. - The
x-destructiveschema stamp at the root ofparameters_json_schema, which is whatbuild_input_schemawrites, so a project deriving a schema with the package's own helper and attaching the tool throughtoolsets=is gated by it too. - Project overrides:
require_approvalforce-gates a name,exemptun-gates one, andexemptwins.
A hint has to say the tool mutates. A missing readOnlyHint, an absent
stamp or metadata of another shape entirely leaves the tool alone — silence
is not a claim, and require_approval is the answer for a tool whose
source declares nothing.
Only kind="function" tools are flipped — an external tool is already
gated client-side, and an output tool is not executed.
The guard touches only prepare_tools, so it is orthogonal to
AuditCapability
and audit still records the tool when an approved call finally runs.
Tool failure¶
ToolFailureConfig¶
ToolFailureConfig
dataclass
¶
Resolved policy for what an unhandled tool exception does to a run.
Tunes the
ToolFailurePolicy
capability. On by default, because without it one raising tool ends the whole
run, discarding the answer the model had assembled and every other tool
result in the turn.
The two flags default opposite ways because they answer different questions.
Whether the run survives is reliability; whether the exception's text reaches
the model is disclosure, since a message can carry a query, a path or a
credential, and whatever the model sees also reaches whatever renders the
transcript. The policy never redacts the operator's copy, whichever way
include_detail is set: the full exception goes to the audit logger, and
to the Python logger whenever the policy converts it. A capability that sorts
after audit can still change what audit records; the
AuditCapability states that
exception.
enabled
class-attribute
instance-attribute
¶
Whether the ToolFailurePolicy capability is composed into the agent.
Set False to restore the fail-the-run behaviour.
include_detail
class-attribute
instance-attribute
¶
Whether the model-facing failure message carries the exception type and text. An exception message is written for an operator, not for a model or the browser that renders its answer.
reraise
class-attribute
instance-attribute
¶
Exception types that pass through untouched, ending the run as they would without the policy.
None means the built-in set: an authorization refusal, in each flavour a
project raises it — django.core.exceptions' PermissionDenied;
rest_framework.exceptions' when DRF is installed; and
django_service_specs' NotPermitted and PrincipalUnavailable when
the kernel is installed. Pass a tuple to replace that set wholesale, or
() to convert every exception. Replacing means re-listing each of those
classes your project can raise to keep denials exempt.
A spent tool retry budget. Add UnexpectedModelBehavior (from
pydantic_ai.exceptions) to end the run when a tool's own ModelRetry
has no retries left, rather than converting it into a failed result. It
ends the run for every UnexpectedModelBehavior a tool raises, including
a sub-agent's exhausted budget the tool propagates, and __cause__
cannot tell the two apart.
Why a denial is not a tool failure. A converted denial leaves the run
alive and the model free to call the same tool on the next row, while a
failed result stays distinguishable from a "not found" one — so a sweep over
ids turns a permission boundary into an existence oracle inside a single
turn. A ToolFailed spends no retry budget, so nothing bounds the sweep
but run-level UsageLimits. Refusing to run is the answer to a denied
call, not a fault to route around.
ToolFailurePolicy¶
ToolFailurePolicy ¶
Bases: AbstractCapability[Any]
Turns an unhandled tool exception into a failed result the model can read.
Without it, a tool that raises takes the run down: the transport emits
RUN_ERROR, the turn ends, and everything the model produced is discarded
along with every other tool result in the round. One broken integration costs
the whole answer.
It hangs off on_tool_execute_error, which is a correctness point rather
than a stylistic one: pydantic-ai does not call that hook for control-flow
exceptions (SkipToolExecution / CallDeferred / ApprovalRequired),
retry signals or failure signals. The approval interrupt the tool guard
depends on therefore passes through untouched, and so does a ModelRetry
while the tool has retries left, where a hand-rolled except Exception
around the handler would swallow them and quietly disable the gate. The
policy neither spends nor grants retries; what it changes is the end of a
tool's own budget, below.
The re-raise is pydantic_ai.exceptions.ToolFailed, so the model sees a
result marked failed rather than one reading as success. (Precisely, a
private subclass of it: pydantic-ai's control flow treats it as a
ToolFailed, and a trace records the subclass's name as the exception
type.) A failed result spends no retry budget, so bound a persistently
broken tool with run-level UsageLimits rather than expecting this to
stop the model calling it.
Nothing is swallowed. An exception this converts is logged with its
traceback to the django_pydantic_agent.failure logger first, and an
AuditCapability in the same chain still records the failure against the
tool that caused it. What changes is only who the failure stops. That
logger hears only about what this converts: not a call another capability
recovered or answered for, not a refusal passing through, and not an
exception pydantic-ai never hands to an error hook, such as a tool's own
ToolFailed, or its ModelRetry while it has retries left.
It converts last. Pinned outermost, and placed first by build_agent,
so its on_tool_execute_error is the last of every capability's to run
(pydantic-ai runs that hook innermost first). Every other capability's error
hook is handed the exception the tool raised, never this policy's redacted
copy: a step recorder such as harness's StepPersistence logs the tool's
failure, and a capability that recovers answers before anything is
converted. What an earlier hook raised in the exception's place is that
capability's answer, and passes through when it is one pydantic-ai gives the
model itself: a ToolFailed already carries its own message for the
model, and a ModelRetry spends the tool's retry budget. Once that is
spent the run ends with UnexpectedModelBehavior, which pydantic-ai
raises after every error hook has run, so this never sees it.
A tool's own ModelRetry with no retries left is converted. While
the tool has retries left pydantic-ai never hands its ModelRetry to an
error hook. Once they are spent it raises UnexpectedModelBehavior in
its place from inside the call, which does reach the error hooks, so this
logs it and converts it into a failed result like any other exception,
where without the policy the run would end. A capability's ModelRetry
is different: pydantic-ai checks its budget outside the error hooks, so it
still ends the run when the budget is spent. The default carries a cost,
since the tool keeps executing on later calls and its side effects repeat,
bounded by the model taking the failed result's "do not retry" (with
include_detail off) or by pydantic-ai's default request limit. Naming UnexpectedModelBehavior in
ToolFailureConfig.reraise ends the run instead, and ends it for a
sub-agent's exhausted budget a tool propagates too.
An authorization refusal is exempt and ends the run as it would without
the policy — see ToolFailureConfig.reraise, which is also how a project
exempts more, or nothing at all.
get_ordering ¶
Pin the policy as an outermost capability.
pydantic-ai runs on_tool_execute_error hooks innermost first, so
the outermost one runs last: whatever else is composed sees the tool's
exception before this converts it. build_agent also places it first
in its list, which keeps it last among other outermost capabilities,
since list order breaks ties within a tier.