Skip to content

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

organization_id: str | None = None

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

target_type: str | None = None

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

target_id: str | None = None

Identifier of the acted-on object, paired with target_type.

ip_address class-attribute instance-attribute

ip_address: str | None = None

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 AuditEvent is recorded to.

required
ip_address str | None

Fallback client IP, used only when the run's deps carry no ip_address. Per-run deps come first because a constructor argument is per-agent: taking the IP from it alone forces a fresh agent per request, and building once anyway fails silently, with every record carrying the IP of whoever arrived first.

None
organization_id str | None

Org scope stamped onto every event, for a multi-tenant host.

None

get_ordering

get_ordering() -> CapabilityOrdering

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

enabled: bool = False

Whether the ToolGuard capability is composed into the agent.

exempt class-attribute instance-attribute

exempt: frozenset[str] = field(default_factory=frozenset)

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

require_approval: frozenset[str] = field(default_factory=frozenset)

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_KEY the bridge stamps into ToolDefinition.metadata.
  • MCP tool annotationsmetadata["annotations"]["readOnlyHint"] is False. A toolset that speaks MCP's own vocabulary rather than this package's key declares a mutation this way, SpecToolset over a drf-services ServiceSpec among 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-destructive schema stamp at the root of parameters_json_schema, which is what build_input_schema writes. 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 through toolsets= got no gate from it.
  • Project overrides: require_approval force-gates a name, exempt un-gates one, and exempt wins.

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

enabled: bool = True

Whether the ToolFailurePolicy capability is composed into the agent. Set False to restore the fail-the-run behaviour.

include_detail class-attribute instance-attribute

include_detail: bool = False

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

reraise: tuple[type[BaseException], ...] | None = None

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.