Reproducing Agent Failures Locally: A Deterministic Replay Strategy

Stop guessing why your agent broke. Capture, replay, and fix deterministically.

by
Reproducing Agent Failures Locally: A Deterministic Replay Strategy

Reproducing Agent Failures Locally: A Deterministic Replay Strategy

You've seen it happen. Your agent runs perfectly in dev, but in production it goes off the rails. You check the logs, but the LLM response is gone. You try to reproduce it, but the model gives a different answer. You're stuck guessing.

This is the single biggest pain point in building autonomous agents: failures are not reproducible. The root cause is nondeterminism. LLM outputs vary with temperature, seed, and even hardware. External API calls return different data. System clocks drift. Random number generators diverge.

But there's a solution: deterministic replay. Capture every nondeterministic input during a run, then replay that exact sequence locally. This isn't new – it's how distributed systems debug for decades. But for agents, we need to extend it to cover LLM calls, tool outputs, and timing.

Here's a concrete strategy, using tools you already have: Postgres, pgvector, and a bit of orchestration. No magic, no black boxes.

The Core Idea

An agent run is a sequence of steps. Each step takes inputs (prompt, tool results, state) and produces outputs (LLM response, tool calls). Nondeterminism enters at specific points:

  • LLM inference: temperature > 0, different seeds, model quantization differences.
  • Tool execution: API latency, network failures, database state changes.
  • Randomness: random() calls, UUID generation, shuffling.
  • Time: now(), timeout, sleep durations.

Deterministic replay means recording all these nondeterministic sources during a run, storing them in an ordered log, and then replaying that log in a controlled environment.

Architecture

We'll use a run log stored in Postgres. Each step gets a row with:

  • run_id: UUID
  • step_number: integer
  • kind: 'llm', 'tool', 'random', 'time'
  • input: JSONB (the prompt, tool arguments, etc.)
  • output: JSONB (the recorded output)
  • checksum: for integrity

During replay, we intercept calls to these sources and return the recorded output instead of making real calls. The agent code remains unchanged – we just swap the backend.

Implementation

Recording

Wrap your LLM and tool calls with a decorator or context manager that logs inputs and outputs.

import uuid
import json
from datetime import datetime
from your_agent import llm_call, tool_call

class RunRecorder:
    def __init__(self, db_url):
        self.run_id = uuid.uuid4()
        self.step = 0
        self.conn = psycopg2.connect(db_url)
        self.conn.autocommit = True

    def record_llm(self, prompt, response):
        self.step += 1
        self.conn.execute(
            "INSERT INTO run_log (run_id, step, kind, input, output) VALUES (%s, %s, 'llm', %s, %s)",
            (self.run_id, self.step, json.dumps(prompt), json.dumps(response))
        )

    def record_tool(self, tool_name, args, result):
        self.step += 1
        self.conn.execute(
            "INSERT INTO run_log (run_id, step, kind, input, output) VALUES (%s, %s, 'tool', %s, %s)",
            (self.run_id, self.step, json.dumps({'tool': tool_name, 'args': args}), json.dumps(result))
        )

# Usage
recorder = RunRecorder("postgres://user:pass@localhost/agent_logs")
# Inside agent loop
prompt = build_prompt(state)
response = llm_call(prompt)
recorder.record_llm(prompt, response)

Replaying

Create a replay context that returns recorded outputs step-by-step.

class ReplayPlayer:
    def __init__(self, run_id, db_url):
        self.run_id = run_id
        self.step = 0
        self.conn = psycopg2.connect(db_url)
        self.cursor = self.conn.cursor()
        self._load_steps()

    def _load_steps(self):
        self.cursor.execute(
            "SELECT step, kind, input, output FROM run_log WHERE run_id = %s ORDER BY step",
            (self.run_id,)
        )
        self.steps = self.cursor.fetchall()
        self.index = 0

    def next_llm(self, prompt):
        step = self.steps[self.index]
        assert step[1] == 'llm', f"Expected llm at step {step[0]}, got {step[1]}"
        assert step[2] == json.dumps(prompt), "Prompt mismatch"
        self.index += 1
        return json.loads(step[3])

    def next_tool(self, tool_name, args):
        step = self.steps[self.index]
        assert step[1] == 'tool', f"Expected tool at step {step[0]}, got {step[1]}"
        expected_input = json.loads(step[2])
        assert expected_input['tool'] == tool_name
        assert expected_input['args'] == args
        self.index += 1
        return json.loads(step[3])

# Usage
player = ReplayPlayer(run_id_to_replay, "postgres://user:pass@localhost/agent_logs")
# Inside agent loop, replace llm_call with player.next_llm

Handling Nondeterminism Beyond LLMs

Time

Record the system time at the start of the run. During replay, mock datetime.now() to return start_time + elapsed_step_time. Also record any sleep durations and replay them exactly.

# Recording
import time
start_time = time.time()
# ...
recorder.record_time('sleep', {'duration': 2.5})

# Replay
mock_time = start_time
# on sleep call, just return without sleeping

Randomness

Record calls to random.random(), random.randint(), etc. During replay, feed the recorded sequence from a pre-seeded generator. Use a custom random class that logs and replays.

import random

class DeterministicRandom:
    def __init__(self, seed=42):
        self.rng = random.Random(seed)
        self.history = []

    def random(self):
        val = self.rng.random()
        self.history.append(val)
        return val

# Replay
class ReplayRandom:
    def __init__(self, recorded_values):
        self.values = recorded_values
        self.index = 0

    def random(self):
        val = self.values[self.index]
        self.index += 1
        return val

Storing the Run Log

Use Postgres with a simple schema:

CREATE TABLE run_log (
    run_id UUID NOT NULL,
    step INTEGER NOT NULL,
    kind TEXT NOT NULL CHECK (kind IN ('llm', 'tool', 'random', 'time')),
    input JSONB NOT NULL,
    output JSONB NOT NULL,
    checksum TEXT,
    PRIMARY KEY (run_id, step)
);

CREATE INDEX idx_run_log_run_id ON run_log(run_id);

Add a checksum column using SHA256 of input || output to detect tampering or corruption.

Replaying in a Sandbox

To avoid side effects during replay, run the agent in a container with network disabled. Use systemd-nspawn or Docker with --network none. The replay player serves as the sole source of truth for external data.

# Start replay container
systemd-nspawn -D /path/to/agent-root --network-namespace-path /var/run/netns/replay

Inside the container, set environment variables to point to the replay player:

export LLM_BACKEND="replay://localhost:5000"
export TOOL_BACKEND="replay://localhost:5001"

The replay player listens on these ports and returns recorded data.

Tooling Integration

You can integrate this with your existing observability stack. For example, if you use OpenTelemetry, you can export the run log as spans. But keep it simple: a Postgres table is enough.

For embedding-based retrieval (e.g., pgvector), you may need to record the embedding vectors as well. Store them as vector(768) in the same run_log table, or in a separate table keyed by run_id and step.

Limitations

  • Determinism is hard: If your agent uses concurrency or async, replay order can diverge. Stick to synchronous agents for now, or use a deterministic scheduler.
  • Storage: Each run can generate thousands of steps. Compress old logs or set a retention policy.
  • Model changes: If you update the model, recorded outputs may not match new inference. You'll need to re-record.

Real-World Example

I had an agent that sporadically called a tool with wrong arguments. Logs showed the LLM output, but the exact prompt was lost. I added recording, reproduced the failure by replaying the exact sequence, and found a bug in prompt construction: a variable was not being updated between steps. Fixed in 10 minutes.

Conclusion

Deterministic replay is not a silver bullet, but it's the closest thing to a time machine for debugging agents. It forces you to make every nondeterministic source explicit, which is good engineering practice anyway. Start with LLM calls and tool outputs – that covers 90% of failures. Add time and randomness as needed.

The overhead is minimal: a few milliseconds per step to write to Postgres. The payoff is huge: every failure becomes a fixed point you can inspect, step through, and fix.

Try it. Your future self will thank you.

#agent-observability#debugging#deterministic-replay#llm-inference#testing
Share — X / Twitter · LinkedIn · HN · Email
Damir Radulić
Founder of RiNET. On the Croatian internet since 1996 (Kvarner Net). In Amsterdam now, building autonomous AI infrastructure that runs on Monday morning when nobody's watching — sovereign stacks, agent swarms, LoRA fine-tuning, civic-intelligence platforms.