Docs menu
Time Travel & Run Forking
Reconstruct the state at any step of a run's journal (reconstructState), or fork a new branch from that point (forkRun) — for debugging and 'what-if' scenarios.
What it's for / when to use it#
An agent run is written to the journal step by step (as model and tool entries). Sometimes you need to answer questions like "what exactly did the model see at step 3?" or "what would have happened from this step onward with a different tool/model?"
reconstructState walks the journal entries PURELY (without side effects) and materializes the message list up to a given step, along with any pending (not-yet-resolved) tool calls — ideal for debugging/inspection (Studio inspector, log analysis).
forkRun goes one step further: it copies the source run's first N model steps + their associated tool records + the original input into a NEW runId. Everything after N is not copied — when continued via resumeRun, the prefix is replayed from the journal (side effects do NOT run again), while step N onward runs LIVE. This lets you try the "what would have happened from the same point with a different model/tool" scenario (what-if / A-B debugging) without disturbing the source run.
Setup / import#
Both functions are exported from @gnldev/durable's main entry point — no separate subpath is needed:
import { reconstructState, forkRun } from '@gnldev/durable';
import { runDurable, resumeRun } from '@gnldev/durable';Step-by-step usage#
First, steps are written to the journal via a normal runDurable call (in the example below, the model calls a charge tool and then finishes with text):
await runDurable({
runId: 'r',
journal,
model: srcModel(),
tools: tools(),
stopWhen: stepCountIs(6),
prompt: 'x',
});Fork a new branch from step 1 (after the first model step) — the source's model:0 entry and its associated tool record + the original :input are copied to the new fork1 runId; model:1 and everything after is NOT copied:
const fork = await forkRun(journal, 'r', 1, 'fork1');
// fork => { newRunId: 'fork1', copiedModel: 1, copiedTool: 1 }Then continue the fork with resumeRun using DIFFERENT model/tool logic: step 0 (the charge tool call) is replayed from the journal — its side effect (e.g. charging again) does NOT run again — while step 1 onward truly runs live and can reach a different outcome than the source:
const r2 = await resumeRun('fork1', {
journal,
model: forkModel(),
tools: tools(),
stopWhen: stepCountIs(6),
});
// r2.text may differ (e.g. 'FORK-final'); the charge counter does NOT increase (replay).To just inspect state (without opening a fork), reconstructState is enough — give it the journal entry array and the desired step:
const entries = await journal.readRun('r');
const state = reconstructState(entries, 1);
// state => { step: 1, messages: [...], pending: [{ toolCallId: 'c1', toolName: 'pay' }] }
// Pass a seed to prepend the original user input as well:
const withSeed = reconstructState(entries, 3, { prompt: 'ode' });API reference#
reconstructStateWalks the journal entry array purely and returns the messages up to uptoStep along with pending tool calls; an optional seed prepends the original input.
forkRunCopies srcRunId's first `step` model steps + their related tool records + :input under newRunId; anything after is not copied — resumeRun continues live from step.
ReconstructedStatereconstructState's return type: { step, messages, pending }.
ReconstructSeedOptional seed input for reconstructState: { system?, messages?, prompt? }.
ForkResultforkRun's return type: { newRunId, copiedModel, copiedTool }.
forkRun requires a journal that provides both write and read (Journal & JournalReader) — because the copied prefix is replayed from the journal, side effects before step N (e.g. charging) do NOT run again in the fork; only step N onward truly runs live.