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: [specs] 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?¶
[specs] |
[drf-mcp] |
|
|---|---|---|
| Path | django-service-specs 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
[specs]. 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.
[specs] — specs as tools¶
build_spec_capability(specs, request, exclude_names=…) returns a
SpecCapability over the specs, bound to request.user. Each call runs through
the kernel's transport-neutral dispatch and enforces the spec's own
permissions.
Choosing the capability over a bare toolset is deliberate. The tool-set
conventions (list-tool page / limit / ordering, and the error contract)
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.
A tool's input schema asks for what a call needs. A selector parameter with no
default, which the toolset does not fill, is listed in required, and a
single-item service advertises the lookup its target is resolved through, so a
tool changing one row names the pk that picks it. A call that leaves such an
argument out is a ModelRetry naming it, Missing required argument(s): `pk`.,
so the model supplies it on the next turn rather than the run ending on the
selector's TypeError. The [specs] route does both, and
the [drf-mcp] route asks for the same arguments and retries the same omission.
The extra is floored where a parameter a kwargs= provider
declines, and the model left out, is retried the same way rather than reaching
the selector, and where a selector parameter the toolset takes out of every
call, a list selector's page or limit,
makes build_spec_capability raise ImproperlyConfigured rather than
advertising an argument whose value never arrives.
Declaring specs once¶
specs takes either a name -> spec mapping or a spec registry —
the kernel's SpecRegistry, the single declaration site for a project
exposing the same specs over more than one transport:
from django_spec_agents.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. The package root names no kernel type and
depends on pydantic-ai-slim alone; the kernel 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_spec_agents.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.
That schema asks for what a call needs. A selector parameter with no default,
which the server does not fill, is listed in required, so get_row(*, pk)
requires pk rather than calling it optional. A service tool also advertises
the target lookup its row is resolved through, its collection_selector_spec
when it declares one and its instance_selector_spec otherwise, so a tool
changing one row names the pk that picks it. A many=True service reads no
target and advertises no lookup. [specs]
builds its schemas the same way, so one
spec asks a model for the same arguments by either route. The extra is floored
where drf-mcp refuses at registration a selector parameter it takes out of every
call, as [specs] refuses it when the toolset is built, so one spec is
served by both routes or refused by both.
A tool that cannot run now¶
A service's affordances can say when the operation is possible at all: a
condition written as a callable reads only the user and the request, never the
call's arguments, so while it is unmet every call is refused. drf-mcp leaves
such a tool out of tools/list, and whether one is unmet can change mid-run.
So the bridge lists definitions once per request, every tool the user may
see included, and on every step:
- offers only the tools drf-mcp would list right now, asking it through
MCPServer.aunavailable_tools, so a tool that becomes available mid-run is offered from the next step without listing again; - names each tool it left out in the toolset's instructions, with the reason its condition gives, so a model asked for one says why rather than guessing or denying the operation exists:
- These operations exist but cannot be performed right now, so they are not among your tools. If the user asks for one, say it is unavailable at the moment and give the reason listed for it, rather than guessing why:
- `close_books`: The books are closed.
That is word for word what [specs] says for the same spec, so the model
is taught one sentence whichever way a spec is exposed. A server declaring no
such condition adds nothing to the instructions and no thread hop per step.
Nothing here is an authorization decision: the call enforces every condition,
so a tool whose condition flips between the step offering it and the call is
refused with its code, as below.
Error semantics¶
The bridge follows MCP's protocol-vs-tool boundary, which decides whether the model gets to recover:
- a fault in the request the model produced →
ModelRetry, so the model retries with the field errors fixed, or with a real name (the retry lists the tools this toolset advertises), instead of the run dying. drf-mcp answers arguments it refuses with a tool-levelvalidation_errorresult, and an unknown tool name on JSON-RPC-32602; the bridge retries both. The retry is drf-mcp's message, then its field detail as JSON, unless the message already names every field in the detail, nested ones included, in which case the detail is left off rather than read twice. A detail is sent whole or not at all, soInvalid arguments: {"a": ["A valid integer is required."]}keeps it, since that message names nothing. Refused arguments include one a selector requires that the call left out, which the model reads asMissing required argument(s): `pk`., word for word what[specs]raises for the same call. They also include a read-shapingQueryParamvalue (afieldsselection, say) the output serializer refuses while rendering, which drf-mcp answers as avalidation_errornaming the argument and quoting the serializer's reason; on a paged tool the likeliest one is a selection written against the page envelope rather than one item; - a tool-level
input_requiredresult (a service asking for more input, which an in-process caller cannot be asked for mid-call) →ModelRetrynaming the arguments to add on the next call; - every other tool-level failure (
service_error,not_found, a timeout, an oversized result) →ToolFailed, so the model reads the sentence and adapts, no retry budget is spent, and the call is recordedoutcome="failed"for anything streaming the run; - genuine protocol faults (auth, rate limits, an internal error) → a hard
RuntimeErrorthat aborts the run: nothing the model can rewrite its way out of.
A refusal keeps what names it. ToolFailed carries a single string, so when
drf-mcp serves an affordance refusal's code, or the chain step
that failed, it rides as a suffix on the sentence:
The first line is word for word what [specs] produces for the same
refusal raised in process, so a spec reads the same to the model whichever way
it is exposed, and the code matches the one a row's affordances answer
advertised. The suffix is written for the model and for a person reading the
tool call; a program that needs the code reads it from drf-mcp's result, or on
the [specs] route off the exception through translate_exception, rather
than parsing it out of the sentence.
Bridged tools also carry destructiveness into the tool guard: the
bridge maps each tool's readOnlyHint annotation onto DESTRUCTIVE_METADATA_KEY.
A spec's own confirmation (AdditionalInputRequired) is the input_required
retry above, which the model can answer itself, on this route and on
[specs] alike. It is not an approval gate: see
what is gated, and what is not.
[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, the StepStore
protocol among them. Even so, the [harness]
extra declares a floor and no upper bound (the number is in
pyproject.toml). A one-minor ceiling over an upstream that releases this
often made the package unresolvable on every harness release, whether or
not anything broke. Three checks stand in for it instead:
- The lock pins the harness and pydantic-ai every CI job runs against, and is moved to the newest deliberately rather than left to drift.
- The
lowest declared versionsjob runs the suite on every pull request against the oldest harness the extra allows, so the floor stays true. - The weekly
upstream driftrun ignores the lock, resolves the newest harness and pydantic-ai the extras allow, and runs the suite, so a breaking release is found by a scheduled job rather than by you.
Recent harness releases pin pydantic-ai-slim to one exact release, so
with [harness] installed the harness version you resolve also decides
your pydantic-ai version.
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.