When an agent misbehaves, the first instinct is to edit the system prompt or move up a model tier. In the reviews we run, that is rarely where the defect lives. The agent called the wrong tool because two tools had near-identical names. It passed a malformed filter because the schema allowed free text. It looped four times because the error it got back said 400 Bad Request.
The tool layer is an interface the model has to program against, at inference time, with no documentation except what you put in the schema. Treat it like an API you are shipping to an inexperienced integrator who never reads a changelog.
Name tools for the decision, not the implementation
The model picks a tool by reading names and one-line descriptions. Ambiguity there costs you more accuracy than any prompt rewrite will recover.
A pattern that holds up: verb_object scoped to the domain, with the disambiguating detail in the name itself.
- Bad:
search,search_v2,query_db,lookup - Better:
search_support_articles,search_customer_orders,get_order_by_id
If two tools need a sentence to tell apart, the model will need it too — and it only gets one. Where we see confusion in traces, the fix is usually merging the two tools or renaming both so the boundary is in the first three words.
Keep the active set small. Agents degrade as the tool list grows; past roughly a dozen tools we start looking at routing — a retrieval step over tool descriptions, or subagents that each own a narrow set. A LangGraph node that exposes four relevant tools beats one node exposing thirty.
Make the schema carry the constraints
Every constraint you express in prose, the model may ignore. Every constraint you express in the schema, the validator enforces before a bad call reaches your system.
- Use enums for anything with a closed set.
status: "open" | "closed" | "pending"removes a whole class of retries versusstatus: string. - Make required fields required. Optional-everything schemas invite under-specified calls.
- Avoid free-text fields that are secretly a DSL. If a parameter accepts a filter expression or raw SQL, you have moved the hard part into a place you cannot validate. Decompose it into typed fields.
- Describe units and formats in the field description:
created_afteras an ISO-8601 date,limitwith a documented maximum,amountin cents. - Keep nesting shallow. Deep nested objects produce more malformed arguments than flat ones, in our traces by a wide margin.
In LangChain, this means your Pydantic argument model is the real prompt. Field names and descriptions are read by the model on every call; they deserve the same review attention as the system message.
Shape responses for the next decision
A tool that returns a raw API payload is dumping thousands of tokens of irrelevant structure into the context window, and the agent has to re-derive meaning from it each turn.
Return what the next step needs:
- Project the fields that matter. Drop internal IDs, audit metadata, and nulls.
- Paginate with intent: ten results and a
total_matches: 418count teaches the agent to narrow the query. Returning all 418 teaches it nothing and costs you the context budget. - Prefer compact, readable text or small JSON over nested envelopes.
- When a result set is empty, say why:
No orders found for customer 4120 after 2024-01-01. The customer has 7 orders before that date.That single line prevents the retry loop.
This is also a cost lever. On one agent we reviewed, trimming two tool responses cut average tokens per run by 38% with no change in task success — a change we only shipped because the eval delta confirmed it.
Write errors the model can act on
An error is a prompt. The question it must answer is: what should the caller do differently on the next attempt?
- Bad:
ValidationError: invalid input - Good:
region must be one of: us-east, us-west, eu-central. Received "USA".
Distinguish the three cases explicitly. Retryable (rate limit, timeout) — say so, and let the graph handle backoff rather than the model guessing. Fixable by the caller (bad argument, missing permission scope) — name the field and the valid values. Terminal (the record does not exist, the action is not allowed for this user) — say it is terminal, so the agent stops instead of burning four more turns.
Never let a raw stack trace reach the model. It is expensive, it leaks implementation detail into the context, and it is not actionable.
Side effects need a confirmation boundary
Read tools and write tools are different risk classes. For anything that spends money, emails a customer, or mutates a record, the tool should be narrow, idempotent where possible, and routed through an interrupt for approval. The schema should make the blast radius visible in the arguments — refund_order(order_id, amount_cents) is reviewable; execute_action(payload) is not.
Eval the interface, not just the agent
You can measure tool design directly, and you should, before changing models.
Build a small fixture set from real traces and score per-call:
- Selection accuracy — did it pick the right tool for the task?
- Argument validity — did the call pass schema validation on the first attempt?
- Retries to success — how many calls before a usable result?
- Tokens per successful call — request plus response.
Run that suite against the current schema, then against a renamed or tightened one. In practice, the biggest single-change wins we see are enum constraints on previously free-text fields and merging two confusable tools. Both are cheap. Both move first-attempt validity by double digits.
If selection accuracy is already high and argument validity is low, your problem is the schema. If selection accuracy is low, it is the names and the tool count. That split tells you where to spend the afternoon — and it is a more reliable signal than any impression formed from reading a handful of traces.
If your agent is looping, retrying, or picking the wrong tool and you want a second read on the interface before you rebuild it, tell us what it does and we will reply with questions.