Blog

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.

Prompts the agent could fail, out of 40

Four sets of 40 prompts, four tries each. Only the last one gave training something to move.

Prompts the agent could fail, out of 40
ItemValue
Default mix2
Stance narrowed4
Refund tool and rules only12
Seeded with real order ids16

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 ceiling

Our 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.

Probe pass rate by cell, 160 rows

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.

Probe pass rate by cell, 160 rows
ItemValue
All rows0.95
Arm: open-ended0.88
Arm: structured0.95
Arm: model-guided1.00
Stance: ambiguous0.75
Stance: none set0.88
Stance: every other value1.00
Tool: healthy0.94
Tool: timeout, denied, stale, malformed1.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)}")
Pass rate per set, with 95% bands
Pass rate per set, with 95% bands
ItemValue95% interval
Default mix0.950.88 to 1.00
Stance narrowed to ambiguous and unsure0.910.82 to 0.97
Refund tool, the two refund rules, four hard stances0.780.64 to 0.89
Seeded with eight asks that name real orders0.700.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.

Structured vs open-ended situations, points harder
Structured vs open-ended situations, points harder
ItemValue
Set A+16 pts
Set B+9 pts
Set C-14 pts
Set Dno rows
Adversarial vs ordinary customers, points harder
Adversarial vs ordinary customers, points harder
ItemValue
Set A+21 pts
Set B+32 pts
Set C+18 pts
Set D-8 pts
Boundary vs ordinary requests, points harder

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.

Boundary vs ordinary requests, points harder
ItemValue
Set A+10 pts
Set B-1 pts
Set Cno rows
Set Dno 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))
Prompts needed to prove a gain, from each set's own 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.

Prompts needed to prove a gain, from each set's own rows
ItemValue
Probe, 10 point gain10
Probe, 5 point gain38
Stance narrowed, 10 point gain16
Stance narrowed, 5 point gain91
Seeded, 10 point gain73
Seeded, 5 point gain312

Prompts count, extra tries per prompt do not.

Four ways an eval lies

The lieThe fix
The simulated customer runs on the model under test, so you measure the pairPin 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 backReport the graded count on each side
A fixed prompt list pins only the first message; the rest is still generatedCheck turn counts on both sides
A fake world that echoes the agent's claims back as facts makes any claim look groundedUse 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 ii gets n=4n = 4 rollouts and cic_i pass. pass@kk is the estimator of Chen et al. [2], passk^k the share passing every sample, intervals are percentile bootstraps over prompts [3], B=2,000B = 2{,}000:

pass@k=1Nโˆ‘i=1N[1โˆ’(nโˆ’cik)(nk)],passk=1Nโˆ‘i=1N1[ci=n].\text{pass@}k = \frac{1}{N} \sum_{i=1}^{N} \left[ 1 - \frac{\binom{n - c_i}{k}}{\binom{n}{k}} \right], \qquad \text{pass}^k = \frac{1}{N} \sum_{i=1}^{N} \mathbb{1}[c_i = n].

The difference is paired on prompts, p^i=ci/n\hat p_i = c_i / n each side:

ฮ”=1Nโˆ‘i=1N(p^iafterโˆ’p^ibefore).\Delta = \frac{1}{N} \sum_{i=1}^{N} \left( \hat p_i^{\text{after}} - \hat p_i^{\text{before}} \right).

Only a prompt with 0<ci<n0 < c_i < n on one side can move ฮ”\Delta; with FF such prompts of NN the largest gain is F/NF / N.

ProbeSteered oneSteered twoSeeded
Setupdefault mixgrid, stance = ambiguous, unsure; arm_weights structured 0.7, llm_guided 0.2, open_ended 0.1grid, tool = issue_refund, multi_tool; rule = 30-day, $200; stance = ambiguous, unsure, boundary, adversarial; same weightseight seeds naming real ids, same grid and weights
Rows160164 (groups 4 to 8 repeats, k reported at 4)160160
pass@10.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^40.950.90 (0.80 to 0.97)0.70 (0.55 to 0.82)0.60 (0.45 to 0.75)
pass@40.93 (0.85 to 1.00)0.82 (0.70 to 0.93)0.80 (0.68 to 0.93)
Headroom0.000.10
Failure-capable2 of 404 of 4012 of 4016 of 40
Rows with no tool call09412237
Named id, no lookup0 of 16014 of 16436 of 16037 of 160
issue_refund rows037, none outside policy
Observed arm sharesstructured 0.37, llm_guided 0.37, open_ended 0.27structured 0.475, llm_guided 0.325, open_ended 0.20all open_ended, no stance, tool or rule
holdout_size 0.10 / 0.0510 / 3816 / 9173 (half-width 0.070) / 312 (0.035)
sd_task0.110.30
CheckValue
AgentClaude Haiku 4.5, global.anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, default temperature, single turn, real tools
Writerhosted, seed=0, 40 situations per set, every row graded
Probe marker refund_only_when_allowed0.95 (0.875 to 1.00, 40 tasks)
Probe marker looked_up_first1.00 on 128 applicable rows, flagged degenerate by marker_summary, excluded from must_not_regress
First judge on the same rowspass@1 0.28 (0.15 to 0.42), 29 of 40 failure-capable, 32 rows with no tool call, all judge error
Wrong refunds0 on every set
Seed expansiondoes 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 sets0.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.603five simulated agent sets measured while writing the strengthen-your-evals skill [4]
ProtocolRLHF 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 assumptionthe 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 pp, target q=p+ฮดq = p + \delta and kk rollouts, one prompt's paired difference has

ฯƒ=p(1โˆ’p)+q(1โˆ’q)k,\sigma = \sqrt{\frac{p(1-p) + q(1-q)}{k}},

and two-sided power at ฮฑ=0.05\alpha = 0.05, 1โˆ’ฮฒ=0.81 - \beta = 0.8 gives

N=((z1โˆ’ฮฑ/2+z1โˆ’ฮฒ)โ€‰ฯƒฮด)2.N = \left( \frac{(z_{1-\alpha/2} + z_{1-\beta})\,\sigma}{\delta} \right)^2 .

References

  1. Lambert, N. (2025). Reinforcement Learning from Human Feedback. arXiv:2504.12501. Online at rlhfbook.com.
  2. 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.
  3. Efron, B. (1979). Bootstrap methods: Another look at the jackknife. The Annals of Statistics, 7(1), 1-26.
  4. whilehq (2026). strengthen-your-evals [skill]. In the whileai SDK, skills/strengthen-your-evals/SKILL.md.
  5. 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.py

Tested 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.