DEV Community

Cover image for Time-Travel Debugging for AI Agents with LangGraph.js Checkpoints
Gabriel Anhaia
Gabriel Anhaia

Posted on

Time-Travel Debugging for AI Agents with LangGraph.js Checkpoints


Normal debugging assumes you can reproduce the bug. Run it again, get the same
behaviour, narrow it down.

An agent does not cooperate. The run that emailed the wrong customer happened
once, at 3am, after eleven turns of accumulated state you cannot recreate by
re-running the same input. Re-running gives you a different run.

But the state at every step was written down. A checkpointer is not only crash
recovery — it is a recording, and you can restart from any frame of it.

Every step is addressable

const config = { configurable: { thread_id: "run-8812" } };

for await (const s of graph.getStateHistory(config)) {
  console.log({
    step: s.metadata?.step,
    next: s.next,
    ckpt: s.config.configurable?.checkpoint_id,
    keys: Object.keys(s.values),
  });
}
Enter fullscreen mode Exit fullscreen mode

Each snapshot carries its own config with a checkpoint_id. That config is
the address of a moment in the run, and passing it back is what replays from
there.

const history = [];
for await (const s of graph.getStateHistory(config)) history.push(s);
history.reverse();                        // oldest first

const before = history.find((s) => s.next.includes("send_email"));
await graph.invoke(null, before.config);  // re-run from just before the email
Enter fullscreen mode Exit fullscreen mode

invoke(null, checkpointConfig) continues from that checkpoint with the state
exactly as it was. No input, no restart — resumption at a chosen frame.

Change one thing and see what happens

Replay alone tells you what happened. The useful move is replaying with an
edit, which is how you test a hypothesis.

const forked = await graph.updateState(
  before.config,
  { customerEmail: "correct@acme.com" },   // the value you suspect was wrong
);

const result = await graph.invoke(null, forked);
Enter fullscreen mode Exit fullscreen mode

updateState writes a new checkpoint descending from that one and returns its
config. The original history is untouched, so you can fork the same frame
repeatedly with different values.

That is the closest thing to a controlled experiment available on a system
whose core component is nondeterministic. Was it the wrong email in state, or
the model misreading a correct one? Fork, correct the state, replay. If the
email is right this time, the bug is upstream in whatever wrote that field.

Forking a run from a chosen checkpoint with one field changed, leaving the<br>
original history<br>
intact.

Replaying is not free, and it is not pure

Two things to hold onto before doing this against anything real.

Replaying re-executes nodes. Every model call after the fork point runs
again and is billed again. Every tool call runs again — including the one that
sends the email you were investigating.

So fork into a context where side effects are inert:

const debugCtx = {
  ...ctx,
  tools: wrapAll(ctx.tools, {
    mode: "dry-run",
    onWrite: (name, args) => log.info("suppressed write", { name, args }),
  }),
};
Enter fullscreen mode Exit fullscreen mode

A dry-run wrapper that lets reads through and records writes without
performing them. Without it, debugging a run that sent a wrong email sends
more wrong emails.

The model may behave differently. Same state, different sampling. If the
forked run does the right thing, that is weak evidence — it might have done
the right thing originally too, one time in five. Fork several times before
concluding.

Keeping the recording useful

Checkpoints are only a debugger if they are still there when you need them.

-- keep failures long, successes briefly
DELETE FROM checkpoints
WHERE thread_id IN (
  SELECT thread_id FROM runs
  WHERE outcome = 'complete' AND finished_at < now() - interval '3 days'
);

DELETE FROM checkpoints
WHERE thread_id IN (
  SELECT thread_id FROM runs
  WHERE outcome <> 'complete' AND finished_at < now() - interval '30 days'
);
Enter fullscreen mode Exit fullscreen mode

Retention by outcome. Successful runs are rarely investigated; failed ones are
exactly what you want a month later when a pattern emerges.

Size is the constraint. Every checkpoint holds the message window, so a long
run writes a large row per step. Compacting the window before checkpointing
keeps this affordable, and it is worth checking the table size early rather
than discovering it as a disk alert.

Make a thread id findable

Time travel is useless if you cannot locate the run. The thread id has to
reach everything a person might start from:

logger.info("agent", { threadId, runId, userId, outcome });
res.setHeader("X-Run-Id", threadId);
await db.message.create({ data: { ...msg, threadId } });
Enter fullscreen mode Exit fullscreen mode

Deriving it from a domain id rather than a random uuid pays off here —
refund-ord_8812 is greppable from a support ticket. A uuid means someone has
to join through three tables first.

What this replaces

The habit it displaces is adding log lines and waiting for the bug to recur.
On a system where a run costs money and recurrence is probabilistic, that loop
can take weeks.

With state history you can answer, from a single failed run:

  • what the state was when the wrong decision was made
  • which node wrote the bad value
  • whether correcting that value fixes the outcome
  • whether the same fix holds across several replays

That is most of a root-cause investigation, from a recording you were already
keeping for crash recovery.

A failed run investigated from its stored history rather than by waiting for<br>
recurrence.

If you wrote your own loop

None of this needs the framework — it needs the property the framework gives
you. Persist a checkpoint per step, keyed by run and sequence, never overwrite
in place, and store enough to resume: cursor, state, window.

await store.append({ runId, seq, cursor, state, window, at: new Date() });
Enter fullscreen mode Exit fullscreen mode

append, not upsert. The moment you overwrite, you have crash recovery and
you no longer have a recording, and the difference only becomes obvious the
first time you want to know what the state looked like three steps ago.


If this was useful

AI That Plans covers checkpointing as
infrastructure — durable state, resume, forking a run to test a hypothesis,
and the retention that keeps a recording worth having.

AI That Plans — Stateful AI Agents with LangGraph.js

The full series is at
xgabriel.com/ai-in-typescript.

Top comments (0)