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.
βRecord once, replay forever β turn a live, expensive, flaky agent run into a cheap, repeatable test.β
- 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.
{
"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
(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"]
Wrap your tool + LLM clients so every call is transparently logged during a real run.
Serialize the session to a versioned fixture file and commit it to your repository.
Swap live clients for a mock layer that returns recorded values in order.
Run evaluators over the deterministic output and gate CI on the scores.
Step-by-Step Implementation
Define the fixture data model
Start with typed, validated schemas. This keeps fixtures forward-compatible and makes replay bugs obvious.
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)
Record real sessions transparently
A recorder wraps your real tool/LLM clients, forwarding calls while logging every request and response.
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())
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.
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}
Write evaluators & a CI test
Evaluators turn output into numbers. Wire them into pytest so every commit re-runs your fixtures offline.
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.
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
Ticket Resolution Agent
Records order lookups, policy checks, and email drafts. Replay to safely test new response templates and tone.
Research & Knowledge Agent
Saves web results and document chunks. Compare different retrievers or LLMs on the exact same context.
Coding & Debugging Agent
Captures repository state, terminal output, and tool usage. Re-evaluate after a model or tool change.
Sales Outreach Agent
Records lead research, personalization, and email generation. Test compliance and tone updates safely.
In Depth, with Code
Customer Support Ticket Agent
Replay a recorded refund conversation to validate that a new email template still reaches the correct decision.
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}
RAG Research Agent
Freeze retrieved chunks so you can compare faithfulness and answer relevance across models on identical evidence.
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,
},
)
Coding Agent
Snapshot the repository state so a coding agent can be re-evaluated deterministically after model or tool changes.
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_versionand 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
LangSmith Evaluation
Tracing, datasets, and evaluators for LLM apps.
DeepEval
Open-source unit testing framework for LLM outputs.
OpenAI Evals
Framework and registry for evaluating models.
VCR (Cassettes)
The record/replay pattern for HTTP that inspired this idea.
VCR.py
Python port for recording and replaying HTTP interactions.
Ragas
Metrics like faithfulness & answer relevance for RAG.