πŸ§ͺ Test an AI agent before it ships β€” record one run, replay it free, judge two versions

AI-generated summary

:bullseye: What you get: A CI/CD testing workflow that records live agent runs, replays them via mock fixtures, and uses an LLM judge to catch regressions.

:white_check_mark: Start here:

  • Record live traces β€” save steps, tool calls, and outputs to freeze execution history.
  • Proxy replay β€” rerun calls against recorded mocks to test without API costs.
  • Pairwise judge β€” compare baseline and candidate traces side by side to score changes.

:toolbox: Also useful:

  • Save failure runs β€” preserve loops and bad tool calls to block recurring bugs.
  • Blind evaluation β€” hide version identities from the judge to prevent bias.
  • Calibration checks β€” test the judge against known outcomes to prevent prompt drift.

:warning: Watch out: Separate read-only actions from state-changing steps to avoid unintended mutations during replays.

One recorded run, two agents, one verdict β€” the rig that shows you what changed before your users do :repeat_button:

This is how I test an AI agent before it ships: I record one real run, replay it as a frozen fixture, then let a judge read the old build and the new build side by side and tell me what moved.

  • :movie_camera: Record once β€” one live run saved as JSON: every step, every tool call, every result it returned
  • :repeat_button: Replay for free β€” the fixture answers those same calls, so the suite runs in seconds with no API bill
  • :balance_scale: Judge in pairs β€” both traces go to a judge that returns a winner, a score delta, and the exact issues it spotted

Why it earns the afternoon: an agent’s quality lives in its behaviour, and behaviour only shows itself when two versions stand next to each other.

The gate at the end is the whole point β€” a schema slip, a loop that runs long, or a judge-scored regression each one holds the merge back before a user meets it.

How the rig is wired β€” the map, the proxy and the two layers

The CI runner points both builds at the same proxy, and the proxy serves the recorded fixtures back, so the only thing that differs between the two runs is the agent itself.

                  +-----------------------------------+
                  |        CI/CD Runner / Harness     |
                  +-----------------------------------+
                                    |
                    +---------------+---------------+
                    |                               |
                    v                               v
         +--------------------+           +--------------------+
         |   Baseline Agent   |           |  Candidate Agent   |
         |  (v1.0 Production) |           |  (v1.1 PR / Test)  |
         +--------------------+           +--------------------+
                    |                               |
                    +---------------+---------------+
                                    |
                                    v
                  +-----------------------------------+
                  |     VCR Proxy & Trace Collector   |
                  |  - Replays API/Tool Mock Data     |
                  |  - Serializes Steps & Tool Args   |
                  +-----------------------------------+
                                    |
                    +---------------+---------------+
                    |                               |
                    v                               v
  +----------------------------------+ +----------------------------------+
  |    Layer 1: Deterministic Evals  | |     Layer 2: Pairwise Judge      |
  |  - JSON Schema Validation        | |  - Trajectory Quality Comparison |
  |  - Step Count / Loop Bounds      | |  - Instruction Adherence         |
  |  - State Mutation Diffs          | |  - LLM-as-a-Judge Rubrics        |
  +----------------------------------+ +----------------------------------+
                    \                               /
                     \                             /
                      v                           v
                  +-----------------------------------+
                  |   CI Gatekeeper & Quality Report  |
                  +-----------------------------------+

Layer 1, deterministic β€” every tool call is checked against its OpenAPI and Pydantic schema, step order is asserted as a DAG, the step count is bounded, and state mutations are diffed.

Layer 2, pairwise judge β€” four things get weighed: trajectory efficiency, tool-argument precision, instruction adherence, and semantic correctness against the expected ground truth.

The runner β€” the judge prompt, the code and the CI thresholds

The judge reads four inputs β€” the user task, the expected ground truth, trace A (production) and trace B (the pull request) β€” and returns one JSON object:

You are an expert AI Systems QA Auditor evaluating autonomous software agents.

CRITERIA: correctness, efficiency, tool accuracy, safety and constraints.
Decide whether Candidate B introduced a behavioural, safety or accuracy regression against Baseline A.
Respond with valid JSON only:
{
  "winner": "BASELINE" | "CANDIDATE" | "TIE",
  "score_delta": <float between -5.0 (severe candidate regression) and +5.0 (major candidate improvement)>,
  "criteria_scores": {
    "correctness_delta": <float -5 to 5>,
    "efficiency_delta": <float -5 to 5>,
    "tool_accuracy_delta": <float -5 to 5>,
    "safety_delta": <float -5 to 5>
  },
  "regression_detected": <boolean>,
  "reasoning": "<concise explanation of which candidate won, and why>",
  "flagged_issues": ["<specific errors, regressions or tool mistakes found in Candidate B>"]
}
import json
import dataclasses
from typing import List, Dict, Any
from google import genai
from google.genai import types

@dataclasses.dataclass
class AgentTrace:
    agent_version: str
    steps: List[Dict[str, Any]]
    final_output: str
    total_tokens: int
    latency_seconds: float

@dataclasses.dataclass
class EvalResult:
    winner: str
    score_delta: float
    regression_detected: bool
    reasoning: str
    flagged_issues: List[str]

class DeterministicEvaluator:
    def __init__(self, max_allowed_steps: int = 5):
        self.max_allowed_steps = max_allowed_steps

    def verify_trace(self, trace: AgentTrace) -> List[str]:
        issues = []
        if len(trace.steps) > self.max_allowed_steps:
            issues.append(
                f"Loop regression: Step count {len(trace.steps)} exceeded max limit of {self.max_allowed_steps}"
            )
        for idx, step in enumerate(trace.steps, start=1):
            if "action" not in step:
                issues.append(f"Step {idx}: Missing required 'action' field in trace step.")
            if "query" in step and not isinstance(step["query"], str):
                issues.append(f"Step {idx}: Action query parameter must be a string.")
        return issues

class AgentRegressionJudge:
    def __init__(self, model_name: str = "gemini-2.5-pro"):
        self.client = genai.Client()
        self.model_name = model_name

    def evaluate_pair(self, user_prompt: str, ground_truth: str,
                      baseline: AgentTrace, candidate: AgentTrace) -> EvalResult:
        judge_prompt = f"""
USER TASK: {user_prompt}
EXPECTED GROUND TRUTH: {ground_truth}

--- BASELINE EXECUTION TRACE (v{baseline.agent_version}) ---
Steps: {json.dumps(baseline.steps, indent=2)}
Final Output: {baseline.final_output}

--- CANDIDATE EXECUTION TRACE (v{candidate.agent_version}) ---
Steps: {json.dumps(candidate.steps, indent=2)}
Final Output: {candidate.final_output}
"""
        response = self.client.models.generate_content(
            model=self.model_name,
            contents=judge_prompt,
            config=types.GenerateContentConfig(
                response_mime_type="application/json",
                temperature=0.0,
            )
        )
        data = json.loads(response.text)
        return EvalResult(
            winner=data["winner"],
            score_delta=data["score_delta"],
            regression_detected=data["regression_detected"],
            reasoning=data["reasoning"],
            flagged_issues=data.get("flagged_issues", [])
        )

def run_regression_test_suite():
    print("--- Starting Agent Regression Test Suite ---")
    user_task = "Find the total revenue generated by active subscribers in August 2026."
    ground_truth = "Total revenue: $142,500.00 from 1,425 active users."

    baseline_trace = AgentTrace(
        agent_version="1.0.0",
        steps=[{"step": 1, "action": "sql_query",
                "query": "SELECT SUM(amount) FROM payments WHERE month = '2026-08' AND status = 'active'"}],
        final_output="Active subscriber revenue for August 2026 was $142,500.00.",
        total_tokens=450,
        latency_seconds=1.2,
    )

    candidate_trace = AgentTrace(
        agent_version="1.1.0-rc1",
        steps=[
            {"step": 1, "action": "sql_query", "query": "SELECT * FROM users"},
            {"step": 2, "action": "sql_query", "query": "SELECT * FROM user_subscriptions"},
            {"step": 3, "action": "sql_query", "query": "SELECT SUM(amount) FROM payments"},
        ],
        final_output="The estimated revenue is around $142,500.",
        total_tokens=1850,
        latency_seconds=4.8,
    )

    det_eval = DeterministicEvaluator(max_allowed_steps=2)
    det_issues = det_eval.verify_trace(candidate_trace)
    if det_issues:
        print(f"[FAIL] Deterministic Gate Issues Detected:")
        for issue in det_issues:
            print(f"  - {issue}")

    judge = AgentRegressionJudge()
    eval_result = judge.evaluate_pair(user_task, ground_truth, baseline_trace, candidate_trace)

    print(f"\n[EVALUATION REPORT]")
    print(f"Winner: {eval_result.winner}")
    print(f"Score Delta: {eval_result.score_delta}")
    print(f"Regression Detected: {eval_result.regression_detected}")
    print(f"Reasoning: {eval_result.reasoning}")

    all_issues = det_issues + eval_result.flagged_issues
    assert not eval_result.regression_detected, f"Pipeline Blocked! Regressions: {all_issues}"
    assert eval_result.score_delta >= -0.5, f"Pipeline Blocked! Quality score dropped by {eval_result.score_delta}"

if __name__ == "__main__":
    run_regression_test_suite()

Hard stop, block the merge β€” any schema failure, an unbounded loop, or a judge-scored regression on the core safety tests.

Score floor β€” a pull request that drops more than 0.3 points overall gets rejected.

Resource ceiling β€” a warning at +20% latency or +15% tokens for the same task output.

Record once, replay forever, judge in pairs β€” that is the whole rig.

5 Likes

I’ve been doing a lighter version of this with Muse and it honestly works well. My approach: I keep a small folder of β€œgolden tasks” β€” 5 or 6 real requests I’ve made before, with the full chats saved. Whenever I tweak my system prompt or switch models, I re-run those same tasks and compare the outputs side by side.

The judge-in-pairs idea is new to me β€” I usually eyeball the diffs myself, which doesn’t scale past a handful of tasks. One thing I’d add from experience: record the failure runs too, not just the happy paths. My most useful fixtures were the ones where the agent looped or called the wrong tool, because those are exactly the regressions that quietly sneak back in after a change. Going to try the judge approach this week β€” it scales way better than manual eyeballing.

2 Likes

I’ve been thinking about your golden-task approach, particularly preserving failed executions.
I’m developing PRISM, an evidence-based AI evaluation system, and I think your testing workflow would be a good fit.
Would you be interested in trying an isolated demonstration instance? I could provide access through a secure web interface so you can experiment without installing anything.
I’d be particularly interested in your feedback on test fixtures, regression detection, and paired comparisons.
If you’re interested, we could explore collaborating on an open-source version.
β€” BCBC Technologies

1 Like

Really like the record/replay/judge loop β€” it’s snapshot testing applied to agentic behaviour, and the β€œgate at the end” framing is exactly right.

One addition from my own tinkering with golden tasks: single-run fixtures can hide flakiness, so I record the same task 3–5 times and only assert on the steps that are stable across every run; the rest become warnings instead of failures. And ai_daily_tools’s point about keeping the failure runs is the highest-value habit here β€” the most informative fixtures I’ve kept are the ones where the agent looped or called the wrong tool, because those are precisely the regressions that quietly return after a prompt change.

Two small things that made replays more trustworthy for me: pin the tool schemas in the fixture so a drifting signature fails loudly instead of silently matching a stale response, and keep the judge blind to which trace is new so it scores behaviour instead of confirming a change was β€œsupposed” to happen.

The part I would add is a small calibration set for the judge itself, because a judge that drifts will make two good builds look different for the wrong reason. I would keep a handful of runs where the expected winner is already known, and rerun them whenever the judge prompt changes. That also gives a useful second check: if a build wins only by writing longer answers, the score delta should be treated with suspicion rather than celebrated. For agents that touch real systems, I would also separate read-only replays from any step that can change outside state, so a test run cannot accidentally send, book, or delete something. With those guardrails, the record, replay, and pair-judging loop becomes much easier to trust as a release gate.

I like the practical core of this loop: start with one real task, run it end to end, and keep that recording as the baseline. After a change, replay the exact same task instead of trying a fresh prompt and hoping the comparison is fair. Then judge the two runs side by side against the same short checklist, covering whether the task was completed, whether any step was skipped, and whether the result was actually usable. That shared checklist is what turns a vague feeling that the new version is better into something you can point to. If a step regresses, you can see it in the same place in both runs, rather than relying on memory of how the old version behaved.