Reference¶
The public API is five symbols, all importable from the package root.
SpecToolset¶
SpecToolset ¶
Bases: AbstractToolset[Any]
Exposes drf-services specs as a Pydantic-AI toolset.
Build it from a name -> spec mapping and hand it to an Agent:
toolset = SpecToolset({
"list_orders": orders_selector_spec, # SelectorSpec -> read-only tool
"create_order": create_order_spec, # ServiceSpec -> mutation tool
})
agent = Agent(model, deps_type=AgentDeps, toolsets=[toolset])
Each key becomes one tool: the description is the spec's selector/service
docstring, the parameter schema comes from spec_to_json_schema (with a
list selector's page / limit args merged in), and the
readOnlyHint annotation is derived from the spec kind (selectors read,
services mutate).
Filtering needs no declaration here, and ordering belongs to the
filter_set. A SelectorSpec.filter_set's fields are already
generated into the tool's input schema and flow through as ordinary
params, which dispatch_spec hands the FilterSet as filter_data.
That includes ordering: a FilterSet carrying an OrderingFilter named
ordering advertises the argument itself — drf-services reflects the
filter's public choices into the schema as an enum — and the toolset keeps
its hands off the value, which the FilterSet validates and applies through
its own param_map.
For anything the keywords below do not cover,
build_context and
translate_exception
are overridable and both receive the live RunContext, which is how
per-run typed deps reach dispatch.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
specs
|
SpecSource
|
The |
required |
id
|
str
|
Identifies this toolset, and keys a wrapping
|
'drf-specs'
|
instructions
|
str | None
|
Replaces the conventions block
|
None
|
get_user
|
UserExtractor | None
|
Reads the acting identity off the run context. Defaults to
|
None
|
get_progress
|
ProgressExtractor | None
|
Reads the run's |
None
|
unknown_arguments
|
UnknownArguments
|
What to do with a tool arg outside the spec's declared
input set — a key the model invented. |
REJECT
|
query_params
|
Sequence[QueryParam]
|
Read-shaping
|
()
|
tool_query_params
|
Mapping[str, Sequence[QueryParam]] | None
|
|
None
|
url_kwargs
|
Sequence[UrlKwarg]
|
|
()
|
tool_url_kwargs
|
Mapping[str, Sequence[UrlKwarg]] | None
|
|
None
|
host
|
str | None
|
The origin the synthesized request reports, so
|
None
|
max_retries
|
int
|
Each tool's retry budget: how many times a
|
1
|
max_result_bytes
|
int | None
|
Ceiling on a rendered result, measured on the encoded
payload because what is being protected is the model's context
window. Over it the call fails with a model-readable
|
None
|
tool_max_result_bytes
|
Mapping[str, int | None] | None
|
|
None
|
max_page_size
|
int | None
|
Clamps a list tool's |
None
|
dispatch_timeout
|
float | None
|
Seconds bounding one call, so the model gets an answer
instead of a hang. It does not stop the work: the dispatch runs in
a |
None
|
require_permissions
|
bool
|
Refuse to construct a toolset containing a spec with
no |
True
|
descriptions
|
Mapping[str, str] | None
|
Overrides |
None
|
ordering_fields
|
Sequence[str]
|
Deprecated second ordering vocabulary, kept for a
list selector with no |
()
|
tool_ordering_fields
|
Mapping[str, Sequence[str]] | None
|
|
None
|
http_request
|
HttpRequest | None
|
The |
None
|
get_http_request
|
HttpRequestExtractor | None
|
|
None
|
exception_map
|
Mapping[type[BaseException], ExceptionHandler] | None
|
Maps an exception type to a handler returning the tool's
result (or raising |
None
|
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
A spec has no |
ValueError
|
A tool name is outside |
specs
property
¶
The resolved name -> spec mapping this toolset exposes.
The synchronous answer to "what tools are these?", for a caller composing
this toolset at configuration time — a name-dedup pass, a tool catalog —
with no run in sight, since get_tools is async and needs a
RunContext. Read-only (a MappingProxyType), so enumerating it
cannot add a tool that skipped the constructor's permission and
description checks.
get_tools
async
¶
The tool catalog this run is offered, one entry per listed spec.
The catalog is not permission-filtered, by design. Every tool is
advertised to every run and permission_classes gate the call: a
denied tool is one the model can see and cannot use. Filtering by
default would be worse in three ways — a permission whose answer depends
on the arguments has none to read at listing time and would deny a tool
the caller can in fact invoke; get_tools runs once per model step, so
a DB-backed check becomes a query per spec per step; and a tool the model
never sees is one it cannot ask about, which is how a run ends in a
guess instead of a denial. Nothing row-level is exposed either way — a
listing carries a name, a description and an input schema.
Override is_tool_listed
when a deployment does want a narrower catalog.
is_tool_listed
async
¶
Whether name belongs in this run's catalog. True by default.
The seam for a deployment that wants a per-run catalog — hiding a
staff-only tool from a non-staff run, or scoping the catalog to a
tenant read off ctx.deps. Hiding a tool is a disclosure decision,
never an authorization one: the call is gated by
spec.permission_classes whatever this returns, so an override that
wrongly returns True grants nothing.
async because get_tools is, and it is called once per tool per
model step. An override that queries the database must wrap that work in
asgiref.sync.sync_to_async — Django refuses ORM access on the event
loop, exactly as call_tool has to for dispatch.
get_instructions
async
¶
Teach the model this toolset's conventions.
The per-tool descriptions and parameter schemas say what each tool is,
but not how the family behaves: that list tools accept page /
limit / ordering, that a business failure comes back as a readable
{"error": …} result (a final answer, not a reason to retry) while a
bad argument comes back as a retry request, and that a permission error
is final. Pydantic-AI appends the block to the system prompt each turn,
for a toolset attached directly or wrapped by a capability.
Returns:
| Type | Description |
|---|---|
str | None
|
The |
str | None
|
derived from the specs — each line conditional on something in this |
str | None
|
toolset being able to act on it, so the prompt carries no advice that |
str | None
|
cannot fire. |
build_context ¶
build_context(
user: Any,
params: Mapping[str, Any],
*,
ctx: RunContext[Any],
action: str | None = None,
kwargs: Mapping[str, Any] | None = None,
query_params: Mapping[str, Any] | None = None,
host: str | None = None,
) -> Any
Build the off-HTTP context one call dispatches under.
Override to vary the synthetic request per run. The default forwards to
drf-services' build_offline_context, resolving http_request
through the configured extractor.
An http_request here is incidental request data — never an auth
channel. The acting identity is user, resolved from ctx.deps,
and nothing downstream re-derives it from the request; supplying an
authenticated one authorizes nothing and would put a second, invisible
identity in the call.
action is the tool name, landing on the synthetic view as
view.action — one of the three attributes (request /
action / kwargs) drf-services documents a permission class as
being able to read off HTTP, and left unset it reads as None for
every spec alike. Rewrite it in an override (it arrives in **kwargs
in the forwarding form) when a permission class branches on the viewset
action names it knows.
translate_exception ¶
Return a handler for exc, or None to leave it to the defaults.
The default consults exception_map by walking the exception's MRO,
so a handler registered for a base class catches its subclasses and the
most specific registration wins. Override for a decision the map
cannot express — one that has to read the run's deps.
SpecCapability¶
SpecCapability ¶
Bases: AbstractCapability[Any]
A Pydantic-AI capability exposing drf-services specs as tools.
SpecToolset is a first-class toolset you
can attach directly (Agent(toolsets=[SpecToolset(...)])), and it already
exposes the tools and teaches the model its conventions. This wraps one to
add the two capability-only knobs, defer_loading and the
description its catalog entry is chosen by. It does not re-emit
those conventions — Pydantic-AI collects them from the owned toolset — so
wrapping and attaching directly yield the same instructions, exactly once.
Construct it the same way as SpecToolset (it forwards the toolset knobs):
agent = Agent(
model,
deps_type=AgentDeps,
capabilities=[SpecCapability({
"list_orders": orders_selector_spec, # SelectorSpec -> read-only tool
"create_order": create_order_spec, # ServiceSpec -> mutation tool
})],
)
or wrap an already-built toolset with
from_toolset
(the compose path). Either way the exposed tool set and instructions are the
toolset's.
Everything else SpecToolset accepts, this accepts, and means there.
That is a guarantee rather than a list, enforced name-by-name by the
forwarding tests: a knob added to the toolset and forgotten here is not a
missing feature but an unreachable one for every consumer composing through
a capability. See SpecToolset for what
each does; the safety-relevant ones are require_permissions,
max_result_bytes, max_page_size and dispatch_timeout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
specs
|
SpecSource
|
As |
required |
defer_loading
|
bool
|
Hide the whole spec toolset and its instructions behind
Pydantic-AI's native |
False
|
id
|
str
|
As |
'drf-specs'
|
description
|
str | None
|
One line saying what this capability is for, rendered
beside |
None
|
from_toolset
classmethod
¶
from_toolset(
toolset: SpecToolset, *, defer_loading: bool = False, description: str | None = None
) -> SpecCapability
Wrap an already-built
SpecToolset (the compose
path).
The capability adopts the toolset's id, and its tools and
instructions are the toolset's own — set an instructions override on
the SpecToolset itself if you need one, so from_toolset(ts) and
SpecCapability(specs, …) behave identically.
description means what it does on the constructor: the catalog line
a deferred capability is chosen by. It has no toolset counterpart to
adopt, so pass it here.
QueryParam¶
QueryParam
dataclass
¶
A request-level query param exposed as a caller-supplied argument off-HTTP.
Generalizes the built-in page / limit / order list-selector
arguments to any read-shaping param a serializer reads off
request.query_params — django-restql field selection (?query= /
?fields=), or a custom serializer that branches on the query string. The
transport advertises it, pops it from the arguments, and hands it to
build_offline_context(query_params=…); it never reaches the spec as an
input, so the unknown-argument policy never flags it.
A SelectorSpec
filter_set does not need this — its fields are already generated into the
schema and flow through as ordinary params.
Declared here rather than in each adapter for the same reason as
UrlKwarg: it is the same
declaration whichever transport carries it. Pair it with
validate_channel_names.
name— the argument / query-string key. Must not collide with a reserved transport key; seevalidate_channel_names.type— the JSON-Schema type advertised to the caller ("string"by default;"integer"/"number"/"boolean"/"array"…).description— optional help text shown to the caller.default— value seeded when the caller omits the argument; also surfaced as the schemadefault. Left atUNSETthere is no default, and the schema carries nodefaultkey;default=Noneis a real declaration ("defaults to null") and is surfaced like any other value. Read it withis not UNSET, never with a truthiness oris not Nonetest.
An explicit null from the caller is not a supplied value. Over HTTP a
query param is always a string, so there is no value a caller can send that
means null; off-HTTP, {"fields": null} is the shape a model emits for a
param it chose not to fill. A transport treats it as an omitted argument —
the default still applies — rather than routing None onto
request.query_params.
No required flag, deliberately. A query param is read-shaping —
omitting one is legitimate by construction, and the spec runs correctly
without it. Requiredness belongs to inputs the spec cannot run without, which
is UrlKwarg and the
InputRequired marker.
json_schema ¶
The JSON-Schema property this param contributes to an input schema.
default is emitted whenever one was declared — UNSET is the
"no default" sentinel, so an explicit default=None reaches the
schema as "default": None instead of vanishing.
UrlKwarg¶
UrlKwarg
dataclass
¶
A URL route capture exposed as a caller-supplied argument off-HTTP.
Over HTTP a nested route's captures (the project_pk of
/projects/{project_pk}/widgets/) reach a spec through view.kwargs —
directly, or through a spec.kwargs provider that scopes by them. Off-HTTP there
is no route, so the caller supplies the value as an ordinary argument: the transport
advertises it in the tool / operation schema, pops it out of the arguments, and
hands it to build_offline_context(kwargs=…), from where
dispatch_spec
spreads it into the selector / target pools — authoritative over the spec params,
below a spec.kwargs provider. It never reaches the spec as an ordinary input, so
the unknown-argument policy never flags it. Adapters import this declaration and
pair it with
validate_channel_names.
Reach for one when the value is a URL-derived input a spec depends on that is
not already an ordinary argument — most commonly a scoping spec.kwargs
provider reading view.kwargs (off-HTTP that mapping is otherwise empty, so
the provider mis-scopes for every caller), or a closed-surface spec whose route
capture must be caller-suppliable. A selector reading the value from its own
**extras: Unpack[TypedDict] needs no UrlKwarg: drf-services reflects
the key into the schema and params delivers it. A key can be both
reflected and registered — the explicit UrlKwarg wins the adapter's schema
merge, registration pops the argument into kwargs=, and the authoritative
spread still delivers it to the selector pool, so both readers see it.
An explicit null from the caller is not a supplied value. A route capture
is a URL segment, so over HTTP there is no value that means null; off-HTTP,
{"project_pk": null} is what a model emits for a capture it could not
resolve. A transport treats that as an omitted argument — required still
refuses it and default still applies — rather than spreading None onto
view.kwargs, where a scoping provider would turn it into a silent
IS NULL lookup that returns rows and looks successful.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
The argument / view-kwarg key. Must not collide with a reserved
transport key; see
|
type |
str
|
The JSON-Schema type advertised to the caller — |
description |
str | None
|
Optional help text shown to the caller. |
default |
Any
|
Value seeded when the caller omits the argument; also surfaced as
the schema |
required |
bool
|
Advertise the key in the schema's |
json_schema ¶
The JSON-Schema property this kwarg contributes to an input schema.
default is emitted whenever one was declared — UNSET is the
"no default" sentinel, so an explicit default=None reaches the
schema as "default": None instead of vanishing.
AgentDeps¶
AgentDeps
dataclass
¶
Dependencies a Pydantic-AI agent passes to a
SpecToolset.
Pass an instance as deps when running the agent:
agent = Agent(model, deps_type=AgentDeps, toolsets=[toolset])
await agent.run("create an order for …", deps=AgentDeps(user=request.user))
user carries the acting identity — a Django user, a custom principal,
whatever get_user returns, hence Any — so the toolset can run each
spec under the same off-HTTP context and permission checks a DRF view would
apply. SpecToolset reads ctx.deps.user by default; a project that
threads identity differently — a richer principal, a lookup keyed off a token
— can keep its own deps type and hand SpecToolset a get_user
extractor instead of using this class.
progress
class-attribute
instance-attribute
¶
Where a spec's progress(...) calls go for this run, or None.
A plain callable (progress, *, total, message, meta) -> None. The toolset
forwards it into the kwarg pool and does nothing else with it.
It arrives here rather than on the toolset because the toolset must never construct one. This package is driven by AG-UI, by A2A, by a management command, by a worker, each with a different idea of where a progress report should go — an SSE frame, a task record, a log line — and a toolset that picked one would have chosen a transport it does not own.
None costs nothing: drf-services substitutes its no-op, so a service
declaring progress runs unchanged whether or not anyone is listening.