Regression Suites in CI
A bug you fixed without a test is a bug you will ship again. Every fixed failure becomes a permanent test case; the whole suite runs on every prompt and model change; the pipeline reports pass/fail and cost. This is how prompts become code.
In normal software, when you fix a bug you add a test so it never comes back. Agent engineering is no different, except the 'code' includes your prompts, your tool descriptions, and the model version. Every one of those is a change that can silently regress behavior you already fixed. The discipline: the moment you fix a failure, you capture it as a case in the regression suite, and the suite runs on every change to any of those inputs.
Turn every fixed bug into a case
The workflow is mechanical and non-negotiable. A failure comes in. You reproduce it, understand it, fix it. Before you close it, you write the minimal case that fails on the old behavior and passes on the new — and you add it to the suite. Over a few months this accretes into a suite that encodes your agent's entire painful history, so it can never repeat it.
# Colab cell — pure Python, no key needed. Stub agent + judge stand in for
# your real ones so the mixed-tier runner executes end to end.
import json
from collections import namedtuple
Usage = namedtuple("Usage", "input_tokens output_tokens")
ToolCall = namedtuple("ToolCall", "name")
Result = namedtuple("Result", "text tool_calls usage")
def run_agent(prompt: str) -> Result: # stub for your real agent
if "cart" in prompt.lower():
return Result("Here is your cart.", [ToolCall("lookup_cart")], Usage(1200, 300))
return Result("Returns are accepted within 30 days.", [], Usage(1500, 400))
FAITHFULNESS_RUBRIC = "PASS if the answer only states facts from context."
def run_judge(text: str, rubric: str) -> str: # stub for your real judge
return "pass" if "30 days" in text else "fail"
# Each case declares HOW it should be scored, so the runner can mix tiers.
# In production these live as cases/*.json; inlined here so the cell runs.
CASES = [
{"id": "bug_412_empty_cart", "prompt": "Show my cart", "check": "assert",
"must_call": "lookup_cart", "must_not_call": "issue_refund"},
{"id": "bug_419_hallucinated_policy", "prompt": "What's the return policy?",
"check": "judge", "rubric": "faithfulness"},
]
def load_cases():
return iter(CASES)
# $/1M tokens at list — keep rates in one constant you can update
PRICE = {"input": 3.00, "output": 15.00} # claude-sonnet-5 list price
def usd(usage) -> float:
# No SDK returns dollars — you always compute them from token counts.
return (usage.input_tokens * PRICE["input"]
+ usage.output_tokens * PRICE["output"]) / 1e6
def score_case(case) -> tuple[bool, float]:
result = run_agent(case["prompt"])
cost = usd(result.usage) # Lesson 4 automates this via Langfuse
if case["check"] == "assert":
called = {c.name for c in result.tool_calls}
ok = (case.get("must_call", None) in called or "must_call" not in case) \
and case.get("must_not_call", "___none___") not in called
return ok, cost
if case["check"] == "judge":
verdict = run_judge(result.text, FAITHFULNESS_RUBRIC)
return verdict == "pass", cost
raise ValueError(f"unknown check type: {case['check']}")
def main():
passed = failed = 0
total_cost = 0.0
failures = []
for case in load_cases():
ok, cost = score_case(case)
total_cost += cost
if ok:
passed += 1
else:
failed += 1
failures.append(case["id"])
print(f"PASS {passed} FAIL {failed} COST $" + f"{total_cost:.3f}")
if failures:
print("failing cases:", ", ".join(failures))
raise SystemExit(1 if failed else 0) # non-zero fails the CI job
try:
main()
except SystemExit as e:
print(f"(in CI this exit code {e.code} gates the merge)")raise SystemExit(1 ...) is what makes it a real CI gate — a non-zero exit fails the pipeline job and blocks the merge (the demo catches it just to print the code cleanly). Storing cases as small JSON files means adding a regression is a one-file commit, and the diff makes the new coverage reviewable. Swap the two stubs for your real agent and judge and the runner is unchanged.What belongs in a prompt-change CI pipeline
- The deterministic suite on every commit — fast, free, blocks obvious breakage.
- The judged suite on changes to prompts, tools, or model version — the ones that can shift behavior subtly.
- A cost budget check — fail the build if aggregate eval cost or per-run cost jumps beyond a threshold, so a prompt that doubles token use gets caught here, not in the bill.
- Pinned model versions in the eval config, so you know whether a change came from your edit or a silent provider update.
- A diff-friendly report posted to the PR: pass/fail counts, newly failing cases, cost delta versus main.
name: agent-evals
on:
pull_request:
paths:
- "prompts/**"
- "src/agent/**"
- "cases/**"
jobs:
regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Run deterministic + judged regression suite
env:
ANTHROPIC_API_KEY: {{ secrets.ANTHROPIC_API_KEY }}
run: python -m evals.run_suite # exits non-zero on any failurepaths filter is deliberate: the judged suite costs money per run, so you gate it on changes to the things that actually move behavior — prompts, agent source, and cases. In real YAML the secret reference uses the dollar-brace syntax around secrets.ANTHROPIC_API_KEY; it is written with plain braces here to keep the sample copy-safe. Keep the deterministic-only suite on a broader trigger since it is free.Statistical rigor at small n
A single pass/fail run over a suite is one sample from a noisy process, and because the API gives you no determinism knob, re-running the identical prompt can produce a different tool call or a differently-phrased answer that a judge scores differently. On a 50-case suite, a swing from 82% to 84% (41/50 → 42/50) sits comfortably inside the noise you'd see from re-running the same unchanged prompt twice — treating it as evidence the change helped is a classic small-n mistake. Concretely: at n=50, the 95% confidence interval around an 80% pass rate spans roughly ±11 points: a genuinely unchanged system can bounce between the low 70s and low 90s across replays. A 2-point delta tells you almost nothing until you either shrink that interval or change how you're measuring.
Two cheap fixes, in order of leverage. (1) Run more items. Confidence intervals shrink with sample size, not with more careful reading of a fixed set — going from 50 to 200 cases tightens the interval far more than any amount of rubric-tuning, because variance is a property of n. (2) Run paired comparisons instead of independent aggregates. Comparing 'baseline: 82% on 50 cases' against 'candidate: 84% on 50 cases' as two independent numbers wastes the fact that they're the same 50 cases — instead, score baseline and candidate on each case and count wins/losses/ties directly (a paired sign test). Pairing cancels out per-case difficulty (a case that's hard for every version stops contributing noise to the comparison) and needs far fewer samples to detect a real difference than comparing two independent percentages. For tasks where any successful attempt counts — code generation, retry-tolerant agent tasks — report pass@k: the probability that at least one of k sampled attempts succeeds, and never conflate it with pass@1 ('did the single production-shaped attempt succeed'), since a system can have great pass@k and mediocre pass@1 if it only succeeds with retries.
# Colab cell — pure Python, no key needed; run it as-is.
import math
def wilson_interval(successes: int, n: int, z: float = 1.96) -> tuple[float, float]:
"""Approximate 95% CI for a pass rate. Cheap gut-check before trusting a delta."""
if n == 0:
return (0.0, 0.0)
p = successes / n
denom = 1 + z**2 / n
center = p + z**2 / (2 * n)
margin = z * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2))
return ((center - margin) / denom, (center + margin) / denom)
baseline = wilson_interval(41, 50) # 82%
candidate = wilson_interval(42, 50) # 84%
print(f"baseline 95% CI: {baseline[0]:.2f}-{baseline[1]:.2f}")
print(f"candidate 95% CI: {candidate[0]:.2f}-{candidate[1]:.2f}")
# These overlap heavily -> the 2-point move is noise, not signal at n=50.
def paired_sign_test(baseline_pass: list[bool], candidate_pass: list[bool]) -> dict:
"""Same cases, both versions -> count flips. Far more powerful than
comparing two independent aggregate percentages."""
newly_passing = sum(not b and c for b, c in zip(baseline_pass, candidate_pass))
newly_failing = sum(b and not c for b, c in zip(baseline_pass, candidate_pass))
return {"newly_passing": newly_passing, "newly_failing": newly_failing,
"net": newly_passing - newly_failing}
# same 50 cases scored under both versions -> count the flips directly:
baseline_pass = [True]*41 + [False]*9
candidate_pass = [True]*40 + [False]*7 + [True]*3 # 3 newly pass, 1 flips the other way
print(paired_sign_test(baseline_pass, candidate_pass))CI integration realities
- Cache model outputs by input hash. Memoize LLM/judge calls keyed on (prompt, model version, inputs) so re-running the suite for an unrelated code change doesn't re-spend money and re-introduce sampling noise on cases nothing touched; invalidate the cache only when the model version, prompt, or case itself changes.
- Set a hard cost ceiling per run and per day. Fail the build (or fall back to the deterministic-only subset) if a PR's eval cost exceeds a threshold — this is the mechanism, not just the aspiration, behind the cost-budget check above.
- Decide gating thresholds and who can override them in advance, not during an incident. A failing gate should default to blocking merge; only a named role (the prompt's owner, an on-call lead — never the change's own author) can override, and the override must carry a logged justification and a follow-up ticket, so 'urgent fix, skip the gate' doesn't quietly become the normal path around it.
temperature=0 to both the agent-under-test and the judge model calls in the regression suite, reasoning it will 'fight flakiness.' The next CI run fails with a 400 error from the Anthropic API on every single case. What happened, and what should the suite do instead to manage flakiness?Whiteboard drills
- ▸Every fixed bug becomes a permanent regression case — no test, no fix.
- ▸Prompts, tool descriptions, and model version are all 'code'; changing any can regress fixed behavior.
- ▸One command runs the mixed suite and exits non-zero to gate the merge.
- ▸CI pipeline: deterministic on every commit, judged on prompt/model changes, plus a cost-budget check.
- ▸Pin model versions so you can tell your change from a provider update.
- ▸Claude models reject temperature/top_p/top_k (400) — you can't dial down sampling noise, so manage it statistically instead: pin what you control, prefer assertions, and use k-sample majority vote or paired comparisons for judged checks.
- ▸A small delta on a small suite is usually noise: check confidence intervals or, better, paired flips before trusting an aggregate percentage move.
- ▸Cache model outputs by input hash, set hard cost ceilings, and predefine who can override a failing gate (never the change's own author) with a logged justification.