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_spec_agents 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 hooks pydantic-ai's tool execution, so it times and records every tool the agent runs — registry tools and composed toolsets alike, not just the ones registered here — with one record per execution describing the tool's own.ToolGuard, whentool_guardis set andenabled. It flips destructive tools to require approval.ToolFailurePolicy, unlesstool_failureturns it off. A raising tool fails its own call rather than the whole run.- Anything in
config.capabilities, verbatim.
Each declares its position via get_ordering() and pydantic-ai's
CombinedCapability sorts them topologically, so a transport appending its own
capability to config.capabilities doesn't have to think about where. Audit is
innermost, closest to the tool, and the failure policy outermost, so it
converts a failure only after every other capability has seen it; the guard is
orthogonal. Within a tier list order breaks ties, which is why build_agent
appends audit after config.capabilities and places the policy before them.
Ordering says what each position buys.
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, rather than
closing over the request. The difference matters beyond tidiness:
- The acting user binds natively. The
SpecToolsetindjango_spec_agents.spec_toolsalready 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. - The request a run arrived on travels as data.
deps.call_contextis a plaindictof values every tool call may read; a transport puts the request there underrequest, and each spec tool call is handed its own copy with the tool's name underaction, so a callable or a permission check reads the request without the agent ever holding it (see per-call seeds and the run's context). - 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 the kernel 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 [specs] 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.