An Agent Called the Right Tool, Got a 200, and Still Leaked Someone Else's Invoice
Why this matters
Your agent picks the right tool. Right arguments, right types, no exception anywhere in the trace. And the output is still wrong. When that happens, everyone's first move is to blame the model or start rewriting the system prompt. I've done it too. Half the time that's the wrong direction entirely — the actual defect is sitting in the tool's description, one sentence written for a human colleague that quietly steers an LLM off a cliff mid-inference.
The incident
A support agent had two tools on the shelf: get_invoice(invoice_id) and get_invoice_by_order(order_id). Both existed, both were type-checked, both worked in every test anyone had written. Then a customer reported something that should have been impossible: the agent showed them someone else's invoice total. No error. No exception. Nothing in the trace that screamed 'wrong tool call.' The agent called get_invoice, passed a string, got a 200 back, and answered with total confidence — using the wrong invoice.
Three days went into this before anyone thought to reread the tool description instead of the model's reasoning trace one more time. The trace looked clean every single time. The interface didn't.
The offending line
Here's the whole description: "Fetch an invoice by ID." Parameter invoice_id: string. That's a completely normal docstring. A human engineer reading it, unsure what 'ID' means, goes and checks the codebase or pings someone in Slack. A model can't do either of those things. It gets one shot at inference time — the text in front of it, plus whatever numeric-looking value happens to be sitting in context, and that's the whole budget.
Upstream, invoices carry two separate identifiers: an internal database row ID (an integer — 40218) and a customer-facing invoice number (a string — INV-40218). Both read as a plausible 'ID' to a model that just pulled a number out of a database result. The description never said which one the tool wanted, so the model guessed off whatever number happened to be nearby — sometimes the row ID, sometimes the invoice number. Both are syntactically valid, so the call never errored. It just quietly returned a different customer's record.
Name the pattern
This is API design. Full stop. Vague parameter semantics and two tools that overlap in scope are the exact bugs that plague undocumented internal APIs for human engineers too — the difference is a human hitting that ambiguity stops. Checks the spec, pings the API owner, finds an example call somewhere. A model mid-inference does none of that. It picks whatever interpretation is statistically most plausible given the description and the surrounding context, and commits — in full confidence, in one forward pass, with no chance to double back.
- ▹A valid-but-wrong input rarely throws an error, so the failure shows up downstream, dressed up as a 'reasoning' problem.
- ▹Whoever's debugging it reads the chain-of-thought, sees a story that hangs together, and blames the model.
- ▹The real defect — a contract that never specified which ID it wanted — never gets reviewed, because nobody treats tool descriptions like code that ships.
The fix, concretely
The rewrite did three things: named the exact format the tool expected, added an example that disambiguates it, and pointed at the sibling tool so the model has a decision rule instead of a coin flip.
- "Fetch an invoice by ID."
- invoice_id: string
+ "Fetch an invoice by its customer-facing invoice number (format: INV-#####,
+ e.g. INV-40218). This is NOT the internal database row ID. If you only
+ have an order ID, use get_invoice_by_order(order_id) instead."
+ invoice_id: string, pattern: "^INV-[0-9]{5}$"The pattern constraint did as much work as the prose, maybe more — it hands the model a concrete shape to match instead of a paragraph to half-remember. After the change, wrong-invoice calls disappeared from the exact test set that used to fail intermittently. And the model started routing to get_invoice_by_order on its own whenever it only had an order number — no prompt change required.
This compounds with scale
Fewer tools in view means a sloppy description gets away with it more often, papered over by lucky context. The pattern you're using right now to read this — deferred tool search, where full schemas load on demand through something like ToolSearch — is exactly where this is heading. Agents routinely sit in front of dozens or hundreds of tools, with only a handful ever loaded into context at once. That's a real fix for scaling and latency. It also raises the stakes on description quality instead of lowering them. When a tool's full schema isn't even in context until the model decides to go fetch it, that one-line description in the tool list is doing all the disambiguating work — there's no neighboring tool definition sitting there for the model to compare against and catch the mismatch itself.
The reframe
Before you touch the prompt or start swapping models, go review your tool descriptions the way you'd review a pull request. One question, per tool: could two different valid-looking inputs both satisfy this description, and would the model have any way to tell them apart? If the answer's yes, you don't have a model problem. You have an underspecified interface, and the fix is the same one you'd reach for on any API — name the format, constrain the type, and say plainly when to reach for the other tool instead of this one.
Extend your knowledge
- ▹Audit your own tool set: for each tool, write down the one ambiguous input that would still pass validation while calling the wrong logic.
- ▹Working in Claude tool use or MCP? Add schema constraints — enum, pattern, minLength — anywhere the description is currently doing that work alone in prose.
- ▹Read Anthropic's tool-use docs on writing effective tool descriptions. It's the closest thing out there to an API style guide for exactly this problem.
- ▹If you haven't already, go back to the earlier lesson on deferred tool loading — it covers the mechanics this one assumes you already know.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.