3. Tools¶
Three responsibilities, three files, and keeping them apart is the main design decision:
| Question | Where it is answered |
|---|---|
| What tools exist, and who may see them? | decorator.py, registry.py |
| How does each provider want to be told? | export.py |
| What actually happens when one runs? | executor.py |
Collapsing these into one class is the usual shortcut, and it is why tool authorization so often ends up inside the agent loop — where it cannot be tested without starting a run.
What we built here
The @tool decorator (JSON Schema from a signature), a ToolRegistry
with an allowlist, exports for three providers, a ToolExecutor with five
gates, idempotency signatures, a realistic five-tool example, and a golden
set that measures selection accuracy.
The schema comes from the signature¶
Writing the function and its JSON Schema separately guarantees they drift. Someone renames a parameter, the schema still advertises the old name, and the model keeps sending arguments that no longer bind. Nothing errors; the tool just receives defaults.
@tool derives the schema from inspect.signature, so that class of bug is
structurally impossible.
from datetime import date
from typing import Annotated, Literal
from pydantic import Field
from aimai_kit.tools import tool
@tool
def find_orders(
customer_id: Annotated[str, Field(description="Customer id, e.g. c-100.")],
status: Literal["open", "shipped", "cancelled", "any"] = "any",
since: date | None = None,
limit: int = 20,
) -> list[dict]:
"""List a customer's orders, most recent first.
Use this when you need a LIST of orders. To fetch ONE order whose id you
already know, use get_order instead.
Read-only.
"""
...
The schema it produces:
{
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "Customer id, e.g. c-100."},
"status": {"type": "string", "enum": ["open", "shipped", "cancelled", "any"]},
"since": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"limit": {"type": "integer"}
},
"required": ["customer_id", "status", "since", "limit"],
"additionalProperties": false
}
Supported types are restricted to str, int, float, bool, date,
Literal and lists of those. An unsupported annotation raises at import time
instead of producing a schema the provider rejects at runtime. This is a
design choice, not a limitation of the technique: a tool that needs a deeply
nested argument object is almost always two tools.
An empty docstring is an error. The description is the only thing the model has for deciding when to call a tool, and leaving it blank guarantees wrong selection.
One implementation detail worth knowing: with from __future__ import
annotations in effect, every annotation arrives as a string, so the decorator
uses get_type_hints(include_extras=True) rather than param.annotation. A
type defined inside an enclosing function cannot be resolved that way, so the
decorator raises with an explicit message instead of letting a bare
NameError surface from inside typing.
Side effects live on the spec, not in the schema¶
side_effect and requires_approval are properties of the tool, not
arguments to it. Putting them in the schema would let the model set
requires_approval=false on its own call, which is not an approval gate.
The operator sets them in the registry; the executor reads them from there.
@tool(side_effect=True, requires_approval=True)
def cancel_order(ctx: CallContext, order_id: str, reason: str) -> dict:
"""Cancel an order. THIS CHANGES DATA and requires human approval."""
...
spec = cancel_order.tool_spec
assert spec.requires_approval is True
assert "requires_approval" not in spec.parameters["properties"] # not in the schema
The allowlist filters the declaration¶
registry.visible(allowlist) filters what is advertised, not just what is
permitted to run.
If a tool the caller may not use is still declared to the model, the model will eventually call it, the executor will refuse, and a step is burned on a refusal that was avoidable. Worse, the refusal message teaches the model that the tool exists.
registry.names() # ['build_report', 'cancel_order', 'find_orders', ...]
[t.name for t in registry.visible(["find_orders", "get_order"])]
# ['find_orders', 'get_order'] — the model never sees the others
This is also the answer to "what happens with twenty tools": you do not send twenty. You send the subset this request is allowed to use.
visible() sorts by name. Not cosmetic — tool declarations sit inside the
cached prompt prefix, and set ordering would produce different bytes on every
process restart.
Three exports, one schema¶
The providers disagree about two things and nothing else:
| Provider | Shape | Schema key |
|---|---|---|
| OpenAI Responses | flat | parameters |
| OpenAI Chat | nested under function |
parameters |
| Anthropic | flat | input_schema |
from aimai_kit.tools import export_for
export_for("openai", registry.visible())[0]
# {'type': 'function', 'name': 'find_orders', 'description': '...',
# 'parameters': {...}}
export_for("anthropic", registry.visible())[0]
# {'name': 'find_orders', 'description': '...', 'input_schema': {...}}
That is exactly the kind of difference which, left unabstracted, gets copied
into every call site. One internal representation, three thin exporters, and
export_for(provider, tools) is the only place above the adapter layer that
consults provider identity — and it consults a name, not a type.
Five gates¶
Each gate answers a different question and produces a message the model can act on, never a stack trace:
| Gate | Code | Retryable | The message |
|---|---|---|---|
| Does the tool exist? | no_such_tool |
yes | lists what does exist |
| Is the caller allowed? | not_allowed |
no | does not name the tool |
| Parseable JSON? | bad_json |
yes | says where parsing failed |
| Matches the schema? | bad_args |
yes | names the offending field |
| Needs approval? | needs_approval |
no | stops the loop cleanly |
from aimai_kit.tools import CallContext, ToolExecutor
executor = ToolExecutor(registry)
ctx = CallContext(user_id="u-1", tenant_id="t-1")
executor.call("get_ordr", "{}", ctx).content
# "There is no tool named 'get_ordr'. Available tools: build_report,
# cancel_order, find_orders, get_customer, get_order. Pick one of those or
# answer without a tool."
executor.call("cancel_order", '{"order_id":"1"}', ctx, allowlist=["get_order"]).content
# "You are not permitted to use that tool in this context. Continue with the
# tools you have been given." <- does not name 'cancel_order'
executor.call("get_order", '{"wrong": 1}', ctx).content
# "The arguments for 'get_order' do not match its schema. order_id: Field
# required. Fix those fields and call the tool again."
The asymmetry between gates 1 and 2 is deliberate. Gate 1 lists the available
tools, because a model that called get_customer when the tool is
fetch_customer corrects itself on the next turn. Gate 2 does not, because
telling an unauthorized caller which tools exist is a disclosure — and a model
that learns a tool exists will keep trying it.
retryable is not the same as ok. A schema error is retryable: the model
can fix its own arguments. A permission error is not: retrying changes
nothing, and the loop should stop rather than burn steps.
After the gates¶
Server context is injected from the call. user_id and tenant_id come
from the session and are stripped from the schema.
@tool
def get_order(ctx: CallContext, order_id: str) -> dict:
"""Fetch a single order by its id."""
# ctx.tenant_id goes into the query; the model cannot see or send it
...
# Even if the model tries, the schema rejects the extra field.
executor.call("get_order", '{"order_id":"1", "tenant_id":"t-evil"}', ctx)
``` Tenancy as a model-supplied
field means the model can claim to be another tenant, and no amount of
prompting fixes that. A test asserts the parameters are absent from the
schema.
**Timeouts use a worker thread.** A hanging tool does not hang the agent; it
produces a `timeout` result the loop can reason about. The thread is left
running, because Python cannot kill one — a tool that needs hard cancellation
belongs in a subprocess.
There are two thread pools, not one. `call_many` submits into a fan-out pool
and each of those calls submits again to enforce its timeout. Sharing one pool
means the outer tasks occupy every worker and the inner ones queue behind
them, so timeouts fire on work that never started. That bug was found by a
test asserting result order.
**Truncation is announced.** A tool returning 200 KB of JSON does not fail —
it quietly eats the window and the next few turns degrade for no visible
reason. The result is cut to `max_result_chars` and the cut is stated in the
content, because an answer built on half a document with no indication
anything was missing is worse than a short answer.
```pycon
>>> result = executor.call("build_report", "{}", ctx)
>>> print(result.content[-120:])
...[truncated: 194201 more characters were omitted. Narrow the query or
request a specific section.]
raw never enters the context. It goes to the trace, so debugging can see
the full payload while the model only ever sees content.
Parallel reads, sequential writes¶
Read-only calls are independent by definition, so running them one after another wastes wall-clock time for no safety gain.
Side-effecting calls run sequentially in the order the model declared them, because they may depend on each other ("cancel the order, then notify the customer") and because a partial failure is far easier to reason about in a known order.
A failure in one call never loses the others: every call gets its own result.
results = executor.call_many([
("get_order", '{"order_id": "1"}'),
("get_order", "{broken"),
("get_order", '{"order_id": "3"}'),
], ctx)
[r.ok for r in results] # [True, False, True]
[r.error_code for r in results] # [None, 'bad_json', None]
Idempotency without asking the model¶
The call signature is the tool name plus normalized arguments, so
{"a":1,"b":2} and {"b":2,"a":1} are recognized as the same call.
from aimai_kit.tools.idempotency import call_signature
call_signature("charge", {"a": 1, "b": 2}) == call_signature("charge", {"b": 2, "a": 1})
# True
The key is never requested from the model. Asking for an idempotency key means the guarantee holds exactly as often as the model remembers to send a stable one, which is not a guarantee.
Side-effecting results are stored by signature, so a resumed run does not charge a card twice. The limit is stated plainly: the process can die after the tool ran and before the result was written. This narrows the window; only an idempotent tool closes it.
Read-only calls are deliberately not cached — caching a read would hide data that changed between calls.
What descriptions are worth, measured¶
Every example tool's description carries three things, and the third is the one people leave out:
- what the tool does,
- when not to use it, pointing at the neighbouring tool,
- whether it has a side effect.
The experiment (scripts/tool_description_experiment.py) runs the same golden
cases against three variants of the same registry — identical schemas, only
the description text differs:
| Variant | Selection accuracy | Forbidden-call rate |
|---|---|---|
| full | 90.9% | 0.0% |
| boundary sentences removed | 90.9% | 0.0% |
| first sentence only | 90.9% | 4.5% |
The finding is not the one that was expected, which is why it is worth reporting: with this selector, description quality did not move selection accuracy at all. It moved the safety number. Cutting descriptions to one line left the agent just as likely to pick the right tool and measurably more likely to reach for the destructive one.
That says something about which metric belongs on a dashboard. Selection accuracy is a quality signal; forbidden-call rate is a security signal, and it degraded first.
The selector is a stub, so the absolute numbers are not a claim about any provider. What transfers is that the experiment costs nothing and can run on every description change.
Checklist¶
Before this layer goes to production:
- The schema comes from the signature. One source of truth; a hand-written schema drifts from the function on the first refactor.
- Side effects are declared on the spec — idempotent, destructive, needs approval — not implied by the description.
- The allowlist filters the declaration. A tool the caller may not use is never shown to the model, so it cannot be called and then refused.
-
not_alloweddoes not name the tool. An error that confirms a tool exists is an enumeration oracle. - Server context is injected from the call.
user_idandtenant_idcome from your session, never from a model-supplied argument. This is the whole ballgame. - Every rejection has a distinct code and a message the model can act on — which field, what was expected.
- Timeouts run on a worker thread. A hanging tool must not hang the loop.
- Truncation is announced in the result. A tool returning 200 KB should say it was cut, not quietly return a prefix.
- Writes are sequential, reads are parallel. Concurrency is a property of the tool, not of the executor.
- Idempotency keys are derived from the call signature, not requested from the model. Asking the model for a key means trusting it to notice it is retrying.
- The boundary sentences stay in the description. Measured on the tool golden set: cutting a description to its first sentence left selection accuracy at 90.9% and moved the forbidden-call rate from 0.0% to 4.5%. Descriptions are a safety control, and the accuracy metric does not show it.
The rest — tools, budgets, the release itself — and a copy-paste version for a PR template: production checklist.