Use the discovery tools
Hand the agent search_capabilities, invoke_tool, and get_skill_content, then assemble each turn's toolset with a top-K pre-filter.
The capability tools wrap a catalog into tools the agent can call itself: self-service access to the whole catalog without it living in the prompt. The language-neutral contract (result shapes, defaults, and the error table) lives on Progressive disclosure.
search = search_capabilities_tool(catalog) # id == "search_capabilities"
invoke = invoke_tool_tool(catalog) # id == "invoke_tool"search_capabilities_tool and invoke_tool_tool return plain ExecutableTool objects (id, name, description, input_schema, output_schema, execute). Wrap each one in your framework's tool type and run your normal loop.
search_capabilities
search_capabilities({query, topKTools?, topKSkills?}) returns two independently-ranked buckets, so a relevant skill is never crowded out by matching tools (result keys are camelCase, a wire contract shared with the TypeScript SDK and MCP):
{
"tools": {
"groups": [
{
"server": {"name": "fs"}, // grouped by server (the id prefix before "__")
"hits": [
{"toolId": "fs__read_file", "score": 1.42, "description": "...", "inputSchema": {}}
]
}
]
},
"skills": [{"skillId": "deploy-vercel", "score": 0.9, "description": "..."}]
}topKTools defaults to 5 and topKSkills to 3: integers above 50 clamp down to 50; a missing, 0, negative, bool, or non-integer value falls back to the default rather than erroring. The skills bucket is always present and stays empty until you pass a SkillCatalog — see Register skills. The keyword-only upstream_servers= advertises upstream MCP servers in the tool's description — see Register MCP servers.
Upgrading from 0.1.x?
search_tools_tool (id search_tools, constant SEARCH_TOOLS_ID) is still exported as a deprecated, tools-only shim that keeps its original {"groups": ...} result shape. Migrate to search_capabilities_tool.
invoke_tool
invoke_tool({toolId, args}) runs catalog.invoke(tool_id, args) and returns the tool's result. Arguments go nested under args, but a flattened call still works: when args is missing or None, every top-level key except toolId and args is forwarded to the tool. On a bad call it returns a structured {"error": ..., "isError": True} instead of raising, so a model mistake (unknown id, malformed args, a handler that throws) stays recoverable inside the loop rather than crashing the host.
Expired upstream credentials get the same treatment. When an executor raises an exception whose type is named UnauthorizedError (duck-typed, so any library's class qualifies), invoke_tool returns {"error": "needs_auth", "isError": True, "hint": "call the auth tool to re-authorize <upstream>", "upstream": "<upstream>"} — the upstream inferred from the id prefix before __. Pass on_unauthorized= to invoke_tool_tool (an OnUnauthorized callback, sync or async, called with the upstream name) to trigger your re-auth flow at that moment.
Skill content
With a SkillCatalog registered, a third capability tool joins the set. It returns a skill's full body on demand:
search = search_capabilities_tool(catalog, skills) # 2nd arg → result gains a populated `skills` bucket
load = get_skill_content_tool(skills) # id == "get_skill_content"Assemble the per-turn toolset
The capability tools are the escape hatch; the pre-filter covers the common case. Each turn, put the two capability tools in the list unconditionally, then add the top-K catalog hits for the user's message. The full catalog never enters the prompt:
def tools_for_turn(user_message: str) -> list[ExecutableTool]:
capabilities = [search_capabilities_tool(catalog), invoke_tool_tool(catalog)]
top_k = [
executable
for hit in catalog.search(user_message, 3) # BM25: the 3 most relevant tools
if (executable := catalog.get_executable(hit.tool_id)) is not None
]
return [*capabilities, *top_k]There are two dispatch paths, and getting them right matters because a registered executor may be sync or async:
- Catalog tools (your top-K hits): dispatch through
await catalog.invoke(tool_id, args). It awaits the handler's coroutine only when needed and records theinvoke_*trace events. Neverawaita rawexecuteyourself, or a sync handler raises. - The two capability tools (
search_capabilities/invoke_tool): they are not catalog entries, and they ownasynchandlers, so awaitt.execute(args)directly.
The full Pydantic AI wiring is on Framework integrations.
Retrieval methods
Ranking is BM25 unless you opt out. Both catalogs take a method= constructor keyword, and search accepts the same values per call ("bm25", the default, "semantic", or "hybrid"); the per-call value wins:
catalog = ToolCatalog(method="hybrid") # catalog-wide default
catalog.search("resize the logo", 5) # ranks with "hybrid"
catalog.search("resize the logo", 5, method="bm25") # per-call overrideSemantic and hybrid rank against a prebuilt embedding cache. A catalog constructed as "semantic" or "hybrid" embeds each tool as it is registered, so a search only ever embeds the query; on a BM25-default catalog, call build_embeddings() before a per-call override — the full cache lifecycle is on Build the embedding cache. A search with an unbuilt cache raises RuntimeError; an unknown method string raises ValueError.
search_capabilities passes no method, so it always inherits the catalog's construction-time default — switching the agent's engine means constructing the catalog with it. How BM25 ranks is covered in Keyword search; how the semantic and hybrid engines rank, fuse, and fail is covered in Semantic & hybrid search.