Tools That Route¶
read_document works only when the user supplies an exact path. A request such
as “find the architecture documentation” has no valid input for that tool.
Adding search_documents creates a new problem. The model now needs a clear
contract for choosing one tool and constructing its arguments.
Outcome¶
You will add a second tool with a distinct description and a bounded input schema. The same Agent can then dispatch exact-path and discovery-shaped calls.
Fast Track¶
Add
AnnotatedandFieldmetadata to each handler parameter.Write
search_documents(query, limit=5)for public document summaries.Give both
Toolobjects explicit, contrasting descriptions.Register both tools on the existing Agent.
Verify each dispatch path with
StubTransport.
Hands-on delta¶
1. See the ambiguous contract¶
Descriptions such as “read documents” and “find documents” overlap. They do not tell a model which input shape belongs to each action.
Request shape |
Exact lookup |
Search |
|---|---|---|
Contains |
yes |
no |
Describes documents without a path |
no |
yes |
This distinction must appear in both the descriptions and argument schemas.
2. Separate the argument shapes¶
Python types define the basic schema. Field adds model-facing descriptions,
defaults, and numeric bounds without creating a second schema definition.
Keep DOCUMENTS from the previous lesson. Replace the handlers with this
version:
async def read_document(
path: Annotated[
str,
Field(description="Exact project document path, for example README.md"),
],
) -> str:
"""Return one project document summary from its exact path."""
calls.append(("read_document", path, 0))
document = DOCUMENTS.get(path)
if document is None:
return f"Document {path} was not found."
return f"{path}: {document['summary']}"
async def search_documents(
query: Annotated[
str,
Field(description="Words to match in a public document path or summary"),
],
limit: Annotated[
int,
Field(description="Maximum summaries to return", default=5, ge=1, le=10),
] = 5,
) -> str:
"""Search public project document summaries."""
calls.append(("search_documents", query, limit))
needle = query.casefold()
matches = [
f"{path}: {document['summary']}"
for path, document in DOCUMENTS.items()
if document["visibility"] == "public"
and needle in f"{path} {document['summary']}".casefold()
]
return "\n".join(matches[:limit]) or "No public documents matched."
Tool.input_schema is a JSON-compatible copy. Axio transports send the
read-only tool schema to the model with the description and name.
The bounds also affect execution. Axio validates model-produced values before the handler runs.
See Tool System for supported annotations and validation order.
3. Offer both tools¶
Give each Tool a contrasting description, then register both definitions on
the same Agent:
read_document_tool = Tool(
name="read_document",
handler=read_document,
description=(
"Read one project document by exact path. Choose this tool when the user "
"supplies a path such as README.md. Do not use it to discover documents."
),
)
search_documents_tool = Tool(
name="search_documents",
handler=search_documents,
description=(
"Search public project documents by path or summary. Choose this tool "
"when the user describes a document without supplying an exact path."
),
)
assert read_document_tool.input_schema["required"] == ["path"]
limit_schema = search_documents_tool.input_schema["properties"]["limit"]
assert limit_schema["default"] == 5
assert limit_schema["minimum"] == 1
assert limit_schema["maximum"] == 10
Change only the tools list in build_agent. The assertions confirm that the
Agent exposes both tools in the intended order:
def build_agent(transport: CompletionTransport) -> Agent:
return Agent(
system="Answer questions about project documents. Use tools for facts.",
transport=transport,
tools=[read_document_tool, search_documents_tool],
)
agent = build_agent(StubTransport([make_text_response("Done.")]))
assert [tool.name for tool in agent.tools] == ["read_document", "search_documents"]
assert agent.system == "Answer questions about project documents. Use tools for facts."
The Agent passes both definitions to the transport. A live model chooses a tool from their names, descriptions, and schemas. Axio resolves the returned name and dispatches the validated arguments.
Keep descriptions specific. A description should state the action, its useful input shape, and the nearby tool that handles a different request.
Try It¶
Use canned model responses to test dispatch without model variance. This check proves that Axio resolves each requested name to the correct handler. It does not claim to measure a live model’s selection quality.
Run uv run python examples/tutorial/tools_that_route.py from the repository
root.
async def dispatch(tool_name: str, tool_input: dict[str, Any]) -> None:
transport = StubTransport(
[
make_tool_use_response(tool_name=tool_name, tool_input=tool_input),
make_text_response("Done."),
]
)
agent = Agent(
system="Use document tools for facts about project documents.",
transport=transport,
tools=[read_document_tool, search_documents_tool],
)
assert await agent.run("test request", MemoryContextStore()) == "Done."
async def try_routing() -> None:
await dispatch("read_document", {"path": "README.md"})
await dispatch("search_documents", {"query": "architecture"})
assert calls == [
("read_document", "README.md", 0),
("search_documents", "architecture", 5),
]
print(calls)
async def main() -> None:
await try_routing()
if __name__ == "__main__":
asyncio.run(main())
The missing limit becomes 5 before search_documents runs. This verifies
default injection as well as name-based dispatch.
Use the same prompt set with your provider transport as an exploratory check: one exact path, one discovery request, and one request near the boundary. Do not use model output as the deterministic contract check.
Done when¶
[ ]
read_documentrequires an exactpath.[ ]
search_documentsaccepts a described query and a bounded limit.[ ] The descriptions distinguish exact lookup from discovery.
[ ] Both tools are registered on the same Agent.
[ ] The stubbed calls reach different handlers by tool name.
[ ] Axio injects the default search limit before execution.
Next failure¶
The schema says that path is a string. It cannot say which documents the
current user may read. A model can still request private file .env, even when
the system prompt tells it not to.
The next lesson moves that decision into executable policy.
Continue with Guard Tool Calls.