Score three dimensions per task, then roll up.
Tool selection score. For each expected step, check whether the recorded tool name matches any allowed name for that step. Count matches over matched steps.
Argument score. For steps where the tool name matches, compare arguments field by field. Prefer exact equality on scalar fields and set equality on arrays. For free-text fields, define an explicit normalizer (trim, lowercase, strip punctuation) and version it alongside the fixture, because a silently changed normalizer shows up as a phantom regression.
Order score. Compute the longest common subsequence between expected and actual tool-name sequences. Divide by expected length. This tolerates a missing step without cascading every later step into a failure, which a strict positional comparison does not.
Then attribute the task to one failure mode. Use a fixed precedence so two engineers scoring the same run reach the same verdict:
| Failure mode | Test | What it usually means | First fix to try |
|---|
| Wrong tool | A step's tool name is not in the allowed set for that step | Routing, tool descriptions, or too many similar tools | Rewrite tool descriptions, remove overlapping tools |
| Wrong arguments | Tool name matches but a required field is missing or the wrong type or value | Schema, enum coverage, examples in the prompt | Tighten schema, add enums, add worked examples |
| Wrong order | All names appear somewhere but the sequence diverges | Planning, missing dependency checks | Add explicit ordering rules and a wait-for-result step |
| Extra call | An invocation appears that has no expected counterpart | Looping, redundant lookups | Add a call budget and dedupe instructions |
| Missing call | An expected step never happens and no incorrect step replaces it | Early stop, premature answer | Require a terminal tool before answering |
The precedence rule: if any step has a wrong tool, the task is a wrong-tool failure, even if it also has argument errors, because the argument comparison was never meaningful. If tool names all resolve but order diverges, mark it wrong-order before scoring arguments as a secondary metric. Record the secondary metrics anyway; they tell you whether fixing routing will leave you with an argument problem underneath.
Here is the comparator in a compact form. It is deliberately boring code, because an evaluation harness that is hard to read will not be maintained.
def score_task(expected, actual, allowed_by_turn):
name_hits = sum(
1 for e, a in zip(expected, actual)
if a["tool"] in allowed_by_turn.get(e["turn"], {e["tool"]})
)
arg_errors = []
for e, a in zip(expected, actual):
if a["tool"] != e["tool"]:
continue
for key, want in e["args"].items():
got = a["args"].get(key)
if got != want:
arg_errors.append({"turn": e["turn"], "field": key, "want": want, "got": got})
order_score = lcs_len([e["tool"] for e in expected], [a["tool"] for a in actual]) / len(expected)
mode = classify(name_hits, arg_errors, order_score, len(expected), len(actual))
return {"tool_score": name_hits / len(expected), "arg_errors": arg_errors,
"order_score": order_score, "failure_mode": mode}
Note the trap: zip silently truncates to the shorter list, so a run with four expected steps and two actual steps scores a perfect tool name rate on the two it attempted. Handle length mismatch before scoring, and count the missing steps explicitly. This is the single most common way a homemade harness flatters a model.