Integrations¶
Optional bridges exposing an existing DRF surface as agent tools. Both are
lazily imported and live under django_spec_agents.integrations rather than
being re-exported at the package root, so the base install pulls neither
dependency. See Integrations for which to choose.
Spec tools ([specs])¶
build_spec_capability¶
build_spec_capability ¶
build_spec_capability(
specs: Mapping[str, Any] | SpecSource,
*,
exclude_names: frozenset[str] = frozenset(),
) -> Any
A SpecCapability over specs, acting as the run's deps.user.
Each spec is called in process through the kernel's dispatch, which
enforces its permissions. Nothing here closes over
a request: SpecToolset's default extractor already reads
ctx.deps.user, so a run given
AgentDeps binds the
acting user natively and the capability stays request-independent, which is
what makes an agent built once reusable across runs.
Every spec here needs its own permissions. A spec with
permissions=None makes SpecCapability raise
ImproperlyConfigured rather than expose an ungated tool: over HTTP
None may mean inherit, from the view and then the project's defaults,
and off HTTP there is nothing to inherit from, so a spec properly guarded
behind a view would become callable by whatever the model decides to call.
The require_permissions=False migration flag is not exposed here; a
project migrating a large registry constructs SpecCapability itself and
passes it to AgentConfig(capabilities=...). That path skips the
tool-catalog registration this function participates in, so tool-call cards
render unlabelled — a migration step, not a destination.
A registry is passed through as a registry, narrowed with its own
subset when exclude_names bites. Flattening it to a mapping first
would drop everything the entry carries beyond the spec -- including the
OfflineContract that says what a caller with no HTTP request has to be
told -- and drop it silently, since the resulting toolset is well-formed
and merely missing declarations nobody asked it for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
specs
|
Mapping[str, Any] | SpecSource
|
A |
required |
exclude_names
|
frozenset[str]
|
Names a higher-precedence source (the |
frozenset()
|
resolve_spec_mapping¶
resolve_spec_mapping ¶
Return the name -> spec mapping, whether given one or a registry.
A SpecSource
is recognised structurally, by having a specs() method.
Public because a transport needs the same normalisation before
build_spec_capability
runs, when it reserves each tool name for collision detection: iterating a
registry yields RegisteredSpec records rather than names, so a transport
that iterated the raw argument would fill its set with dataclasses and
silently stop detecting collisions.
SpecSource¶
SpecSource ¶
Bases: Protocol
Anything that can hand back a name -> spec mapping.
django-service-specs' SpecRegistry is the intended
implementation: a project exposing the same specs over more than one
transport declares them there once, and each transport reads that source.
It is matched structurally rather than imported, because this substrate
depends on pydantic-ai-slim alone and the kernel arrives only with the
optional [specs] extra. Naming SpecRegistry in a signature would
force that dependency on every install or bury the type behind a lazy import
where a signature cannot reach it.
A plain dict is not a SpecSource — it has no specs() — which is
what lets a caller accept either and tell them apart.
MCP tools ([drf-mcp])¶
DRFMCPToolset¶
DRFMCPToolset ¶
Bases: AbstractToolset[Any]
Exposes a drf-mcp MCPServer's tools as a Pydantic-AI toolset.
Built per request, so the agent acts as the request's logged-in user. Both
schemas and execution route through drf-mcp's public in-process surface
(MCPServer.list_tools / acall_tool), so the advertised
parameters, serializer validation and permissions match the HTTP transport
exactly — without the network hop. Tool definitions carry the default
kind="function", the in-process kind the run loop calls itself; an
external tool would instead be deferred to the client and never run.
Definitions once, availability every step. drf-mcp leaves a tool out of
tools/list while an operation-scope affordance refuses it, and whether one
does can change mid-run — an admin closes the books. So the definitions are
listed once, with every tool the user may see, and each step asks drf-mcp
which of them a fresh listing would leave out (aunavailable_tools):
those are not offered that step, and the instructions name
each with its reason, so a model asked for one can say why rather than
guess or deny the operation exists. A server declaring no such condition
answers that without leaving the event loop.
Failures split three ways, along MCP's protocol-vs-tool boundary:
- JSON-RPC
-32602and tool-levelvalidation_errorandinput_requiredresults raisepydantic_ai.ModelRetry, so the model calls again with the field errors fixed or the missing input supplied, instead of the run dying; - every other tool-level failure (
service_error,not_found, a timeout, an oversized result) raisespydantic_ai.ToolFailed, so the model reads the sentence and the call is recordedoutcome="failed"; - protocol faults (auth, rate limits, an internal error) raise
RuntimeErrorand abort the run.
The mapping is the one spec_tools applies to the same
exceptions raised in process, and a refusal reads identically through both:
The books are closed. (code: books_closed).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
server
|
Any
|
The drf-mcp |
required |
request
|
HttpRequest
|
The request carried into every call; its |
required |
exclude_names
|
frozenset[str]
|
Names the |
frozenset()
|
max_retries
|
int
|
Per-tool retry budget: how many times a |
1
|
get_tools
async
¶
The tools this step may call: every definition, less the unavailable.
Definitions come from drf-mcp's tools/list once per toolset, which is
once per request. Availability is asked again here every step, because
pydantic-ai calls this once per step and a condition may flip between
two: offering a tool drf-mcp would leave out only invites a refusal, and
withholding one that became available hides an operation the model could
now perform. The call enforces every condition whatever was offered.
get_instructions
async
¶
Name each tool left out this step, with the reason its condition gives.
get_tools withholds such a tool, so without this the model sees
neither it nor any sign it exists, and a user asking for it gets a guess
where a sentence was available. None when nothing is left out, which
is every step for a server declaring no operation condition.
Asked separately from get_tools, as the spec-tools route asks: the
run context carries no key that could safely scope one answer to one
step, and asking twice costs a second evaluation of conditions that read
only seeds. A condition flipping between the two reads can leave one
step's tools and instructions disagreeing about a tool; the call's own
enforcement is authoritative either way.