"Did the agent call the right tool?" and "did it call it with the right arguments?" fail for different reasons and are fixed in different places. Tested as one blob, a red test tells you nothing about whether to rewrite tool descriptions or fix argument extraction. Assert them separately — deterministically where you can.
Tier: Advanced · Workflow area: Testing AI systems — judges, agents & robustness
This playbook builds a pytest suite over an agent's recorded tool calls that asserts tool selection and parameter construction as independent dimensions, so every red test points at exactly one fix.
An agent routes across multiple tools and users see wrong-tool behavior (searching the web for data that lives in the CRM). Tool schemas or descriptions just changed and you need regression coverage. Parameter bugs — wrong date ranges, swapped IDs, units — are reaching downstream systems.
Single-tool agents with no routing decision — test parameters only. The interesting failures are multi-step ordering or loops — that is trajectory testing, the next playbook. The tool layer itself is unreliable — contract-test the MCP/tool server first, or agent tests will blame the model for server bugs.
tool_use blocks from the API response.Write the scenario table first. For each scenario, decide what is determined — a scenario where a human expert would always pick crm_lookup with customer_id="C-1042" gets exact asserts; one where either of two tools is defensible gets a set-membership assert. Ten to thirty scenarios covering each tool, plus distractor cases designed to tempt the wrong tool:
S-01 "What did Acme order last March?"
expect tool: order_search
expect params: customer="Acme", date_from=2025-03-01, date_to=2025-03-31
S-07 "What's our refund policy?" <- distractor: no tool needed
expect tool: NONE (answer from system prompt)
S-12 "Cancel order 8812 and email the customer"
expect first tool: order_cancel(order_id="8812")
Capture tool calls in a harness, not in production. Run the agent with a stubbed tool layer that records calls and returns canned results — you are testing the model's decisions, and stubs make runs cheap and deterministic on the environment side:
class RecordingTools:
def __init__(self, canned):
self.calls, self.canned = [], canned
def dispatch(self, name, args):
self.calls.append({"tool": name, "args": args})
return self.canned[name]
Assert selection deterministically. Tool names are exact strings — never use an LLM judge for this:
def test_selection_order_search(agent, tools):
agent.run("What did Acme order last March?")
assert [c["tool"] for c in tools.calls][0] == "order_search"
def test_no_tool_for_policy_question(agent, tools):
agent.run("What's our refund policy?")
assert tools.calls == []
The no-tool distractors matter as much as the positives — over-eager tool use is the most common agent regression after a prompt change.
Assert parameters in two layers. Layer one is deterministic — required keys present, types correct, values that are determined by the input match exactly:
def test_params_order_search(agent, tools):
agent.run("What did Acme order last March?")
args = tools.calls[0]["args"]
assert args["customer"] == "Acme"
assert args["date_from"] == "2025-03-01"
assert args["date_to"] == "2025-03-31"
assert set(args) <= {"customer", "date_from", "date_to", "limit"}
Layer two is semantic, only for genuinely free-text parameters (a rewritten search query, a summary field) — assert with embedding similarity or a calibrated judge, never exact match.
Run under repetition, because selection is sampled. A scenario that picks the right tool 7 of 10 times is a real bug at temperature > 0. Repeat each scenario N times and gate on a rate:
pytest test_tool_calls.py --count=10
S-01 selection: 10/10 params-exact: 9/10 FAIL (date_to=2025-03-30 once)
S-07 no-tool: 8/10 FAIL (called doc_search twice)
Gate at 100% for determined selections on gating suites; anything lower is a description or prompt problem worth fixing, not tolerating.
Route failures by dimension. Selection failures → fix tool descriptions and disambiguation guidance in the system prompt (the model chooses from descriptions, so that is where routing lives). Parameter failures → fix schema descriptions, add format examples, or tighten the schema itself (enums, patterns) so the API rejects what the model should not send. Re-run only the failed dimension to confirm.
Everything deterministic stays deterministic. Tool names, required keys, enum values and derivable dates need assert ==, not a judge — save judges for the two free-text fields that actually need them, and calibrate those separately.
ToolCorrectnessMetric compares called tools against expected_tools on an LLMTestCase — a quick aggregate layer; keep the per-parameter deterministic asserts underneath it, since the metric alone will not tell you which argument was wrong.order_search was "an appropriate choice" adds noise to a string comparison. Exact match.Wrong tool and wrong arguments are different bugs with different owners — descriptions versus schemas. A suite that asserts them separately turns "the agent is flaky" into two short, fixable tickets.