🎬
Layer 4 of 6 Β· Agent Evaluation Stack
Replay Harnesses

Record Real Sessions.
Replay Them Deterministically.

Freeze entire AI agent interactions into fixtures so you can evaluate, debug, and improve reliably β€” without repeated model calls, flaky APIs, or non-deterministic noise.

100%
Deterministic replays
~0
Inference cost on replay
10–100Γ—
Faster eval iterations
1 file
Portable fixture per run

What Are Replay Harnesses?

A Replay Harness captures a complete real-world agent session β€” the original query, every tool call, retrieved documents, intermediate reasoning, and the final output β€” into a reusable fixture file.

You then replay that frozen snapshot as many times as you like against new prompts, models, evaluators, or code changes. Because all external effects (LLM calls, API responses, retrieval) are recorded, every replay is fully deterministic.

This is the same idea as VCR-style HTTP cassettes or golden-file testing, applied to the messy, multi-step, non-deterministic world of AI agents.

In One Sentence

β€œRecord once, replay forever β€” turn a live, expensive, flaky agent run into a cheap, repeatable test.”


Analogy
  • Like a film take you can re-cut
  • Like a VCR cassette for HTTP
  • Like a golden snapshot test

Why This Layer Matters

πŸ”

Reproducibility

Eliminate randomness from LLM temperature, external APIs, and retrieval. Get identical traces every time you run.

πŸ’°

Cost & Speed

Score complex multi-step agents hundreds of times without paying for new inference or waiting on rate limits.

πŸ”

Deep Debugging

Quickly detect regressions when you update prompts, models, or agent logic β€” and pinpoint the exact step that broke.

πŸ§ͺ

CI-Friendly

Fixtures are just files. Commit them to your repo and run the whole eval suite offline in continuous integration.

πŸ›‘οΈ

Safety

Replay dangerous or irreversible tool calls (payments, deletes) as mocks β€” never trigger real side effects during tests.

πŸ“Š

Benchmarking

Build a curated fixture dataset that becomes your regression benchmark for every future model or prompt change.

Replay vs. Live vs. Golden Output

Replay harnesses sit between running everything live and comparing static golden outputs. They give you determinism and the ability to re-run your real agent logic.

Dimension Live Evaluation Replay Harness Golden Output Only
Determinism Non-deterministic Fully deterministic Static
Cost per run High (real inference) ~Zero (mocked calls) Zero
Runs your agent code Yes Yes (with mocks) No
Catches logic regressions Yes Yes No
Tests new models/prompts Yes Partial* No
CI / offline friendly Needs network Fully offline Offline

*You can replay the inputs/context against a new model, but that new model call must run live (or be re-recorded). See Partial Replay.

Anatomy of a Fixture

A fixture is the serialized, replayable memory of one agent session. A robust schema records not just the happy path, but timing, metadata, and enough context to reproduce every decision.

fixtures/support_12345.json
{
  "schema_version": "1.2",
  "session_id": "support_12345",
  "recorded_at": "2026-07-14T15:04:22Z",
  "metadata": {
    "agent": "support-agent",
    "model": "gpt-4o-2024-08-06",
    "git_sha": "a1b9f3c",
    "tags": ["refund", "priority:high"]
  },
  "query": "Can I get a refund for order #12345?",
  "steps": [
    {
      "type": "tool_call",
      "name": "get_order",
      "args": { "order_id": "12345" },
      "output": { "status": "shipped", "total": 79.90 },
      "latency_ms": 142
    },
    {
      "type": "tool_call",
      "name": "check_refund_policy",
      "args": { "status": "shipped" },
      "output": { "eligible": true, "window_days": 30 }
    },
    {
      "type": "llm",
      "prompt_hash": "sha256:8f2a…",
      "output": "You're eligible for a full refund…"
    }
  ],
  "final_output": "You're eligible for a full refund of $79.90.",
  "expected": { "refund_eligible": true }
}

Identity & Versioning

session_id, schema_version, and git_sha make fixtures traceable and safe to evolve over time.

Ordered Steps

Each step is typed (tool_call, llm, retrieval) with args + output so the replay engine can match and return the recorded value.

Observability

Latencies and timestamps let you replay realistic timing and analyze performance offline.

Ground Truth

The optional expected block turns a recording into a scored test case for regression detection.

How Replay Harnesses Work

End-to-End Data Flow
flowchart LR A["πŸ—£οΈ Live Session"] --> B["πŸ“Ό Recorder
(intercept tools/LLM)"] B --> C["πŸ“„ Fixture JSON"] C --> D{"πŸŽ›οΈ Replay Engine"} D --> E["🧩 Mock Tools & APIs"] D --> F["πŸ€– Mock / Real LLM"] E --> G["βš™οΈ Deterministic Run"] F --> G G --> H["πŸ“ Evaluators"] H --> I["πŸ“Š Scores & Reports"] I --> J["🚦 CI Gate / Dashboard"]
Record Phase (Sequence)
sequenceDiagram participant U as User participant A as Agent participant R as Recorder participant T as Tools/LLM U->>A: Query A->>R: tool_call(get_order) R->>T: forward request T-->>R: real response R-->>A: response (and log it) A->>R: llm(generate) R->>T: forward T-->>R: completion R-->>A: completion (logged) A-->>U: Final answer R->>R: write fixture.json
Replay Phase (Sequence)
sequenceDiagram participant H as Harness participant A as Agent participant M as Mock Layer participant E as Evaluators H->>A: replay(query from fixture) A->>M: tool_call(get_order) M-->>A: recorded output (no network) A->>M: llm(generate) M-->>A: recorded completion A-->>H: Final output H->>E: score(output, expected) E-->>H: metrics
01 Β· Record

Wrap your tool + LLM clients so every call is transparently logged during a real run.

02 Β· Store

Serialize the session to a versioned fixture file and commit it to your repository.

03 Β· Replay

Swap live clients for a mock layer that returns recorded values in order.

04 Β· Evaluate

Run evaluators over the deterministic output and gate CI on the scores.

Step-by-Step Implementation

1

Define the fixture data model

Start with typed, validated schemas. This keeps fixtures forward-compatible and makes replay bugs obvious.

harness/models.py
from __future__ import annotations
from dataclasses import dataclass, field, asdict
from enum import Enum
import json, time


class StepType(str, Enum):
    """Discriminates the kind of recorded interaction."""
    TOOL_CALL = "tool_call"
    LLM = "llm"
    RETRIEVAL = "retrieval"


@dataclass(frozen=True)
class Step:
    """A single, replayable interaction within a session.

    Args:
        type: The category of the step.
        name: Logical name (e.g. the tool or model called).
        args: Inputs used to produce ``output``.
        output: The recorded result returned to the agent.
        latency_ms: Observed latency, retained for offline analysis.
    """
    type: StepType
    name: str
    args: dict
    output: object
    latency_ms: int = 0


@dataclass
class Fixture:
    """A complete, serializable record of one agent session."""
    session_id: str
    query: str
    steps: list[Step] = field(default_factory=list)
    final_output: str | None = None
    expected: dict = field(default_factory=dict)
    schema_version: str = "1.2"

    def to_json(self) -> str:
        return json.dumps(asdict(self), indent=2, default=str)

    @classmethod
    def from_json(cls, raw: str) -> "Fixture":
        data = json.loads(raw)
        data["steps"] = [Step(**s) for s in data.get("steps", [])]
        return cls(**data)
2

Record real sessions transparently

A recorder wraps your real tool/LLM clients, forwarding calls while logging every request and response.

harness/recorder.py
import time
from .models import Fixture, Step, StepType


class Recorder:
    """Transparent proxy that logs tool and LLM calls into a Fixture.

    The recorder forwards each call to the real implementation and stores
    the result, so a live run produces a replayable snapshot as a side effect.
    """

    def __init__(self, session_id: str, query: str):
        self.fixture = Fixture(session_id=session_id, query=query)

    def tool(self, name: str, fn):
        """Wrap a callable so its inputs and outputs are recorded.

        Args:
            name: Logical tool name stored in the fixture.
            fn: The real tool implementation to invoke.

        Returns:
            A wrapped callable with identical behavior.
        """
        def wrapped(**kwargs):
            start = time.perf_counter()
            output = fn(**kwargs)
            elapsed = int((time.perf_counter() - start) * 1000)
            self.fixture.steps.append(
                Step(StepType.TOOL_CALL, name, kwargs, output, elapsed)
            )
            return output
        return wrapped

    def save(self, path: str) -> None:
        """Persist the recorded session to disk as JSON."""
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(self.fixture.to_json())
3

Build the mock layer & replay engine

On replay, the mock layer returns recorded outputs in order β€” matched by tool name and arguments β€” so the agent runs its real logic with zero network calls.

harness/replay.py
from typing import Callable
from .models import Fixture, StepType


class ReplayError(RuntimeError):
    """Raised when the agent requests a call not present in the fixture."""


class MockLayer:
    """Serves recorded outputs deterministically during replay."""

    def __init__(self, fixture: Fixture):
        self._steps = list(fixture.steps)
        self._cursor = 0

    def tool(self, name: str):
        """Return a mock callable that yields the next recorded output.

        Raises:
            ReplayError: If the requested tool does not match the recording.
        """
        def wrapped(**kwargs):
            if self._cursor >= len(self._steps):
                raise ReplayError(f"No recorded step left for tool '{name}'")
            step = self._steps[self._cursor]
            if step.name != name:
                raise ReplayError(
                    f"Expected '{step.name}' but agent called '{name}'"
                )
            self._cursor += 1
            return step.output
        return wrapped


class ReplayHarness:
    """Loads fixtures, replays them against agent logic, and scores output."""

    def __init__(self, fixtures_dir: str = "fixtures"):
        self.fixtures_dir = fixtures_dir

    def load(self, session_id: str) -> Fixture:
        with open(f"{self.fixtures_dir}/{session_id}.json") as fh:
            return Fixture.from_json(fh.read())

    def replay(
        self,
        session_id: str,
        agent_factory: Callable[[MockLayer], Callable[[str], str]],
        evaluators: dict[str, Callable] | None = None,
    ) -> dict:
        """Replay a fixture and evaluate the deterministic output.

        Args:
            session_id: Fixture identifier to load.
            agent_factory: Builds an agent bound to the mock layer.
            evaluators: Named scoring functions ``fn(output, fixture) -> value``.

        Returns:
            A mapping of evaluator name to score, plus the raw output.
        """
        fixture = self.load(session_id)
        agent = agent_factory(MockLayer(fixture))
        output = agent(fixture.query)
        scores = {
            name: fn(output, fixture)
            for name, fn in (evaluators or {}).items()
        }
        return {"output": output, "scores": scores}
4

Write evaluators & a CI test

Evaluators turn output into numbers. Wire them into pytest so every commit re-runs your fixtures offline.

tests/test_support_agent.py
import pytest
from harness.replay import ReplayHarness


def exact_match(output: str, fixture) -> float:
    """Return 1.0 when the output matches the recorded final output."""
    return float(output.strip() == (fixture.final_output or "").strip())


def mentions_refund(output: str, fixture) -> float:
    """Heuristic: reward answers that address the refund intent."""
    return float("refund" in output.lower())


@pytest.mark.parametrize("session_id", ["support_12345", "support_67890"])
def test_support_agent_regression(session_id):
    harness = ReplayHarness()
    result = harness.replay(
        session_id,
        agent_factory=build_support_agent,   # binds tools to the mock layer
        evaluators={
            "exact_match": exact_match,
            "mentions_refund": mentions_refund,
        },
    )
    assert result["scores"]["mentions_refund"] == 1.0
    assert result["scores"]["exact_match"] >= 0.0

Partial Replay: Test a New Model on Old Context

A powerful pattern: replay all tool calls and retrieval from the fixture (deterministic, free), but let the final LLM step run live against a new model. This isolates the model change while keeping context identical.

harness/partial.py
def replay_with_new_model(harness, session_id, new_model):
    """Replay recorded context but call a new model for the final answer.

    Tool + retrieval steps are served from the fixture (free, deterministic);
    only the generation step incurs real inference on ``new_model``.
    """
    fixture = harness.load(session_id)
    mocks = MockLayer(fixture)              # deterministic tools/retrieval
    agent = build_agent(mocks, llm=new_model)   # real LLM for generation
    return agent(fixture.query)

Real-World Use Cases

Customer Support

Ticket Resolution Agent

Records order lookups, policy checks, and email drafts. Replay to safely test new response templates and tone.

RAG Systems

Research & Knowledge Agent

Saves web results and document chunks. Compare different retrievers or LLMs on the exact same context.

Software Engineering

Coding & Debugging Agent

Captures repository state, terminal output, and tool usage. Re-evaluate after a model or tool change.

Business Automation

Sales Outreach Agent

Records lead research, personalization, and email generation. Test compliance and tone updates safely.

In Depth, with Code

Use Case 01

Customer Support Ticket Agent

Replay a recorded refund conversation to validate that a new email template still reaches the correct decision.

examples/support.py
def build_support_agent(mocks):
    """Construct a support agent bound to the mock (or real) tool layer."""
    get_order = mocks.tool("get_order")
    check_policy = mocks.tool("check_refund_policy")

    def agent(query: str) -> str:
        order = get_order(order_id=extract_id(query))
        policy = check_policy(status=order["status"])
        if policy["eligible"]:
            return f"You're eligible for a full refund of ${order['total']}."
        return "This order is outside the refund window."
    return agent

result = ReplayHarness().replay(
    "support_12345",
    agent_factory=build_support_agent,
    evaluators={"mentions_refund": mentions_refund},
)
print(result["scores"])   # -> {'mentions_refund': 1.0}
Use Case 02

RAG Research Agent

Freeze retrieved chunks so you can compare faithfulness and answer relevance across models on identical evidence.

examples/rag.py
from harness.replay import ReplayHarness

def faithfulness_score(output, fixture) -> float:
    """Fraction of claims in ``output`` grounded in retrieved context."""
    context = _collect_retrieved_text(fixture)
    claims = split_claims(output)
    grounded = [c for c in claims if supported_by(c, context)]
    return len(grounded) / max(len(claims), 1)

def answer_relevance(output, fixture) -> float:
    """Cosine similarity between the answer and the original query."""
    return cosine(embed(output), embed(fixture.query))

result = ReplayHarness().replay(
    "research_42",
    agent_factory=build_rag_agent,
    evaluators={
        "faithfulness": faithfulness_score,
        "answer_relevance": answer_relevance,
    },
)
Use Case 03

Coding Agent

Snapshot the repository state so a coding agent can be re-evaluated deterministically after model or tool changes.

examples/coding.py
class CodingHarness(ReplayHarness):
    """Replay harness specialized for repository-editing agents."""

    def replay_execution(self, session_id: str):
        """Load a repo snapshot and re-run the agent's edit against it.

        Returns:
            A diff describing the changes the agent produced.
        """
        fixture = self.load(session_id)
        repo = load_snapshot(fixture.steps[0].output["repo_state"])
        mocks = MockLayer(fixture)
        agent = build_coding_agent(mocks, repo)
        return agent(fixture.query)   # deterministic edit, no live tools

Best Practices & Common Pitfalls

Do

  • Version your schema. Store a schema_version and migrate fixtures deliberately.
  • Redact secrets & PII. Scrub tokens, emails, and card data before writing fixtures to disk.
  • Match calls precisely. Key mocks on tool name + normalized args to catch logic drift.
  • Curate a golden set. Keep a diverse fixture library covering edge cases as a benchmark.
  • Commit fixtures to git. They are your regression suite; treat them as first-class test assets.

Avoid

  • Over-strict matching. Timestamps or UUIDs in args cause false replay misses β€” normalize them.
  • Stale fixtures. When tool contracts change, re-record; don't silently patch outputs.
  • Recording secrets. Never commit raw API keys or customer data into fixtures.
  • Only exact-match evals. LLM output varies; combine with semantic and heuristic scorers.
  • Giant monolithic fixtures. Prefer one focused session per file for clarity and diffs.

Frequently Asked Questions

How is this different from mocking in unit tests?

Traditional mocks are hand-written and brittle. A replay harness auto-records real interactions into a fixture, so your mocks always reflect true system behavior and stay in sync with reality.

Can I test a brand-new model with a replay?

Yes β€” with partial replay. Serve all tool/retrieval steps from the fixture and let only the final generation call run live on the new model. The context stays identical, isolating the model's impact.

What happens if the agent calls a tool not in the fixture?

The mock layer raises a ReplayError. That's a feature β€” it means your agent's control flow diverged from the recording, which is exactly the regression you want CI to catch.

How do I handle non-deterministic tool arguments?

Normalize before matching: strip timestamps, sort keys, and canonicalize UUIDs. Match on a stable subset of arguments rather than an exact byte-for-byte comparison.

Do fixtures belong in version control?

Absolutely. Small, redacted fixtures are ideal test assets. For large ones, use Git LFS or an object store and reference them by hash.

Recommended Resources