Dynamic Tool Selection
Also known as: tool retrieval, progressive tool disclosure, tool RAG
Retrieve a small relevant subset of tools per turn instead of pasting a catalog of two hundred into every request.
The problem
Tool-calling accuracy degrades as the tool list grows. Past roughly thirty tools the model starts confusing similar ones, and past a hundred it picks near-randomly among plausible candidates. Meanwhile the definitions themselves consume the context window on every single turn — two hundred tools at 150 tokens each is 30k tokens spent before the user's question is even read, on every request, forever.
The pattern
Treat the tool catalog as a retrieval corpus. Index each tool by its name, description, and representative queries; at each turn, retrieve the 10–20 most relevant tools and expose only those to the model. Keep a small set of always-present core tools, and give the model an explicit escape hatch to search the full catalog when the retrieved subset is inadequate.
Use when
- The catalog exceeds roughly thirty tools, especially with many MCP servers connected
- Tool definitions consume a meaningful fraction of your context budget
- Tools cluster into domains where any one request touches only one or two clusters
- Selection accuracy is measurably degrading as you add integrations
Avoid when
- You have fewer than about twenty tools — the retrieval layer adds failure modes for no gain
- Nearly every tool is plausibly relevant to every request
- You cannot tolerate the failure mode where the right tool is not retrieved and the model cannot see it
Tradeoffs
GainSelection accuracy recovers to small-catalog levels, and context cost drops sharply
CostA retrieval miss makes a tool invisible — the model cannot call what it was not shown
GainThe catalog can grow without bounded degradation
CostTool retrieval quality becomes its own system to evaluate, index, and keep fresh
GainDomain clustering makes permissioning and auditing by domain natural
CostChanging the exposed tool set mid-conversation invalidates prompt caching
This is RAG applied to tools rather than documents, and it becomes necessary at exactly the moment an assistant stops being a single-purpose app and starts being a hub connected to many systems.
Index tools like documents
Build the index from what a user's request would look like, not from implementation detail. The best index text combines the tool name, its description, and three to five example utterances that should route to it — those examples are what close the vocabulary gap between how users ask and how tools are named.
def index_text(tool: Tool) -> str:
return "\n".join([
tool.name.replace("_", " "),
tool.description,
*tool.example_queries, # "cancel my order", "I want a refund", ...
f"domain: {tool.domain}",
])
for tool in catalog:
tool_index.upsert(id=tool.name, vector=embed(index_text(tool)), metadata={"domain": tool.domain})Select per turn, with a stable core
Retrieve against the user's latest turn, but always include a small core set — the tools needed for basic operation regardless of topic, plus the catalog-search escape hatch. Also pin any tool used earlier in the conversation: a model that called `get_order` on turn two will want it again on turn five, and having it silently vanish is confusing to both the model and the user.
CORE = ["search_tools", "ask_user", "finish"]
def tools_for_turn(conversation: Conversation, k: int = 15) -> list[Tool]:
query = conversation.latest_user_message
retrieved = [hit.id for hit in tool_index.search(embed(query), limit=k)]
names = dict.fromkeys([ # ordered, de-duplicated
*CORE,
*conversation.tools_already_used, # pinned: don't yank them mid-task
*retrieved,
])
return [catalog[n] for n in names]The escape hatch
Retrieval will sometimes miss. Give the model a way out: a `search_tools` tool that queries the full catalog and returns matching definitions, which you then add to the exposed set for subsequent turns. This converts a hard failure — the tool is invisible, the model claims it cannot help — into a soft one costing an extra turn.
SEARCH_TOOLS = {
"name": "search_tools",
"description": (
"Search the full catalog for a capability not in your current tool list. "
"Use this before telling the user something is impossible."
),
"parameters": {
"type": "object",
"properties": {"capability": {"type": "string", "description": "What you need to do, in plain language."}},
"required": ["capability"],
},
}Measure tool recall directly: for a labelled set of requests, does the retriever surface the tool a human would pick? Recall@15 on that set is the metric that predicts end-to-end agent quality, and it is far cheaper to measure than full-run evaluation.
Changing the tool list between turns invalidates the cached prompt prefix, which can cost more than the tokens you saved. Keep the exposed set stable within a conversation once it has settled — recompute on topic shifts, not on every turn.
- Index name + description + example utterances, not implementation detail
- Always expose a small core set, plus every tool already used in this conversation
- Provide a catalog-search escape hatch so a retrieval miss costs a turn, not the task
- Recompute the set on topic shift rather than every turn, to preserve prompt caching
- Track recall@k on labelled requests as the health metric for the whole layer