Skip to content

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 name -> spec mapping to expose, one tool per key. A SpecRegistry is accepted anywhere the mapping is (drf-services 0.27+) — the shared declaration site for a project exposing the same specs over more than one transport, so the agent reads the source MCP and the HTTP views read. A filtered view is itself a registry, so several toolsets can be projected from one declaration with no shared state (SpecToolset(registry.by_tag("read"), id="reads")). Only the names come from it; everything else here is transport-specific, which the registry deliberately carries none of.

required
id str

Identifies this toolset, and keys a wrapping SpecCapability's defer_loading catalog entry — so give each projection of one registry its own.

'drf-specs'
instructions str | None

Replaces the conventions block get_instructions derives from the specs. None derives it.

None
get_user UserExtractor | None

Reads the acting identity off the run context. Defaults to ctx.deps.user (the AgentDeps shape).

None
get_progress ProgressExtractor | None

Reads the run's ProgressReporter sink off the run context, for a spec that reports progress. Defaults to ctx.deps.progress, tolerating a deps type without the field.

None
unknown_arguments UnknownArguments

What to do with a tool arg outside the spec's declared input set — a key the model invented. UnknownArguments.REJECT surfaces it as a ModelRetry so the model self-corrects, IGNORE drops it, PASSTHROUGH forwards it to the callable. Specs whose declared set is open (a filter_set, a **kwargs selector) are unaffected.

REJECT
query_params Sequence[QueryParam]

Read-shaping QueryParam args that seed request.query_params over the off-HTTP path — the extensible generalization of page / limit / ordering. Each is advertised as a tool arg, then popped at call time and handed to build_offline_context(query_params=…), never to the spec as an input, so unknown_arguments never sees it. For whatever reads request.query_params directly — django-restql field selection, a serializer branching on the query string — with no toolset awareness of the library.

()
tool_query_params Mapping[str, Sequence[QueryParam]] | None

query_params for one tool only, keyed by tool name. A per-tool param overrides a toolset-wide one of the same name.

None
url_kwargs Sequence[UrlKwarg]

UrlKwarg args — URL route captures (parent_pk) seeded into build_offline_context(kwargs=…) and spread by drf-services into the selector / target pools, authoritative over params. Advertised then popped like query_params. Use them for a URL-derived value not already in the tool schema: a scoping spec.kwargs provider reading view.kwargs (which params alone cannot cover), or a closed-surface route capture. A selector reading the value from its **extras: Unpack[TypedDict] needs none — drf-services reflects the key and delivers it through params — though a key may be both reflected and registered, in which case the UrlKwarg schema wins and the authoritative kwargs= spread still reaches the selector. A name cannot be both a QueryParam and a UrlKwarg on one tool: a value cannot route to two channels.

()
tool_url_kwargs Mapping[str, Sequence[UrlKwarg]] | None

url_kwargs for one tool only, same override rule.

None
host str | None

The origin the synthesized request reports, so build_absolute_uri builds real absolute URLs — DRF's FileField and the Hyperlinked* fields call it for every value once a request is in the serializer context, which off the HTTP path it always is. Accepts "example.com", "example.com:8000" or a full origin like "https://example.com", whose scheme decides whether links are https. Nothing is inferred: only the project knows its public origin, and a guess emits confidently wrong links that look valid. Unset, those fields produce relative URLs, which is what they fall back to on their own. Toolset-wide only — an origin is a property of the deployment, not of a tool.

None
max_retries int

Each tool's retry budget: how many times a ModelRetry is fed back to the model before the run aborts with UnexpectedModelBehavior. The default matches pydantic-ai's own function-tool default.

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 {"error": …} — never truncates, because a partial payload looks complete.

None
tool_max_result_bytes Mapping[str, int | None] | None

max_result_bytes per tool. An explicit None opts that tool out; an absent key inherits the default.

None
max_page_size int | None

Clamps a list tool's limit and advertises the ceiling as JSON-Schema maximum. With it set, an omitted limit becomes the ceiling rather than "everything" — the unbounded read is the one that hurts, and it is what a model produces by not thinking about pagination.

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 sync_to_async thread and asyncio cannot interrupt a thread parked in a database driver's socket read, so the query runs to completion regardless. Pair it with a database statement timeout.

None
require_permissions bool

Refuse to construct a toolset containing a spec with no permission_classes. Over HTTP that means inherit; here there is nothing to inherit from, so it means ungated. False downgrades the refusal to an UnguardedSpecWarning while migrating.

True
descriptions Mapping[str, str] | None

Overrides spec.description per tool — the docstring an API developer reads is rarely the sentence a model needs. A tool left with no description anywhere gets an UndescribedToolWarning.

None
ordering_fields Sequence[str]

Deprecated second ordering vocabulary, kept for a list selector with no filter_set and therefore no other route. It declares what such a tool may sort by, advertised as an enum on an ordering argument and validated against it; the values are raw ORM paths, because the toolset applies them with queryset.order_by. Nothing declared means no ordering argument at all. Declaring it for a tool whose spec already advertises ordering raises: public filter choices and ORM paths are two vocabularies for one argument name, and quietly preferring either is how a schema and its dispatch come to disagree.

()
tool_ordering_fields Mapping[str, Sequence[str]] | None

ordering_fields per tool. Per-tool replaces the toolset-wide set rather than merging with it.

None
http_request HttpRequest | None

The HttpRequest the off-HTTP context is built from. Incidental request data, never an auth channel: it exists so a serializer or scoping provider reading request.META finds something plausible. The acting identity is the user, and passing an authenticated request authorizes nothing. Its query string never reaches the spec: every call replaces it with the declared query_params for that tool, empty declaration included, so the ambient endpoint's own query string cannot shape a result. Its headers and META are what it contributes; drf-services wraps a copy, so nothing a dispatch does is visible on it afterwards.

None
get_http_request HttpRequestExtractor | None

http_request resolved per run from RunContext, the way get_user is. Wins over a static http_request.

None
exception_map Mapping[type[BaseException], ExceptionHandler] | None

Maps an exception type to a handler returning the tool's result (or raising ModelRetry). Matched along the MRO, most specific first, and consulted before the built-in arms, so a project can override those too.

None

Raises:

Type Description
ImproperlyConfigured

A spec has no permission_classes and require_permissions is set.

ValueError

A tool name is outside ^[a-zA-Z0-9_-]{1,64}$, a per-tool mapping names a tool this toolset does not expose, one name is registered on both parameter channels, or a tool declares ordering through both ordering_fields and its filter_set.

specs property

specs: Mapping[str, Spec]

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

get_tools(ctx: RunContext[Any]) -> dict[str, ToolsetTool[Any]]

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

is_tool_listed(name: str, ctx: RunContext[Any]) -> bool

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

get_instructions(ctx: RunContext[Any]) -> str | None

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 instructions override when one was given, else a block

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

translate_exception(
    exc: BaseException, *, ctx: RunContext[Any]
) -> ExceptionHandler | None

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 SpecToolset, including a SpecRegistry or a filtered view of one, so a project declaring its specs once can project several capabilities from them.

required
defer_loading bool

Hide the whole spec toolset and its instructions behind Pydantic-AI's native load_capability tool until the model loads it — progressive disclosure for a large spec map.

False
id str

As SpecToolset. It keys defer_loading's catalog entry, so give each capability projected from one registry its own.

'drf-specs'
description str | None

One line saying what this capability is for, rendered beside id in defer_loading's catalog. Give one to every deferred capability: Pydantic-AI's loader renders - {id}: {description} when there is one and a bare - {id} when there is not, so several undescribed capabilities leave the model choosing between names alone — the guess-or-load-everything outcome deferring exists to avoid. Not the same knob as descriptions, which relabels individual tools.

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; see validate_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 schema default. Left at UNSET there is no default, and the schema carries no default key; default=None is a real declaration ("defaults to null") and is surfaced like any other value. Read it with is not UNSET, never with a truthiness or is not None test.

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

json_schema() -> dict[str, Any]

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 validate_channel_names.

type str

The JSON-Schema type advertised to the caller — "string" by default, or "integer" / "number" / "boolean"

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 default. Left at UNSET there is no default and the schema carries no default key; default=None is a real declaration ("defaults to null") and is surfaced like any other value. Read it with is not UNSET, never with a truthiness or is not None test.

required bool

Advertise the key in the schema's required list. Use it for a route capture the spec genuinely cannot run without, so a caller is told up front instead of failing mid-dispatch. Setting both required and a default is contradictory and raises in validate_channel_names. It is the registered-declaration counterpart of InputRequired, which does the same job for a key the callable's own TypedDict declares; both end up in the schema's required.

json_schema

json_schema() -> dict[str, Any]

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

progress: ProgressReporter | None = None

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.