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 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_repr holds the arguments the tool received, after every other capability's before_tool_execute has rewritten them.
  • success and error describe what the tool did. error is Type: message of the exception the tool raised, before any capability converts it (as ToolFailurePolicy does) or recovers from it with an on_tool_execute_error that 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 own ToolFailed as the ToolFailedError pydantic-ai converts it into, with the same message, and ModelRetry as ToolRetryError. From a code-mode sandbox, whose nested tool manager converts neither, they are recorded as raised: ToolFailed: <message> and ModelRetry: <message>. A ToolFailed keeps its own message either way; its cause and context are never read. A ModelRetry on an ordinary call with the tool's retry budget spent is different: pydantic-ai raises UnexpectedModelBehavior in its place, which does reach the error hooks, and that is what is recorded.
  • result_size is the length of the tool's own result, before any after_tool_execute rewrites it.
  • duration_ms spans the tool alone: from the last moment audit sees before the tool runs, which is after every other capability's before_tool_execute, to the first moment it sees the outcome, which is before every other capability's on_tool_execute_error or after_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_error and after_tool_execute: a record misses its argument rewrite, records a ModelRetry its before_tool_execute raises 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 a ModelRetry its after_tool_execute raises 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 raised ModelRetry or ToolFailed, 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's result_guard does, 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 a ModelRetry raised 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 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 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

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 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, SpecToolset over a ServiceSpec among 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-destructive schema stamp at the root of parameters_json_schema, which is what build_input_schema writes, so a project deriving a schema with the package's own helper and attaching the tool through toolsets= is gated by it too.
  • 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 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

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 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

get_ordering() -> CapabilityOrdering

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.