Integrations¶
Three optional extras, all lazily imported — the base install stays slim and none of these dependencies is pulled unless configured.
Two of them are bridges: [spec-tools] and [drf-mcp] expose an existing
DRF surface as agent tools, so you don't re-declare your API as @tool
functions. Both make the agent act as the logged-in user — the request is
carried into every call, so your existing permission checks apply exactly as they
would over HTTP.
The third, [harness], is a different shape: it brings in ready-made
capabilities (compaction, agent skills, step persistence and more) that plug
into the same AgentConfig.capabilities seam this package's own audit and
tool-guard capabilities use.
Which bridge?¶
[spec-tools] |
[drf-mcp] |
|
|---|---|---|
| Path | drf-services spec → tool, in process |
drf-mcp-server registry → toolset, in process |
| Needs an MCP server | No | Yes (but no network hop) |
| Use when | The agent is the consumer, and you have specs | You already run an MCP server and want the same tools in the agent |
If you have ServiceSpec / SelectorSpec objects and no MCP server, take
[spec-tools]. If you already run an MCP server for external clients, take
[drf-mcp] and get the identical tool surface without standing up a second one.
[spec-tools] — specs as tools¶
build_spec_capability(specs, request, exclude_names=…) returns a
SpecCapability over the specs, bound to request.user. Each call runs through
drf-services' transport-neutral surface and enforces the spec's own
permission_classes.
Choosing the capability over a bare toolset is deliberate, though the reason
changed in PAI 0.6.0: the tool-set conventions (list-tool page / limit /
ordering, and the error contract) now live on SpecToolset.get_instructions(),
so they reach the model either way and the capability delegates rather than
re-emitting them. What wrapping buys is the capability seam itself —
defer_loading, and a uniform place to compose spec tools alongside audit and
guard.
Declaring specs once¶
specs takes either a name -> spec mapping or a spec registry —
drf-services 0.27's SpecRegistry, the single declaration site for a project
exposing the same specs over more than one transport:
from django_pydantic_agent.integrations.build_spec_capability import build_spec_capability
capability = build_spec_capability(registry, request)
The registry is matched structurally, through a SpecSource Protocol
declaring only specs() -> dict. This package names no drf-services type and
depends on pydantic-ai-slim alone; drf-services arrives only with the extra.
If you are writing a transport, normalise with resolve_spec_mapping() before
you touch the argument. Iterating a registry yields RegisteredSpec records,
not names — so a transport that reserves tool names by iterating the raw
argument fills its collision set with dataclasses and silently stops detecting
duplicates:
from django_pydantic_agent.integrations.resolve_spec_mapping import resolve_spec_mapping
specs = resolve_spec_mapping(source) # mapping or registry -> mapping
seen.update(specs) # names, as intended
[drf-mcp] — an MCP registry as a toolset¶
DRFMCPToolset bridges a drf-mcp-server registry through drf-mcp's public
in-process surface (list_tools / acall_tool), so its validation and
permission checks apply as they would over HTTP — without the network hop and
without reaching into handler internals.
Tool schemas come from drf-mcp's own tools/list rather than being re-derived
locally, so the bridge advertises the same merged inputSchema the HTTP
transport would — including a selector tool's filter / ordering / pagination
arguments and its additionalProperties policy, not just the input serializer's
fields.
Error semantics¶
The bridge follows MCP's protocol-vs-tool boundary, which decides whether the model gets to recover:
- malformed argument shape (JSON-RPC
-32602) and tool-levelvalidation_errorresults →ModelRetry, so the model retries with the field errors instead of the run dying; - other tool-level failures (
service_error/not_found) → returned as the tool's content, model-readable; - genuine protocol faults (unknown tool, auth, rate limits) → a hard
RuntimeErrorthat aborts the run.
Bridged tools also carry destructiveness into the tool guard: the
bridge maps each tool's readOnlyHint annotation onto DESTRUCTIVE_METADATA_KEY.
[harness] — upstream capabilities¶
The other two extras bridge your API into the agent. This one is different: it
pulls in pydantic-ai-harness,
a library of ready-made capabilities — the same seam this package's own audit
and tool-guard capabilities use. Nothing here wraps or re-implements them; you
construct one and hand it to AgentConfig:
from pydantic_ai_harness.compaction import SlidingWindow
config = AgentConfig(model=..., capabilities=[SlidingWindow(max_messages=80)])
agent = build_agent(registry, config)
That is the whole integration. capabilities takes live instances (never dotted
paths), so a harness capability, a first-party one, and your own all compose the
same way, and pydantic-ai orders them by their own get_ordering().
Version-sensitive
pydantic-ai-harness is 0.x and its minors may break. This package pins
>=0.12,<0.13; check the changelog before widening it. The 0.7 → 0.12 jump
changed the StepStore protocol, which is why DefaultStepStore grew an
include_interrupted argument and a state column.
Long runs: compaction¶
A tool-heavy run grows its message history until it crowds the context window.
The compaction module trims it, and the choice is mostly about cost:
| Strategy | Cost | What it does |
|---|---|---|
SlidingWindow(max_messages=80, keep_messages=40) |
free | Drops the oldest messages once a threshold is crossed, preserving tool-call / tool-return pairs. No model calls. |
ClearToolResults(max_messages=60, keep_pairs=3) |
free | Keeps the conversation shape but blanks old tool results, which are usually the bulk of the tokens. |
SummarizingCompaction(max_messages=60, keep_messages=20) |
a model call | Replaces the trimmed span with an LLM-written summary, so older context survives in compressed form. |
TieredCompaction(tiers=[...], target_tokens=…) |
varies | Applies cheaper strategies first, escalating only if still over target. |
Start with SlidingWindow — it is free and transparent. Reach for
SummarizingCompaction only when losing the old turns outright actually hurts,
since it spends a model call on every compaction.
Trimming happens inside before_model_request and is deliberately invisible to
the rest of the run: nothing is emitted when a compaction fires. A transport
that wants to tell the user "earlier turns were condensed" has to observe it
itself — CompactionStrategy is a one-method protocol
(compact(messages, ctx) -> messages), so a thin wrapper that compares its input
to its output is the seam for that.
Progressive disclosure: agent skills¶
A skill is a folder with a SKILL.md — a name, a description, and a body of
instructions. Skills discovers them and exposes each as a deferred
capability: the model sees only the name and description up front, and the body
loads into context if it selects that skill. A dozen skills therefore cost a
dozen one-line descriptions, not a dozen instruction blocks.
from pydantic_ai_harness.skills import Skills
config = AgentConfig(
model=...,
capabilities=[Skills("/srv/app/skills")],
)
directories takes one path or several. Discovery scans immediate child
directories for a SKILL.md, so /srv/app/skills/summarise/SKILL.md registers a
skill named summarise (the frontmatter name wins over the directory name when
both are present).
include / exclude select by exact skill name — and are validated against
what was actually discovered, so exclude={"draft-only"} raises ValueError
when no such skill exists rather than silently excluding nothing. That is the
behaviour you want (a typo'd name fails loudly), but it does mean a selection
list and a skills directory have to be kept in step.
Instructions only, in v1
Upstream loads the SKILL.md body and nothing else — bundled scripts and
resources are deferred to a future sandbox integration. A skill that assumes
it can execute its own files will not work yet.
Each skill becomes one capability whose id is the skill name, which is what a
client needs to show "using skill X". Enumerate them with skills.apply(visitor).
Also available¶
step_persistence (used by DefaultStepStore), subagents,
code_mode, overflowing_tool_output, guardrails, filesystem, shell and
ManagedPrompt all compose the same way. None of them need support from this
package — if it is a pydantic-ai capability, capabilities=[...] takes it.
Name collisions¶
Both bridges take exclude_names, and precedence is fixed: the @tool registry
wins, then drf-mcp, then spec tools. A duplicate that slipped through would make
pydantic-ai raise UserError at run time, so the excluded set is threaded
through composition instead.
Full signatures in the integrations reference.