Quickstart¶
1. Have some specs¶
SpecToolset works with the ServiceSpec and SelectorSpec objects you already
define for djangorestframework-services. A read selector and a write service:
from rest_framework_services import SelectorKind, SelectorSpec, ServiceSpec
def list_orders(user):
"""List the current user's orders."""
return Order.objects.filter(owner=user)
list_orders_spec = SelectorSpec(
kind=SelectorKind.LIST,
selector=list_orders,
output_serializer=OrderSerializer,
)
def create_order(data, user):
"""Create an order for the current user."""
return Order.objects.create(owner=user, **data)
create_order_spec = ServiceSpec(
service=create_order,
input_serializer=OrderInputSerializer,
output_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE,
output_serializer=OrderSerializer,
),
)
2. Build the toolset¶
from rest_framework_pydantic_ai import SpecToolset
toolset = SpecToolset(
{
"list_orders": list_orders_spec,
"create_order": create_order_spec,
}
)
Each key is the tool name. The description comes from the selector/service
docstring, the parameter schema from the selector's parameters or the service's
input serializer and target lookup (see
What a tool asks the model for), the
return_schema from its output serializer, and the readOnlyHint annotation
from the spec kind (selectors read, services mutate). List selectors
additionally accept page and limit tool args, plus ordering where the
selector's filter_set declares one.
3. Run an agent¶
The acting user flows through RunContext.deps. The default
AgentDeps carries it:
from pydantic_ai import Agent
from rest_framework_pydantic_ai import AgentDeps
agent = Agent("anthropic:claude-opus-4-8", deps_type=AgentDeps, toolsets=[toolset])
result = await agent.run(
"show me my last 5 orders, newest first",
deps=AgentDeps(user=request.user),
)
No request in sight?
request.user is the HTTP shape. A Celery task, a management command or a
scheduled job resolves the acting user itself and passes it the same way —
see Running from a worker, which also covers the one
setting a fan-out genuinely needs.
For that request the model can call list_orders with
{"limit": 5, "ordering": "-created"} and the toolset enforces permissions,
runs the selector as request.user, hands ordering to the selector's
filter_set, slices the result, and renders it through
OrderSerializer.
Every list result is a page¶
A list selector answers with the pagination envelope, never a bare array:
limit defaults to 100 rows and page to 1, so a tool that used to return an
entire table now returns its first hundred rows and says so. That is the
point: page and limit were advertised on every list tool from the start
while the payload was a bare slice, so a model asking for a collection received
50 of 51 rows with nothing in the answer telling it more existed. hasNext is
what was missing — the model can ask for page: 2, or narrow the request with a
filter, instead of answering from a page it took for the whole set.
Because the toolset takes page and limit out of a list tool's call to serve
the page, a list selector taking a parameter of either name is refused with
ImproperlyConfigured when the SpecToolset is built: the parameter would never
receive what the model sent, and one with a default would quietly run on it.
Rename the parameter. A retrieve selector's page is not paginated and reaches
it.
max_page_size caps limit and advertises itself as JSON-Schema maximum
on it. A call naming no limit is served the smaller of 100 and the ceiling, and
that is the default both the limit description and the toolset's instructions
state, so with the toolset below the model reads "Defaults to 25" beside
maximum: 25. A ceiling above 100 raises what a call may ask for, not what it
gets by asking for nothing: the stated default stays 100.
The same envelope is what the tool's return_schema describes, generated from
the same spec — so the schema and the payload cannot disagree about it.
The output schema¶
Each tool definition carries a return_schema derived from the spec's output
serializer, projected the same way the payload is: a field marked
hidden is absent from both, and a marked handle carries its
description in both.
It is populated but not sent by default, because a return schema costs context on every turn of every run and only your model and your serializers say whether that trade is worth it. Pydantic-AI owns the opt-in, at either scope:
A spec with no output_serializer gets None rather than a guessed shape.
A return_schema admits null, with the root type ["object", "null"],
where the spec says dispatch may hand the model None, as drf-services'
can_present_nothing answers it for every route that states an output schema:
- a retrieve selector declaring
allow_none=True, when nothing matches; - a service whose
output_selector_specre-reads through aselector, because dispatch materializes the re-read with.first(), and a re-read that filters out the row it wrote finds none; - a service with nothing to re-read that declares
ServiceSpec(allow_none=True), since dispatch then presents the service's own return, aNoneincluded.
A None the spec does not declare is still handed to the model. A service
with nothing to re-read and no allow_none=True that returns None is
presented as None, against a return_schema whose root is "object" and does
not admit null. ServiceSpec(allow_none=True) is the declaration that admits
it, so declare it on any such service that can return nothing.
allow_none on a service's nested output_selector_spec is not read, because
dispatch does not read it, and a list result is never null, only empty. A
service whose output_selector_spec declares LIST is an array whether or not
it has a selector to re-read through: with none, dispatch presents the
service's own return as the list, and a return that is not a set of rows, such
as the one row a create returns, raises ImproperlyConfigured out of the run.
Return the rows, or declare RETRIEVE to present one.
What a tool asks the model for¶
A tool's parameter schema is the model's only account of what to send, and a
selector's signature cannot say on its own which parameters the model sends and
which the toolset fills. So the toolset says so when it builds the schema
(through drf-services' spec_to_json_schema(supplied=...)):
- A name the toolset fills is not advertised. That is drf-services' own
pool seeds (
request,user,progressand the rest), every name registered inpool_seeds, and the keys akwargs=provider returns when its return annotation is aTypedDict, less any it may decline withUNSET(below). The annotation is read by drf-services'provider_keys, the reader dispatch's own contract is stated in, so this toolset and drf-mcp read a provider the same way. AUrlKwargthat declares adefaultfills its name too, so the selector's parameter of that name is not required, but theUrlKwargitself stays advertised, as the optional argument it always is. - Every other parameter without a default is required.
get_widget(user, pk)is advertised as{"properties": {"pk": {}}, "required": ["pk"]}. A parameter with a default stays optional, andInputRequiredstill makes one required. - A
kwargs=provider without aTypedDictreturn annotation may fill any parameter, a plaindictor a lambda included, so for its spec nothing is required merely for lacking a default, thoughInputRequiredstill requires one. So may a provider whose return annotation does not resolve. Only the return annotation is read for the keys, so a parameter typed with a name imported only underTYPE_CHECKINGchanges nothing, and aTypedDictvalue that does not resolve makes only its own key one the provider may decline, so that key stays advertised and the others are still filled. Annotate the provider to have the rest required:
from typing import TypedDict
class ProjectScope(TypedDict):
ceiling: int
def scope(view) -> ProjectScope:
# ceiling is filled here, so the model is never asked for it.
return {"ceiling": ceiling_for(view.kwargs["project_pk"])}
list_spec = SelectorSpec(kind=SelectorKind.LIST, selector=priced_under, kwargs=scope)
- A key whose value admits
UnsetTypeis not one the toolset fills. A provider may decline a key by returning drf-services'UNSETfor it, which drops the key from the pool and lets the model's value through, soceiling: int | UnsetTypekeepsceilingadvertised for the model to send, and optional, since the provider may fill it instead. The provider's other keys are still not asked for. It is the key's own value that has to admitUNSET, as a union member:regions: list[str | UnsetType]always comes back as a list, soregionsis filled. A genericTypedDictis read with what binds its type parameters, so-> Scope[str | UnsetType]declinesScope'stenant: Tas the written-outtenant: str | UnsetTypedoes. If the provider declines the key and the model has not sent it either, drf-services refuses the call before the selector runs, and the model is handed backMissing required argument(s): 'ceiling'.to send it on its next turn, as it is when an untyped provider leaves a parameter unfilled. Where the selector can run without the value, give the parameter a default. - A name a
build_contextoverride fills has to be declared. An override is code the schema cannot read, so a selector parameter it fills throughkwargs(which becomeview.kwargs) is advertised as required like any other parameter without a default, and a call leaving it out is handed back before the override runs. Mark the selector parameter with drf-services'NotClientInput: the name is left out of the schema and the override fills it. A call that sends it anyway is handed back as an unexpected argument by default where the selector's input set is closed (nofilter_set, no**kwargs). Otherwise, and under the other policies, drf-services drops the model's value before the selector reads its arguments, so the value the override writes is the one the selector receives. Where the value can be resolved from what a seed resolver receives, register it as a pool seed and resolve it there instead:
from typing import Annotated
from rest_framework_services import NotClientInput, SelectorKind, SelectorSpec
def list_tasks(user, project_pk: Annotated[int, NotClientInput]):
"""List the user's tasks in the run's project."""
return Task.objects.filter(project_id=project_pk, assignee=user)
class ProjectScopedToolset(SpecToolset):
def build_context(self, user, params, *, ctx, kwargs=None, **rest):
scoped = {**(kwargs or {}), "project_pk": current_project_pk(ctx)}
return super().build_context(user, params, ctx=ctx, kwargs=scoped, **rest)
# project_pk is marked NotClientInput and filled by the override, so the model
# is never asked for it, and a call that sends one is refused.
toolset = ProjectScopedToolset(
{
"list_tasks": SelectorSpec(
kind=SelectorKind.LIST, selector=list_tasks, output_serializer=TaskSerializer
)
}
)
A UrlKwarg with a default also keeps a call that leaves the name out from
being refused, but it does not 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 that asks for one project is served another's rows.
A service tool also advertises its target lookup. drf-services hands the
arguments it validates against the input serializer to the selector that
resolves the row or the set, too, so that selector's parameters are reflected
beside the serializer's fields by the same rules. It is the
collection_selector_spec or the instance_selector_spec, whichever the
service declares: drf-services refuses a spec declaring both when it is built,
and any lookup beside many=True, so a many=True service's schema stays the
list alone. A rename tool whose instance selector is
task_by_pk(user, *, pk) asks for pk as well as the new title, and requires
it. Where a lookup parameter and a serializer field share a name, the
serializer's property is the one advertised, since the serializer validates
the value, and the name is required if either requires it, since the lookup
cannot run without it whatever a partial serializer says.
A key that any callable in the call marks NotClientInput, the service, one
of its preconditions or the lookup itself, is left out of what the lookup
contributes, as drf-services' server_owned_keys names them: dispatch keeps the
caller's value for it from every callable, and refuses it as an
unexpected argument, so the lookup naming it plainly
does not make it the model's to send. A field the input serializer declares
under such a name is still advertised, since the serializer validates it into
data whatever a callable hides under the name.
A service with no input serializer takes the model's arguments as its data
bundle, under drf-services' default binding for a service, so its own
parameters are not arguments: its schema lists its lookup's keys and nothing
else, which is what an unexpected argument check
admits of it.
A call that leaves out a required selector parameter is handed back, the
tool's own or its target lookup's, as ModelRetry naming each one left out,
as in Missing required argument(s): `pk`., the same sentence a required
UrlKwarg gets, so the model corrects the call on its next turn. It used to
reach the selector, which raised TypeError out of the run. A required
serializer field is checked by the serializer once the call runs, so a service
call missing both pk and a field is told about pk first, and about the
field on the turn after. A name a provider may fill is not checked before the
call, since only the pool the call assembles can say whether it arrived, and
drf-services checks that pool: a selector parameter nothing filled comes back
as a retry naming it too, Missing required argument(s): 'ceiling'. A
required parameter no argument could fill, such as one a precondition marks
NotClientInput and nothing supplies, is a
configuration error rather than something the model can correct, and fails as
the callable's own TypeError.
Custom identity¶
If your project carries identity on a richer deps object, hand the toolset a
get_user extractor instead of using AgentDeps:
Project pool seeds¶
Over HTTP a service reads its tenant, locale or clock off request. Off HTTP
there is no request carrying any of that, and drf-services'
PoolSeeds is the channel
for it. Hand the registry to the toolset as pool_seeds=:
from rest_framework_services import DEFAULT_POOL_SEEDS
seeds = DEFAULT_POOL_SEEDS.extend(tenant=lambda user: user.profile.tenant)
toolset = SpecToolset(specs, pool_seeds=seeds)
Every dispatch the toolset makes is handed the registry. A service, a selector
or an affordance's condition that declares tenant therefore receives the
resolver's value, as it would from dispatch_spec(pool_seeds=seeds) called
directly. The per-step check that
leaves out an unavailable operation
asks its conditions against the same seeds, so a step's catalog and the call's
own refusal answer alike.
A registered name is reserved the way user and request are:
- The model cannot supply it. An argument of that name is not passed on and is not refused as unknown either, so the callable always sees the resolver's value.
- A channel cannot declare it. A
QueryParamorUrlKwargwith that name raisesImproperlyConfiguredwhen the toolset is built, as one nameduserdoes. Dispatch strips a reserved name from the route captures it hands a selector, so an acceptedUrlKwargwould offer the model an argument that is then dropped on every call. - No schema advertises it. A selector or target lookup declaring the seed as a parameter does not offer it to the model, so the model is not asked for a value the call would ignore. See What a tool asks the model for.
The registry applies to the whole toolset, with no per-tool or per-call form. A
seed is ambient to the deployment, and what varies from call to call belongs in
the resolver, which declares user or request to receive them.
What a permission class sees¶
Every call is authorized by spec.permission_classes, run against a synthetic
request and view. drf-services supports a permission class reading request,
view.action and view.kwargs off HTTP — anything beyond those three (a
view.queryset, as DjangoModelPermissions wants) is not available. Here is
what this package puts in each:
request.useris the acting identity —deps.user, or whateverget_userreturns. A configuredhttp_requestnever contributes an identity.request.query_paramsholds exactly the query params declared for that tool, and nothing else. A tool that declares none dispatches with an empty query string even when the toolset was given anhttp_request, so the ambient endpoint's own query string can never reach a serializer or afilter_set.view.actionis the tool name — the key the spec is registered under in the mapping you passed. Off HTTP there is no router to name an action, and the tool name is the identity the model called, so it is the honest answer; it is also what the MCP transport reports for the same spec. A permission class branching on viewset action names ("create","retrieve") will not match one of those unless a tool happens to be named that — check theelsebranch of such a class before exposing its spec, and rewriteactionin abuild_contextoverride if you need a specific one.view.kwargsholds the URL kwargs declared for that tool, the off-HTTP counterpart of a route's captures.
The tool catalog is not permission-filtered¶
get_tools advertises every spec to every run, except a tool whose operation
condition is unmet at
that step. A tool whose permissions will deny this caller is still listed — the
denial happens on the call. That is
deliberate: a permission whose answer depends on the arguments has none to read
at listing time and would hide a tool the caller can actually use, the listing
runs once per model step so a database-backed check would cost a query per spec
per step, and a model that cannot see a tool cannot ask about it. A listing
carries a name, a description and an input schema; no row data.
If a deployment does want a narrower catalog, override is_tool_listed:
from asgiref.sync import sync_to_async
class OpsOnlyToolset(SpecToolset):
async def is_tool_listed(self, name, ctx):
if name != "suspend_account":
return True
# Django refuses ORM access on the event loop, so anything that
# queries has to go through a thread — as dispatch itself does.
return await sync_to_async(ctx.deps.user.groups.filter(name="ops").exists)()
Hiding a tool is a disclosure decision, never an authorization one: the call is
gated by permission_classes whatever this returns.
An operation that is unavailable right now is left out¶
A spec's Affordance answered without a row, a callable when such as
lambda: Period.current().is_open (see below),
is asked on every model step, and a tool whose condition is unmet is left out of
that step's catalog. The arguments against filtering by permission do not apply
to it. The condition reads only the seeds (the user, the request and any
registered seed), never an argument, so it cannot hide a tool the caller could
have invoked with other arguments. Only the specs declaring one are asked, and a
toolset declaring none pays nothing. And the model can still tell the user about
the missing tool, because the instructions for that step end by naming each tool
left out with its reason:
- 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:
- `post_invoice`: The books are closed.
The rest of the instructions are derived from the tools that step offers, so no
line describes a tool it lacks. An instructions= override replaces those
conventions but not this list, which is appended after the override: which
tools a given step lacks is not something an override written in advance can
say. The heading is the unavailable_heading field of
conventions=, and setting it to None
drops the heading and the list, with an override or without one.
A few things follow from how it is asked:
- An
is_tool_listedoverride cannot put the tool back. The omission is applied beside it rather than inside the default, so an override returningTruedoes not need to callsuper()to keep it. - Conditions run where a dispatch runs, in a thread under the toolset's
thread_sensitiveandexecutor, so one that queries is fine. The connection it opens is closed after it, as a dispatch's is. - The request a condition reads is built through
build_context, so a condition readingrequestsees the same object when the catalog is listed as at the call, including anything an override adds to it. That listing call carries no arguments, noactionand an empty query string. - A condition on the row (an ORM expression) is never asked here. There is no row when the catalog is listed, so the call answers it for its own row.
get_toolsandget_instructionseach ask once per step. A shared answer would need a key scoping it to one run, and the run context has none that is safe:run_idcan be supplied by the caller and is reused when a failed run is retried. A condition that flips between the two reads can leave one step's catalog and instructions disagreeing about one tool.
None of this changes the call. dispatch_spec enforces every affordance
whatever was listed, a call against a listing that went stale is refused as
below, and a model calling a tool that
was left out gets pydantic-ai's unknown-tool retry, which names the tools that do
exist.
Changing what the model is told¶
Every line of the instructions block, the heading of the unavailable list
included, is a field of
AgentConventions,
and so are three sentences outside it: the description a handle field gets when
it declares none, the sentence scoping a paged tool's read-shaping parameters,
and the retry for a missing argument. conventions= changes them one line at a
time. Each field defaults to the toolset's own wording, so AgentConventions()
changes nothing, and leaving conventions= unset is the same as passing it.
Nothing else the toolset writes is a field. The descriptions of the arguments it
adds, such as limit and page, stay fixed, and so do its other retries: a
limit or page that is not a positive integer, an ordering the tool does not
take, a service asking for more input, and the opening of a
render retry, whose closing scope
sentence is the one part that is a field.
from rest_framework_pydantic_ai import AgentConventions, SpecToolset
toolset = SpecToolset(
specs,
conventions=AgentConventions(
# {page_size} is the page an omitted `limit` is served; a literal brace is doubled.
pagination=(
"- Collections come back one page at a time, as "
'{{"items": [...], "page": 1, "totalPages": N, "hasNext": true|false}}, '
"{page_size} items unless you pass `limit`. Ask for the next `page` while "
"`hasNext` is true."
),
handles=None, # this audience never sees identifiers, so say nothing about them
),
)
A field changes what a line says, never whether it appears. The toolset still
decides that, exactly as before: the pagination line above is still absent from
a toolset with no list tool, and the block is still re-derived on each step an
unmet condition leaves
a tool out, so an overridden line goes with the tool it describes. None drops a
line wherever it would have been said.
| Field | Where it lands | Said when | Placeholders |
|---|---|---|---|
base |
instructions, first | always | none |
pagination |
instructions | some tool returns a page | {page_size} |
ordering |
instructions | some tool advertises a sort argument | {names} |
handles |
instructions | some tool's output marks a handle | none |
read_shaping |
instructions | some tool declares a QueryParam |
{names} |
read_shaping_on_pages |
instructions, after read_shaping |
that tool also returns a page | none |
unavailable_heading |
instructions, last, above the tools left out | a condition left a tool out this step | none |
handle_field_description |
a handle field's output schema | the field declares no description of its own | none |
query_param_on_pages |
a QueryParam's description, and the render retry |
the tool returns a page | none |
missing_arguments |
the retry for a call missing an argument | a selector parameter or a required UrlKwarg is left out |
{names} |
A few rules follow from what each field is:
- Every field is a
str.formattemplate, placeholders or not, so a literal brace is written twice everywhere.{names}is each name in backticks, joined with,(sorted, exceptordering's, which follow the tools). - A typo fails at startup. A placeholder the field does not accept, an
unbalanced brace, or a format spec its value cannot take raises
ImproperlyConfigurednaming the field when theAgentConventionsis built. Noneonread_shapingdropsread_shaping_on_pagestoo, because that sentence continues it;Noneonunavailable_headingdrops the list beneath it. To drop a line, useNone, not"", which leaves a blank line.missing_argumentscannot beNone, empty or only whitespace: it is the retry's whole text.missing_argumentscovers the two checks made before the input serializer runs. A field the input serializer requires is still reported in the serializer's own words.
Some lines state facts about behaviour, and an override owns keeping them
true. pagination and query_param_on_pages name the page envelope's keys, and
base says which failures come back as a retry and which as a failed call. The
toolset goes on behaving as the defaults describe whatever the text says, so a
rewording that drops or contradicts one of those facts tells the model something
false.
conventions= and instructions=. instructions= replaces the whole
derived block, so changing one of its lines (base through
read_shaping_on_pages) beside it would be ignored, and the toolset refuses the
pair with ImproperlyConfigured naming the fields. The other four land outside
the block and apply either way, unavailable_heading included, since the list of
unavailable operations is appended after an override too. Prefer conventions=
where it can say what you need: an override is a copy of the block, so it keeps
advising about pagination on a step with no list tool, and it misses every
correction a later release makes to the wording.
SpecCapability takes the same keyword, and
SpecCapability.from_toolset
keeps the conventions of the toolset it wraps. They reach this toolset's tools
only: the MCP transport, djangorestframework-mcp-server, writes its own schemas
and retries, so a model reading the same specs over MCP is told what that
transport is configured to say.
Unexpected arguments¶
By default the toolset rejects tool args outside a spec's declared input set
— a key the model invented — surfacing them as a ModelRetry so the model
self-corrects. Specs whose declared set is open (a filter_set or **kwargs
selector) are unaffected. Pass unknown_arguments= to change this:
from rest_framework_services import UnknownArguments
# silently drop unexpected keys instead of rejecting them
toolset = SpecToolset(specs, unknown_arguments=UnknownArguments.IGNORE)
IGNORE drops a key no parameter declares. A parameter marked NotClientInput
is not the model's under any policy: REJECT refuses a value sent for it where
the input set is closed, and everywhere else drf-services drops the value before
any callable in the call reads it.
A list as input¶
A ServiceSpec declaring many=True becomes a tool that takes its list as one
named argument. Its input validates as a JSON array, and a model's tool
arguments are always a JSON object, so the array travels under the argument the
spec's many_argument names, items unless it names another:
def create_widgets(*, data, user):
"""Create several widgets in one call."""
return [Widget.objects.create(owner=user, **item) for item in data]
create_widgets_spec = ServiceSpec(
service=create_widgets,
input_serializer=WidgetInputSerializer,
many=True,
many_argument="widgets", # optional; the default is "items"
output_selector_spec=SelectorSpec(
kind=SelectorKind.RETRIEVE, output_serializer=WidgetSerializer
),
)
The model sends {"widgets": [{...}, {...}]} and the service receives the list,
exactly as it would from a REST view's bare array body. The tool's parameter
schema says so: an object with that one required property, an array of the item
schema, carrying the list serializer's allow_empty, min_length and
max_length as minItems / maxItems, and additionalProperties: false. A
declared QueryParam or UrlKwarg is advertised beside it, since the toolset
takes those out of the arguments before dispatch. The result is the rendered
list, and the tool's return_schema describes it as an array even though the
output_selector_spec is RETRIEVE.
What the model is told when a call is wrong:
- An invalid item comes back as a
ModelRetrynaming the argument, then the invalid item's index, then the field, on every Django REST framework version this package supports. The model readswidgets[1].price: This field is required., the same rendering every validation error gets, and corrects that one item. - The argument missing,
nullor not a list comes back under the argument in the words that field would use:widgets: This field is required. - Any other argument sent beside the list comes back as
Unexpected argument(s): 'note'.under everyunknown_argumentspolicy, because the service receives only the list and the argument would have nowhere to go. The policy still decides what happens to an undeclared key inside an item.
A QueryParam or UrlKwarg sharing the list's argument name is refused with
ImproperlyConfigured when the toolset is built, whether it is declared
toolset-wide, per tool or on the registry entry's OfflineContract: it would be
taken out of the arguments first and carry the list away with it.
A tool that needs arguments beside its list cannot be many=True. Name the
list as a field of the input serializer instead, and loop over it:
class BulkWidgetInput(serializers.Serializer):
items = WidgetInputSerializer(many=True)
dry_run = serializers.BooleanField(default=False)
The model sends {"items": [...], "dry_run": true}, and an invalid item comes
back placing its errors at its index, as items[1].price: .... Django REST
framework keys them by index from 3.18 and lists them below it, with an empty
entry for each valid item; the model reads the same line either way.
Ordering¶
The filter_set owns ordering. Declare a django-filter OrderingFilter
named ordering on the selector's FilterSet and you are done:
import django_filters
class OrderFilterSet(django_filters.FilterSet):
ordering = django_filters.OrderingFilter(
fields=(("created_at", "created"), ("total_cents", "total")),
)
class Meta:
model = Order
fields = ["status"]
drf-services reflects that filter into the tool's input schema as its public
choices (created, -created, total, -total) — OrderingFilter subclasses
ChoiceFilter, which the schema generator maps to a set of const options
carrying the filter's own labels — so the model is told exactly what it may sort
by, in the words the FilterSet uses. At call time the value is handed to
the FilterSet as filter data: it validates the choice, applies its own
param_map, and a value outside the enum comes back as a ModelRetry. The
toolset contributes nothing and takes nothing away.
One vocabulary, one declaration site, and the same ordering your HTTP views already serve.
The filter's name is yours to pick — an OrderingFilter declared as sorting
is found and used the same way. What the schema advertises is what the model may
send, under whatever it is called.
A list selector with no filter_set¶
A selector that takes its own sort argument works too: declare it on the callable and it is reflected into the tool schema like any other parameter, and handed to the callable to apply.
def list_orders(user, ordering: str = "-created_at"):
"""List the acting user's orders."""
return Order.objects.filter(customer=user).order_by(ordering)
Prefer the FilterSet where there is one: it validates the value against a
published set of choices before anything reaches the ORM, while a bare parameter
is only as safe as what the selector does with it.
Migrating from ordering_fields¶
SpecToolset(specs, ordering_fields=[...]) and its per-tool
tool_ordering_fields form were deprecated in 0.16.0 and have now been removed;
passing either raises TypeError at construction. Move the names onto an
OrderingFilter as (orm_path, public_name) pairs — the FilterSet at the top of
this section is exactly ordering_fields=["created_at", "total_cents"]
rewritten — and drop the argument.
The vocabularies are not the same, and that is the point of the move: the knob's
values were raw ORM paths, because the toolset applied them with
queryset.order_by itself, while a FilterSet's choices are public names it maps
through its own param_map. Picking public names is the migration's one
decision — they are what the model sees, so give them the words a reader would
use.
Read-shaping query params¶
page / limit are built in for list selectors and ordering comes from the
filter_set, but you can register your own request-level params with
QueryParam. Each is
advertised as a tool arg, then — instead of reaching the spec as an input — seeded
into request.query_params over the off-HTTP path. That is for whatever reads
request.query_params directly: django-restql field selection, or a custom
serializer that branches on the query string.
You don't need this for filter_set
A SelectorSpec.filter_set's fields are already generated into the tool's
input schema (the [filter] extra) and flow through as ordinary params —
which dispatch_spec hands the FilterSet as its filter_data. So the model
can filter a list selector with no QueryParam declaration at all, and the
same goes for ordering; QueryParam is only for params a
serializer reads off request.query_params.
from rest_framework_pydantic_ai import QueryParam
toolset = SpecToolset(
specs,
# applies to every tool
query_params=[QueryParam("query", description="django-restql field selection")],
# or scope params to one tool
tool_query_params={"list_orders": [QueryParam("status", default="open")]},
)
A registered param is popped before dispatch, so unknown_arguments never flags
it; a declared default is seeded when the model omits the arg or sends it as
null, which never reaches the query string itself. (Names can't be
page / limit / ordering — those are reserved transport keys. ordering is
reserved even when a filter_set owns it: a registered channel pops the value at
call time, so the FilterSet would never see it.) For the same reason a tool may
not register a QueryParam under a name it advertises as the call's own input:
a selector parameter or filter_set field, a service's input serializer field,
or a parameter of the service's target lookup,
which a service tool advertises beside its serializer's fields. The
SpecToolset refuses it with ImproperlyConfigured when it is built, whether
the QueryParam is declared on the toolset, per tool or on the entry's
OfflineContract, and the refusal says whether the selector, the service or its
target lookup takes the name. Fill the parameter from request.query_params
with a typed kwargs= provider (the first exemption below), read the value
there in the callable and drop the parameter, or drop the QueryParam.
The check reads the names the tool's schema offers the model, so a name the model is not offered is exempt:
- a name a
kwargs=provider declares it fills, by returning aTypedDictwhose key cannot beUNSET. The provider owns the parameter, so a provider readingrequest.query_params["status"]into the selector'sstatus, besideQueryParam("status"), is how to route the value to the selector, and the model's value is served; - a key the server keeps from the call, one a callable in it marks
NotClientInput, which no caller's value reaches.
A key the provider may decline with UNSET, and any key of a provider whose
annotation does not say which keys it returns, is still refused: on a call where the provider does
not fill it, the model's value is the only one, and the QueryParam has taken
it. A UrlKwarg may share a selector parameter's name, since its value reaches
the selector through view.kwargs.
Requires djangorestframework-services>=0.23, which added the
build_offline_context(query_params=…) seam.
On a list tool, a param shapes each row¶
Every list tool returns a page, {"items": [...], "page": 1, "totalPages": N,
"hasNext": ...}, but the serializer that reads a read-shaping param renders one
row at a time and never sees the envelope. So a selection written against the
documented shape, fields=items or django-restql's {items{id, name}}, asks
each row for an items field it does not have. The toolset tells the model this in two places:
- each
QueryParamon a list tool has "On a paged result it applies to each item initems, never to the page envelope (items,page,totalPages,hasNext)." appended to its description, or as its description when it declares none; a retrieve tool's param is left as declared; - the read-shaping line of the instructions the toolset derives says the same,
"On a tool that returns a page, they apply to each item in
items, never to the page itself.", when some list tool declares aQueryParam.
An instructions= override replaces the derived block, and this line with it:
an override written before this sentence existed does not gain it, so add the
advice to your own text if your tools page and take a selection. Changing the
lines you need with conventions= instead
keeps this one, and both sentences are fields there (read_shaping_on_pages and
query_param_on_pages).
A selection the serializer rejects¶
A read-shaping param is the one argument used while the result is rendered
rather than while the call runs, so it is the one that can fail after the work
is done. When the output serializer raises a ValidationError (or a
ServiceValidationError) while rendering, and the model supplied a value for at
least one of the tool's read-shaping params, the model gets a retry naming the
argument:
`fields` was rejected while rendering the result: Unknown field `items`. On a
paged result it applies to each item in `items`, never to the page envelope
(`items`, `page`, `totalPages`, `hasNext`).
What follows the colon is the serializer's own message, verbatim. The toolset
never reads a param's value, so this works the same for a fields your own
serializer parses, for django-restql's query, or for anything else that raises.
The last sentence is there only on a list tool. With several params supplied,
all of them are named, since the serializer does not say which one it refused.
exception_map= / translate_exception sees the error first, as on the dispatch
path.
The same error stays loud, and ends the run as before, when nothing the model
sent shaped the render: no read-shaping value supplied, an explicit null, or a
value that came from a declared default. A retry cannot fix a serializer that
fails on its own or a default that is wrong, and would hide the bug behind the
retry budget. Only validation errors are converted; an AttributeError in a
serializer is a bug whatever the model sent.
Use strict selection on tools an agent calls
This only works if the serializer says no. Make a name it does not know
raise a ValidationError (django-restql does by default), and the model
corrects itself in one retry.
A tolerant selection, one that drops fields it cannot find, turns the same
mistake into a page of empty rows, "items": [{}, {}], which is a successful
result as far as anything downstream can tell. The toolset cannot tell that
apart from a real answer without knowing the param's grammar, so it does not
try.
URL-derived values (route captures)¶
Over HTTP a nested route (/projects/{project_pk}/widgets/) supplies
project_pk from the URL, and a selector reads it from view.kwargs — directly,
or through a spec.kwargs provider that scopes by it (a tenant/role lookup). Off
the HTTP path there is no route, so register the value with
UrlKwarg. It is advertised
as a tool arg, then popped and seeded into build_offline_context(kwargs=…),
from where drf-services spreads it into the selector / target pools —
authoritative over the spec params, below a spec.kwargs provider (mirroring
HTTP precedence exactly).
from rest_framework_pydantic_ai import UrlKwarg
toolset = SpecToolset(
specs,
url_kwargs=[UrlKwarg("project_pk", type="integer", description="owning project")],
# or scope to one tool: tool_url_kwargs={"list_widgets": [UrlKwarg("project_pk")]}
)
Reach for UrlKwarg when the value is request state rather than an ordinary
argument to the callable — the axis is where the value has to land, not whether
it is advertised:
- a scoping
spec.kwargsprovider that readsview.kwargs— the case ordinaryparamscannot cover, because the provider readsview.kwargs, notparams; - a closed-surface spec whose route capture must be model-suppliable.
Like QueryParam, a registered kwarg is popped before dispatch (so
unknown_arguments never flags it) and its default is seeded when the model
omits it. A kwarg with a default therefore fills a selector parameter of the
same name, and the tool does not require it; one without a default reaches the
selector only when the model sends it, so the selector's own signature still
decides whether the tool requires it. A name can't be page / limit / ordering, nor one of drf-services'
pool seeds (request / user / data / instance / serializer /
collection / progress — a caller must not be able to route a value onto
those) or a name
registered in pool_seeds, nor be registered as both a
QueryParam and a UrlKwarg on the same tool.
A capture the spec genuinely cannot run without takes required=True:
The name joins the tool's required list, so the model is told up front. Because
a schema hint is only a hint — models omit required arguments routinely — a call
that omits it raises ModelRetry naming the argument, giving the model a turn to
supply it rather than failing deeper in. required can't be combined with a
default (a default always satisfies the argument, so requiring it would be a
no-op); that raises at construction.
A reflected **extras key is not a route capture¶
A selector typed def list_widgets(user, **extras: Unpack[WidgetExtras]) that
reads extras["project_pk"] already has that key reflected into the tool schema
by drf-services (0.26+) — no UrlKwarg needed for the selector itself, which
receives it through params. Marking it InputRequired makes the model supply
it; that is a schema statement and changes nothing about where the value lands.
The two declarations answer different questions, and only one of them puts a value on the request:
reflected **extras key (± InputRequired) |
registered UrlKwarg |
|
|---|---|---|
| In the tool schema | yes | yes |
| Can be required | yes (InputRequired) |
yes (required=True, plus a ModelRetry when omitted) |
| Reaches the selector | yes, via params |
yes, via the view.kwargs spread |
Reaches view.kwargs |
no | yes |
Ranks above caller-supplied params |
no — it is caller input | yes |
So anything that reads request state rather than its own arguments — a
spec.kwargs provider, extend_queryset, a permission class, an
output_serializer_context provider — sees nothing for a reflected-only key. A
scoping provider doing view.kwargs.get("project_pk") returns None and
mis-scopes every call instead of failing, which is the failure mode worth
naming: it is silent.
Register the UrlKwarg as well when the value is scope. It is a strict superset
— the selector still receives it in **extras, the schema keeps one property and
one required entry (an explicit UrlKwarg wins the merge over a reflected key
of the same name), and the provider gets its value:
# project_pk reflected from WidgetExtras *and* registered here:
# selector's extras -> 7 view.kwargs -> {"project_pk": 7}
toolset = SpecToolset(
specs,
tool_url_kwargs={"list_widgets": [UrlKwarg("project_pk", type="integer", required=True)]},
)
That split mirrors HTTP, where a route capture arrives in the URL and never in
the body — which is what makes it unspoofable. Off the HTTP path, params are
whatever the model chose; a UrlKwarg value outranks them. If a provider scopes
by it, it has to come through the channel that carries that precedence.
UrlKwarg and QueryParam are
drf-services' types,
re-exported here — the declaration is the same whichever transport carries it,
and this package's copy had drifted from the MCP transport's on which names each
reserved. from rest_framework_pydantic_ai import UrlKwarg, QueryParam keeps
working. Requires djangorestframework-services>=0.28.1.
Absolute URLs (file and hyperlinked fields)¶
Off the HTTP path there is no ambient request, so there is no origin to build
absolute URLs from — and DRF's FileField, HyperlinkedIdentityField, and
HyperlinkedRelatedField call request.build_absolute_uri() for every value.
Name your origin and they resolve:
host accepts "example.com", "example.com:8000", or a full origin whose
scheme decides whether links are https. It is toolset-wide, with no per-tool
variant: an origin is a property of the deployment, not of a tool.
Left unset, those fields produce relative URLs (/media/doc.pdf) — usable,
and exactly what they fall back to on their own when no request is in the
serializer context. Nothing is inferred: only your project knows its public
origin, and a guess would emit confidently-wrong links that look valid.
Requires djangorestframework-services>=0.29.1.
Error handling¶
The toolset maps drf-services' failure kinds onto the Pydantic-AI model loop:
| drf-services outcome | What the agent sees |
|---|---|
ServiceValidationError (bad input) |
ModelRetry with the field errors, one field: message line each — the model self-corrects |
| A read-shaping value the output serializer rejects while rendering | ModelRetry naming the argument — see A selection the serializer rejects |
ActionUnavailable (an Affordance condition not met) |
ToolFailed with the reason followed by the rule's code — The books are closed. (code: books_closed); see below |
AdditionalInputRequired (the service wants one more input) |
ModelRetry with the service's message, ending in the names to supply (Call this tool again, additionally supplying: `confirmed`.) — see the warning below |
ServiceError (business rule) |
ToolFailed with the rule's own message — a failed result the model reads and reports |
| Unresolved instance | ToolFailed("not found"), unless the spec sets allow_none=True, when the tool returns None |
A dispatch past dispatch_timeout |
ToolFailed — abandoned, with the sentence telling the model to narrow and call again |
A rendered result over max_result_bytes |
ToolFailed — refused rather than truncated, since a partial payload looks complete |
Unexpected argument (default REJECT) |
ModelRetry naming the unknown key |
A required argument left out: a selector parameter with no default, or a service's target lookup such as pk |
ModelRetry naming each such parameter left out; a missing serializer field is reported by the serializer when the call runs — see What a tool asks the model for |
An invalid item in a many=True list |
ModelRetry with the errors keyed under the list's argument, then the item's index — see A list as input |
An argument sent beside a many=True list |
ModelRetry naming it, whatever unknown_arguments says |
Non-integer page / limit |
ModelRetry — naming what is accepted |
An ordering sent to a list tool that advertises no sort at all |
ModelRetry — saying the tool has none, rather than letting it fall through as an unknown key |
A limit over max_page_size, or a page past the last one |
Clamped, not refused — the envelope reports the page and totalPages actually served, so the clamp is visible rather than silent |
An ordering outside a filter_set's OrderingFilter choices |
ModelRetry — the FilterSet rejects it, which arrives as the ValidationError row above |
A FilterSet param_map target that isn't a real column |
Django's FieldError propagates — an author's error the model cannot correct by picking a different sort, and one that can't be checked at construction without a queryset |
Denied permission_classes (class-level has_permission or object-level has_object_permission) |
PermissionDenied is raised and aborts the run — see the caveat below |
A confirmation raised as AdditionalInputRequired is not a gate
The retry for AdditionalInputRequired goes to the model, so the model can
answer it: a spec that raises AdditionalInputRequired("Confirm to
proceed.", schema={"confirmed": ...}) gets its first call back as a retry,
and the model may re-send the same call with confirmed=true and run the
mutation. No person is asked at any point.
Treat that confirmation as a prompt for missing input, not as a security
boundary. An operation that needs a human decision has to be gated by an
approval mechanism outside the spec: pydantic-ai's deferred-tool approval,
or ToolGuard in django-pydantic-agent when it is installed. ToolGuard
is off by default, and a tool named in its exempt list stays ungated
even when it is also in require_approval. Every tool built from a mutating
spec carries readOnlyHint: False in its annotations metadata (only a
SelectorSpec reads True), so an approval policy keyed on that annotation
covers them.
A tool-failure policy does not change the last row
"Aborts the run" is what a plain pydantic_ai.Agent does: nothing catches
the exception, so it propagates out of agent.run. A tool-failure policy
normally converts an escaping exception into a failed-tool result and lets
the run continue — but django-pydantic-agent's exempts an authorization
refusal from exactly that, by default, and django-ag-ui inherits the
exemption. So a denial aborts the run under those hosts too.
That exemption is deliberate and worth knowing rather than working around: converting a denial would leave the run alive with the model free to try the next row, and a refusal a model can tell apart from a missing row turns a permission boundary into an existence oracle over rows the acting user cannot read — inside one turn, spending no retry budget.
The denial itself is unaffected on every host: nothing is dispatched and no data is rendered.
Your own exception has to be a ServiceError
Every row above is matched by type. A service that raises its own error
class gets none of this unless that class derives from drf-services'
ServiceError:
from rest_framework_services import ServiceError
class BookingError(ServiceError): # not Exception
"""Something the booking rules refuse."""
Deriving from Exception is the ordinary thing to reach for, and the
failure is silent in a way worth spelling out, because it is not confined to
the agent. The same exception escapes the DRF view as a 500 for what is
plainly a 409, escapes MCP as a protocol error, and aborts an agent run
rather than settling the call as failed. Nothing warns — not at
declaration, not at mount, not at dispatch.
Two separate projects made this exact mistake and neither test suite could
see it, because every write test asserted a path that succeeds. If a project
genuinely cannot change its exception's base class, exception_map= is the
other door: it takes the type and returns what the model should be told.
A refused affordance names its code¶
drf-services raises ActionUnavailable when one of a spec's Affordance
conditions is not met, with the affordance's reason as the message and its
code as an attribute. ToolFailed carries a message and nothing else, and that
message is also what a transport forwards as the tool result, so the toolset puts
both into it — the reason first, then the code, labelled:
from rest_framework_services import Affordance, ServiceSpec
def post_invoice(data, user):
"""Post an invoice to the current period."""
...
post_invoice_spec = ServiceSpec(
service=post_invoice,
input_serializer=InvoiceInputSerializer,
affordances=[
Affordance(
code="books_closed",
reason="The books are closed.",
when=lambda: Period.current().is_open,
),
],
)
While the period is closed the tool is left out of the catalog, so a run reaches this refusal only through a listing that went stale: the step was offered the tool, and the period closed before the call arrived. A condition on the row is refused the same way, and it is never asked when the catalog is listed. The failed tool result's content, which is what the model reads and what a transport streams, is:
The code is the part that connects the refusal to what the model may already have
read. A selector naming the same spec in its own affordances
(affordances={"post_invoice": post_invoice_spec}) answers it on each row it
renders, under affordances.post_invoice, as
{"available": false, "code": "books_closed", "reason": "The books are closed."}
— and the reason is a sentence a project may reword while the code stays put. The
conventions block get_instructions returns tells the model a refusal may end
this way. A ServiceError or ServiceConflict raised by hand has
no code and keeps its message exactly as written.
The suffix is written for the model and for a person reading a tool card, not
for a program. It is meant to read the same on every agent transport serving
these specs, and its wording is not an interface. A program that branches on the
code reads .code off the exception instead: the ActionUnavailable is the
ToolFailed's __cause__, and translate_exception receives it before the
toolset's own handling runs. A handler returned from there, or registered in
exception_map for ActionUnavailable or any class above it, replaces the
sentence along with the rest of the default.
Why the failed rows raise instead of returning¶
Every ToolFailed row above used to be a returned {"error": "..."} dict. The
model read the same sentence either way, so the change is not about what it
sees — it is about what everything else sees. Pydantic-AI marks an ordinary
return outcome="success" on the resulting ToolReturnPart; only a raised
ToolFailed marks it "failed". Returning the dict therefore made a refusal
indistinguishable from an answer to a log, an audit record, or a transport
streaming the call to a browser, all of which had nothing but the payload's
wording to go on.
ToolFailed keeps the two properties the dict was chosen for: it spends none of
the tool's retry budget, and it does not end the run. It also prepends no
correction instructions, which ModelRetry does — right for a bad argument, and
wrong for a conflict the model cannot argue with.
If a failure is one your project would rather express as an ordinary result,
exception_map still takes a handler that returns a value, and a returned
value is still marked "success".
Each ModelRetry row consumes one unit of the tool's retry budget: after
max_retries failed attempts (default 1, pydantic-ai's function-tool
default) the run aborts with UnexpectedModelBehavior. Raise it for models
that need more attempts to converge: