Agents From First Principles 06: AI Agent Chooses the Wrong Tool? Design Better Tool Interfaces, Schemas and Routing
An agent can have a perfectly capable model and still behave badly because its tools are badly designed.
This is one of the most common agent failures in production:
user goal
↓
agent
↓
wrong tool
↓
wrong action
The model may understand the task.
The agent may have enough context.
The problem is that the action space is ambiguous.
If two tools overlap, their descriptions are vague, their schemas are huge, or their results are difficult to interpret, the model has to guess.
And once the model guesses wrong, the rest of the loop can be perfectly implemented and still fail.
This post is about fixing that boundary.
The central idea is simple:
A tool is not just a function the model can call. It is part of the agent’s decision space.
That means tool design is agent design.
The failure people actually see
Suppose an agent can call these two tools:
def search(query: str):
...
def lookup(query: str):
...
What is the difference?
A human reading the code may know.
The model does not.
If the descriptions are also vague:
search: search for things
lookup: look things up
then choosing the right tool is mostly a language-model guess.
The first fix is therefore not a better model.
It is a better action space.
The tool-selection pipeline
A useful tool-using agent can be decomposed into:
goal
↓
available tools
↓
tool descriptions + schemas
↓
router / policy
↓
selected tool
↓
argument validation
↓
execute
↓
structured result
↓
state update
Each stage can fail independently.
That matters because “the agent chose the wrong tool” is too vague to debug.
We want to ask:
Was the right tool available?
Was its description discriminative?
Was the wrong tool too similar?
Were the arguments valid?
Did execution succeed?
Did the agent interpret the result correctly?
That gives us something measurable.
1. Start with a finite action space
Do not begin with a generic tool like:
def do_anything(action: str, payload: dict):
...
That gives the model an enormous semantic space to invent inside.
Prefer narrow tools:
def read_file(path: str) -> dict:
...
def edit_file(path: str, old: str, new: str) -> dict:
...
def run_tests(target: str) -> dict:
...
Now the model chooses among a small number of explicit capabilities.
This is the same principle we used earlier with structured actions:
probabilistic model
↓
small deterministic interface
The runtime should make invalid actions difficult to express.
2. Tool descriptions are part of the routing policy
Consider these descriptions:
read_file
Read a file.
search_files
Search files.
They are technically correct.
They are operationally weak.
A better version is:
read_file
Return the complete contents of one known file path.
Use this when the exact file path is already known.
Do not use it to discover files.
search_files
Search repository files for a filename, symbol, phrase or pattern.
Use this when the relevant file path is unknown.
Do not use it when the exact path is already known.
Notice what changed.
We did not merely describe what each function does.
We described when to choose it and when not to choose it.
That makes the routing boundary much clearer.
A useful tool description therefore contains:
what the tool does
when to use it
when not to use it
what inputs it expects
what output it returns
important side effects
3. Overlapping tools create routing ambiguity
Suppose a coding agent exposes:
search_code
find_code
lookup_symbol
repository_search
find_file
Five tools may look richer than one.
But if their semantic boundaries overlap, the model has a harder classification problem.
A useful way to think about tool selection is:
task description
↓
classification over available actions
If the classes overlap heavily, classification gets harder.
This suggests a practical rule:
Prefer fewer tools with sharp semantic boundaries over many tools with fuzzy boundaries.
This does not mean one giant tool.
It means each tool should represent a distinct action.
4. Tool count itself can become a problem
Imagine giving the model 80 tools.
Even if every tool is valid, several new costs appear:
more prompt/schema tokens
more candidate actions
more overlapping descriptions
more routing mistakes
more difficult debugging
The naive architecture is:
agent
↓
all 80 tools
A better architecture can be hierarchical:
agent
↓
choose domain
├─ repository
├─ browser
├─ database
└─ deployment
↓
choose tool
For example:
coding task
↓
repository tool group
↓
read_file / search_code / edit_file / run_tests
The first routing step reduces the second routing problem.
This does not automatically require another LLM call.
The first routing layer can be deterministic when context already tells us which tool family is relevant.
5. Tool schemas should constrain, not merely document
Consider:
{
"tool": "run_tests",
"args": {
"whatever": "pytest maybe"
}
}
That is structured JSON.
It is still a weak interface.
A stronger schema might be:
RUN_TESTS_SCHEMA = {
"target": str,
"mode": {"unit", "integration", "all"},
}
Then the runtime can reject:
{
"target": 42,
"mode": "fast-ish"
}
before anything executes.
The model proposes.
The runtime validates.
The executor acts only after validation succeeds.
6. Separate syntactic, structural and semantic validity
An action can be valid at several different levels.
Syntax
Is the model output parseable?
{"tool": "read_file", "args": {"path": "src/app.py"}}
Structure
Does it contain the required fields with correct types?
tool: string
args: object
Semantics
Does the action make sense?
Does the path exist?
Is the path inside the repository?
Is this operation allowed?
Policy
Is this action authorized in the current context?
read_file allowed
edit_file allowed
production_deploy denied
These checks belong in runtime code where possible.
Do not ask the model:
Is this tool call valid?
when the program can answer deterministically.
7. A small tool registry
We can make the action space explicit:
from dataclasses import dataclass
from typing import Callable
@dataclass
class Tool:
name: str
description: str
handler: Callable
required_args: dict[str, type]
TOOLS = {
"read_file": Tool(
name="read_file",
description=(
"Read one known file path. Use when the exact path is known. "
"Do not use for file discovery."
),
handler=read_file,
required_args={"path": str},
),
"search_code": Tool(
name="search_code",
description=(
"Search repository content when the exact file is unknown. "
"Use for symbols, phrases and patterns."
),
handler=search_code,
required_args={"query": str},
),
}
Now validation can be generic.
def validate_tool_call(tool_name: str, args: dict):
if tool_name not in TOOLS:
raise ValueError(f"unknown tool: {tool_name}")
tool = TOOLS[tool_name]
for name, expected_type in tool.required_args.items():
if name not in args:
raise ValueError(f"missing argument: {name}")
if not isinstance(args[name], expected_type):
raise TypeError(
f"{name} must be {expected_type.__name__}"
)
unknown = set(args) - set(tool.required_args)
if unknown:
raise ValueError(f"unexpected arguments: {sorted(unknown)}")
This is deliberately boring.
Boring boundaries are useful in agent systems.
8. Do not let the model invent tool names
A common failure looks like:
{
"tool": "read_repository_file_safely",
"args": {"path": "main.py"}
}
The model has invented a plausible tool.
Do not fuzzy-match that automatically to read_file unless you have explicitly designed and tested that behaviour.
The safe default is:
unknown tool
↓
reject
↓
return available tool names
↓
request a corrected action
For example:
def tool_error(tool_name: str):
return {
"ok": False,
"error": "unknown_tool",
"requested": tool_name,
"available": sorted(TOOLS),
}
The correction loop receives evidence rather than a vague message.
9. Tool results should be structured too
Tool inputs are often structured.
Tool outputs should be as well.
Weak result:
Looks like the tests mostly passed but two things went wrong.
Better:
{
"ok": False,
"passed": 184,
"failed": 2,
"errors": [
"tests/test_auth.py::test_expired_token",
"tests/test_api.py::test_unauthorized",
],
}
Why?
Because the result becomes the next observation.
If the next decision depends on fuzzy prose, we inject unnecessary ambiguity back into the loop.
A useful pattern is:
structured action
↓
deterministic execution
↓
structured observation
The model can still receive a human-readable rendering, but the runtime should retain the structured representation.
10. Separate tool selection from tool execution
Do not implement:
response = model(prompt)
execute(response)
Use:
response = model(prompt)
action = parse(response)
validate(action)
authorize(action)
result = execute(action)
This separation gives us hooks for:
logging
validation
permissions
rate limits
confirmation
retries
simulation
dry-run
policy enforcement
The model does not own the side effect.
The runtime does.
11. Tool routing can be deterministic
Not every tool choice needs an LLM.
Suppose the task state says:
state.current_phase == "verification"
and only one verification tool is legal:
run_tests
Then calling the model to choose between all tools adds unnecessary uncertainty.
We can simply route:
if state.current_phase == "verification":
allowed_tools = ["run_tests"]
The model chooses only when there is a genuine decision to make.
This is important.
Agent systems are not improved by maximizing the number of decisions delegated to the model.
They are often improved by minimizing unnecessary decisions.
12. Restrict tools dynamically
A planner may produce:
1. locate relevant code
2. inspect implementation
3. edit implementation
4. run tests
There is no reason to expose deployment tools during step 1.
We can narrow the action space by state:
def allowed_tools(state):
if state.phase == "discover":
return ["search_code", "read_file"]
if state.phase == "modify":
return ["read_file", "edit_file"]
if state.phase == "verify":
return ["run_tests", "read_file"]
return []
This reduces routing ambiguity and limits accidental side effects.
13. When should we add a dedicated router?
At small scale:
model sees tools
↓
model chooses tool
is often enough.
As the tool space grows, we can split routing:
user task
↓
router
↓
relevant tool subset
↓
agent policy
↓
exact tool
The router itself can be:
deterministic rules
classifier
embedding similarity
small local model
large language model
learned policy
The important thing is not the sophistication of the router.
It is whether routing accuracy improves enough to justify the additional machinery.
14. Measure routing instead of guessing
Create a small labelled dataset:
cases = [
{
"task": "Find where UserService is defined",
"expected_tool": "search_code",
},
{
"task": "Open src/users/service.py",
"expected_tool": "read_file",
},
{
"task": "Run the authentication unit tests",
"expected_tool": "run_tests",
},
]
Then measure:
tool-selection accuracy
unknown-tool rate
argument-validity rate
execution-success rate
recovery rate
mean tool calls per task
latency
cost
Now tool design can be benchmarked.
For example:
baseline descriptions
vs
explicit use / do-not-use descriptions
vs
state-restricted tool set
vs
dedicated router
This tells us whether the routing mechanism actually works.
15. Confusion matrices are useful for agents too
Suppose the expected tools are:
read_file
search_code
run_tests
We might discover:
predicted
read search tests
expected read 84 15 1
expected search 23 75 2
expected tests 1 2 97
Now the problem is obvious.
read_file and search_code overlap semantically.
Instead of changing the entire agent, we can improve those two interfaces.
This is much better than saying:
The model is bad at tools.
16. A tool can succeed while the task fails
Consider:
agent chooses search_code
↓
search_code succeeds
↓
returns irrelevant files
The tool executed successfully.
The action was still unhelpful.
So we need to distinguish:
routing success
argument validity
execution success
observation usefulness
goal progress
These are different metrics.
A production agent should not collapse all of them into one success=True flag.
17. Tool success should feed the loop controller
The previous post introduced progress and stopping conditions.
Tool results now become part of that mechanism.
Example:
result = execute(action)
state.tool_calls += 1
state.history.append((action, result))
if result["ok"]:
state.successful_tool_calls += 1
else:
state.failed_tool_calls += 1
But execution success alone is not progress.
For a coding agent:
read_file succeeded
may not mean anything improved.
Better progress signals might be:
relevant symbol located
patch created
failing tests decreased
requested behaviour verified
This connects tool design to the broader agent loop.
Where these ideas appear in real software
The pattern is generic, but the tool interfaces are domain-specific.
Coding agents
Typical tools:
search_code
read_file
edit_file
run_tests
inspect_diff
git_status
Common routing failures:
reading random files instead of searching first
editing before understanding dependencies
running the full test suite when a targeted test exists
repeatedly searching after the symbol is already known
Good design makes the distinction explicit:
search_code → discover location
read_file → inspect known location
edit_file → mutate known file
run_tests → verify behaviour
This is exactly the kind of environment where narrow tools and state-dependent exposure work well.
Browser agents
Typical tools:
navigate
click
type
select
extract
scroll
Common failure:
agent uses click when it should type
agent repeatedly clicks a disabled button
agent searches the page when navigation is required
Useful structured observations include:
{
"url": "...",
"element_found": True,
"element_enabled": False,
"page_changed": False,
}
That is much more useful than:
The click probably did not work.
Research agents
Typical tools:
search_web
open_source
find_in_source
extract_claim
record_citation
The important distinction is often:
discovery
vs
source inspection
vs
evidence extraction
A weak research agent repeatedly searches when it should already be reading the sources it found.
Tool boundaries can encode that progression.
Customer-support agents
Typical tools:
lookup_customer
lookup_order
check_refund_policy
issue_refund
escalate_case
Here the action boundary also becomes an authority boundary.
Reading an order and issuing a refund should not have equivalent permissions.
A useful architecture is:
read-only tools
↓
policy checks
↓
side-effect tools
↓
confirmation / authorization
The tool registry can carry those capabilities explicitly.
Data and analytics agents
Typical tools:
inspect_schema
run_query
profile_data
transform_data
validate_output
write_dataset
Common failure:
agent writes before validating
agent reruns expensive queries unnecessarily
agent chooses transformation tools before inspecting schema
State-restricted tool sets are especially useful here.
DevOps agents
Typical tools:
inspect_logs
check_metrics
restart_service
deploy
rollback
These tools have radically different risk levels.
The agent should not see them as flat alternatives.
A safer action hierarchy is:
observe
↓
diagnose
↓
propose remediation
↓
authorize
↓
execute
↓
verify
restart_service and rollback may require stronger gates than inspect_logs.
The model can propose them.
The runtime still owns permission.
Application matrix
| Software | Useful tools | Common routing error | Useful restriction |
|---|---|---|---|
| Coding agent | search, read, edit, test | edit before discovery | phase-specific tools |
| Browser agent | navigate, click, type, extract | repeat ineffective action | element/state validation |
| Research agent | search, open, extract, cite | search instead of inspect | discovery vs evidence phase |
| Support agent | lookup, policy, refund, escalate | side effect too early | permission tiers |
| Data agent | schema, query, transform, validate | transform before inspect | workflow/state gating |
| DevOps agent | logs, metrics, restart, rollback | remediation before diagnosis | risk-based authorization |
The same underlying agent mechanism appears in all of them.
The useful tool boundaries differ.
A complete minimal tool-routing loop
Here is the basic shape:
def run_agent(goal, model, state):
while not state.done:
tools = allowed_tools(state)
prompt = build_prompt(
goal=goal,
state=state,
tools=tools,
)
raw = model(prompt)
action = parse_action(raw)
validate_tool_call(action.tool, action.args)
authorize(action, state)
result = TOOLS[action.tool].handler(**action.args)
observation = normalize_result(action.tool, result)
state.observe(action, observation)
if verify_goal(goal, state):
state.done = True
state.stop_reason = "goal_verified"
elif state.no_progress():
state.done = True
state.stop_reason = "no_progress"
return state
Notice what the model does not own:
which tools exist
whether arguments are valid
whether an action is authorized
how the tool executes
whether the goal is actually complete
That separation is one of the foundations of reliable agent software.
Debugging: the agent keeps choosing the wrong tool
Start with evidence.
Log:
task
tools shown to model
tool descriptions
selected tool
arguments
validation result
execution result
next observation
Then classify the failure.
Wrong tool, but descriptions overlap
Fix the tool boundaries.
Add explicit use when and do not use when language.
Correct tool does not exist
The action space is incomplete.
Adding prompt instructions will not manufacture a capability.
Tool exists but is rarely selected
Check whether its description is dominated by a more generic tool.
Tool selection is correct but arguments fail
This is a schema problem, not a routing problem.
Tool executes but result is useless
Inspect the arguments and observation structure.
Agent keeps bouncing between two tools
Feed tool history and progress into the loop controller.
Do not debug every tool problem as a prompting problem.
Debugging: the agent has too many tools
Measure selection accuracy as the tool set grows.
For example:
5 tools
10 tools
20 tools
40 tools
If routing degrades, test:
better descriptions
dynamic subsets
hierarchical routing
tool families
specialist agents
Do not immediately jump to multi-agent architecture.
A small deterministic routing layer may solve the problem.
Debugging: the model ignores tool results
This is often an observation problem.
Check whether the result is:
structured
short enough
relevant
explicit about success/failure
linked to the action that produced it
A result like:
{
"tool": "run_tests",
"ok": False,
"failed": 2,
"failures": [...],
}
is easier to reason over than a large raw terminal dump.
You can still preserve the raw output for debugging.
The model does not necessarily need all of it on every step.
Do you need an LLM router?
Probably not at first.
Try this order:
1. sharpen tool boundaries
2. reduce overlapping tools
3. validate schemas
4. restrict tools by state
5. add deterministic routing where obvious
6. benchmark routing accuracy
7. only then consider a learned/LLM router
This follows the same rule as the rest of this series:
Add the smallest mechanism that fixes a measured failure.
An experiment worth running
Create 100 representative tasks.
Label the correct first tool for each.
Then compare:
A: original descriptions
B: explicit use / do-not-use descriptions
C: B + state-restricted tools
D: C + dedicated router
Measure:
first-tool accuracy
valid-argument rate
successful execution rate
verified task success
mean tool calls
latency
cost
The interesting result may be that B or C beats D.
A more complicated router is not automatically a better router.
The deeper lesson
It is tempting to think of agent tools as plugins attached to an intelligent model.
A better mental model is:
agent intelligence
=
model policy
+
action space
+
observations
+
runtime constraints
Change the action space and you change the problem the model is solving.
A model choosing among:
read
search
edit
test
has a much easier problem than a model choosing among dozens of overlapping functions with vague descriptions.
That is not prompt engineering trivia.
It is system architecture.
What we have built so far
The series now looks like this:
00 agent loop
↓
01 structured actions + validation
↓
02 multiple candidates + ranking
↓
03 critique + revision
↓
04 planning + execution
↓
05 state + progress + stopping
↓
06 tools + routing + schemas
Each mechanism adds something different.
And none of them is automatically required.
The next problem appears once the agent works across longer tasks or multiple runs:
What happens when the agent needs information it no longer has in the current context?
That is where memory begins.
Next: Agents From First Principles 07
AI Agent Forgets Previous Work? Add Working, Semantic and Episodic Memory
We will separate three ideas that are frequently collapsed into one word:
state
memory
retrieval
Then we will build each one independently and look at where they appear in coding agents, research systems, support software, long-running automation and personal assistants.