Skip to content

Reference

The public API is six symbols, all importable from the package root, plus a testing module of doubles for driving a toolset through a real run loop.

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), the return_schema comes from the same spec's projected output path, and the readOnlyHint annotation is derived from the spec kind (selectors read, services mutate).

The parameter schema asks for what a call needs and nothing the toolset fills. A selector parameter named after a pool seed, a defaulted UrlKwarg or a key a typed kwargs= provider returns is not advertised; every other selector parameter without a default is required, except one the provider may fill, which stays advertised but optional: any parameter, beside a provider whose return annotation does not name a TypedDict, and a key the provider may decline with UNSET, both as drf-services' provider_keys reads the annotation. A name a build_context override fills is invisible here, so it has to be declared (see build_context). A single-item service also advertises its target lookup -- the parameters of its collection_selector_spec, or failing that its instance_selector_spec, such as a pk -- beside its input serializer's fields, the serializer's property winning a shared name, and less every key a callable in the call marks NotClientInput, which dispatch keeps from the lookup. A call leaving out a required selector parameter is handed back as ModelRetry naming each one left out, before anything runs. A required serializer field is the serializer's to report, once the call runs, so a call missing both hears about the field on its next turn.

A list selector's result is a page, always. It comes back as {"items": [...], "page": 1, "totalPages": N, "hasNext": bool} — never a bare list — with at most DEFAULT_PAGE_SIZE rows unless max_page_size lowers it. That is the input contract this toolset has always published being honoured: page and limit were advertised on every list tool and the payload was a bare slice, so a model asking for a collection got 50 of 51 rows with nothing saying more existed. hasNext is what it was missing.

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.

A many=True service takes its list as one named argument. Its input validates as a JSON array and a model's arguments are always an object, so the list travels under the argument ServiceSpec.many_argument names -- items unless the spec names another -- and nothing may be sent beside it but this toolset's own query_params / url_kwargs. An invalid item comes back as a ModelRetry keyed under that argument and then by the item's index. The result is the rendered list, advertised as an array.

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. Prefer the registry over registry.specs(): an entry carries an OfflineContract and the flattened mapping does not, so a contract's url_kwargs, query_params and field_audiences are silently absent from a toolset built off the mapping. 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. What it does not replace is the per-step list of operations that are unavailable right now, which is appended after it whenever an operation condition leaves one out of the catalog, unless conventions sets unavailable_heading to None, which drops that list with or without an override: the override replaces conventions, and which operations are offered on a given step is state no override written in advance could have described.

Refused beside a conventions= that changes a line only the derived block says, because the override would ignore it without a word. The fields that land elsewhere -- unavailable_heading, handle_field_description, query_param_on_pages and missing_arguments -- apply with or without it.

None
conventions AgentConventions | None

The wording the model is told, one line per field -- an AgentConventions. None is AgentConventions(), today's wording. A field changes what its line says and never whether it appears, which the toolset still decides per step; None on a field drops its line. Unlike instructions=, an override of one line leaves every other line derived, conditional and current.

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.

Both are this mount's declarations, and both override the entry's own OfflineContract by name. Where a project runs more than one agent transport, the contract is the better home: the operation needs the identical params whichever transport calls it, and declaring them per mount is how two mounts come to disagree.

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, and the same preference for the entry's OfflineContract where one exists.

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 ToolFailed — 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. It lowers the default page size only when it is below DEFAULT_PAGE_SIZE: an omitted limit is served min(DEFAULT_PAGE_SIZE, max_page_size), and that is the default the limit description and the instructions state. A ceiling above it raises what a call may ask for, not what it gets by asking for nothing. Unset, a list tool still returns at most DEFAULT_PAGE_SIZE rows per page — the unbounded read is the one that hurts, and it is what a model produces by not thinking about pagination.

None
thread_sensitive bool

Whether every dispatch shares one thread. True (the default, and asgiref's) is what keeps Django's thread-local database connections coherent, and is why it is not flipped for you.

The cost is that concurrent tool calls serialise, process-wide. asgiref's single_thread_executor is a class attribute, so it is one thread shared by every toolset instance and every concurrent run in the process -- not one per toolset. Pydantic-AI genuinely runs function tools in parallel within a segment, so four 0.30s calls under one model step take ~1.2s rather than ~0.3s. That is invisible in a chat turn calling one tool and severe in a fan-out.

Set False only when you know the dispatched work is safe off the main thread -- typically because each call opens and closes its own connection, or you pass an executor you control.

True
executor ThreadPoolExecutor | None

A ThreadPoolExecutor to run dispatch on, instead of asgiref's shared single thread. Only consulted when thread_sensitive is False, which is asgiref's own rule.

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
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
json_schema_registry JsonSchemaRegistry

Consumer rules for turning a custom serializer field, django-filter filter or Python type into a JSON Schema fragment — a JsonSchemaRegistry, threaded into every schema this toolset generates (input and return). Without it a project's own field type reaches the model as {} — "any value" — which is the schema saying nothing at exactly the field the model is most likely to get wrong. Build one by extending the shared default: DEFAULT_JSON_SCHEMA_REGISTRY.extend(fields=[(MoneyField, {"type": "string"})]).

DEFAULT_JSON_SCHEMA_REGISTRY
pool_seeds PoolSeeds

The project's own always-available pool seeds -- a PoolSeeds registry of names such as a tenant, a locale or a clock, which over HTTP hang off request and off it have no channel. Handed to every dispatch this toolset makes, so a service, a selector or an affordance's condition declaring one receives it as it would from dispatch_spec(pool_seeds=) called directly; and to the per-step check that leaves out an operation whose condition is unmet, so the catalog and the instructions are decided against the same seeds the call is refused with. A registered name is also reserved: the model cannot supply it, an argument of that name is neither spread nor refused as unknown, no tool's input schema advertises it, and a QueryParam / UrlKwarg declaring it is refused here with ImproperlyConfigured. Toolset-wide, with no per-tool or per-call form: a seed is ambient to the deployment, and what varies per call belongs in its resolver, which declares user / request to receive them.

DEFAULT_POOL_SEEDS

Raises:

Type Description
ImproperlyConfigured

A spec has no permission_classes and require_permissions is set, a QueryParam / UrlKwarg on a many=True spec's tool -- declared here or on its entry's OfflineContract -- shares the name its list travels under, one is named after a registered pool seed, a selector takes a parameter that a list tool's page / limit or one of the tool's QueryParam declarations takes out of the call before it runs, or instructions= is given beside a conventions= that changes a line of the block it replaces.

ValueError

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

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.

An operation whose condition is unmet right now is left out, and that is not the same decision. A spec's Affordance answered without a row -- a callable when, "the books are open" -- is asked each step, and a tool it fails is omitted until it holds again. Each of the three arguments above has an answer here. Such a condition reads only the seeds -- the user, the request, a registered seed -- and never an argument, so it cannot hide a tool the caller could in fact invoke: whatever the model would send, the call would be refused. Only the specs declaring one are asked, so a toolset that declares none pays nothing, not even a thread hop. And the model can still ask about what it cannot see, because get_instructions names every tool left out this way together with its reason, which is what it needs to tell the user why rather than guess.

A condition on the row (an ORM expression) is never asked here: there is no row at listing time, and it goes on being answered per object at the call. A condition that raises aborts the step, as it would the call -- one that cannot be answered is not a no.

The omission is applied beside is_tool_listed, not inside it, so an override of that seam -- which is free to return True -- cannot switch it off by not calling super(). Neither is an authorization decision: the call enforces every affordance whatever this listed, and a model calling a name left out gets pydantic-ai's unknown-tool retry.

This and get_instructions each ask the conditions for themselves, once per step apiece, rather than sharing one answer. The run context carries no key that could safely scope a shared answer to one run and one step: run_id may be supplied by the caller and is reused when a failed run is retried under it, and a context built by hand has none. A memo keyed on it could hand one run's -- one user's -- answer to another, where asking twice costs a second evaluation of conditions that read only seeds. The price is that a condition flipping between the two reads can leave one step's catalog and its instructions disagreeing about one tool; the call's own enforcement is authoritative either way.

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.

Not consulted for a tool an unmet operation condition already left out of this step's catalog, and returning True does not put one back: that omission is applied by get_tools itself, so an override need not call super() to keep it.

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

Per step, when an operation condition leaves a tool out. The block is then derived from the tools this step offers, so no line advises about one it does not, and it ends by naming each tool left out with its reason: the model sees neither the tool nor, otherwise, any sign it exists, and a user asking for it would get a guess where a sentence was available. The conditions are asked here and in get_tools separately -- see that method for why, and for what it costs. With an instructions override, the override stands in for the derived block and the unavailable tools are still appended after it, because an override replaces the conventions and cannot have described which operations a given step would lack. unavailable_heading=None in the conventions drops that list either way, override or not.

A toolset declaring no such condition is untouched by any of this: it asks nothing, and returns the same string every step.

Each line is worded by the toolset's conventions, which change what a line says and never whether it appears.

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 — followed in either case by the operations unavailable

str | None

this step, when there are any and unavailable_heading is not

str | None

None. None when conventions dropped

str | None

every line that would have been said and nothing is unavailable.

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.

A name an override fills has to be declared. The tool schema is built from what the toolset can read, and an override is code: a selector parameter it fills through kwargs (which become view.kwargs) is, to the schema, a parameter without a default like any other, so it is advertised as required, and a call leaving it out is handed back as ModelRetry before the override runs. Mark the selector parameter with drf-services' NotClientInput (project_pk: Annotated[int, NotClientInput]): the name is left out of the schema, so the model is never asked for it, and the override fills it. Under the default unknown_arguments a call that sends it anyway is handed back as an unexpected argument where the selector's input set is closed (no filter_set, no **kwargs); otherwise, and under the other policies, drf-services drops the model's value before the selector reads its arguments, so the override's value is the one it receives (test_a_value_the_model_sends_for_a_marked_name_never_reaches_the_selector). Where the value can be resolved from what a seed resolver receives, register it in pool_seeds= and resolve it there instead, since dispatch fills a seed from its resolver and drops a route capture of the same name.

A UrlKwarg with a default also keeps a call that leaves the name out from being refused, but it is not a way to keep the name from the model: it stays advertised, as an optional argument the model can see and send, and the override's value replaces whatever the model sent, with nothing said to the model. A model asking for project 5 is served project 42's rows. test_a_name_a_build_context_override_fills_is_kept_from_the_model holds the marker, test_a_name_a_build_context_override_fills_is_declared_to_the_toolset the seed and the defaulted UrlKwarg, and test_a_name_only_a_build_context_override_fills_is_asked_of_the_model the undeclared case.

Also called when the catalog is listed, once per get_tools and once per get_instructions, whenever some spec declares an Affordance answered without a row: that condition is asked against this context's request, so it reads the same object at listing time as at the call. That call carries no arguments, no action and an empty query_params, since a listing has none of them, and runs in the dispatch thread as the call's does. An override that reads action to decide something should expect None there.

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.

This runs ahead of every built-in arm, so it is also where a program reads a refusal's code: an ActionUnavailable arrives here with .code intact, before the default renders it into the sentence <reason> (code: <code>). A handler returned for it replaces that sentence along with everything else the default would have done.

shape_page

shape_page(
    rows: Any,
    *,
    ctx: RunContext[Any],
    page: int | None,
    limit: int | None,
    max_page_size: int | None,
) -> OutputPage

Slice a list selector's rows into the page this call serves.

Called only for list selectors -- the specs that advertise pagination arguments and return something sliceable. The default is drf-services' shared paginate_output (which counts the rows and clamps both bounds) followed by forcing evaluation, so nothing downstream holds a lazy queryset.

No sorting happens here, and an override should not add any. Ordering belongs to whatever the spec declared it on, which has already applied it to the queryset by the time this runs; an order_by here would replace that sort rather than compose with it.

render_output

render_output(
    spec: Spec,
    value: Any,
    *,
    ctx: RunContext[Any],
    projection: AudienceProjection | None,
    many: bool,
    request: Any,
    view: Any,
    extras: dict[str, Any],
) -> Any

Turn a dispatch result into the payload the model reads.

The default is drf-services' render_for_audience: render_spec_output plus the serializer's audience markings, using the projection this toolset resolved once at registration.

This is the opt-out of projecting, and the only one. Passing projection=None is not it -- render_for_audience reads None as "derive one from the spec", so the payload is projected anyway and a serializer is instantiated per call to decide how. An override that wants the unprojected payload calls render_spec_output directly.

Two cases want that. A chaining pipeline feeding one spec's output into the next needs the handles the next step reads by, and drf-services says so in render_for_audience's own docstring: project them away and the next step has nothing to key on. And a serializer with a single ChoiceField whose display differs from its value is projected without any marking being declared -- choice_labels is derived from the field itself -- so "we marked nothing, so nothing is projected" is not true, and an override is how a project that means it says so.

output_extras

output_extras(
    spec: Spec,
    value: Any,
    *,
    ctx: RunContext[Any],
    many: bool,
    dispatch_result: DispatchResult | None = None,
) -> dict[str, Any]

The resolved-data pool a spec's output-context provider may read.

Keyed the same way the HTTP path keys it, deliberately: result for a service, instance for a selector, page for a list. A provider written against one transport therefore reads the same names under the other.

Note what the default pool leaves out, because it is the question this seam attracts: drf-services' DispatchResult.service_result -- the flags carrier an upsert's created rides on. The HTTP path does not put it in this pool either; it feeds a callable success_status and a response_finalizer, both of which are status-code machinery a toolset has no wire for, and applying either here would be inventing one. A spec whose model-visible outcome depends on such a flag should put the flag in its output serializer, where both transports can see it.

dispatch_result is the escape hatch for a project that disagrees, and it carries the whole result rather than the one field: instance (the pre-mutation target, resolved once, so an override reading it cannot get a different answer by resolving again) and data (the validated input) were dropped on the same floor. The loss it repairs is conditional -- with no output_selector_spec the service's return is value, and only a re-fetch replacing value puts the flags out of reach -- but the upsert that wants created is exactly the spec that re-fetches.

Parameters:

Name Type Description Default
dispatch_result DispatchResult | None

The dispatch's full DispatchResult. Keyword-only, and always supplied by this toolset -- an override that does not accept it raises TypeError on the next tool call. That break is deliberate: the parameter is the only honest signature for a seam that is handed the carrier, and a failure the author sees on the first call is worth more than a gate that quietly stops receiving what it asked for. Declare dispatch_result, or a **kwargs, to take it. The default is None only so the method stays callable by something other than this toolset -- a test asserting the default pool, say.

None

enforce_result_bytes

enforce_result_bytes(
    payload: Any, *, ctx: RunContext[Any], max_bytes: int | None, label: str
) -> Any

Return payload, or raise a model-readable refusal when it is over budget.

Measured on the envelope, because the envelope is what is sent. Override to bound on something other than serialized length -- a row count, a per-run running total read off ctx.usage, a taper against ctx.usage_limits as a run gets long -- or to shape the refusal differently.

The default raises ToolFailed; an override is free to return a value instead, and that value becomes the tool's result marked outcome="success". Delegating to super() and inspecting what comes back is the one shape that changed -- the refusal used to be a returned {"error": …} and is now an exception.

ctx is not merely passed through to an override here: the default reads this run's correlation fields off it and stamps them on the WARNING a fired bound emits, which is the one line saying why a result the model expected came back as a refusal.

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 or conventions on the SpecToolset itself if you need them, so from_toolset(ts) and SpecCapability(specs, …) behave identically. A toolset's conventions are kept because the toolset is: this wraps that instance rather than rebuilding it.

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.

AgentConventions

AgentConventions dataclass

The instructions a toolset gives the model, and three sentences beside them, one field each.

Every field defaults to the sentence the toolset says today, so AgentConventions() changes nothing and AgentConventions(pagination=...) changes exactly one line. Pass it as conventions= to SpecToolset or SpecCapability; leaving that unset is the same as passing AgentConventions().

What is a field: every line of the instructions block, the heading of its unavailable list included; the description of a handle field that declares none; the sentence scoping a paged tool's read-shaping parameters; and the retry for a missing argument. Nothing else is. The descriptions of the arguments the toolset adds, such as limit and page, and its other retries stay fixed.

A field changes what a line says, never whether it appears. The toolset still decides that: the pagination line only when some tool returns a page, the ordering line only when some tool can sort, and so on, re-derived per step when an operation condition leaves a tool out. An override keeps those conditions, which is what instructions= loses by replacing the whole block, and a later release that corrects one default reaches every field you did not override.

None drops the line wherever the toolset would have said it; "" does not, and leaves a blank line where an instructions line was. The one exception is missing_arguments, which is the whole text of a retry and so can be neither dropped nor left blank.

Each field is a str.format template. The placeholders a field accepts are the ones listed on it, and nothing else; anything outside them raises ImproperlyConfigured naming the field when the instance is built, so a typo fails at startup rather than inside a prompt. A literal brace is written twice, {{ or }}, in every field, as the default pagination does.

Some lines state facts about behaviour, not just advice: pagination names the keys of the page envelope, base says which failures come back as a retry and which as a failed call, and query_param_on_pages lists the envelope's keys again. The toolset keeps behaving as the defaults describe whatever the text says, so an override that rewords one of those owns keeping it true.

Each transport words these for its own reader, which is why they live here and not in drf-services, and why changing them here changes nothing the MCP transport says about the same specs.

Raises:

Type Description
ImproperlyConfigured

A field uses a placeholder it does not accept, is not a valid format string, is neither a string nor None, or is missing_arguments set to None or to an empty or whitespace-only string.

base class-attribute instance-attribute

base: str | None = (
    "The following tools call Django REST Framework services and selectors.\n- A successful call returns the tool's data. A business-rule failure comes back as a failed call whose content is a sentence explaining why — that is a final answer, not a reason to retry; read it and report it, do not call the same tool the same way again. The sentence may end with `(code: <name>)`, naming the rule that refused; it is the same code an item's `affordances` answer carries when a tool advertised one.\n- An invalid or missing argument comes back as a retry request naming the problem; correct the argument and call again.\n- A permission error is final: the current user may not perform that call — do not retry it.\n- Only pass documented parameters; unknown arguments are rejected."
)

The opening of the instructions block, said whatever the tools are.

Unconditional because the failure contract it states holds for every tool, and because no spec can answer whether a refusal will carry a (code: <name>): a declared Affordance produces one, and so does a service raising ActionUnavailable by hand, which nothing declares. No placeholders.

pagination class-attribute instance-attribute

pagination: str | None = (
    '- Read-only tools that return a collection always return one page, shaped {{"items": [...], "page": 1, "totalPages": N, "hasNext": true|false}}. They accept optional `limit` (items per page, default {page_size}) and `page` (1-based). When `hasNext` is true there are more items than you were shown: ask for the next `page`, or narrow the request with a filter — never answer as if the page were the whole collection.'
)

The block's line on pages, when some tool returns one.

{page_size} is the page an omitted limit is served, the same number each tool's limit description states, so the model is not told one default here and another on the tool.

ordering class-attribute instance-attribute

ordering: str | None = (
    "- Some collection tools also accept {names}. It takes exactly one of the values listed in that tool's schema (a sortable name, or the same name prefixed with `-` for descending) — not a comma-separated list, and not an arbitrary column."
)

The block's line on sorting, when some tool advertises a sort argument.

{names} is every sort argument the tools advertise, each in backticks and joined with ", ": the name is the project's to choose, and one toolset can carry tools whose sorts are declared under different names.

handles class-attribute instance-attribute

handles: str | None = (
    "- Some tools return opaque identifier fields, described as such in the tool's output. Pass them to other tools that ask for one; refer to records by their name in anything you say, never by the identifier."
)

The block's line on opaque identifiers, when some tool's output marks one.

The block-level half of handle_field_description. No placeholders.

read_shaping class-attribute instance-attribute

read_shaping: str | None = (
    "- Some tools accept read-shaping parameters ({names}) that adjust the shape of the returned data without filtering it."
)

The block's line on read-shaping parameters, when some tool declares a QueryParam.

{names} is every declared name, sorted, each in backticks and joined with ", ". None drops the line and read_shaping_on_pages with it, since that sentence continues this one.

read_shaping_on_pages class-attribute instance-attribute

read_shaping_on_pages: str | None = (
    "On a tool that returns a page, they apply to each item in `items`, never to the page itself."
)

Appended to read_shaping, after a space, when some tool that returns a page declares a QueryParam.

The block-level half of query_param_on_pages. No placeholders.

unavailable_heading class-attribute instance-attribute

unavailable_heading: str | None = (
    "- 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:"
)

The heading of the per-step list of operations an unmet condition left out.

Followed by one line per such tool, its name and its reason, and said only on a step that left one out. It is appended after an instructions= override as well, so it is the one block line that override does not replace. None drops the heading and the list beneath it. No placeholders.

handle_field_description class-attribute instance-attribute

handle_field_description: str | None = (
    "An opaque identifier. Pass it to other tools that ask for one; refer to the record by its name in anything you say, never by this value."
)

The description of a handle field in a tool's output schema, when the field's own marking declares none.

drf-services supplies no wording for a handle on purpose: what a reader should do with an identifier depends on the reader. No placeholders.

query_param_on_pages class-attribute instance-attribute

query_param_on_pages: str | None = (
    "On a paged result it applies to each item in `items`, never to the page envelope (`items`, `page`, `totalPages`, `hasNext`)."
)

What a read-shaping parameter applies to on a tool that returns a page.

Appended after a space to each QueryParam's description on such a tool, or its whole description when it declares none, and to the retry a render-time rejection on such a tool produces. No placeholders.

The envelope is the likeliest target for a bad selection because it is exactly the shape the tool documents returning -- {items{id, name}} is a natural reading of it -- while the serializer that reads the param only ever sees one row. Saying so at the parameter, where the model is choosing a value, turns that mistake into one retry.

missing_arguments class-attribute instance-attribute

missing_arguments: str = 'Missing required argument(s): {names}.'

The retry for a call that left out an argument it cannot run without.

Covers both checks made before the input serializer runs: a parameter the selector requires, and a UrlKwarg declared required. A field the input serializer requires is reported by the serializer, in its own words. {names} is every missing name, sorted, each in backticks and joined with ", ". Never None, empty or only whitespace: it is the retry's whole text.

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.

testing

Test doubles for driving a toolset through a real Agent run loop. See Running from a worker.

testing

Test doubles for driving a toolset through a real Agent run loop.

Asserting on call_tool directly answers does the tool work. It does not answer the questions that only the run loop can: that the tools execute in-process rather than being deferred to the client, that a ModelRetry is fed back for another attempt instead of aborting, that the toolset's get_instructions actually reaches the model. Those need an Agent, and an Agent needs a model.

FunctionModel is the model with no provider and no network behind it, and the two recipes here are the ones this package's own suite grew and then kept re-deriving. They lived in a test file, which is to say outside the wheel, so every consumer wanting the same coverage wrote them again from the pydantic-ai docs.

Test doubles, not fixtures. Nothing here touches Django, the database or pytest, so it composes with whatever a project already uses.

from pydantic_ai import Agent
from rest_framework_pydantic_ai import AgentDeps, SpecToolset
from rest_framework_pydantic_ai.testing import tool_calling_model

agent = Agent(tool_calling_model("list_widgets"), deps_type=AgentDeps, toolsets=[toolset])
result = await agent.run("go", deps=AgentDeps(user=user))

tool_calling_model

tool_calling_model(
    tool_name: str,
    args: Mapping[str, Any] | None = None,
    *,
    retry_args: Mapping[str, Any] | None = None,
    final_text: str = "done",
) -> FunctionModel

A model that calls one tool, optionally corrects itself once, then stops.

Parameters:

Name Type Description Default
tool_name str

The tool to call. It must be one the toolset advertises -- an unknown name is a retry from the tool manager, not a failure the model sees, so the run would loop.

required
args Mapping[str, Any] | None

The arguments for the first call.

None
retry_args Mapping[str, Any] | None

The arguments for the second call, used only if the first raised ModelRetry. Defaults to args, which makes the model repeat itself -- fine for asserting the retry reached it, and wrong for asserting recovery, since a spec that rejected those arguments once will reject them again and the run will not settle.

None
final_text str

The output once a tool call has returned.

'done'

Returns:

Type Description
FunctionModel

A FunctionModel: no provider, no network, no key.

The branch order matters and is not arbitrary. A retry prompt is checked before a tool return, because a retried call's message history carries both and the tool-return arm would end the run on the failed attempt.

streaming_tool_calling_model

streaming_tool_calling_model(
    tool_name: str,
    args: Mapping[str, Any] | None = None,
    *,
    retry_args: Mapping[str, Any] | None = None,
    final_text: str = "done",
) -> FunctionModel

tool_calling_model, for a transport that streams.

The arguments and the behaviour are that function's; the difference is that this one supplies stream_function as well, and it exists because a FunctionModel built with function= alone cannot serve a streamed request at all -- it raises FunctionModel must receive a `stream_function` to support streamed requests.

That matters more than a missing convenience. AG-UI always streams, so a consumer reaching for the double next door to test what its browser receives gets a run that dies before the toolset is touched -- and the transport redacts the reason, so what reaches the client is "The run failed", naming nothing. The one release whose entire subject is what a consumer sees downstream of a tool call could not be tested downstream of a tool call.

Both functions are supplied, which FunctionModel allows: the same double then serves a streamed run and a non-streamed one, so a test does not have to know which the transport under it chose.

from rest_framework_pydantic_ai.testing import streaming_tool_calling_model

# An AG-UI endpoint, driven end to end.
agent = Agent(streaming_tool_calling_model("refund_order", {"reference": "ENC-1"}))

instruction_capturing_model

instruction_capturing_model(
    captured: dict[str, Any], *, final_text: str = "done"
) -> FunctionModel

A model that records the instructions it was given and calls nothing.

Writes captured["instructions"] on every request, so the last value is what the final request carried.

The caller owns the dict rather than getting one back with the model, because the assertion happens after agent.run returns and a tuple return reads worse at every call site than a name that is already in scope:

captured: dict[str, Any] = {}
agent = Agent(instruction_capturing_model(captured), toolsets=[toolset])
await agent.run("go", deps=deps)
assert "Sort with" in captured["instructions"]

A toolset's get_instructions is collected by pydantic-ai rather than by anything here, so this is the only way to assert the conventions a SpecToolset contributes actually arrive -- and they arrive on the request, not on the toolset.