Runtype
GuidesGuide

How to test tool calling, not just the final answer

Grading the final answer hides wrong tools, bad arguments and skipped lookups. Four levels of tool-call assertion, recorded results, live calls versus mocks.

Last updated 6 min read

Grade the tool-call sequence as an artifact in its own right, next to the final text. Assert four things about it: the right tool was selected, its arguments were well-formed, the order and data dependencies held, and the agent recovered correctly when a call failed. Record tool results so a case replays identically, and inject failures on purpose instead of waiting for production to supply them.

A test that reads only the final answer passes the run where the agent guessed. It also passes the run where the agent wrote to the wrong record and then described that write accurately.

My agent's answer looks right but it called the wrong tool

A refund assistant is asked about order A-10482. It has get_order, which takes an order id, and search_orders, which takes a free-text query. The model calls search_orders with the customer's email address, gets back the customer's three most recent orders, picks the first one, and refunds it. The answer reads "I have refunded $49.00 for order A-10482" and the amount happens to match, because the customer buys the same item every month.

That run is a pass under answer grading and a defect in the ledger. The same shape appears without a write: an agent that skips the entitlement lookup entirely and answers from the system prompt's stale summary is right about four tenants out of five, and confidently wrong about the fifth.

The call log holds facts the final message does not: which tools ran, what arguments they carried, and how many times each one ran. None of that is recoverable from the text, because the model writes its summary after the calls and is free to describe them however it likes. The wider practice this sits inside is covered under AI agent evals.

Assert on the call log in six steps

1. Capture the call log as a structured artifact

The assertion target is a list, one entry per call, with the tool name, the arguments as the model produced them, the result as the tool returned it, whether it succeeded, and how long it took. A trace viewer is not enough, since a test needs to read this without a human.

{
  "calls": [
    {
      "index": 0,
      "name": "get_order",
      "arguments": { "order_id": "A-10482" },
      "ok": true,
      "result": { "status": "delivered", "total_cents": 4900 },
      "duration_ms": 214
    },
    {
      "index": 1,
      "name": "create_refund",
      "arguments": { "order_id": "A-10482", "amount_cents": 4900 },
      "ok": true,
      "result": { "refund_id": "re_88f1" },
      "duration_ms": 631
    }
  ],
  "final_text": "I have refunded $49.00 for order A-10482."
}

Keep the raw arguments string as well as the parsed object. Models emit malformed JSON often enough that "the tool was never called" and "the tool call could not be parsed" are different bugs with different fixes, and a parsed-only log erases the difference.

2. Assert selection, including the calls that should not happen

A selection assertion has three parts: a required set, a forbidden set, and a cap. The required set catches the skipped lookup. The forbidden set catches the destructive neighbor, the tool with an overlapping description that the model reaches for when the right one is ambiguous. The cap catches the loop.

case: refund_delivered_order
assert_calls:
  required:
    - name: get_order
    - name: create_refund
  forbidden: [cancel_order, contact_customer]
  max_occurrences:
    create_refund: 1
  max_calls_total: 4

Set the cap two or three above the expected count, not at the expected count. A retry after a genuine 500 is correct behavior and should not fail the case, while eleven calls to the same search tool is the empty-array loop. When selection fails repeatedly for one tool, the fix is usually in the tool descriptions rather than the prompt, which is the subject of why an agent picks the wrong tool.

3. Assert arguments at the strictness the case deserves

Pinning every argument exactly produces a suite that fails on paraphrase. Pinning nothing produces a suite that passes on nonsense. Pick a level per argument:

LevelAssertionUse it for
Exactorder_id == "A-10482"identifiers, amounts, tenant ids, booleans
Subsetrequired keys present, extras ignoredfilter objects and option bags
Predicate0 < amount_cents <= order totalanything derived from a previous tool result
Schemavalidates against the tool's own schemafree-text queries and generated payloads

Two argument bugs are worth their own assertions because they survive answer grading intact. Unit confusion, where the model passes 49 to a field measured in cents, and identity leakage, where a tenant id is copied from the example in the tool description rather than from the request context. Assert the tenant id on every call in a multi-tenant agent, on every case, even the ones about something else.

4. Assert order through data dependency, not position

Index comparisons break the moment the model interleaves an unrelated read. The durable version asserts provenance: the argument of a later call must equal a value that appeared in an earlier call's result.

- name: create_refund
  arguments:
    order_id: { same_as: 'calls[get_order].arguments.order_id' }
    amount_cents: { same_as: 'calls[get_order].result.total_cents' }

This catches the failure that matters, which is a write built from a value the model invented or carried over from an earlier turn. Add a plain ordering rule only where the ordering is the contract, such as a lock acquired before a mutation, or a consent check before a message send.

5. Record tool results so a case replays the same way twice

A case that calls the real search index is scoring the index as much as the agent. Record each tool's result the first time the case runs and replay it afterwards, keyed by the tool name plus a canonical hash of its arguments.

Normalize before hashing, or the key will never hit. Strip or freeze timestamps, request ids, generated uuids and any field the client sets per call, then sort object keys. Store the recording with the case and treat a cache miss as a case failure rather than a silent fall-through to the live tool, since a silent fall-through is how a suite quietly becomes a live integration test again.

Recordings go stale. Give each one the date and the upstream version it was captured against, and re-record on a schedule rather than when a case finally breaks.

6. Inject failures so recovery is tested rather than assumed

Production supplies failures at a rate that is far too low to test against and far too high to ignore. Write cases whose recorded result is the failure, one per class:

Injected resultBehavior the case should requireCommon wrong behavior
HTTP 500one retry, then an honest failure messageretry loop until the turn budget expires
HTTP 429 with Retry-Afterbackoff, or a handoff to a humanimmediate retry in the same second
Timeout at the tool ceilingturn ends stating the lookup did not completethe model invents a plausible result
200 with an empty arrayanswers "no matches found"reads it as an error and retries the same argument
Malformed JSON bodythe call fails and is reportedthe model scrapes a number out of the raw string
Partial page with a cursorfetches the next page or says it is partialanswers from page one as though it were complete

Write the empty-array row first for any agent that searches. It is invisible to answer grading, since the fallback text reads fine and the retry loop behind it shows up only in the call count. The timeout row needs a real clock, so set the injected delay just above whatever ceiling the runtime enforces on a tool call, commonly 30 seconds.

Mocked tools, recorded results, or live calls

The three modes catch different things, and a suite should use all three at different frequencies rather than picking one.

ModeCatchesMissesFrequency
Recorded resultsprompt, model and tool-selection regressionsupstream contract driftevery commit
Handwritten mocksfailure branches you cannot capture from productionanything about the real payload shapeevery commit
Live callsschema drift, expired auth, rate limits, latency shiftsnothing, but it is slow and flakynightly, small suite

The practical split is a large recorded suite on every change, and roughly five to ten live contract cases per integration that run on a schedule and page someone when a real endpoint has moved. Never mix modes inside one case without labeling it, or a red case leaves nobody able to say whether the agent or the vendor changed.

Tool assertions also give you a deterministic backbone under an agent whose prose cannot be scored exactly, which is the harder half of grading an answer with no single right answer. When one case fails and the reason is not obvious from the assertion, the next move is reading that single run end to end, covered in debugging an AI agent.

Where this gets easier

Runtype records each tool call with its arguments, its result and its timing as part of the execution trace, so tool-level assertions run against the real call log rather than a reconstruction stitched together from logs after the fact. Executions can be promoted into eval cases directly, which means a production run that took a wrong turn becomes a regression case with its call sequence already attached, and later runs are compared against it record by record.

Frequently asked questions

Should tool-call assertions be strict or loose?
Strict on writes, loose on reads. A case that calls a refund, an email or a database mutation should pin the tool name, the exact arguments and the number of occurrences, because a second call there is a second side effect. A lookup usually only needs the tool name and one or two argument predicates, so that a harmless change in a search string does not turn the case red.
How do I test that the agent did not call a tool?
Add a forbidden set to the case and fail on any match, then add a total call cap so a tool nobody thought to forbid still trips the case. Negative cases need a positive twin: pair every "must not escalate" case with one where escalation is required, or a model that never calls anything will pass the whole negative half of your suite.
Do I still need to grade the final answer once tool calls are asserted?
Yes, but the two graders answer different questions. Tool assertions say the agent did the right work, and answer grading says it reported that work honestly. An agent that calls the correct tools and then contradicts their results in the summary passes the first and fails the second, which is a real failure mode with retrieval-heavy prompts.