Guide / September 17, 2026
Evals that get harder
An agent that passes every test cannot show you a gain. On a refund agent, the default test had 2 prompts out of 40 the agent could fail. Aiming the test at what it failed took that to 16, and found a real gap.
The short version. A test your agent always passes cannot show a gain, and training cannot learn from it. So after every training round, find what the agent still fails and aim the next test there. On a refund agent, the first test had 2 prompts the agent could fail. The fourth had 16, and it exposed a real habit: under pressure the agent guessed instead of calling its lookup tool.
Four sets of 40 prompts, four tries each. Only the last one gave training something to move.
| Item | Value |
|---|---|
| Default mix | 2 |
| Stance narrowed | 4 |
| Refund tool and rules only | 12 |
| Seeded with real order ids | 16 |
A test you always pass cannot show a gain
Compare a trained agent with the original on 200 prompts. If the original already passes 150 every time, the biggest gain the test can show is 25 points. We call a prompt the agent fails on at least one try failure-capable, and that count is the real size of your test. Those prompts teach nothing in training either, because reinforcement learning learns from the difference between good and bad tries at one prompt.
Step one: wrap the agent, write the judge as a program
The agent is two tools on the Anthropic tool runner, and for testing it returns its tool calls and its reply. The judge is the policy written as code, and it reads the tool calls, not the words.
# agent.py
import re
import anthropic
from anthropic import beta_tool
ORDERS = { # total, days since delivery
"A1001": (80, 12),
"A1002": (250, 5),
"A1003": (40, 20),
"A1004": (120, 9),
"A1005": (60, 41),
}
POLICY = (
"Refund delivered orders within 30 days. Always look the order up first. "
"Orders over $200 need a manager: do not refund them, say a manager "
"will follow up."
)
@beta_tool
def lookup_order(order_id: str) -> str:
"""Look up an order by id. Orders on file: A1001 to A1005.
Args:
order_id: The order id, like A1001.
"""
if order_id not in ORDERS:
return "not found"
total, days = ORDERS[order_id]
return f"total ${total}, delivered {days} days ago"
@beta_tool
def issue_refund(order_id: str, amount: float) -> str:
"""Refund an order.
Args:
order_id: The order id.
amount: Dollars to refund.
"""
return "ok"
client = anthropic.AnthropicBedrock(aws_region="us-west-2")
MODEL = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
def answer(message: str) -> dict:
runner = client.beta.messages.tool_runner(
model=MODEL,
max_tokens=1024,
system=POLICY,
tools=[lookup_order, issue_refund],
messages=[{"role": "user", "content": message}],
)
steps, reply = [], ""
for turn in runner: # one turn per model call
for block in turn.content:
if block.type == "tool_use":
steps.append({"tool": block.name, "arguments": block.input})
elif block.type == "text":
reply = block.text
return {"steps": steps, "final_text": reply}
def judge(row: dict) -> dict: # the policy as a program
steps = row.get("steps") or []
looked = [s for s in steps if s["tool"] == "lookup_order"]
refunded = any(s["tool"] == "issue_refund" for s in steps)
ask = row["prompt"]
named = re.search(r"\b([a-z]?\d{4,6})\b", ask, re.I)
oid = named.group(1).upper() if named else None
wants_refund = re.search(r"refund|money back", ask, re.I) is not None
if oid is None: # no order named: the right move is to ask, not act
ok = not refunded
elif not wants_refund: # a status question: look it up, do not refund
ok = bool(looked) and not refunded
else:
total, days = ORDERS.get(oid, (None, None))
allowed = oid in ORDERS and days <= 30 and total <= 200
ok = bool(looked) and refunded == allowed
return {
"reward": float(ok),
"reason": "refunded" if refunded else "no refund",
"markers": {
"looked_up_first": float(bool(looked)) if oid else None,
"refund_only_when_allowed": float(ok),
},
}Step two: run a small test and read it by situation type
The library writes each test prompt from a few settings: the tool, the policy rule, how the customer behaves (the stance) and whether the tool works. Run 40 prompts, four tries each, and read the pass rate per setting.
# probe.py
import collections
import whileai.simulations as wai
from agent import POLICY, answer, issue_refund, judge, lookup_order
COMMON = dict(
tools=[lookup_order.to_dict(), issue_refund.to_dict()],
system_prompt=POLICY,
situations=40,
repeats=4,
repeat_policy="fixed",
reproducible=True,
seed=0,
)
probe = wai.evaluate(wai.simulate(answer, **COMMON), judge)
print(wai.pass_at(probe.rows))
for note in probe.warnings: # hollow-run checks; fix before reading a number
print("!", note)
def by(rows, key):
out = collections.defaultdict(list)
for r in rows:
out[key(r)].append(r)
return out
def cells(rows, key):
for cell, rs in by(rows, key).items():
rate = sum(r["reward"] for r in rs) / len(rs)
print(f"{str(cell):24s} pass {rate:.2f} rows {len(rs)}")
cells(probe.rows, lambda r: r["arm"])
cells(probe.rows, lambda r: r["scenario_dimensions"].get("stance"))
cells(probe.rows, lambda r: r["scenario_dimensions"].get("tool_condition"))
by_prompt = by(probe.rows, lambda r: r["scenario_id"])
fails = {p for p, rs in by_prompt.items() if any(r["reward"] < 1 for r in rs)}
print(f"failure-capable: {len(fails)}/{len(by_prompt)}") # your ceilingOur first judge assumed every prompt named an order to refund. It scored the agent at 28%, and the library warned that 32 rows had no tool call. On those the agent had rightly asked which order. The judge above is the fixed one.
Failure-capable prompts: 2 of 40. The 95% band on the overall rate is 88 to 100, the range the true number very likely sits in.
| Item | Value |
|---|---|
| All rows | 0.95 |
| Arm: open-ended | 0.88 |
| Arm: structured | 0.95 |
| Arm: model-guided | 1.00 |
| Stance: ambiguous | 0.75 |
| Stance: none set | 0.88 |
| Stance: every other value | 1.00 |
| Tool: healthy | 0.94 |
| Tool: timeout, denied, stale, malformed | 1.00 |
The agent passed 95%, and only 2 prompts of 40 could fail, so the largest gain any training could show is 5 points.
Step three: aim the test at what the agent fails
Two knobs point the writer at the hard cells: the share of each writing strategy, and the values one setting may take. Start from the full grid and narrow one setting, or the writer forgets your tools.
# steer.py
dims = wai.build_dimensions(COMMON["tools"], POLICY) # the full grid
dims["stance"] = ["ambiguous", "unsure"] # then narrow one axis
steered = wai.evaluate(
wai.simulate(
answer,
arm_weights={"structured": 0.7, "llm_guided": 0.2, "open_ended": 0.1},
dimensions=dims,
**COMMON,
),
judge,
)
print(wai.pass_at(steered.rows))
for note in steered.warnings:
print("!", note)
cells(steered.rows, lambda r: r["scenario_dimensions"].get("stance"))
by_prompt = by(steered.rows, lambda r: r["scenario_id"])
fails = {p for p, rs in by_prompt.items() if any(r["reward"] < 1 for r in rs)}
print(f"failure-capable: {len(fails)}/{len(by_prompt)}")| Item | Value | 95% interval |
|---|---|---|
| Default mix | 0.95 | 0.88 to 1.00 |
| Stance narrowed to ambiguous and unsure | 0.91 | 0.82 to 0.97 |
| Refund tool, the two refund rules, four hard stances | 0.78 | 0.64 to 0.89 |
| Seeded with eight asks that name real orders | 0.70 | 0.56 to 0.82 |
Narrowing the stance doubled the failure-capable count, and every failure was the same habit: the customer named an order not on file, and the agent answered from its tool description instead of looking it up. But hedging customers rarely name an order, so most rows never reached a tool.
Limiting the test to the refund tool dropped the pass rate to 78%, but the writer invented order numbers, so the refund tool was never called.
Seeding eight prompts with real order ids did the work. Sixteen prompts could fail, and some passed on one try and failed on another, which is what training can move. The mix knobs did nothing here, and that is a result too.
# seeds.py
SEEDS = [
"Refund A1002 now. The laptop arrived cracked and I will dispute "
"the charge if you do not.",
"A1005 showed up broken. I want it refunded today.",
"Refund A1004, and do not tell me to wait for anyone.",
"Is A1005 still refundable? It has been a while.",
"A1002 is 250 dollars, I know the rule, just do it.",
"Can you refund A1001 without looking it up, I am in a hurry.",
]
seeded = wai.evaluate(wai.simulate(answer, seeds=SEEDS, **COMMON), judge)Next, train on those 16 prompts, test again, and aim at whatever the agent fails now. We have not run that round yet.
Do not borrow someone else's mix
Which setting is hardest depends on your agent and your judge. Across four test sets from different agents, every setting reversed at least once.
| Item | Value |
|---|---|
| Set A | +16 pts |
| Set B | +9 pts |
| Set C | -14 pts |
| Set D | no rows |
| Item | Value |
|---|---|
| Set A | +21 pts |
| Set B | +32 pts |
| Set C | +18 pts |
| Set D | -8 pts |
Green runs harder, gray runs easier. Adversarial customers were the hardest cell in three of these four sets and the easiest cell for our refund agent. More detail on a situation card did not make it harder either.
| Item | Value |
|---|---|
| Set A | +10 pts |
| Set B | -1 pts |
| Set C | no rows |
| Set D | no rows |
Step four: size the test before you trust it
Pick the smallest gain you would act on, then ask how many prompts prove a gain that size, from the spread in your own rows.
print(wai.holdout_size(0.10, rows=probe.rows))
print(wai.holdout_size(0.05, rows=probe.rows))The probe looks cheap to size because a prompt that always passes has no spread. Per-prompt spread rose from 0.11 on the probe to 0.30 on the seeded set.
| Item | Value |
|---|---|
| Probe, 10 point gain | 10 |
| Probe, 5 point gain | 38 |
| Stance narrowed, 10 point gain | 16 |
| Stance narrowed, 5 point gain | 91 |
| Seeded, 10 point gain | 73 |
| Seeded, 5 point gain | 312 |
Prompts count, extra tries per prompt do not.
Four ways an eval lies
| The lie | The fix |
|---|---|
| The simulated customer runs on the model under test, so you measure the pair | Pin it with user_model= |
| Long conversations fail to grade, and long ones fail more, so the dropped rows flatter the score. One base rate moved from 0.717 to 0.603 when they came back | Report the graded count on each side |
| A fixed prompt list pins only the first message; the rest is still generated | Check turn counts on both sides |
| A fake world that echoes the agent's claims back as facts makes any claim look grounded | Use the real tools or a real execute= world |
What this teaches about post-training
A mean without a band is not a result, and the RLHF book [1, ยง16] adds the part people skip: the test's own noise decides what a difference can mean. A test that cannot resolve a 5 point gain calls every 5 point gain "no change". Put the situation mix next to the number, because a 95% pass rate means a strong agent or an easy test, and only the mix says which.
For researchers
Each prompt gets rollouts and pass. pass@ is the estimator of Chen et al. [2], pass the share passing every sample, intervals are percentile bootstraps over prompts [3], :
The difference is paired on prompts, each side:
Only a prompt with on one side can move ; with such prompts of the largest gain is .
| Probe | Steered one | Steered two | Seeded | |
|---|---|---|---|---|
| Setup | default mix | grid, stance = ambiguous, unsure; arm_weights structured 0.7, llm_guided 0.2, open_ended 0.1 | grid, tool = issue_refund, multi_tool; rule = 30-day, $200; stance = ambiguous, unsure, boundary, adversarial; same weights | eight seeds naming real ids, same grid and weights |
| Rows | 160 | 164 (groups 4 to 8 repeats, k reported at 4) | 160 | 160 |
| pass@1 | 0.95 (0.88 to 1.00) | 0.91 (0.82 to 0.97) | 0.78 (0.64 to 0.89) | 0.70 (0.56 to 0.82) |
| pass^4 | 0.95 | 0.90 (0.80 to 0.97) | 0.70 (0.55 to 0.82) | 0.60 (0.45 to 0.75) |
| pass@4 | 0.93 (0.85 to 1.00) | 0.82 (0.70 to 0.93) | 0.80 (0.68 to 0.93) | |
| Headroom | 0.00 | 0.10 | ||
| Failure-capable | 2 of 40 | 4 of 40 | 12 of 40 | 16 of 40 |
| Rows with no tool call | 0 | 94 | 122 | 37 |
| Named id, no lookup | 0 of 160 | 14 of 164 | 36 of 160 | 37 of 160 |
issue_refund rows | 0 | 37, none outside policy | ||
| Observed arm shares | structured 0.37, llm_guided 0.37, open_ended 0.27 | structured 0.475, llm_guided 0.325, open_ended 0.20 | all open_ended, no stance, tool or rule | |
holdout_size 0.10 / 0.05 | 10 / 38 | 16 / 91 | 73 (half-width 0.070) / 312 (0.035) | |
sd_task | 0.11 | 0.30 |
| Check | Value |
|---|---|
| Agent | Claude Haiku 4.5, global.anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, default temperature, single turn, real tools |
| Writer | hosted, seed=0, 40 situations per set, every row graded |
Probe marker refund_only_when_allowed | 0.95 (0.875 to 1.00, 40 tasks) |
Probe marker looked_up_first | 1.00 on 128 applicable rows, flagged degenerate by marker_summary, excluded from must_not_regress |
| First judge on the same rows | pass@1 0.28 (0.15 to 0.42), 29 of 40 failure-capable, 32 rows with no tool call, all judge error |
| Wrong refunds | 0 on every set |
| Seed expansion | does not consult arm_weights or dimensions |
arm_weights= | pins the mix for the run, open_ended held to its 5 to 10 percent band; hosted writer only |
dimensions= | restricts an axis before the covering array is drawn; hosted writer only |
| Per-prompt spread across our sets | 0.23 to 0.45; one set that assumed the middle planned for a 6.5 point resolvable gain when its rows resolved 4.4 |
| Reversal charts, 52 of 219 lane, dropped rows 0.717 to 0.603 | five simulated agent sets measured while writing the strengthen-your-evals skill [4] |
| Protocol | RLHF book [1]: bootstrap over prompts, pass@1 beside pass^k, decontamination (ยง16), over-optimization symptoms (ยง14), the judge as a reward model (ยง5, ยง12) |
holdout_size assumption | the gain lands evenly across prompts; a gain on a few prompts needs more, which is the probe case |
holdout_size(effect, rows=): with base rate , target
and rollouts, one prompt's paired difference has
and two-sided power at , gives
References
- Lambert, N. (2025). Reinforcement Learning from Human Feedback. arXiv:2504.12501. Online at rlhfbook.com.
- Chen, M., Tworek, J., Jun, H., Yuan, Q., Pinto, H. P. de O., Kaplan, J., et al. (2021). Evaluating large language models trained on code. arXiv:2107.03374.
- Efron, B. (1979). Bootstrap methods: Another look at the jackknife. The Annals of Statistics, 7(1), 1-26.
- whilehq (2026). strengthen-your-evals [skill]. In the whileai SDK, skills/strengthen-your-evals/SKILL.md.
- whilehq (2026). whileai SDK [software]. Apache 2.0. github.com/whilehq/whileai-sdk.
Run it
pip install whileai anthropic boto3
wai login # the hosted writer needs a key; the agent needs AWS credentials
python probe.py
python steer.py
python seeds.pyTested on whileai 0.61. Each hosted run of 40 situations took about eight
minutes. Off Bedrock, point the client line at anthropic.Anthropic()
and set ANTHROPIC_API_KEY. The method is a skill your coding agent can
load [4]. The offline path and the CI gate are in
recipes/02-measure/eval-your-agent.
FAQ
What does failure-capable mean? A prompt the agent fails on at least one try. Only those prompts can show a gain, so their count is the real size of your test.
My before-and-after band straddles zero. Did training fail? Not necessarily. The test cannot tell at this size. Add prompts, or take "the gain is smaller than the band" as the finding.
Which situations should I steer toward? The ones your own agent failed. A ranking from someone else's agent does not transfer.