Build a Native Tool Search Tool for Orchestrator Agents (LangGraph Example)
This tutorial shows how to implement a native Tool Search Tool as a first-class @tool-decorated function bound to a LangGraph orchestrator agent.
The objective is practical: instead of loading every tool definition into the context up front, the orchestrator discovers relevant tools on demand and only expands what it needs.
Why this matters
As your agent stack grows (especially with MCP-style integrations), static tool loading becomes expensive and noisy.
From publicly available engineering reports:
- Multi-server tool catalogs can consume ~55K tokens before the task starts.
- In internal evaluations, on-demand tool search reported about 85% token reduction for tool context loading.
- Typical retrieval returns 3–5 relevant tools instead of loading the whole catalog.
Reference:
What is a Tool Search Tool?
A Tool Search Tool is a discovery tool that returns tool references based on intent.
It usually:
- accepts a query (regex / keyword / semantic),
- searches tool metadata,
- returns top-k tool references,
- lets the orchestrator expand only those definitions before execution.
This pattern improves:
- context efficiency,
- tool selection precision,
- maintainability at scale.
Behind the scenes behavior
A robust orchestrator loop looks like this:
- Intent extraction from user request
- Tool search call to fetch candidate tools
- Policy filter (risk, auth scope, environment)
- Top-k expansion into execution context
- Execution with selected tool
- Retry/clarification if confidence is low
In other words: discovery first, expansion second, execution third.
Step 1: Define your tool catalog
The catalog is a list of metadata records describing every tool your system can offer. Each entry needs enough information for keyword matching and risk assessment.
TOOL_CATALOG = [
{
"name": "github_create_pull_request",
"description": "Create a GitHub pull request for a repository",
"tags": ["github", "code", "pr", "version-control"],
"risk_level": "medium",
},
{
"name": "github_list_issues",
"description": "List open issues for a GitHub repository",
"tags": ["github", "issues", "code"],
"risk_level": "low",
},
{
"name": "slack_post_message",
"description": "Post a message to a Slack channel",
"tags": ["slack", "messaging", "notification"],
"risk_level": "low",
},
{
"name": "database_execute_query",
"description": "Execute a SQL query against the production database",
"tags": ["database", "sql", "data"],
"risk_level": "high",
},
{
"name": "email_send",
"description": "Send an email to one or more recipients",
"tags": ["email", "messaging", "notification"],
"risk_level": "medium",
},
{
"name": "jira_create_ticket",
"description": "Create a new Jira ticket in a project",
"tags": ["jira", "project-management", "tracking"],
"risk_level": "low",
},
]Step 2: Implement the search function
Start with a simple keyword scorer. This can be upgraded to BM25, TF-IDF, or embedding-based search later.
def _search_catalog(query: str, top_k: int = 5) -> list[dict]:
"""Score and rank catalog entries by keyword overlap with the query."""
q_tokens = query.lower().split()
def score(entry: dict) -> int:
haystack = " ".join([
entry.get("name", ""),
entry.get("description", ""),
" ".join(entry.get("tags", [])),
]).lower()
return sum(1 for token in q_tokens if token in haystack)
scored = [(entry, score(entry)) for entry in TOOL_CATALOG]
scored = [(e, s) for e, s in scored if s > 0]
scored.sort(key=lambda x: x[1], reverse=True)
return [e for e, _ in scored[:top_k]]Step 3: Build the native Tool Search Tool with @tool
Wrap the search function as a proper @tool-decorated function so the orchestrator agent can call it natively through the tool-calling loop.
from langchain.tools import tool
@tool("tool_search")
def tool_search(query: str) -> str:
"""Search the tool catalog to discover available tools matching your intent.
Use this tool BEFORE calling any specialized tool you are unsure about.
It returns tool names, descriptions, and risk levels so you can make
an informed choice.
Args:
query: Keywords describing the capability you need
(e.g., 'create pull request', 'send message', 'query database').
"""
matches = _search_catalog(query, top_k=5)
if not matches:
return "No matching tools found. Try different keywords."
lines = [f"Found {len(matches)} matching tool(s):\n"]
for m in matches:
lines.append(
f"- **{m['name']}**: {m['description']} "
f"(risk: {m['risk_level']})"
)
return "\n".join(lines)Key design decisions:
- The docstring tells the model when and why to call this tool. This is what the LLM reads to decide whether to invoke it.
- Returns a formatted string (not a dict). This serializes cleanly in the message stream and is easy for the model to parse.
- Includes
risk_levelin results so the agent can reason about safety before proceeding.
Step 4: Build the orchestrator agent with create_agent
Bind tool_search as a native tool alongside your always-loaded domain tools. The system prompt instructs the orchestrator to discover before acting.
from langchain.tools import tool
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
# --- Always-loaded domain tools ---
@tool("get_weather")
def get_weather(location: str) -> str:
"""Get current weather for a location.
Args:
location: City name or coordinates.
"""
return f"22 degrees C, partly cloudy in {location}."
# --- Orchestrator agent ---
SYSTEM_PROMPT = """\
You are an orchestrator agent with access to a large tool catalog.
You have a `tool_search` tool that lets you discover available
capabilities by keyword.
Workflow:
1. When the user's request involves a capability you are not sure
about, call `tool_search` first with relevant keywords.
2. Review the returned tool names, descriptions, and risk levels.
3. If a matched tool is high-risk, inform the user and request
confirmation before proceeding.
4. Call the appropriate domain tool once you have confirmed the
right one.
Do NOT guess tool names. Always use `tool_search` to discover them.
"""
model = ChatOpenAI(model="gpt-4o")
agent = create_agent(
model,
tools=[tool_search, get_weather],
system_prompt=SYSTEM_PROMPT,
)
# --- Run the agent ---
result = agent.invoke(
{"messages": [{"role": "user", "content": "I need to open a PR on GitHub."}]}
)
# Expected agent behavior:
# 1. Call tool_search("create pull request github")
# 2. Receive: github_create_pull_request (risk: medium)
# 3. Inform the user about the available tool and its risk level
# 4. In a full system, the discovered tool would then be loaded and executedStep 5: StateGraph with ToolNode variant
For custom orchestration (conditional routing, human-in-the-loop gates, or multi-step discovery flows), use StateGraph with ToolNode:
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.prebuilt import ToolNode, tools_condition
tools = [tool_search, get_weather]
model = ChatOpenAI(model="gpt-4o").bind_tools(tools)
def call_model(state: MessagesState):
response = model.invoke(state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()
result = graph.invoke(
{"messages": [{"role": "user", "content": "I need to open a PR on GitHub."}]}
)The Tool Search Tool works identically in both patterns—it is always a native tool the agent calls through the normal tool-calling loop, never a graph node.
Step 6: Confidence and fallback behavior
Never force low-confidence tool picks. Build fallback logic into your system prompt:
| Scenario | Recommended action |
|---|---|
| No candidate tools returned | Ask the user a clarifying question |
| Low confidence (few keyword matches) | Show top 2–3 options and request confirmation |
| High-risk tool matched | Require explicit user approval before execution |
| Multiple equally relevant tools | Present options with descriptions and let the user choose |
Step 7: Observability checklist
Log at each turn:
- user intent (original request),
- search query sent to
tool_search, - candidate tools returned,
- final selected tool,
- risk level of selected tool,
- execution outcome,
- retries and reasons.
This makes failures diagnosable rather than mysterious.
Expected benefits
With a well-implemented Tool Search Tool:
| Benefit | Why |
|---|---|
| Lower token usage | Only relevant tool definitions enter the context |
| Fewer wrong-tool calls | Discovery narrows the candidate set |
| Clearer selection traces | Every search query and result is logged |
| Better scaling | Adding tools to the catalog does not bloat the agent prompt |
Trade-off: one extra discovery step can add latency. Generally worth it once tool count exceeds ~10.
Common mistakes
- Deferring every tool — keep a small set of always-loaded baseline tools (e.g.,
think,tool_searchitself). - Poor tool descriptions — ambiguous or generic names cause low match quality. Write descriptions as if explaining the tool to a new engineer.
- Returning full schemas too early — tool search should return names and descriptions, not complete input schemas. Expansion comes later.
- No policy filtering — always check risk level and authorization scope before letting the agent execute a discovered tool.
- No confidence thresholds — if the scorer returns weak matches, the agent should ask for clarification instead of guessing.
- Using tool search as a graph node — like the Think Tool, this should be a native
@toolthe agent calls through the tool-calling loop, not a custom graph node.
Related reading
- Blog: Tool Search Tool for AI Agents
- Blog: Powerful Tools for AI Agents
- Blog: Programmatic Tool Calling for AI Agents
- Tutorial: Build a Native Think Tool for Orchestrator Agents
Final takeaway
If your orchestrator has more than a handful of tools and starts making wrong selections or consuming excessive tokens, a native Tool Search Tool is one of the highest-ROI architecture upgrades you can make. It is a single @tool function backed by a searchable catalog—simple to implement, and it keeps your agent’s context lean and its tool picks precise as your system scales.