recipes/02-measure/eval-your-agent
(offline, no key, seconds).
1. Install
simulator=False; the key, the
trial and where a saved login lives are on Install.
2. Wrap your agent
The engine calls your function once per rollout with the ask, and wants back the tool calls and what it said:- It runs its own real tools. The mock world and scheduled faults apply to model-backed agents; your callable answers its own calls.
- It is played single-turn.
avg_turnsandmax_turnsdo not apply. - The writer does not know your ids. Both writers read the tool and
parameter descriptions; the hosted one also reads the system prompt. Put
your order numbers in the tool description (“Orders on file: A1001,
A1002, …”) or in
seeds=, or every rollout is “not found” and the run is hollow.
@wai.tool: the signature is the schema,
the docstring the description.
{"name", "description", "parameters"} dict, and the Anthropic shape with
input_schema. If your bot records calls through a shared global, wrap the
recorder in a threading.local: concurrency defaults to 32 threads, and
one shared list interleaves rollouts.
Or run wai init-evals. It reads your Python with ast, never imports
it, picks the tool list, the system prompt and the callable, and writes
evals/agent.py, evals/judge.py, evals/run.py, evals/test_judge.py
and evals/README.md, wired together. A wrong guess is one flag away:
--agent module:callable, --tools module:NAME,
--system-prompt module:NAME. It ends with the three commands to run next.
3. Write the judge as a program
The judge reads the trajectory, not the prose. A polite reply that issued a refund it should not have scores 0. The policy lives in the judge, once:reward in [0, 1], a reason string, and optional
markers (name to 0/1). Name markers so 1.0 is the good outcome
(refund_only_when_allowed, not refunded_wrongly). A marker that does
not apply to a row is None. A verifier (wai.verify.*) is a judge too.
Any other key you return lands under row["judge_meta"].
3b. Find what your tests miss
Which parts of the policy does your suite never reach?asks is a list of prompt strings, rows with a prompt key, or a path to a
.py or .jsonl file. From a .py file the asks are the string literals
that look like asks, a heuristic, so read report["asks"] first.
The report is in the engine’s words: untested_rules, untested_tools,
single_shot, and notes naming each fix. world_state and
tool_condition are not readable from an ask, which is why a hand-written
suite misses fault handling. Rules match on shared words, so a branch only
the fixture data selects (an amount) reads as untested; pass rows= from a
graded run to check the world side.
preflight(tools, system_prompt)["rules"] is the rule axis alone. Both put
every clause on the axis (rule_cap=None); rule_cap=16 keeps the first
sixteen. The generation grid keeps its own cap (RULE_AXIS_CAP_GRID, 16)
and a run with more clauses says so in data.warnings.
4. Run it
pass@1is how often the agent does the job.pass^kis how often it did on every one ofktries: for anything that moves money, that is the number.pass@kminuspass@1is headroom for training [1, 2].- The interval is a bootstrap over tasks, not tries, because the tries of one task are not independent draws [3, 4].
repeat_policy="fixed"asks for all repeats up front. Themode="rl"default,"successive", stops early on unanimous asks: right for training data, wrong for an eval.pass^kandpass@kprint asn/abelowrepeats=4(min_k); the line ends with the fix, which is also.note.- Slice by category: tag each row (
row["category"] = classify(prompt)) and callwai.pass_atper slice. situationscounts asks,budgetcounts rows. Keepbudget >= situations * repeatsor later situations never roll out.- To measure a policy branch, pin the tool result. “Credits over 200, and the writer invents the amount. Pin it:
wai.local_model(..., result_shapes={"lookup_invoice": {"invoice_id": "INV-1000", "amount_usd": 900.0, "status": "open"}}). Numbers move by up to about a third per call, so pick a value whose range sits on one side of the threshold and run the same pinnedtasks=once per side. - A served model that scaled to zero takes two to three minutes to answer
first;
timeout=is 300 s. A pass with fewer rows than the base arm is the dangerous case (some tasks at k=1 against k=4); readdata.warningsbeforepass_at. - Progress goes to the
whileai.simulationslogger (12/64 rollouts, 3 situations written, 1m40s elapsed, ~5m left);logging.basicConfig(level=logging.INFO)shows it. Silence for a minute on a hosted run is not a hang.
5. Gate CI on it
Two lanes. The slow one runs the agent and exits non-zero under a floor:6. Check the judge
A judge is a claim until it is measured. Label a sample by hand, attach the labels as human, and ask:labels is a list of dicts as above, a {key: 0/1} dict, or a JSONL path.
The key is rollout_id when the row has one, else
scenario_id#rollout_index (what a simulate row carries); a bare
scenario_id names its one rollout. A key that names no row is counted and
the report’s warnings spells the form; when no key names a row the call
raises instead of reporting zero. Rows pooled from several runs on one pinned
task grid share scenario_id#rollout_index, so attach_labels refuses them
until each row carries its own rollout_id; the error says how to stamp one.
judge_trust reads gold_reward and reports agreement with its Wilson
lower bound, held-out halves, a length bias check and re-judge flips [5].
FAIL on eight labels means label more: at perfect agreement the lower
bound needs sixteen labels to clear 0.8, and the report says how many.
Labels attached any other way count as model-made and keep ok false
unless allow_model_gold=True.
Two or more judges in the running? Compare them on the same labeled rows in
one call. Each model judge needs its provider’s key (TYPESAFE_API_KEY,
WHILEAI_API_KEY, ANTHROPIC_API_KEY here); the rules judge needs none:
whileai.Hosted,
whileai.OpenAI, whileai.Anthropic). Spec strings and backends run the
package’s conduct-floor judge prompt under the run’s system prompt and
tools, so every model reads the same evidence. The floors are
judge_trust’s (floors=(0.8, 0.6)).
7. Return shapes
PassAt fields, with the name each prints as:
Marker stats (
marker_summary(rows)["grounded"]): mean, ci95 (not
ci), n_tasks, n_rows (not n), n_rows_at_1, n_rows_at_0,
degenerate, and note (too few tasks) or warning (never varied).
judge_trust(rows): ok, agreement.{agreement, ci95, kappa, n},
gold_kind ("human", "program", "model", "unknown"; the first two
are trusted), n_labeled,
held_out_halves, length_sensitivity, perturbation, probes,
disagreements, and warnings, where every line names its fix.
8. Then
- Every failure row is a training example:
simulate(traces=scored.failures())aims the next round at what broke.evaluaterows are stampedlineage.source == "eval", and every selector counts them aseval_sourcedand warns before you train on them. - Push the eval set with a purpose so it stays out of training:
scored.push("refund-evals", purpose="eval"). - Production traces are rows too:
wai.rows_from_otel(spans)reads OpenTelemetry spans, and the same judge and markers score them.
References
- Chen, M. et al. Evaluating Large Language Models Trained on Code. 2021. The pass@k estimator.
- Yao, S. et al. τ-bench: A Benchmark for Tool-Agent-User Interaction in Real-World Domains. 2024. pass^k.
- Miller, E. Adding Error Bars to Evals: A Statistical Approach to Language Model Evaluations. 2024. Intervals over questions, and pairing when two models answer the same ones.
- Lambert, N. Reinforcement Learning from Human Feedback, chapter Evaluation. 2025.
- Zheng, L. et al. Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena. NeurIPS 2023. Agreement with people, and the length bias.