Reference¶
django_spec_agents.spec_tools exports three symbols: SpecToolset,
SpecCapability and AgentConventions. A django-service-specs spec declares
what it reads as Parameters in its reads and shapes, documented with the
kernel. The deps a run
passes are the package's one
AgentDeps, and the
doubles for driving a toolset through a real run loop are in
django_spec_agents.testing.
SpecToolset¶
SpecToolset ¶
Bases: AbstractToolset[Any]
Exposes specs as a Pydantic-AI toolset.
A spec is anything django-service-specs' kernel_spec compiles: a
django-service-specs ServiceSpec or SelectorSpec, or a declaration
that compiles to one. Every read of it is the
compiled spec's. 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 is the kernel's spec_input_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 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
the kernel's provider_keys reads the annotation. A name a
call_seeds override fills is invisible here, so it has to be
declared (see call_seeds). 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's fields, the input's property winning a shared name, and less every
key the kernel's server_owned_keys names, which dispatch fills itself. A
call leaving out a required selector parameter is handed back as
ModelRetry naming each one left out, before anything runs. A required
input field is the Validator's to report, once the call is dispatched, 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, which is what the page and
limit arguments every list tool advertises promise. hasNext is what
tells a model asking for a collection that more rows exist than it was
shown.
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 the kernel hands the FilterSet as filter_data.
That includes ordering: a FilterSet carrying an OrderingFilter named
ordering advertises the argument itself — the kernel derives 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 the spec's own reads. 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.
What a tool reads off HTTP is the spec's to declare, in its reads,
where every transport and every mount reads the same answer; the toolset
reads them off the compiled spec, so a spec arriving in a SpecRegistry
and the same spec in a bare mapping are served alike. A read no callable
takes is one of the compiled spec's shapes: it is advertised as a tool
argument and dispatched as one, and the kernel hands what the caller sent
to the presenter alone, on PresentationContext.shapes, never to the
spec's callables, so a field selection is the presenter's to read, with no
toolset awareness of how it parses one. A read a callable does take,
by name or through **kwargs, is that callable's argument: advertised as
a plain one, and handed its declared default when the model leaves it out.
A read standing for a URL route capture (parent_pk) is an argument of
the spec, which a selector, a service or a kwargs= provider takes by
its name. A
null sent under a read that cannot be null is the model declining it,
and its default answers. A value the model may not choose is not a read at
all but tool_server_arguments, which keeps it from the model rather
than defaulting it.
For anything the keywords below do not cover,
call_seeds and
translate_exception
are overridable and both receive the live RunContext, which is how
per-run typed deps reach dispatch; the run's AgentDeps.call_context is
copied into every call's context.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
specs
|
SpecSource
|
The |
required |
id
|
str
|
Identifies this toolset, and keys a wrapping
|
'spec-tools'
|
instructions
|
str | None
|
Replaces the conventions block
Refused beside a |
None
|
conventions
|
AgentConventions | None
|
The wording the model is told, one line per field -- an
|
None
|
get_user
|
UserExtractor | None
|
Reads the acting identity off the run context. Defaults to
|
None
|
get_progress
|
ProgressExtractor | None
|
Reads the run's |
None
|
unknown_arguments
|
UnknownArguments
|
What to do with a tool arg outside the spec's declared
input set — a key the model invented. |
REJECT
|
tool_server_arguments
|
Mapping[str, ServerArguments] | None
|
Values the server supplies for a tool's
arguments, and names it keeps from the model, keyed by tool name --
a django-service-specs
|
None
|
max_retries
|
int
|
Each tool's retry budget: how many times a
|
1
|
max_result_bytes
|
int | None
|
Ceiling on a rendered result, measured on the encoded
payload because what is being protected is the model's context
window. Over it the call fails with a model-readable
|
None
|
tool_max_result_bytes
|
Mapping[str, int | None] | None
|
|
None
|
max_page_size
|
int | None
|
Clamps a list tool's |
None
|
thread_sensitive
|
bool
|
Whether every dispatch shares one thread. The cost is that concurrent tool calls serialise, process-wide.
asgiref's Set |
True
|
executor
|
ThreadPoolExecutor | None
|
A |
None
|
dispatch_timeout
|
float | None
|
Seconds bounding one call, so the model gets an answer
instead of a hang. It does not stop the work: the dispatch runs in
a |
None
|
require_permissions
|
bool
|
Refuse to construct a toolset containing a spec
declaring no permissions, whose |
True
|
descriptions
|
Mapping[str, str] | None
|
Overrides |
None
|
exception_map
|
Mapping[type[BaseException], ExceptionHandler] | None
|
Maps an exception type to a handler returning the tool's
result (or raising |
None
|
pool_seeds
|
PoolSeeds
|
The project's own always-available pool seeds -- a
|
DEFAULT_POOL_SEEDS
|
Raises:
| Type | Description |
|---|---|
ImproperlyConfigured
|
|
TypeError
|
A value in |
ValueError
|
A tool name is outside |
specs
property
¶
The resolved name -> spec mapping this toolset exposes.
The synchronous answer to "what tools are these?", for a caller composing
this toolset at configuration time — a name-dedup pass, a tool catalog —
with no run in sight, since get_tools is async and needs a
RunContext. Read-only (a MappingProxyType), so enumerating it
cannot add a tool that skipped the constructor's permission and
description checks.
get_tools
async
¶
The tool catalog this run is offered, one entry per listed spec.
The catalog is not permission-filtered, by design. Every tool is
advertised to every run and the spec's permissions 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
¶
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.permissions 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
¶
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 |
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 |
str | None
|
|
str | None
|
every line that would have been said and nothing is unavailable. |
call_seeds ¶
The seeds one call registers beside the mount's pool_seeds, by name.
Each value is a resolver, as for
PoolSeeds' extend: called once
per call, with whatever it declares of the kernel's base pool
(user, progress). The default returns none. Override it for a
value that depends on the run or on the tool being called, which
pool_seeds=, toolset-wide, cannot carry: a tenant read off
ctx.deps, or the role a permission check reads for this operation.
Called in the dispatch thread, before the call's arguments are
checked, so a name a seed fills is never asked of the model
(test_a_seed_the_hook_returns_reaches_the_call_and_is_not_asked_of_the_model),
and once per conditioned tool when the catalog is listed, so a condition
reading a seed it returns is asked with the tool's own
(test_a_listing_asks_each_condition_with_its_own_tool_s_seeds).
A name the mount's pool_seeds registers already is refused with
ValueError at the call, as PoolSeeds.extend refuses one
registered twice
(test_a_hook_seed_the_mount_already_registers_is_refused). Returning
request or view replaces the one the call carries, which is
otherwise the run's call_context entry of that name, None where
the run put none: a view returned here is what a permission check
or a presenter declaring view reads.
A name a seed fills has to be declared. A selector parameter only a
seed fills is, to the tool's schema, a parameter without a default like
any other, so it is advertised as required. Leave it out of the
spec's reads, as a library compiling a spec does for a parameter it
marks as no client input: the name is left out of the schema, so the
model is never asked for it, and the seed fills it. Under the default
unknown_arguments a call that
sends it anyway is handed back as an unexpected argument
(test_a_marked_name_sent_to_an_open_selector_is_refused).
test_a_name_a_call_seeds_override_fills_is_kept_from_the_model holds
the marker, and
test_a_name_a_call_seeds_override_fills_is_declared_to_the_toolset
a seed the mount registers instead.
translate_exception ¶
Return a handler for exc, or None to leave it to the defaults.
The default consults exception_map by walking the exception's MRO,
so a handler registered for a base class catches its subclasses and the
most specific registration wins. Override for a decision the map
cannot express — one that has to read the run's deps.
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 the kernel's
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,
result: DispatchResult,
*,
ctx: RunContext[Any],
projection: AudienceProjection | None,
) -> Any
Turn a dispatch result into the payload the model reads.
result is the kernel's
DispatchResult: for a paged
tool, with the page's rows as its value, and the envelope is added
after this returns. The default is the kernel's
present_for_audience: the spec's presenter, handed each value with
the call's PresentationContext -- its seeds, its context and the
read-shaping arguments the caller sent, on shapes -- then the
output's audience markings, using the projection this toolset resolved
once at registration. result.instance (the pre-mutation target),
result.data (the validated input) and result.service_result
(the flags an upsert's created rides on) are here for an override
that wants them.
This is the opt-out of projecting, and the only one. Passing
projection=None is not it -- present_for_audience reads None
as "derive one from the spec", so the payload is projected anyway. An
override that wants the unprojected payload calls the kernel's
present(spec, result) 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 the kernel
says so in present_for_audience's own docstring: project them away
and the next step has nothing to key on. And an output field declaring
choices 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.
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". An override delegating to super() sees the
refusal as an exception, not as a returned value.
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.
Subclassing SpecToolset¶
A project scoping an assistant to what one run may reach subclasses
SpecToolset, and these are the parts of it a subclass builds on. They are
public.
get_tools(ctx)andcall_tool(name, tool_args, ctx, tool), pydantic-ai's toolset methods. Overrideget_toolsto narrow the catalog a run is offered andcall_toolto wrap a call, each delegating tosuper(): the run loop offers the model whatget_toolsreturns and dispatches every call throughcall_tool.specsandid, read-only properties that answer at configuration time, with no run:specsis every tool the toolset was built with, whatever aget_toolsoverride offers a given run.- The five seams, each receiving the live
RunContext:call_seedsandtranslate_exceptioncover the front half of a call,shape_page,render_outputandenforce_result_bytesthe back half. AgentDeps.call_context, the run's context, of which every call is handed its own copy.
An attribute whose name starts with an underscore is not part of it, and a minor release may rename one.
SpecCapability¶
SpecCapability ¶
Bases: AbstractCapability[Any]
A Pydantic-AI capability exposing specs as tools.
SpecToolset is a first-class toolset you
can attach directly (Agent(toolsets=[SpecToolset(...)])), and it already
exposes the tools and teaches the model its conventions. This wraps one to
add the two capability-only knobs, defer_loading and the
description its catalog entry is chosen by. It does not re-emit
those conventions — Pydantic-AI collects them from the owned toolset — so
wrapping and attaching directly yield the same instructions, exactly once.
Construct it the same way as SpecToolset (it forwards the toolset knobs):
agent = Agent(
model,
deps_type=AgentDeps,
capabilities=[SpecCapability({
"list_orders": orders_selector_spec, # SelectorSpec -> read-only tool
"create_order": create_order_spec, # ServiceSpec -> mutation tool
})],
)
or wrap an already-built toolset with
from_toolset
(the compose path). Either way the exposed tool set and instructions are the
toolset's.
Everything else SpecToolset accepts, this accepts, and means there.
That is a guarantee rather than a list, enforced name-by-name by the
forwarding tests: a knob added to the toolset and forgotten here is not a
missing feature but an unreachable one for every consumer composing through
a capability. See SpecToolset for what
each does; the safety-relevant ones are require_permissions,
max_result_bytes, max_page_size and dispatch_timeout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
specs
|
SpecSource
|
As |
required |
defer_loading
|
bool
|
Hide the whole spec toolset and its instructions behind
Pydantic-AI's native |
False
|
id
|
str
|
As |
'spec-tools'
|
description
|
str | None
|
One line saying what this capability is for, rendered
beside |
None
|
unknown_arguments
|
UnknownArguments
|
As |
REJECT
|
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 a shared library, 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 |
base
class-attribute
instance-attribute
¶
base: str | None = (
"The following tools call this application's 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.
It names no framework: which library declared a tool is nothing a model can act on, and the line is read on every step.
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 has one.
A read-shaping parameter is one of the spec's shapes, which no callable
of the spec takes and its presenter reads instead; one a selector or
provider takes, by name or through **kwargs, is that callable's
argument and is not named here. {names} is every such 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
has a read-shaping parameter.
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.
The kernel 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 read-shaping parameter'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 presenter 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
¶
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 one of the spec's reads declared required. A
field the input requires is reported by the spec's Validator, 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.
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 recipes here are the ones a consumer wanting the same coverage would
otherwise re-derive 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.
Pydantic-AI is the only thing it imports, which is why it sits at the
package's top level rather than under spec_tools: a host-only install, with
no [specs] extra and no Django REST Framework, can drive its own agents with
these doubles. The package root never imports it.
from pydantic_ai import Agent
from django_spec_agents import AgentDeps
from django_spec_agents.spec_tools import SpecToolset
from django_spec_agents.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 |
None
|
final_text
|
str
|
The output once a tool call has returned. |
'done'
|
Returns:
| Type | Description |
|---|---|
FunctionModel
|
A |
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.
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.
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.