Tools¶
Tools are how an agent does something other than talk.
Is this page for you?
Reach for it when you want the agent to do something rather than only produce text.
Skip it for now if you are still deciding what the agent should say, not what it should touch.
The problem it solves¶
A model on its own can only produce text. It cannot read your database, call your billing API, or find out what today's price is. Left with only its training data it will answer anyway — confidently, and sometimes wrongly. A tool is the seam where the model stops guessing and your code takes over: the model says which action it wants and with what arguments, your function does the work, and the real answer goes back into the conversation.
Two things have to be true for that to be safe. The model has to understand what a tool does well enough to pick the right one — so the description and the argument schema are not documentation, they are the interface. And the framework has to know which tools change the world — so it can pause a refund for a human, dedupe a retried email, and refuse to serve a cached "success" for something that never happened.
agentkit makes both of those declarations, checked when you define the tool rather than guessed at when it runs.
The smallest thing that works¶
import asyncio
from agentkit.core.contracts.types import ToolRequest
from agentkit.core.testing import FakeLLM, make_test_ctx
from agentkit.tools import ToolRegistry, tool
@tool(side_effecting=False, idempotent=True)
def city_population(city: str) -> int:
"""Look up the most recent census population for a named city."""
return {"lisbon": 545_000, "porto": 231_000}.get(city.lower(), 0)
registry = ToolRegistry.from_tools([city_population])
async def main() -> None:
ctx = make_test_ctx(llm=FakeLLM())
the_tool = registry.get("city_population")
result = await ctx.invoker.invoke_tool(
ToolRequest(name="city_population", arguments={"city": "Lisbon"}, tool=the_tool),
ctx,
)
print(result)
print(the_tool.schema.parameters)
asyncio.run(main())
That is the whole shape: a plain Python function, one decorator, and a
JSON Schema the model can call. Nothing about city_population had to
be written twice.
Note the call path. Tools are not invoked directly by the loop — they go
through ctx.invoker.invoke_tool, which runs the
tool-middleware chain (tracing, metering, egress
checks, idempotency, audit, retry) and only then calls tool.run. That
is why the flags you declare below actually do something.
And here is the same tool inside an agent, driven by a scripted fake model so it runs with no API key:
import asyncio
from agentkit.agents import Agent
from agentkit.core.contracts.types import ToolCall
from agentkit.agents.cognition import ReActCognition
from agentkit.core.testing import FakeLLM, Turn, make_test_ctx
from agentkit.tools import tool
@tool(side_effecting=False, idempotent=True)
def city_population(city: str) -> int:
"""Look up the most recent census population for a named city."""
return {"lisbon": 545_000, "porto": 231_000}.get(city.lower(), 0)
llm = FakeLLM.script([
Turn(tool_calls=(ToolCall("c1", "city_population", {"city": "Lisbon"}),)),
Turn(content="Lisbon has about 545,000 residents."),
])
agent = Agent(
name="atlas",
model="fake",
prompt="You answer questions about cities.",
cognition=ReActCognition(tools=[city_population]),
)
async def main() -> None:
ctx = make_test_ctx(llm=llm)
result = await agent.run("How big is Lisbon?", ctx)
print(result.output)
asyncio.run(main())
Tools live on the cognition, not on the Agent. ReActCognition
accepts a plain list and wraps it in a ToolRegistry for you.
How it works¶
The schema comes from the signature¶
The model cannot see your Python. Before it can ask for a tool, it has to be told the tool exists — its name, what it does, and what arguments it takes. That description is the schema, and it is sent along with every request.
Writing it by hand would mean maintaining the same information twice,
and the two copies would drift the first time someone renamed a
parameter. So agentkit reads your function signature and writes the
schema for you: FunctionTool.from_callable (which @tool calls on
your behalf) inspects the parameters and type hints and produces the
ToolSchema the model sees.
In practice this means the annotations you write are a promise to the model, and getting one wrong misleads it rather than failing loudly — which is why the mapping below is worth a glance.
| Python annotation | JSON Schema fragment |
|---|---|
str / int / float / bool |
{"type": "string" \| "integer" \| "number" \| "boolean"} |
list[X], set[X], tuple[X, ...] |
{"type": "array", "items": <X>} |
Sequence[X], Iterable[X], Collection[X], Set[X] |
{"type": "array", "items": <X>} |
bare list / tuple, or tuple[X, Y] |
{"type": "array"} — no single element type to name |
dict[str, X], Mapping[str, X] |
{"type": "object", "additionalProperties": <X>} |
a TypedDict |
its real object schema, keys and all |
Literal["a", "b"] |
{"enum": ["a", "b"], "type": "string"} |
an Enum subclass |
{"enum": [...member values...]}, typed when homogeneous |
| a Pydantic model / dataclass / attrs class | its real object schema, via the same adapt() the Agent.output= path uses |
Optional[X] / X \| None |
{"anyOf": [<X>, {"type": "null"}]} |
| a genuine multi-type union | {"anyOf": [...]} |
| anything else, or unannotated | {"type": "string"} |
items is a promise in both directions. Because the model is told the
element type, the value it sends is reconstituted to match — a
list[Unit] arrives as a list of Unit members, not of strings — and
the container is rebuilt to whatever the annotation asked for, so a
set[X] parameter receives a set.
*args is refused at decoration time. A tool is called with a JSON
object, so every argument arrives by keyword; a positional-only
parameter can never be filled. Use **kwargs if the tool genuinely
accepts arbitrary keys.
A parameter with no default lands in required. One with a default does
not.
The two rich cases are there because their absence actively misled the
model. A structured parameter advertised as a bare {"type": "string"}
made the model send a string where the function's annotation promised a
Filter — the schema was instructing the model to call the tool wrongly.
An Enum advertised as {"type": "string"} told the model nothing about
which strings were legal, so it invented one and the tool raised
ValueError on a value the schema had implied was fine.
import json
from enum import Enum
from typing import Literal
from agentkit.tools import tool
class Unit(Enum):
CELSIUS = "celsius"
FAHRENHEIT = "fahrenheit"
@tool(side_effecting=False, idempotent=True)
def forecast(
city: str,
unit: Unit,
days: int = 3,
mode: Literal["hourly", "daily"] = "daily",
) -> str:
"""Return a short weather forecast for a city over the next few days."""
return f"{city}: mild for {days} days ({unit}, {mode})"
print(json.dumps(forecast.schema.parameters))
{"type": "object", "properties": {"city": {"type": "string"}, "unit": {"enum":
["celsius", "fahrenheit"], "type": "string"}, "days": {"type": "integer"},
"mode": {"enum": ["hourly", "daily"], "type": "string"}}, "required":
["city", "unit"]}
The docstring is the description — all of it¶
Plain version first: everything you write in a tool's docstring is
sent to the model. Not the summary line, not the part above the first
blank line — the whole text, verbatim, as part of the schema attached to
every request. This is the same family of check as *args above: both
are settled when you define the tool, not when it runs.
That is worth stating outright because it used to be false, and the old rule was this framework's most expensive silent decision. The description used to be the docstring's first line. Everything below it was read by people editing the source and by nobody else, while still sitting in the docstring looking as though it had been delivered.
It cost a real defect. agentkit's own ask_human tells the model "Use
this only when the information genuinely cannot be obtained any other
way — a one-time code, a confirmation, a preference only they know."
That sentence is the entire difference between a tool the model reaches
for when it is genuinely stuck and one it reaches for instead of
thinking. It sits in the second paragraph. It never shipped.
The rule also cut ORDINARY docstrings mid-clause, because a single paragraph wrapped over two source lines is two lines, not one. Measured, two tools in this repo's own test suite were advertised to the model as:
"A tool, so the ReAct loop has something to run and a reason to"
"Look up a Hit for query `q`. (Will return a malformed payload to"
Neither author wrote a bad docstring. The framework delivered half of a good one and said nothing.
Why the fix wasn't "refuse the second paragraph"¶
The tidy alternative was considered and rejected on evidence. @tool
already refuses a missing side_effecting= and a thin docstring, so the
consistent move would have been to refuse a multi-paragraph docstring
too, and make the author say out loud which audience the tail was for.
The measurement killed it. Across agentkit/, tests/, tests/examples/ and
docs/ there are 139 tools. Six have a multi-paragraph
docstring, and in all six the second paragraph is model-facing
guidance — "Use this whenever the user mentions an order number." —
not an implementation note. Refusing them would have rejected six tools
whose authors had already written exactly the right thing, forced
ask_human to delete the sentence this change exists to deliver, and
still left the two truncations above in place, since those docstrings
are single paragraphs.
Taking everything costs +7% description bytes repo-wide (8307 → 8892 characters across all 139 tools), and zero of the 139 change verdict against the 30-character floor. That is the whole trade, in numbers, and it is why the long form is the default rather than a flag.
from agentkit.tools import tool
@tool(side_effecting=False, idempotent=True)
def order_status(order_id: str) -> str:
"""Look up the current shipping status of a customer order.
Use this whenever the user mentions an order number, even in
passing. The status is the carrier's own wording, and it can be up
to an hour stale.
"""
return "in transit"
print(order_status.description)
Look up the current shipping status of a customer order.
Use this whenever the user mentions an order number, even in
passing. The status is the carrier's own wording, and it can be up
to an hour stale.
The cost, stated plainly¶
The whole docstring reaching the model means the notes you wrote for a
human reach it too. TODO: this is O(n^2), see ticket #442, the
vendor lies about 404s — none of that was visible before, and all of it
is now text inside the tool's instructions, competing for attention with
the sentence that actually tells the model when to call.
There is no length cap. Nothing in agentkit/tools/declarations/schema.py trims a
long description or warns about one; what you write is what ships, on
every request, for the whole run. Two escape hatches exist instead, and
they are deliberately different sizes.
A --- line ends the model-facing part. A docstring line containing
nothing but three hyphens is the cut: everything above it is the
description, everything below it is for whoever is editing the source.
from agentkit.tools import tool
@tool(side_effecting=False, idempotent=True)
def order_status(order_id: str) -> str:
"""Look up the current shipping status of a customer order.
Use this whenever the user mentions an order number.
---
Backed by the carrier's v2 endpoint, which lies about 404s.
See ticket #442 before touching the retry count.
"""
return "in transit"
print(order_status.description)
Look up the current shipping status of a customer order.
Use this whenever the user mentions an order number.
That is an opt-out, and the direction was the decision. An opt-in —
long_description=True, say — would have made all 139 existing tools do
paperwork to keep prose their authors already meant for the model; the
opt-out is one line for the few who need it, and its absence is the
common case. It was also safe to switch on for a measured reason: across
those 139 tools, not one already contained a bare --- line, so turning
it on changed no existing description by a single byte.
A developer note above the cut is refused at decoration time. This
is the other half of the whole-docstring decision. TODO, FIXME,
XXX and HACK used to be invisible to the model; the moment they
became shippable text they had to be either shipped deliberately or
moved. The framework cannot tell which the author meant, so it asks —
the same call it already makes for a missing side_effecting=.
from agentkit.tools import ToolDefinitionError, tool
try:
@tool(side_effecting=False)
def catalogue_search(q: str) -> str:
"""Search the product catalogue for a free-text query.
Notes on behaviour:
- results are ranked by the vendor, not by us
- TODO: this is O(n^2), rewrite before the Q3 launch
"""
return "ok"
except ToolDefinitionError as exc:
print(exc)
tool 'catalogue_search' has a developer note (TODO) in the part of its docstring
that is sent to the model, where it reads as an instruction; move it below a
line containing only '---' (everything after that line is for humans) or pass an
explicit description=
Note where that note was written, because the anchoring is the part
that took a second attempt. The pattern matches at the start of a
line, allowing for indentation and an optional list bullet. Column
zero alone was not enough, and the gap was not hypothetical:
inspect.getdoc dedents by the common leading whitespace, so a note
written one level in keeps its extra indent all the way through — and
the most ordinary way to write one is as a bullet under a heading,
exactly as above. Both the indent and the - would have stood between
TODO and a column-zero anchor, the check would have stayed silent, and
the note would have shipped as an instruction.
What is deliberately not a note is the marker used as a word inside a sentence. A to-do-list tool may say "Append a TODO: item to the checklist" — there the marker is preceded by prose, and the pattern admits only whitespace and a bullet before it. A word boundary keeps "XXXL is a size" out of it as well.
The check runs on the model-facing half only. A TODO below the ---
line is exactly what the marker is for, and is left alone.
The 30-character floor, and what it now measures¶
@tool still refuses a description shorter than 30 characters
(_MIN_DESCRIPTION_LEN in agentkit/tools/declarations/schema.py). What changed is
the thing being measured: the floor applies to the whole chosen
text, not to line one. The total is what the model is shown, so the
total is what has to clear the bar.
from agentkit.tools import ToolDefinitionError, tool
try:
@tool(side_effecting=False)
def lookup(q: str) -> str:
"""Look it up."""
return "ok"
except ToolDefinitionError as exc:
print(exc)
tool 'lookup' needs a docstring/description of at least 30 characters so the
model can understand it; got 11 chars: 'Look it up.'
Thirty characters is not a style rule. The description is the spec:
it is what the model reads when deciding between your twelve tools, and
"Look it up." does not distinguish a vector search from a DNS lookup.
A tool the model cannot tell apart from its neighbour gets picked at
random, and the failure shows up as a bad answer several turns later
rather than as an error. The floor makes that a ToolDefinitionError at
decoration time — at import, before a run starts, before any money is
spent.
Measuring the total only ever widens what passes: a docstring with a thin first line and a substantial body used to be rejected and now is not. Measured, 0 of this repo's 139 tools change verdict.
It narrows in exactly one place, and the error names that place rather
than leaving you to guess. An author who put everything below the ---
line has a docstring that looks generous and a description that is
nearly empty:
from agentkit.tools import ToolDefinitionError, tool
try:
@tool(side_effecting=False)
def order_status(order_id: str) -> str:
"""Look it up.
---
Look up the current shipping status of a customer order. Use
this whenever the user mentions an order number.
"""
return "in transit"
except ToolDefinitionError as exc:
print(exc)
tool 'order_status' needs a docstring/description of at least 30 characters so
the model can understand it; got 11 chars: 'Look it up.' (everything below the
'---' line in its docstring is for humans and was not counted)
description= still wins outright¶
An explicit description= replaces the docstring entirely, and it is
passed through with nothing but a strip() — no marker hunt, no
developer-note check. The asymmetry is the point. A docstring is text
the framework interprets, addressed to two audiences at once, and
every rule above is about splitting it. description= is text you
handed the framework directly and meant every byte of; going looking for
markers in it would be the framework second-guessing an explicit
instruction. It still has to clear the 30-character floor.
from agentkit.tools import tool
@tool(
side_effecting=False,
description="Search the product catalogue and return matching SKUs.",
)
def catalogue_search(q: str) -> str:
"""Search.
TODO: this is O(n^2), rewrite before the Q3 launch.
"""
return "ok"
print(catalogue_search.description)
A thin docstring and a developer note, and neither is an error — because neither is what the model was given.
Docstrings written on Windows
inspect.getdoc dedents by finding common leading whitespace, and a
CRLF docstring defeats it completely — measured, the four-space
indent survives the dedent and a trailing carriage return rides
along. Under the first-line rule that never showed, because the
split threw the tail away. Now the whole text ships, so schema.py
normalises the line endings and re-runs the dedent the \r defeated
before anything reaches a provider.
side_effecting= is required, and three subsystems read it¶
from agentkit.tools import ToolDefinitionError, tool
try:
@tool
def send_email(to: str, body: str) -> str:
"""Send an email to a single recipient and return the message id."""
return "ok"
except ToolDefinitionError as exc:
print(exc)
@tool requires an explicit `side_effecting=` keyword (True if the tool mutates
the world / has external effects, False if it's read-only). The framework's
approval gating and idempotency depend on knowing upfront.
There is no default because there is no safe default. Guessing "read-only" lets a mutation be cached; guessing "mutating" turns caching off for everyone. Three real mechanisms consult the flag:
1. Approval gating. ReActCognition copies side_effecting onto the
tool request as key_step, and
should_gate
combines it with the run's autonomy tier: under MANUAL everything
pauses, under GATED a side-effecting step pauses too, under AUTO
(the default) only a tool that declared requires_approval=True pauses.
An author's requires_approval=True is honoured at every tier —
autonomy only adds gating.
import asyncio
from agentkit.agents import Agent, Suspended
from agentkit.core.contracts.types import ToolCall
from agentkit.tools import tool
from agentkit.agents.cognition import ReActCognition
from agentkit.core.testing import FakeLLM, Turn, make_test_ctx
@tool(side_effecting=True, requires_approval=True)
def refund(order_id: str, amount_usd: float) -> str:
"""Issue a refund against a customer order and return the confirmation id."""
return f"refunded {amount_usd} on {order_id}"
agent = Agent(
name="billing",
model="fake",
prompt="You handle refunds.",
cognition=ReActCognition(tools=[refund]),
)
llm = FakeLLM.script([
Turn(tool_calls=(ToolCall("c1", "refund", {"order_id": "A-1", "amount_usd": 20.0}),)),
Turn(content="Refund issued."),
])
async def main() -> None:
ctx = make_test_ctx(llm=llm) # autonomy="auto", the default
result = await agent.run("Refund order A-1 for $20", ctx)
suspended = result.evals["suspended"]
assert isinstance(suspended, Suspended)
print(result.stop_reason, suspended.reason)
print([(c.name, dict(c.arguments)) for c in suspended.pending])
asyncio.run(main())
The refund never ran. See Human-in-the-loop tool approval for the resume half.
2. idempotent() middleware. An at-least-once retry must not re-fire
a mutation. idempotent() is the one caller in the framework that opts
into caching side-effecting calls, and it stays safe by pinning the key to
(correlation_id, scope, tool name, arguments) — replay is confined to a
single run.
import asyncio
from agentkit.state.stores.memory import InMemoryStore
from agentkit.core.contracts.types import Scope, ToolRequest
from agentkit.core.policies import idempotent
from agentkit.core.testing import FakeLLM, make_test_ctx
from agentkit.tools import tool
sent: list[str] = []
@tool(side_effecting=True)
def send_email(to: str, body: str) -> str:
"""Send one email to a single recipient and return the provider message id."""
sent.append(to)
return f"queued for {to}"
async def main() -> None:
ctx = make_test_ctx(
llm=FakeLLM(),
store=InMemoryStore(),
scope=Scope(org_id=1, domain_id=1),
tool_middleware=[idempotent()],
)
request = ToolRequest(
name="send_email",
arguments={"to": "ops@example.com", "body": "prod is down"},
tool=send_email,
)
print(await ctx.invoker.invoke_tool(request, ctx))
print(await ctx.invoker.invoke_tool(request, ctx)) # a retry of the SAME call
print("emails actually sent:", len(sent))
asyncio.run(main())
3. memoize() refuses. The general-purpose cache reads the same flag
and declines side-effecting calls outright. The comment in
agentkit/core/policies/memoize.py records the measured failure that made
this the default: send_email(to=…) invoked twice sent one email and
the second caller was handed the first call's stored {'sent': True}.
Caching a side effect reports success for an action that never happened;
a missed cache hit costs a re-execution. The asymmetry decides the
default.
The declaration of record is the tool object, not the request.
ToolRequest.side_effecting defaults to False and plenty of call sites
build the request positionally, so the middleware ORs the two — reading
the request alone fixed only half the reproduction.
idempotent= is a separate axis
side_effecting asks does this change the world?; idempotent
asks is a retry safe?. Read-only lookups are usually
side_effecting=False, idempotent=True. Mutations are usually
side_effecting=True, idempotent=False. They are independent, and
idempotent defaults to False.
ctx is injected, not advertised¶
A parameter named ctx or context receives the live RunContext and
is stripped from the schema the model sees. That is how a tool reaches
the tenant scope, the budget, the trace, or ctx.emit.
import asyncio
from agentkit.core.contracts.types import Scope
from agentkit.core.testing import FakeLLM, make_test_ctx
from agentkit.tools import tool
@tool(side_effecting=False, idempotent=True)
def list_projects(prefix: str, ctx) -> list[str]:
"""List the calling tenant's projects whose name starts with a prefix."""
return [f"{ctx.scope.key()}/{prefix}-alpha", f"{ctx.scope.key()}/{prefix}-beta"]
async def main() -> None:
ctx = make_test_ctx(llm=FakeLLM(), scope=Scope(org_id=7, domain_id=1))
print(list_projects.schema.parameters)
print(await list_projects.run({"prefix": "web"}, ctx))
asyncio.run(main())
{'type': 'object', 'properties': {'prefix': {'type': 'string'}}, 'required': ['prefix']}
['org7:dom1/web-alpha', 'org7:dom1/web-beta']
The hijack is narrow on purpose: the name only counts as the injected
context when it is unannotated or annotated as RunContext. A real
data parameter like def f(context: str) stays advertised and is passed
normally, so a tool that genuinely wants a field called context is not
silently broken.
A ctx key arriving from the model is dropped rather than reported as
an error — but only when the function actually declares such a parameter.
A tool with no ctx parameter has no name to shadow, so the key is
rejected like any other unknown argument.
The technical contract¶
Tool is a Protocol¶
Anything with those attributes plus async def run(args, ctx) is a
tool. Tool is @runtime_checkable, and ToolRegistry.register uses
isinstance at the seam so a half-shaped object (one with name and
run but no schema) is refused there rather than exploding later
inside schemas(). FileTool is a duck-typed tool that satisfies the
Protocol without inheriting anything; so are MCP tools and remote-
procedure adapters.
run's args is typed Mapping[str, Any] — the widest useful type. In
a normal run the invoker hands down a FrozenDict (a dict subclass
that refuses mutation), so args["x"] = 1 inside a tool raises
TypeError. Copy with dict(args) if you need to edit, and note that
copy is shallow — nested values stay frozen all the way into the tool
body. That is the point: the arguments that were authorised stay the
arguments that get executed, and the audit record that names them
stays true.
ToolRegistry is lookup, and nothing else¶
Execution moved out to the invoker's middleware chain, so the registry answers three questions: name → tool, what schemas to advertise, and does this name need approval.
register(tool, replace=False)accepts aFunctionToolor a plain callable, which it runs throughfrom_callable— same docstring floor, same contract, and a thin docstring fails here rather than at runtime.from_tools([...])builds a registry from a mixed list.schemas()returns tools in sorted name order, deliberately: a stable order keeps the cacheable system+tools prefix byte-identical across turns.- Name collisions raise instead of overwriting.
from agentkit.tools import ToolRegistry, tool
@tool(side_effecting=False)
def forecast(city: str) -> str:
"""Return a short weather forecast for a city over the next few days."""
return "mild"
registry = ToolRegistry.from_tools([forecast])
try:
registry.register(forecast)
except ValueError as exc:
print(exc)
Silently swapping would be worse than it sounds: the model still sees the
same advertised name, but the implementation behind it has changed. A
typo or an import-order accident would be indistinguishable from correct
behaviour. replace=True makes a deliberate hot-swap say so.
Agents as tools¶
as_tool wraps anything with async run(task, ctx) -> result — a leaf
Agent, a coordinator Agent, a Workflow — into a FunctionTool an
outer agent can call. The sub-run executes on ctx.child(), so budget,
cancellation, depth and observation all flow through it, and the result
is rendered back to text for the outer loop.
import asyncio
from agentkit.agents import Agent
from agentkit.core.contracts.types import ToolCall
from agentkit.agents.cognition import ReActCognition
from agentkit.core.testing import FakeLLM, Turn, make_test_ctx
from agentkit.tools import as_tool
researcher = Agent(
name="researcher",
model="fake",
prompt="You summarise a topic in one sentence.",
)
research_tool = as_tool(
researcher,
name="researcher",
description="Delegate a research sub-task and get a one-sentence summary back.",
side_effecting=False,
)
lead = Agent(
name="lead",
model="fake",
prompt="You answer by delegating research.",
cognition=ReActCognition(tools=[research_tool]),
)
llm = FakeLLM.script([
Turn(tool_calls=(ToolCall("c1", "researcher", {"task": "octopus tool use"}),)),
Turn(content="Octopuses carry coconut shells as portable shelters."),
Turn(content="They do: they carry coconut shells around as shelter."),
])
async def main() -> None:
ctx = make_test_ctx(llm=llm)
print(research_tool.schema.parameters)
print((await lead.run("Do octopuses use tools?", ctx)).output)
asyncio.run(main())
{'type': 'object', 'properties': {'task': {'type': 'string', 'description': 'the sub-task / goal to run'}}, 'required': ['task']}
They do: they carry coconut shells around as shelter.
The schema is fixed: one task string. That is the whole point — a
sub-agent takes a goal, not a parameter list, and the composition is
"everything is callable" rather than a second runtime.
render_result is the default renderer: it returns AgentResult.output
when the result has a string output, joins a WorkflowResult's
terminal outputs as key: value lines, and falls back to str(res).
Pass render= to override.
For the reverse direction — an Agent as a node inside a
Workflow — see Agents; it is native, no adapter needed.
Validating what a tool returns¶
output_schema= validates the tool's result after the function ran.
It is auto-inferred from the return-type annotation when that annotation
is a Pydantic model, a dataclass, or an attrs class; primitives, Any,
generics and unions are skipped, so a tool typed -> str pays nothing.
Pass an explicit class to override, or output_schema=None to opt out
even when the return type looks enforceable.
import asyncio
from dataclasses import dataclass
from agentkit.core.testing import FakeLLM, make_test_ctx
from agentkit.tools import ToolShapeError, tool
@dataclass
class Quote:
symbol: str
price: float
@tool(side_effecting=False, idempotent=True)
def get_quote(symbol: str) -> Quote:
"""Return the latest traded price for a stock ticker symbol."""
return "not a Quote at all" # the bug this catches
async def main() -> None:
ctx = make_test_ctx(llm=FakeLLM())
print("inferred:", get_quote.output_schema)
try:
await get_quote.run({"symbol": "ACME"}, ctx)
except ToolShapeError as exc:
print(exc)
print("raw =", repr(exc.raw), "| expected =", exc.expected)
asyncio.run(main())
inferred: <class '__main__.Quote'>
tool 'get_quote' returned value not matching schema 'Quote'
raw = 'not a Quote at all' | expected = Quote
A mismatch also drops a tool.shape_mismatch event on the open
execute_tool span, best-effort — a misbehaving tracer must never break
the tool path.
Three error types, not one¶
| Error | Fires | Who can fix it |
|---|---|---|
ToolDefinitionError |
at decoration/registration time | you — missing side_effecting=, thin description, *args, a developer note above the --- cut |
ToolArgumentError |
at call time, before the function runs | the model — it authored a bad call (missing, unexpected, or uncoercible argument) |
ToolShapeError |
at call time, after the function returned | the tool — it produced a bad value |
The split is about recovery, not taxonomy.
ToolArgumentError carries three kinds of complaint, because a repair
turn recovers from all of them the same way — reflect and reissue:
missing and unexpected name arguments, and invalid names arguments
that were present but could not be coerced to the annotated type.
import asyncio
import enum
from agentkit.tools import ToolArgumentError, tool
class Unit(enum.Enum):
C = "celsius"
F = "fahrenheit"
@tool(side_effecting=False)
async def weather(city: str, unit: Unit) -> str:
"""Report the weather for a city in the requested unit of measure."""
return f"{type(unit).__name__}={unit!r}"
async def main() -> None:
# A legal value arrives as the Enum member, not the wire string.
print(await weather.run({"city": "Lisbon", "unit": "celsius"}, None))
# An illegal one is refused before the body runs.
try:
await weather.run({"city": "Lisbon", "unit": "kelvin"}, None)
except ToolArgumentError as exc:
print(exc.invalid)
asyncio.run(main())
Unit=<Unit.C: 'celsius'>
(('unit', "'kelvin' is not a valid Unit; expected one of ['celsius', 'fahrenheit']"),)
All bad arguments are collected before raising, so one repair turn learns every mistake at once rather than discovering them one call at a time.
ToolDefinitionError is a bug in your wiring and there is no runtime
recovery for it, so it fires at import — before an agent exists, let
alone a bill. It subclasses ValueError so existing except ValueError
paths still trip.
ToolArgumentError is the model's mistake, and the model is the only
party that can fix it — so the message names the tool, the offending
arguments, and the accepted set, and the retry middleware reflects it
back as something actionable.
import asyncio
from agentkit.core.testing import FakeLLM, make_test_ctx
from agentkit.tools import ToolArgumentError, tool
@tool(side_effecting=True)
def notify(msg: str = "default message") -> str:
"""Page the on-call engineer with a short incident message."""
return f"sent: {msg}"
async def main() -> None:
ctx = make_test_ctx(llm=FakeLLM())
try:
await notify.run({"message": "prod is down"}, ctx)
except ToolArgumentError as exc:
print(exc)
print("unexpected =", exc.unexpected, "| accepted =", exc.accepted)
asyncio.run(main())
tool 'notify' call rejected: unexpected argument(s) ['message']. Accepted arguments: ['msg']
unexpected = ('message',) | accepted = ('msg',)
That example is the exact failure the error exists to stop.
Unknown keys used to be dropped silently, so the parameter ran with its
default instead. A model calling
notify(message="page the on-call, prod is down") against
def notify(msg: str = "default message") got back
"sent: default message".
That is a side-effecting tool reporting success for something it never did, with nothing downstream able to tell the difference.
A tool that genuinely accepts arbitrary keys declares **kwargs, and
then extras are passed through instead of rejected:
import asyncio
from agentkit.core.testing import FakeLLM, make_test_ctx
from agentkit.tools import tool
@tool(side_effecting=False)
def flexible(query: str, **filters) -> str:
"""Search the catalogue with a free-form set of extra filter keys."""
return f"{query} + {sorted(filters)}"
async def main() -> None:
ctx = make_test_ctx(llm=FakeLLM())
print(await flexible.run({"query": "boots", "colour": "red", "size": 42}, ctx))
print(flexible.schema.parameters)
asyncio.run(main())
boots + ['colour', 'size']
{'type': 'object', 'properties': {'query': {'type': 'string'}}, 'required': ['query']}
ToolShapeError is the mirror image: the value is bad on the way out,
the model did not author it, and the useful recovery is different — the
model can re-issue with different arguments or pivot entirely. It is
deliberately distinct from OutputCoercionError, which is about
coercing the model's own response: different fire site, different
recovery.
Other things in the box¶
FileTool advertises itself as memory, and that is deliberate
FileTool().schema.name is "memory", which reads like a clash with
the agentkit.state.memory package. It is not a naming slip: this is
agentkit's implementation of Anthropic's client-side memory tool, and
four things are fixed by that contract — the name, the six commands
(view, create, str_replace, insert, delete, rename),
single-call command= dispatch, and the /memories root. Renaming
it would break the vendor integration, so the name is pinned by a
test rather than left to judgement.
FileTool is a duck-typed tool that gives the model a self-managed note
tree — view · create · str_replace · insert · delete ·
rename — over a backend that defaults to InMemoryFiles. It is
side_effecting=True, and its advertised name is memory. The read
side of the same tree is FileMemory; see Memory.
from agentkit.tools import FileTool
ft = FileTool()
print(ft.name, ft.side_effecting, ft.requires_approval)
What bites people¶
Rich types arrive as the type you annotated
An Enum, a Literal or a typed struct is coerced at the call
boundary, driven by the same annotation the schema was derived from.
This used to be the opposite — the body received the raw JSON value,
so a parameter annotated unit: Unit got a str and unit.value
raised AttributeError at runtime on a value the schema said was a
Unit.
Primitives are deliberately not coerced. Nothing turns "3" into
3: a provider sending a string for an integer parameter has a bug
the framework should surface, not launder.
A value that cannot be coerced raises ToolArgumentError before the
function runs — see the error table.
Agent has no tools= keyword
Tools go on the cognition: ReActCognition(tools=[...]). Passing
tools= to Agent(...) raises TypeError.
Your whole docstring is the model's instructions
Everything above a --- line goes to the model, implementation
notes included. TODO/FIXME/XXX/HACK are caught at decoration
time, but "see ticket #442" and "the vendor lies about 404s" are
not, and they will ship as guidance. Put a --- line above the
human half, or pass description=.
Give the model less than you have
A schema of None makes a tool loop-invisible: it stays
callable through the registry and the invoker but is never
advertised to the model. Useful for tools a cognition drives itself.
Don't mutate args
It arrives frozen, deeply. dict(args) gives you a shallow mutable
copy; nested values stay frozen. A tool that popped a key off its
own arguments would make the authorised call and the executed call
two different things, with the audit trail recording only the first.
from_callable defaults side_effecting=False
The decorator forces you to declare it. FunctionTool.from_callable
and ToolRegistry.register(plain_function) default to False for
compatibility. If you go through those paths — including passing bare
functions to ReActCognition(tools=[...]) — pass side_effecting=
knowingly, or your mutation will be treated as cacheable.
Related¶
- Middlewares — the chain every tool call passes
through: tracing, metering, egress,
idempotent(), audit, retry. - Agents — where a
ToolRegistryis wired, and how a tool loop terminates. - Memory — the other way an agent reaches outside itself,
and
ToolMemory, which turns a tool into a memory source. - Skills — a prompt + cognition + memory bundle that can be handed to another agent as a tool.
- Human-in-the-loop tool approval —
the resume half of
requires_approval=True. - Concepts · Integrations — MCP tools adapted into this
same
ToolProtocol, and the server side where agentkit exposes one. - Consume MCP tools from an agent.
- API › tools — the generated reference.