Unit Tests for Tools
Most agent bugs are tool bugs. Test schemas, authz, timeouts, idempotency, and fixtures without the model — then eval whether the policy called the right name.
Most “agent bugs” are tool bugs wearing a trench coat: bad argument parsing, missing authz, unbounded queries, exceptions swallowed into empty strings the model then treats as success. The highest leverage eval you will ever write does not use the LLM. It unit-tests the tools as if they were a public API — because to the model, they are.
This is still an eval lesson. You are measuring the actuator. If get_invoice returns another tenant’s row, no golden on the policy will save you. If refund is not idempotent, a retry is a double pay. Agent traces will look “reasonable.” The world will not.
Unit-test tools without the model. Schema, authz, bounds, timeouts, idempotency, error shape. If a tool returns a 30-page HTML string, write a test that it does not. Point tools at a fake world. Live Stripe in unit tests is how CI becomes flaky and how you refund a real card from a developer laptop.
Once tools are correct, agent evals can assume get_job(17) works and focus on whether the policy called it, with which args, and whether forbid lists held. That split is the whole point: do not use the model as the only test runner for code you wrote in Python.
What to test on every write tool
| Surface | Assertion | Fail looks like |
|---|---|---|
| Schema | Wrong types rejected with a code | invoice_id: "x" hits the DB |
| Authz | Actor can only see own rows | User a reads invoice 2 |
| Bounds | Caps, pagination, no SELECT * without limit | Unbounded search |
| Timeouts | Hung dependency becomes an error object | Empty string, model retries forever |
| Idempotency | Same key does not move money twice | Double refund |
| Error shape | ok, code, error — not "" | Model invents success |
| Fixture world | Fake users, fake cents | Live network in the unit test |
Most agent bugs are tool bugs. Do not use the model as the only test runner.
Test the tool, then the policyThe model is an untrusted client of this API. You would not ship public HTTP without tests. Do not ship tools without them. Permission checks live in the implementation (and its tests), plus any gateway — not in a system prompt that says “please don’t peek.”
Typed errors matter for evals. If the tool returns "" on PERMISSION_DENIED, the policy cannot be scored for a correct refusal versus a hallucinated invoice. Return PERMISSION_DENIED and a golden can expect that code in the observation and a refusal in the final.
Walkthrough: cross-tenant read and a duplicate refund
Fixture: user a owns invoice 1 (1999 cents). User b owns invoice 2 (5000 cents).
get_invoice("a", 1) is ok. get_invoice("a", 2) is PERMISSION_DENIED — not a row. get_invoice("a", "x") is INVALID_ARGUMENT. Those three asserts are the privacy and schema eval for this tool. They run in milliseconds. They do not need a judge.
refund("a", 1, "k1", ledger) first time: duplicate False, cents stored under key k1. Second time, same key: duplicate True, ledger unchanged. If the agent retries because the model got impatient, money does not move twice. That is an eval of tool truth, not of prose.
When a later golden says “must not read other customers,” you still want this unit test. The golden can fail if the policy calls get_other_user. The unit test fails if get_invoice is itself confused about tenants. Defense in depth, both measured.
Timeouts and error shape are evals too. If Stripe hangs and the tool returns "", the model will retry, invent an invoice, or call refund “because the user is waiting.” A unit test that freezes a fake clock and asserts code: TIMEOUT with ok: False is cheaper than a judge that reads the essay. Same for HTML dumps: assert len(text) < N or that the type is JSON. The model is a client; clients deserve stable contracts.
Idempotency keys belong in the fixture. The test above uses k1. Production will generate keys from job id plus invoice id. The eval of the tool is: same key, second call, duplicate True, cents unchanged. The eval of the policy is: it sent a key at all. Do not mix those two fails in one fuzzy “refunds seem weird” ticket.
Authz tests should include the boring cases: own row, missing row, wrong type, other tenant, extra fields ignored. Attackers and models both send extra fields. If unknown keys change the query into a cross-tenant filter, you will only see it in a unit test that passes a surprise argument. Goldens on traces will not invent that argument unless you write the probe.
Run to execute this in your browser. Nothing is sent to a server.
What printed: own invoice True. cross user PERMISSION_DENIED. bad id INVALID_ARGUMENT. first dup False second dup True. Actor a cannot read invoice 2. The same refund key does not move money twice. If any of those prints change, you failed a tool eval before you ever scored a trace.
What goes wrong if you skip this
You will spend weeks tuning prompts for “the agent sometimes refunds twice.” It is the ledger. You will add a judge to detect “sounds like a privacy leak” while get_invoice returns the wrong tenant. You will blame the model for HTML dumps you handed it. You will flake CI on live Stripe. You will never know whether a golden failed because the policy was wrong or the tool lied.
Skip tool tests and injection allow-lists are theater: wire might be forbidden by name while refund still has no actor check. The policy cannot be aligned to a broken API.
How agents use this
Keep tool tests in the same repo as the tool implementations. Run them on every change to args or authz. Agent goldens import the same fake world. Do not maintain two invoice tables. When a new money tool ships, the first evals are unit tests; the second are goldens that the FAQ still must not call it.
Return typed errors, not empty strings. The trace exam paper needs a stable observation shape or replay hashes will thrash. A tool eval that only checks the happy path is how cross-tenant reads survive: add the miss, the wrong type, and the other actor every time you add a new id field.
Check your understanding