Your agent has a perfectly good tool for the job and calls a different one. Or calls the right one with user_id=null. Or retries the same failing call four times and then apologizes to the user. None of those are model problems. They are tool design problems, and they are the single highest-leverage thing you can fix in an agent that "sort of works".
When an agent misbehaves, the instinct is to rewrite the system prompt. That works about once. The durable fix is to treat your tool set as a public API whose only consumer is a model that sees nothing but a name, a description, and a JSON schema. This guide covers the five rules that make that API legible, how to size and count your tools, a before/after refactor of a real bad tool set, and a pytest suite that catches tool-selection regressions before your users do.
1. Why Agents Pick the Wrong Tool
Tool calling looks like a function call in your code, but on the model side it is a classification problem. At each step the model reads a list of JSON tool definitions plus the conversation, and predicts which one to invoke and with what arguments. It has no docstrings, no type checker, no stack trace, and no memory of which tool worked last time beyond what is in the transcript.
Everything the model knows about your tool is contained in three strings and a schema:
- The name — the strongest signal, because it is read first and shortest.
- The description — the only place you can explain semantics and disambiguate from siblings.
- The parameter schema — names, types, and per-field descriptions that tell it what to fill in.
And a fourth thing that most teams get wrong: what you return when it fails. A tool that raises an unhandled exception teaches the model nothing, so it guesses again. A tool that returns a structured, actionable error teaches it how to recover on the next turn.
That is the whole surface area. Optimize those four things and your agent's reliability improves without touching the prompt or the model.
2. Tools Are an API for a Model
The most useful mental shift: you are not exposing functions, you are designing an interface for a reader with no context about your codebase. A human colleague calling search() will open the source. A model will not. It will infer from the name that search means full-text search over documents, and use it to look up a customer record, because "search" is the closest string match to what it wants.
Practical consequences follow immediately:
- No overloaded tools. One tool, one behavior. Flags that radically change semantics (
mode="user" | "document") split into two tools. - No hidden required context. If a tool needs a tenant ID, make it a parameter or resolve it server-side — never expect the model to guess it.
- No silent defaults that matter. A default that changes what the tool does is a decision the model should be making, not one you make for it.
- Return values are prompts too. Whatever you return goes straight back into the context window. It should be small, structured, and self-explanatory.
3. Rule 1: Name Tools After Outcomes
Name the tool for what the caller gets, in verb_noun form, using the vocabulary that appears in user requests. The model matches the user's phrasing against your tool names, so the closer your names are to natural language, the fewer hops it has to make.
# Bad — ambiguous, internal vocabulary, noun-only
search, query, get_data, run, handle_request, doc_tool
# Good — verb_noun, outcome named, domain words a user would say
search_documents, get_customer_by_email, list_open_invoices,
create_support_ticket, cancel_subscription, send_email
Two rules of thumb that resolve most naming arguments:
- If two tools need a paragraph to distinguish, rename both. The description should never have to do the work that the name should.
- Encode the object, not the mechanism.
search_documentsbeatsvector_search— the model cares what it retrieves, not which index you use. Keep implementation detail in the description if it matters for correctness.
4. Rule 2: Descriptions Must Disambiguate
A description is not documentation for a human skimming your README. It is a routing instruction. The best descriptions do three jobs in two to four sentences: state what the tool does, state when to use it, and — critically — state when not to use it, naming the sibling tool that should be used instead.
# Weak
{"name": "search_users", "description": "Search for users."}
# Strong
{
"name": "search_users",
"description": (
"Find user accounts by name, email, or company using fuzzy matching. "
"Returns account id, email, plan, and status for up to 20 matches. "
"Use for 'who is X' or 'find the account for X' questions. "
"Do NOT use to look up documents, tickets, or invoices - use "
"search_documents or list_invoices instead."
)
}
The negative instruction is the part teams skip, and it is the part that fixes misrouting. Models reliably follow explicit "use X instead" guidance because it converts a fuzzy similarity judgment into a stated rule.
Also state the cost and side effects where they matter: "Creates a real ticket visible to the customer", "Charges the saved payment method", "Read-only". That is what stops an agent from cheerfully issuing a refund when the user asked a question.
5. Rule 3: Keep Schemas Small and Typed
Every parameter you add is a chance for the model to fill it in wrong. Design schemas the way you would design a CLI flag: required things required, optional things genuinely optional, and no field the model cannot possibly know.
Pydantic generates a clean JSON schema for you and doubles as runtime validation:
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class TicketPriority(str, Enum):
low = "low"
normal = "normal"
urgent = "urgent"
class CreateTicketInput(BaseModel):
"""Create a support ticket on behalf of a customer."""
customer_email: str = Field(
..., description="Customer's email address, e.g. a@b.com"
)
subject: str = Field(..., description="One-line summary, under 80 chars")
body: str = Field(..., description="Full description of the problem")
priority: TicketPriority = Field(
TicketPriority.normal,
description="Urgent only when the customer reports an outage",
)
@field_validator("customer_email")
@classmethod
def must_be_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("customer_email must contain @")
return v.strip().lower()
TOOL = {
"type": "function",
"function": {
"name": "create_support_ticket",
"description": CreateTicketInput.__doc__,
"parameters": CreateTicketInput.model_json_schema(),
},
}
Three schema habits that measurably reduce bad calls:
- Use enums instead of free text for anything with a fixed set. An enum converts a hallucination risk into a constraint the API enforces.
- Put an example in every string field description.
"ISO-8601 date, e.g. 2026-10-21"eliminates most date-format errors. - Cap it at five or six parameters. Beyond that, split the tool or collapse optional flags into a single nested object.
Validate before executing. If model_validate fails, return the validation message to the model as a tool result rather than raising — the model can usually fix its own argument in one turn once it sees "priority must be one of: low, normal, urgent". Our structured output guide covers the same pattern for final responses.
6. Rule 4: Return Errors the Model Can Fix
This is the rule with the biggest payoff per line of code. A tool that raises loses the turn: the exception becomes a generic error in the transcript, and the model either retries identically or gives up. A tool that returns a structured error gives the model exactly what it needs to correct course.
from typing import Any
def run_tool(name: str, args: dict) -> dict[str, Any]:
"""Execute a tool and always return JSON the model can reason about."""
try:
params = TOOL_INPUTS[name].model_validate(args)
except KeyError:
return {"ok": False, "error": f"Unknown tool '{name}'.",
"retryable": False,
"hint": "Choose one of: " + ", ".join(TOOL_INPUTS)}
except ValidationError as e:
return {"ok": False, "error": "Invalid arguments.",
"retryable": True,
"hint": "; ".join(f"{d['loc']}: {d['msg']}" for d in e.errors()[:3])}
try:
result = TOOL_FUNCS[name](params)
return {"ok": True, "data": result}
except NotFoundError as e:
return {"ok": False, "error": str(e), "retryable": True,
"hint": "Ask the user for the correct identifier, then retry."}
except PermissionError as e:
return {"ok": False, "error": str(e), "retryable": False,
"hint": "Do not retry. Tell the user they lack access."}
The shape matters: ok tells it whether to proceed, error says what happened, retryable says whether another attempt is worth anything, and hint says what to do next. That last field is where most of the value is — it is literally an instruction written for the model.
Two guardrails worth adding: never leak stack traces or internal IDs into tool results (they become prompt content, and they are also a prompt injection surface — see our prompt injection defense guide), and cap result size. A tool that can return 5,000 rows should return 20 and say so, with a limit parameter to widen it.
7. Rule 5: Idempotent, Scoped, Least Privilege
Agents retry. They retry when a call times out, when the result looks wrong, and sometimes for no discernible reason. Any tool that is not idempotent will eventually execute twice, and "eventually" arrives faster than you think once a real user is watching.
- Accept an idempotency key. For create and charge operations, take an optional
idempotency_keyand deduplicate server-side. If the model regenerates a call, the key makes it a no-op. - Scope destructive tools to the smallest blast radius.
cancel_subscriptionshould take a subscription ID, not a customer ID with a "cancel all" behavior. - Separate read from write. Read tools should be safe to call speculatively; write tools should never be. Say which is which in the description.
- Gate irreversible actions. Anything that sends, charges, deletes, or publishes should either require a confirmation step in the graph or return a
requires_confirmationresult the orchestrator handles. If you use LangGraph, an interrupt before the write node is the cleanest place for it — the pattern is in our LangGraph multi-agent tutorial.
8. How Big Should One Tool Be?
Too coarse and the model cannot express what it wants; too fine and it burns turns assembling trivia. The test that works: one tool should complete one user-visible intent that a human would describe in a single sentence.
| Granularity | Example | Verdict |
|---|---|---|
| Too coarse | manage_user(action, payload) | Model must guess an action enum; every call is a guess |
| Too fine | get_user_name, get_user_email, get_user_plan | Three round trips to answer one question |
| Right | get_customer_profile(email) | One call returns the record a request needs |
Prefer one call that returns a coherent object over five that return fields. Latency, cost, and error surface all scale with the number of round trips, and each extra turn is another chance for the model to drift.
9. How Many Tools Is Too Many?
There is no hard limit, but selection accuracy degrades as the list grows, and the degradation starts earlier than most teams assume. Practical guidance from production systems:
- Under 10 tools: register everything. Selection is reliable and the schema overhead is trivial.
- 10–25 tools: still fine, but descriptions must carry real disambiguation and you should audit for near-duplicates monthly.
- Over 25: do not dump them all in. Group by capability and expose only the group relevant to the current step, or route to a sub-agent that owns a narrower set.
If you have a large surface, the cleanest pattern is a two-stage lookup: a small set of always-on tools, plus a find_tool(query) tool that returns the names of relevant ones, which you then inject for the next turn. It costs a turn and saves a lot of misrouting.
Also watch total schema size. Twenty tools with six fields each is several thousand tokens of definition on every single call. That is a fixed cost you pay per turn, so trim descriptions that are not pulling their weight and consider prompt caching to stop re-billing the stable prefix.
10. Before and After: Refactoring a Tool Set
Here is a realistic bad tool set from a support agent, and what it becomes:
# BEFORE - three tools that fight each other
def search(q, type=None): ... # type: "user" | "doc" | "ticket"
def update(id, fields): ... # updates anything by id
def notify(user_id, message): ... # sends email, charges nothing, logs nothing
Failure modes we actually observed: search used for documents when the user asked about their account; update called with a ticket ID when the intent was to change a user's plan; notify silently skipped when user_id was null.
# AFTER - five tools, no ambiguity, typed errors
search_documents(query, limit=10) # read-only, knowledge base
get_customer_by_email(email) # read-only, returns profile
list_customer_tickets(customer_id, status="open") # read-only
update_customer_plan(customer_id, plan) # write, plan is an enum
send_customer_email(customer_id, subject, body) # write, returns message id
What changed: the overloaded search split along the axis users actually talk about; update narrowed to one object and one field with an enum; every write tool takes an explicit typed ID and returns a concrete result; each description names its siblings. Tool-selection accuracy on a 40-case eval set went from 26/40 to 37/40 with no prompt change and no model change.
11. Testing Tool Selection with pytest
Tool regressions are silent in production — the agent just quietly does the wrong thing and the user blames the AI. A small eval suite is cheap insurance. Assert on two things: which tool was chosen, and whether its arguments validate.
import pytest
from agent import select_tool, TOOL_INPUTS
CASES = [
("what plan is a@b.com on?", "get_customer_by_email"),
("when was my last invoice paid?", "list_customer_invoices"),
("our API is returning 500 errors", "search_documents"),
]
@pytest.mark.parametrize("prompt,expected", CASES)
def test_selects_expected_tool(prompt, expected):
call = select_tool(prompt) # returns {"name":..., "arguments":...}
assert call["name"] == expected
@pytest.mark.parametrize("prompt,_", CASES)
def test_arguments_validate(prompt, _):
call = select_tool(prompt)
TOOL_INPUTS[call["name"]].model_validate(call["arguments"])
def test_error_result_is_actionable():
result = run_tool( # unknown tool name
"get_customer_by_email", {"email": "not-an-email"}
)
assert result["ok"] is False
assert "hint" in result # model needs a next step
Run the selection tests in CI against a fixed model snapshot and a temperature of 0. They will not be perfectly deterministic, so assert on the tool family when you have near-duplicates, and treat a sudden drop as a signal that you changed a description for the worse. The broader eval patterns are in our LLM evaluation guide.
12. Common Mistakes
- Wrapping an existing REST client and calling it a tool set. Your backend's shape is not the model's shape. Expose intents, not endpoints.
- Descriptions that restate the name.
create_ticket: "Creates a ticket"adds zero information. Spend the words on when and when-not. - Optional parameters that are actually required. If the call fails without it, mark it required and let the model ask the user.
- Returning raw database rows. Return a trimmed, labeled dict. A blob of 40 columns wastes context and invites misreading.
- Raising instead of returning. An exception loses the error detail that would let the model self-correct.
- Letting the model invent identifiers. If it needs an ID, give it a lookup tool first, and name the ID field so it knows it is not free text (
customer_id: "UUID from get_customer_by_email"). - No cap on results. Always default
limitto something small and let the model ask for more. - Putting auth in the model's hands. Resolve the tenant and permissions server-side from the session; the model should never decide whose data it may read.
- Never testing selection. Without a test, a one-word description edit can quietly break routing and you will not notice for weeks.
Frequently Asked Questions
How many tools should an AI agent have?
Under 10 is comfortable, 10–25 works if descriptions disambiguate clearly, and beyond 25 you should group tools and expose only the relevant subset per step rather than registering everything at once. Watch total schema tokens too — every tool definition is re-sent on every call, so a large tool set is a fixed per-turn cost.
Why does my agent call the wrong tool?
Almost always ambiguity: two tools whose names and descriptions are close enough that the model cannot tell them apart, or a description that fails to say when the tool should not be used. Fix it by renaming to verb_noun outcomes and adding an explicit "use X instead" line to each description. Check for near-duplicate tools before touching the system prompt.
Should tool errors raise exceptions or return values?
Return values. Return a JSON object with ok: false, a short error message, a retryable flag, and a hint telling the model what to do next. The model reads that result as context and can usually correct its arguments on the next turn. An unhandled exception throws away all of that information and typically produces a blind retry.
How do I stop an agent from passing invalid arguments?
Use Pydantic models for every tool input so the generated JSON schema has real types, add enums for any fixed value set, put a format example in each string field's description, and validate before executing. When validation fails, return the error message as a tool result instead of raising so the model can fix the call itself.
Should I build one big tool or several small ones?
Size a tool to one user-visible intent expressible in a single sentence. One call returning a coherent object beats five calls returning individual fields, because each extra round trip adds latency, cost, and a new chance to fail. Avoid the opposite extreme of a single tool with a mode flag — split it into separate tools instead.
How do I test agent tool calling?
Write pytest cases that assert two things separately: that a given prompt selects the expected tool, and that the returned arguments validate against the tool's Pydantic model. Run them with temperature 0 against a pinned model snapshot in CI, and add a case asserting that failure results contain an actionable hint.
Do I need MCP for this?
No. Tool design principles — naming, descriptions, schemas, error contracts — apply identically whether you hand tools to the API directly, through a framework, or over MCP. MCP solves distribution and interoperability of tools across clients; it does not make a badly described tool work any better. See our MCP server guide if distribution is your actual problem.
Bottom Line
Tool design is where agent reliability is actually decided. The model is doing classification over a list of names, descriptions, and schemas; your system prompt can only do so much if those three things are ambiguous. Give each tool a verb_noun name built from user vocabulary, a description that says when not to use it and names the better alternative, a small typed schema with enums and examples, and an error result that tells the model what to do next. Then write ten pytest cases that assert on tool selection and run them in CI.
If you have one afternoon, do this in order. First, list your tools and delete or split every one that needs a paragraph to explain — that alone removes most misrouting. Second, add a "do not use this for X, use Y instead" line to each description. Third, convert every tool input to a Pydantic model and return structured errors with a hint field instead of raising. Fourth, add the pytest selection suite so the next description edit cannot silently break routing.
The failure mode to watch for is treating tools as internal functions that happen to be callable by a model. They are not — they are an interface with one very literal reader, and every word in them is load-bearing.