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

  1. Add Annotated and Field metadata to each handler parameter.

  2. Write search_documents(query, limit=5) for public document summaries.

  3. Give both Tool objects explicit, contrasting descriptions.

  4. Register both tools on the existing Agent.

  5. Verify each dispatch path with StubTransport.

Download the complete example.

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 README.md

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:

examples/tutorial/tools_that_route.py
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:

examples/tutorial/tools_that_route.py
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:

examples/tutorial/tools_that_route.py
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.

examples/tutorial/tools_that_route.py
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_document requires an exact path.

  • [ ] search_documents accepts 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.