- Book: Agents in Production — Building, Tracing, and Shipping Multi-Step AI You Can Trust
- Also by me: Observability for LLM Applications — the companion book in The AI Engineer's Library (2-book series)
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You gave the agent two tools. One looks up an order by ID. One cancels an order. The user types "where is my order 4821?" and the agent calls cancel_order. Nobody wrote a bug. The loop is correct. The model read two descriptions that both said "handle an order" and picked the wrong one, because from where the model sits, they were interchangeable.
The model never sees your Python. It sees a JSON Schema and a string description of each tool, and it routes on that alone. When the routing is wrong, the fix is almost never in the loop code. It is in the schema. Four levers decide whether a tool gets called correctly: the description, the parameter schema, strict validation, and the tool_choice knob.
The model routes on the description
Anthropic's own tool-use guidance calls the description the single biggest lever in tool quality, and you can watch it happen. A tool with a vague description gets skipped or misfired. A tool that says exactly when to reach for it gets picked.
Here is the difference on the same function.
# Vague: the model has to guess.
{
"name": "get_order",
"description": "Get an order.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
},
"required": ["order_id"],
},
}
# Explicit: says what, when, what it returns.
{
"name": "get_order",
"description": (
"Look up a single order by its ID and return "
"its status, items, and shipping tracking. "
"Use this for ANY question about where an order "
"is, what is in it, or when it will arrive. "
"This is READ-ONLY and never changes the order."
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "e.g. '4821', digits only",
},
},
"required": ["order_id"],
},
}
Write the description the way you would onboard a new hire. Say what the tool does, when to use it, what it returns, and any gotcha. The "READ-ONLY" clause is doing real work: it tells the model this is the safe default for a status question, which pushes it away from the destructive neighbor.
Make the neighbors impossible to confuse
The misfire at the top of the post is a boundary problem. Two tools sat next to each other and the model could not tell them apart. You fix that by making the boundary explicit in both descriptions, not just one.
{
"name": "cancel_order",
"description": (
"Cancel an order that has NOT yet shipped. "
"This is a WRITE and cannot be undone. "
"Only call this when the user explicitly asks "
"to cancel, refund, or stop an order. "
"For status questions, use get_order instead."
),
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"reason": {
"type": "string",
"description": "User's stated cancel reason",
},
},
"required": ["order_id", "reason"],
},
}
Two things changed. The description names its sibling ("use get_order instead"), and it added a required reason field. That second one is a design trick: a destructive tool that demands a justification argument is harder to trip by accident, because the model has to produce a real reason before it can call, and a status question does not supply one.
The schema is a contract, so tighten it
Every field you leave loose is a field the model can fill with garbage. additionalProperties: false stops the model from inventing parameters you never declared. Enums collapse a free-text field into a fixed set. Bounds on numbers keep a "page size" of 5 from becoming 5000.
{
"name": "search_orders",
"description": (
"Search a customer's orders by status and date "
"range. Returns up to `limit` matching orders."
),
"input_schema": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["open", "shipped",
"delivered", "cancelled"],
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 50,
"default": 10,
},
},
"required": ["status"],
"additionalProperties": False,
},
}
The enum is the highest-value line here. Without it the model will pass "in transit", "pending", "OPEN", and a dozen other strings your backend does not recognize. With it, the model is steered to one of four values you actually handle. Constrain at the schema so you do not have to validate in the tool.
Strict validation catches the ones that slip through
A schema describes intent. It does not, by itself, guarantee the arguments arrive well-formed. On Anthropic's API the input comes back as a parsed dict in block.input, and you should validate it against the schema before you run anything. Pydantic is the clean way to do that in Python.
from pydantic import BaseModel, Field, ValidationError
class SearchOrdersArgs(BaseModel):
status: str = Field(pattern="^(open|shipped|"
"delivered|cancelled)$")
limit: int = Field(default=10, ge=1, le=50)
def run_search_orders(raw_input: dict) -> str:
try:
args = SearchOrdersArgs(**raw_input)
except ValidationError as e:
return f"ERROR: invalid arguments: {e}"
return do_search(args.status, args.limit)
Notice the failure mode. A validation error does not raise out of the loop. It becomes a string that goes back to the model as the tool result. The model reads ERROR: invalid arguments, sees which field it got wrong, and retries with a legal value on the next step. An exception that escapes the loop kills the session and shows the user a 500. A stringified error is a second chance.
OpenAI exposes this at the API layer with strict: True, which constrains generation to the schema at the token level so a malformed argument never gets produced in the first place. It requires additionalProperties: false and every property listed in required. Anthropic does not expose an identical flag, so on Claude you validate on receipt with the pattern above. Same goal, different seam.
The tool_choice knob
By default Claude decides whether to call a tool at all. That default (tool_choice: {"type": "auto"}) is right for a conversational agent. But there are moments where "decide for yourself" is exactly the wrong instruction, and the API gives you three other modes.
import anthropic
client = anthropic.Anthropic()
resp = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
tools=TOOLS,
# Force the model to call exactly this tool.
tool_choice={"type": "tool", "name": "extract_fields"},
messages=[{"role": "user", "content": doc_text}],
)
The four modes, and when each earns its place:
-
auto— the model calls a tool or answers in text, its call. The default for chat-style agents. -
any— the model must call one of the tools, but picks which. Good when text-only replies are never valid, like a router that has to dispatch somewhere. -
tool— you name the exact tool. This turns the model into a structured extractor: one document in, one guaranteed well-formed call out. No routing decision to get wrong. -
none— tools are visible but forbidden this turn. Handy when you want a plain-language summary of what the agent just did without it firing another call.
Forcing a specific tool is the cleanest fix for a whole class of misfires, because you have removed the routing choice entirely. When you already know which tool must run, do not ask the model to decide. Tell it.
Keep the surface small
The last lever is subtraction. A model choosing among five sharp tools routes better than one choosing among twenty fuzzy ones. Every tool you add is another line in the routing table the model reads on every single turn, and overlapping tools are where misfires breed. If two tools could plausibly answer the same request, either merge them or rewrite both descriptions until the boundary is a wall. When in doubt, ship fewer tools with tighter descriptions.
None of this is loop code. It is schema design, and it is where agents actually go wrong in production. The while loop was correct on day one. The tools are what you tune for the rest of the project.
If you want the full build from the bare loop up through tool design, guardrails, and shipping, Agents in Production in The AI Engineer's Library is the walkthrough. Its companion, Observability for LLM Applications, covers the tracing and evals that tell you which tool is misfiring once real traffic hits it. Tighten the schema first; the trace will show you which one to tighten next.

Top comments (0)