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 wrap_tool_execute lifecycle hook, so it
times and records every tool the agent runs: registry tools, the drf-mcp
and spec bridges, attachment and skill tools alike.
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.
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 the outermost capability in the chain.
Its wrap_tool_execute has to surround every other capability's
execution hooks so the tool is recorded whatever else composes the run.
Declaring it here rather than relying on list order at the
build_agent call site keeps that true however the capabilities are
inserted, since pydantic-ai sorts by these constraints.
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 drf-services 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 a drf-servicesServiceSpecamong them. Without this the same spec was gated when it arrived over the drf-mcp bridge and ungated when it was attached in process, so a transport swap silently removed the gate. - The
x-destructiveschema stamp at the root ofparameters_json_schema, which is whatbuild_input_schemawrites. That key is documented as the client-side signal and had no server-side reader; a project deriving a schema with the package's own helper and attaching the tool throughtoolsets=got no gate from it. - 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 operator's copy is never redacted either way: the full exception goes to the audit logger and the Python logger regardless.
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 both the
flavours a Django project raises it — django.core.exceptions' and, when
DRF is installed, rest_framework.exceptions'. Pass a tuple to replace
that set wholesale, or () to convert every exception.
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 and the model's retry budget therefore pass through untouched,
where a hand-rolled except Exception around the handler would swallow
them and quietly disable the gate.
The re-raise is pydantic_ai.exceptions.ToolFailed, so the model sees a
result marked failed rather than one reading as success. That 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. The exception is logged with its traceback to the
django_pydantic_agent.failure logger, 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.
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.