Concepts¶
Config in, agent out¶
The whole package is one shape: a ToolRegistry and an
AgentConfig go in, a pydantic_ai.Agent comes out.
from django_pydantic_agent import AgentConfig, ToolRegistry, build_agent, tool
registry = ToolRegistry()
@tool(registry)
def list_orders(status: str) -> list[dict]:
"""List orders with the given status."""
...
agent = build_agent(
registry,
AgentConfig(
model="anthropic:claude-sonnet-4-5",
instructions="You help staff manage orders.",
),
)
AgentConfig is a frozen dataclass carrying the model, instructions,
model_settings, retries, extra toolsets and capabilities, an
audit_logger, and a tool_guard policy. Everything is optional except
model.
A transport resolves those values from its settings namespace and hands the record down. This package never reads them itself.
What gets composed into the agent¶
build_agent registers each registry tool as a plain Pydantic-AI tool, then
composes capabilities:
AuditCapability, whenaudit_loggeris set and isn't the null logger. It rides thewrap_tool_executelifecycle hook, so it times and records every tool the agent runs — registry tools and composed toolsets alike, not just the ones registered here.ToolGuard, whentool_guardis set andenabled. It flips destructive tools to require approval.- Anything in
config.capabilities, verbatim.
Capabilities are composed order-independently. Each declares its position
via get_ordering() (audit is outermost; the guard is orthogonal), and
pydantic-ai's CombinedCapability topologically sorts them — so the list
build_agent assembles needn't be pre-ordered, and a transport appending its
own capability doesn't have to think about where.
The agent's output_type includes DeferredToolRequests, which turns on the
tool-approval interrupt loop for server-side tools. That is deliberate and
not merely a default: the AG-UI adapter only augments output_type when a run
carries frontend tools, so a run whose only gated tool is server-side would
otherwise never defer. Setting it here makes the approval path independent of
whether the client declared any tools of its own.
Per-run dependencies¶
The agent is typed Agent[AgentDeps, ...], so every run is given an
AgentDeps — pydantic-ai's own seam for request-scoped values. Tools, toolsets
and capabilities read them off RunContext.deps:
This is what a transport passes as deps= when it starts a run, and it is what
replaced closing over the request. The difference matters beyond tidiness:
- The acting user binds natively.
djangorestframework-pydantic-ai'sSpecToolsetalready defaults to readingctx.deps.user, so spec tools act as the right user with nothing passed at the call site. - The agent stops being request-shaped. A capability that closes over a
request can only serve that request, which forces a rebuild — schemas and all
— per call. Request-independent collaborators are the precondition for
reusing a built agent across runs.
ip_addressis the worked example:AuditCapabilityreads it off the run's deps, falling back to its constructor argument, so one audited agent can serve requests from many clients. Taken only from the constructor, building once would silently stamp every audit record with the IP of whoever arrived first. - AG-UI state has somewhere to land.
AgentDepssatisfies pydantic-ai'sStateHandlerprotocol, so a run'sRunAgentInput.stateis validated intodeps.stateinstead of being dropped with a warning.
AgentDeps is deliberately not frozen, unlike every other record here: the
UI adapter assigns deps.state = ... directly. Deps are per-run and never
shared, so the mutability is contained.
- A spec's progress reports have somewhere to go.
SpecToolsetreads its reporter offctx.deps.progressexactly as it reads the user offctx.deps.user. Pass a callable and a long-running spec'sprogress(...)calls reach it; leave itNoneand drf-services substitutes its no-op. Where the reports go is a transport's decision, so nothing here constructs a sink.
user has no default: an unauthenticated run says user=None rather than
leaving it out. Pydantic-AI types deps as AgentDepsT = None and never
validates it, so a run built without deps at all is otherwise silently
constructible — spec tools fail closed there, but registry tools run with no
user context and the answer reads like any other.
Projects needing more per-run context subclass it — user, ip_address,
state and progress are the four fields the framework itself reads. Build the
subclass in your transport's deps_factory (django-ag-ui:
AGUIServer(deps_factory=...)), which is where per-request construction
belongs; AgentConfig is built once and reused.
Inbound state only
Nothing emits STATE_SNAPSHOT / STATE_DELTA back to the client yet. A run
receives client state; a tool returning those events as ToolReturn
metadata is a separate piece of work.
Model resolution¶
AgentConfig.model takes whatever Pydantic-AI takes — a "provider:model"
string or a Model instance.
pydantic-ai-slim ships no providers, so a provider string needs its library
installed. The extras exist for exactly that:
Replacing construction entirely¶
When build_agent's composition isn't what a project wants — a custom output
type, an unusual toolset arrangement, bespoke instrumentation — a transport can
accept an AgentFactoryFn instead. It receives the tool registry and that
transport's resolved config object, and fully replaces build_agent.
The config argument is deliberately untyped: this substrate reads no settings and owns no settings namespace, so the second argument is whatever configuration record the calling transport passes down.
Core versus transport¶
The litmus test: if it maps agent output to a specific wire format, or serves a specific frontend, it is a transport; everything upstream of "how do I speak to the peer" is core.
| Concern | Lands in |
|---|---|
AgentConfig, model / settings / retries resolution, the factory escape hatch |
core |
ToolRegistry + @tool + typed schema derivation |
core |
External toolsets and capabilities; the [drf-mcp] and [spec-tools] bridges |
core (optional extras) |
The AuditLogger protocol and the tool guard |
core |
ConversationStore / AttachmentStore protocols, contrib.store models |
core |
An HTTP view, an SSE encoder, a wire adapter, .urls |
a transport |
| Browser-facing sub-views (thread drawer, attachments, transcription) | a transport |
The stores are the non-obvious call: they are contracts, which are core, while the HTTP views over them are per-transport. A thread drawer is a browser REST surface; an agent-to-agent peer models the same history as tasks and contexts entirely differently, so the view can't be shared even though the storage can.
Reasoning is the mirror image — it is produced here (a model-settings thinking config) but mapped to wire events by the transport.