Deterministic Evaluators
Most of what matters about an agent run can be checked by code — schemas, tool calls, trajectory shape, budgets — and those checks are exact, free and never flaky; use them before reaching for a judge.
Why code first
A deterministic evaluator is a function from (trace, case) to a score with no model in the loop. It runs in microseconds, costs nothing, gives the same answer every time and fails with a message that says exactly what went wrong. Every criterion you can move from a judge into code makes the eval suite cheaper, faster and more trustworthy.
Agents are unusually amenable to this because so much of their behaviour is structured: tool names, JSON arguments, step counts, end state in a database. The final prose is the only part that is hard to check, and even there, references, numbers and required phrases are checkable.
A catalogue of checks
The checks below cover most agent evals. Compose them per case; each returns pass/fail plus a reason.
- Exact / normalised match: final answer equals reference after trimming, casing and whitespace normalisation. For numbers, parse and compare with tolerance. Use for closed-form answers only.
- Schema validation: final output and every tool call's arguments validate against their JSON Schema (see Tool Schemas, Structured Outputs). Catches the most common regression after a model change.
- Tool-call assertions: required tool was called; forbidden tool was not; a specific argument equals an expected value; a tool was called at most once (idempotency, see Idempotency).
- Trajectory checks: ordering constraints (
lookup_orderbeforeissue_refund), no repeated identical calls (a loop signature), termination via the finish action rather than the step cap. - Regex / JSON checks: output contains an order id matching
ORD-\d{6}, contains no email address or card-number pattern, parses as JSON with required keys. - Cost and step budgets as assertions:
steps <= 8,tokens <= 20_000,cost_usd <= 0.05,latency_ms <= 6_000. Budgets in the eval suite catch efficiency regressions before they reach the invoice (see Budgets, Limits and Termination). - End-state checks: query the sandboxed database or filesystem after the run; the refund row exists with the right amount, the file was written, nothing else changed.
A small evaluator harness
A harness needs three things: a registry of named checks, a way for each case to say which checks apply with what parameters, and a runner that collects results per case and aggregates. Sixty lines of Python is enough to start; frameworks can come later.
1import json, re2from dataclasses import dataclass3 4@dataclass5class Result:6 name: str7 passed: bool8 reason: str = ""9 10def tool_calls(trace):11 return [s for s in trace.spans if s.kind == "tool"]12 13def check_called(trace, case, tool, **_):14 ok = any(s.name == tool for s in tool_calls(trace))15 return Result("called:" + tool, ok, "" if ok else f"{tool} never called")16 17def check_not_called(trace, case, tool, **_):18 ok = all(s.name != tool for s in tool_calls(trace))19 return Result("not_called:" + tool, ok, "" if ok else f"forbidden {tool} was called")20 21def check_arg(trace, case, tool, key, expected, **_):22 for s in tool_calls(trace):23 if s.name == tool:24 got = s.input.get(key)25 return Result(f"arg:{tool}.{key}", got == expected, f"got {got!r}, want {expected!r}")26 return Result(f"arg:{tool}.{key}", False, f"{tool} not called")27 28def check_order(trace, case, before, after, **_):29 names = [s.name for s in tool_calls(trace)]30 ok = before in names and after in names and names.index(before) < names.index(after)31 return Result(f"order:{before}<{after}", ok, "" if ok else f"sequence {names}")32 33def check_no_repeat(trace, case, **_):34 seen, dup = set(), None35 for s in tool_calls(trace):36 key = (s.name, json.dumps(s.input, sort_keys=True))37 if key in seen: dup = key; break38 seen.add(key)39 return Result("no_repeat", dup is None, f"repeated {dup}" if dup else "")40 41def check_regex(trace, case, pattern, present=True, **_):42 found = re.search(pattern, trace.final_output or "") is not None43 return Result(f"regex:{pattern}", found == present, f"found={found}")44 45def check_budget(trace, case, steps=None, cost_usd=None, **_):46 n = len([s for s in trace.spans if s.kind == "llm"])47 c = sum(s.cost_usd for s in trace.spans)48 ok = (steps is None or n <= steps) and (cost_usd is None or c <= cost_usd)49 return Result("budget", ok, f"steps={n} cost={c:.4f}")50 51CHECKS = {f.__name__.removeprefix("check_"): f for f in52 [check_called, check_not_called, check_arg, check_order, check_no_repeat, check_regex, check_budget]}53 54def evaluate(trace, case):55 # case.checks: [{"type": "called", "tool": "lookup_order"}, {"type": "budget", "steps": 8}, ...]56 results = [CHECKS[c["type"]](trace, case, **{k: v for k, v in c.items() if k != "type"})57 for c in case.checks]58 return {"case": case.id, "passed": all(r.passed for r in results), "results": results}59 60def summarize(evaluations):61 n = len(evaluations)62 return {"pass_rate": sum(e["passed"] for e in evaluations) / n, "n": n}Handling non-determinism in the run itself
Deterministic evaluators do not make the agent deterministic. The same case can pass in one run and fail in the next. Run each case N times (3–5 is typical) and report the pass fraction per case rather than a single bit; a case at 3/5 is a real signal about instability that a single run would hide. Set temperature=0 where the provider honours it, fix the model version, and mock external tools with recorded responses so the only variance left is the model's.
- Record tool responses once; replay them in evals so a flaky search API does not fail your suite.
- Pin model identifiers; "latest" aliases silently change the distribution.
- Report per-case pass fraction across N runs; alert on cases that flip between versions.
Key points
- Deterministic checks are exact, free and never flaky — exhaust them before adding a judge.
- Agent behaviour is mostly structured: tool names, arguments, order, step counts, end state.
- Budgets (steps, tokens, cost, latency) belong in the eval suite as assertions.
- A harness is a registry of named checks plus per-case configuration; start tiny.
- Run cases N times and report pass fraction; mock tools with recorded responses.
- Pin model versions so the only variance is the model's sampling.
When to use — and when not to
- Any criterion expressible as a predicate on the trace or the end state.
- CI gates where speed and zero variance matter (Regression Gates and Online Evaluation).
- Unit-level evals for routers, tool selection and argument construction.
- Open-ended quality criteria (faithfulness, tone) — use LLM-as-Judge with calibration.
- Exact match on free text; normalise or use a reference-based judge instead.
- As a substitute for end-state checks when the tool has side effects — check the sandbox, not just the call.
Failure modes
- Exact string match on a free-text field fails on harmless formatting differences.
- Tool-call assertion checks the name but not the arguments; a wrong refund amount passes.
- Live external tools in the eval loop make the suite flaky for reasons unrelated to the agent.
- No step budget in the suite, so a regression to 20-step runs is discovered on the invoice.
- Checks written against the current output instead of the requirement, freezing bugs as expected behaviour.