Skip to main content
The scripts are in the repository, not in the installed package. Clone it, then cd recipes/02-measure/eval-your-agent before running the commands below. Browse this recipe on GitHub.
Wrap the agent you ship, write its policy as a judge, roll every ask four times, and read pass@1 with an interval per policy branch. Ends at a CI gate, not a push. Two scripted refund bots are included, one careful and one eager, so the eval visibly separates a good agent from a bad one before you plug in your own. What you will learn: which parts of your policy the asks you already send never reach (--gap), the agent(message) -> {steps, final_text} contract, why the judge reads the trajectory and not the prose, what a hollow run looks like and how the SDK flags it, and how to turn a pass rate into an exit code. You need nothing; this recipe is offline. Seconds.

Run it

--k stays at 4 or above: pass^k and pass@k are None below four repeats (min_k), and the note field says so. Rows are the budget and asks are the situations, so a run needs budget >= situations * repeats or the later asks never get rolled out.

Find what is untested

Before writing the eval: wai.coverage_gap takes the asks a suite already sends and says which parts of the policy they never reach. The axes are the ones simulate covers, so the answer comes back in the engine’s own words: which tool, which policy rule, what stance the person takes, what the world looks like, what condition the tool is in. OLD_TESTS in run.py is the three-ask suite this recipe replaces.
The over-limit branch is the one to act on: no ask in the old suite names an amount or a manager, so nothing tests it. The eval below confirms it from the other side (escalates_over_limit fires on 0 rows until a seed reaches it). Rule matching is word overlap, so a branch that only the fixture data selects reads as untested even when an ask lands on it: pass rows= from a graded run and the report also names the rules whose every row ended in the same tool fault, which is a missing fixture, not a missing ask.
Asks can also come from the file that holds them: wai.coverage_gap("tests/test_refunds.py", ...) reads the string literals that look like asks (passed to a call or sitting in a list, over fifteen characters, with a space in them), and wai.coverage_gap("asks.jsonl", ...) reads the prompt of every row.

What you get

The eager bot passes every ask that wants an eligible refund and fails every ask that wants an ineligible one: the old three-assert test suite ("refund" in reply.lower()) would have passed it. The branch table is the number that matters; the overall pass@1 hides it.

The four pieces

The wrapper. The engine hands your function one ask and wants the tool calls it made and what it said. A callable agent runs its own real tools and is played single-turn. run.py reads the raw rollouts as data.trajectories; data.rows is the same rollouts exported, and both data.rows and data.rows() work.
The seeds. One per policy branch. The writer varies wording and stance; the order id keeps the branch. The order ids are also in the lookup_order description, because a writer that does not know which ids exist invents ones that do not, every rollout is “not found”, and the run is hollow. The judge. The refund policy as a program (refundable()), shared with the careful bot so it lives in one place. It reads row["steps"]: which tools ran, in what order, with what. Markers are named so 1.0 is always the good outcome; a marker that does not apply to a row is None and its rate counts only the rows it measured. The gate. --gate 0.9 exits 1 under the floor and 2 when the SDK’s coverage warnings say the run is hollow (no rollout called a tool, a declared tool no rollout touched, a marker that fired on no row). A pass@1 from a hollow run is not reported.

Swap in your agent

Keep run.py, replace AGENTS. For a model-backed bot that records calls through a shared list, make the recorder thread-local: concurrency defaults to 32, so 32 rollouts call your function at once and one shared list mixes their calls together.
Convert the bot’s tool list to OpenAI function shape for TOOLS (a bare {"name", "description", "parameters"} dict works too, and so does the Anthropic {"name", "description", "input_schema"} shape), put your real ids in the descriptions or seeds, and keep refundable() as the policy your bot is supposed to follow. Then pass simulator="hosted" instead of simulator=False to let the hosted writer produce more varied asks, and raise --k. The hosted writer needs a key: whileai login, or WHILEAI_API_KEY in the environment. A hosted run is minutes, and it says where it is on the whileai.simulations logger (12/64 rollouts, 3 situations written, 1m40s elapsed, ~5m left) once logging.basicConfig(level=logging.INFO) is on.

Next

  • Fast lane: a pytest file that runs refund_judge on eight hand-written rows, no model calls, one second. Judge edits cannot drift unnoticed.
  • Judge trust: wai.attach_labels(rows, labels, kind="human") then wai.judge_trust(rows, refund_judge). FAIL on eight labels means label more (about thirty clears the Wilson bound), not that the judge is wrong.
  • Every failure is a training example: wai.simulate(traces=scored.failures()) aims the next round at what broke (scored.failed_traces() and scored.traces are the same list under other names). evaluate rows are stamped so the selectors refuse to use them as the reward.
  • scored.push("refund-evals", purpose="eval") keeps the set out of training on the platform.
Last modified on September 19, 2026