DEV Community

Shridhar Shah
Shridhar Shah

Posted on

Your Agent Has a Bug You Can't Reproduce. Here's How to Catch It.

Deterministic simulation testing drives every fault, clock, and random choice from one seed — so a flaky, once-in-production agent bug becomes a reproducible artifact you can shrink to one line.

TL;DR: The worst agent bugs only appear under a specific interleaving of faults — a tool fails right after a side effect, a retry fires, and money moves twice. Happy-path tests miss it, and when it hits production you can't reproduce it. Deterministic simulation testing (DST) — the technique behind FoundationDB, TigerBeetle, and Antithesis — makes faults, timing, and randomness a pure function of one seed, so any failure replays exactly and can be shrunk to its minimal cause. In a runnable Python demo, the happy path passes, seeded fuzzing catches a double-charge, replays it identically, and shrinks a 4-fault schedule down to the single fault that matters.


Mental model: a flight simulator with a record button. Instead of waiting for a storm to hit a real plane, you conjure storms on demand — and when one crashes the plane, you can replay that exact storm frame-by-frame until you understand it, then strip it down to the one gust that did the damage.

The problem: the bugs that matter are the ones you can't reproduce

Agents run in a hostile world. Tools time out, APIs return errors, retries fire, and steps race. Most of the time everything is fine. But somewhere in the space of when exactly does the fault land hides a bug — a retry that isn't idempotent, a state update that assumes a call succeeded, a compensation that runs twice. It shows up once, in production, moves real money, and then vanishes: you re-run the same input and it works, because the timing was different this time.

Traditional tests can't help. Example-based tests exercise the happy path. Even randomized tests, if they do trip the bug, can't tell you how — the randomness that triggered it is gone.

The pattern: make nondeterminism a function of a seed

DST flips the model. Every source of nondeterminism — fault injection, the clock, thread scheduling, RNG — is routed through a single seed. That buys three things:

  • Reproducibility. The same seed always produces the same run. A failure is a permanent artifact.
  • Exploration. Sweep thousands of seeds (fast, in simulated time) to explore fault interleavings a human would never think to write by hand.
  • Shrinking. Once you have a failing scenario, mechanically remove pieces until only the minimal trigger remains — turning a chaotic failure into a one-line repro.

Here a "fault schedule" is just the set of steps that will fail on their first attempt — the entire source of nondeterminism, made explicit and seedable. The workflow has a real bug: confirm's retry re-runs charge with no idempotency guard.

for step in PLAN:
    if step == "confirm":
        for t in range(2):
            try:
                attempt("confirm", t); break
            except Fault:
                attempt("charge", 1)   # <-- the bug: a second, unguarded charge on retry
    else:
        for t in range(2):            # every other step has a safe, idempotent retry
            try:
                attempt(step, t); break
            except Fault:
                continue
Enter fullscreen mode Exit fullscreen mode

Because the run is a pure function of its schedule, fuzzing, replay, and shrinking are trivial:

def fuzz(seeds):
    for seed in range(seeds):
        schedule = random_schedule(random.Random(seed))
        if not invariant_holds(run_agent(schedule)):   # property: charges ≤ 1
            return seed, schedule

def shrink(schedule):
    minimal = set(schedule)
    for step in list(minimal):
        if not invariant_holds(run_agent(minimal - {step})):
            minimal -= {step}       # drop faults that aren't needed to trigger the failure
    return minimal
Enter fullscreen mode Exit fullscreen mode

The result

1. happy-path test (no faults):      PASS  <- the bug is invisible here

2. fuzzing seeded fault schedules:   FAIL on seed 1
      schedule = ['analyze', 'confirm', 'notify', 'plan']  ->  charged the customer 2x

3. replay same schedule twice:       2x and 2x charges  ->  identical (reproducible)

4. shrink to the minimal cause:      ['confirm']
      one fault at 'confirm' is all it takes to double-charge.
Enter fullscreen mode Exit fullscreen mode

The happy path passed — that's why this bug would ship. Fuzzing found a failing schedule with four faults, three of them irrelevant noise. Replay proved it reproduces exactly. Shrinking stripped the noise to a one-line repro: a single fault at confirm double-charges. That's a bug report a developer can fix in minutes, not a haunted "works on my machine."

Reality check: the state space here is tiny, but the technique isn't a toy — this exact method (FoundationDB, TigerBeetle's VOPR, Antithesis) catches real, money-moving bugs in production databases. At scale the hard part is exploration — which fault interleavings to try — not the seed-and-replay mechanism shown here.

Why this is where 2026 is heading

DST is having a moment. FoundationDB pioneered it; TigerBeetle's VOPR "speeds up time arbitrarily — one minute of simulation equals days of real testing"; Antithesis raised a large round selling a deterministic hypervisor that replays every instruction to reproduce failures. Nearly every serious database company is now using or evaluating it.

Agents are the next obvious target, and arguably a better fit: an agent's think→act→observe loop is already a sequence of discrete, mockable steps with well-defined failure points (tool errors, timeouts, partial writes). The engineering shift is to write the agent so its nondeterminism is injectable — clock, retries, and tool faults behind seams you control — and then let a simulator manufacture the chaos. The property you assert ("charges ≤ 1", "no orphaned reservations", "the plan never regresses") becomes the specification of correct behavior under failure.

How faithful is this demo?

It's a minimal model — nondeterminism is just "which steps fault first" and the state space is tiny. Real DST is harder in two ways: determinism is a discipline (every clock read and interleaving must route through the seed, which is why Antithesis built a hypervisor to enforce it), and exploration is the real problem (huge state spaces mean DST pairs with property-based testing and guided fuzzing). Start small: make one agent's faults and clock injectable, then assert one invariant across many seeds.

When not to use this

  • Stateless, single-shot prompts. With no faults, retries, or ordering to interleave, there's no nondeterminism to seed — plain example tests are enough.
  • The bug is model quality, not orchestration. DST catches control-flow and fault-handling bugs; it won't tell you the model gave a worse answer.
  • You can't make nondeterminism injectable. If the clock, retries, and tool faults can't be routed through a seam you control, you can't get determinism — retrofit the seams first.

Try it

python3 demo.py   # standard library only
Enter fullscreen mode Exit fullscreen mode

Sources & further reading

Platforms & write-ups

Top comments (0)