Scrub a finished run
Time travel over an agent's commit log — stop on iterations, LLM turns, tool calls and decisions instead of on all forty stages, with milestoneStopsStrategy.
A finished run is a commit log: one bundle per executed stage, in order.
footprintjs 9.17 opens a reader's cursor over it — timeTravel — with a
fold at every stop, and takes a strategy that says where the cursor may rest.
Its own strategy, commitStops, stops on every stage. That is the truth and it
is unreadable: a two-turn agent commits around forty of them, most called
context, sf-cache or sf-thinking. milestoneStopsStrategy is the
agent's answer to "which of those would a person scrub to?" — the same
milestoneFor classifier the library has always used, mapped onto the log.
import { timeTravel } from 'footprintjs/trace';
import { milestoneStopsStrategy, milestoneOf } from 'agentfootprint';
const cursor = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
cursor.stops.map((s) => `${s.label} · ${milestoneOf(s)?.kind ?? s.kind}`);
// Run start · start
// Iteration · iteration
// System prompt · slot
// Messages · slot
// Tools · slot
// LLM turn · llm-turn
// Route · decision
// Tool call · tool-call
// … Run end · endFive kinds, and they are the domain's, not the chart's: iteration · slot ·
llm-turn · tool-call · decision. milestoneOf(stop) is how you read the
kind back off a stop — footprintjs's Stop has no slot for a consumer's own
vocabulary, so the kind is re-derived from the stop's runtimeStageId by the
same classifier that put it on the axis. A stage that classifies as none of them
gets no stop; its commits fold into the stop before it, so nothing is lost from
stateAt — it just stops being a place the slider can land.
What `'start'` means on this axis
On footprintjs's own per-stage axis, 'start' is the fold base: the state
before any stage ran. Here it also absorbs every stage that ran before the first
milestone — and an agent seeds a couple of dozen keys in seed first. Measured
on a two-turn run: commitStops' start folds no commits and 0 keys; this one
folds the first commit and 32. So stateAt(startStop) is the state the first
milestone read, not the run's raw base. Right for an axis whose stops must
still partition the log; wrong to assume from kind === 'start' alone.
Why this and not "one stop per stage"
Granularity used to be a structural accident: a stage earned a scrub stop by happening to be a subflow. That put an observability decision behind a chart lever, and it put agent vocabulary ("what is a slot?") inside a generic renderer. The classifier moves the decision to the layer that owns the meaning, and this strategy is how every reader gets the same answer from it — a Why Lens, a custom panel, a script over a saved recording.
Ask the three questions
The cursor answers what a reader actually wants to know. One position, always:
const turns = cursor.stops.filter((s) => milestoneOf(s)?.kind === 'llm-turn');
cursor.jumpTo(turns[1]!.step);
cursor.stateAt().state.currentSkillId; // 'beta' — where the run stood at turn 2
cursor.changedSince(turns[0]!); // ['currentSkillId', 'history', …]
cursor.mark('the turn that went wrong'); // the READER's note — never written into the runA refused move never moves. jumpTo('seed#0') — a stage that really ran and
really committed, but is not a milestone — comes back
{ moved: false, reason: 'miss', at, nearest }, and the panel keeps showing
what it was showing.
Where the LLM turn lives depends on the chart shape
One strategy, both shapes
milestoneStops classifies the LOCAL segment of a stage id, so it never needs
to be told whether it was handed a run's log or a subflow's. Hand it either.
reactMode: 'dynamic' — the turn is a commit on the run's own log, so the
llm-turn stop is on the outer cursor, beside the iteration and slot stops.
const cursor = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
const turn = cursor.stops.find((s) => milestoneOf(s)?.kind === 'llm-turn')!;
cursor.stateAt(turn).state; // the state that turn left behindreactMode: 'dynamic-grouped' — each turn is an sf-llm-call subflow with
its own isolated log, so the outer axis holds the iterations and the turn is
one drill down. drill() returns a separate cursor over that subflow's own log;
the same strategy reads it.
const iterations = cursor.stops.filter((s) => milestoneOf(s)?.kind === 'iteration');
const inner = cursor.drill(iterations[1]!.runtimeStageId)!; // its own log, its own base
const turn = inner.stops.find((s) => milestoneOf(s)?.kind === 'llm-turn')!;
inner.stateAt(turn).state;Drill by runtimeStageId, not by path: a subflow inside a loop runs many times
and every iteration shares one path, so sf-llm-call#1 and sf-llm-call#26 are
two different turns and two different logs.
Which keys are visible where
In the grouped shape the settled skill cursor for turn k is on the OUTER
axis, at iteration k (currentSkillId). Inside the drill, currentSkillId is
the value the turn started from — it crosses the mount as a read-only input —
and the move this turn made is nextSkillCursor, which the outputMapper merges
back out. Both logs are truthful about different questions; scrub the outer axis
for what settled, drill for what happened.
A resumed run gets an axis of its own
The cursor reads the snapshot it is handed, and a resume is its own execution
with its own commit log. After agent.resume(checkpoint, answer),
getSnapshot() carries the resumed half: the axis begins at the stage the
resume re-entered, and the milestones from before the pause are not on it — they
are on the snapshot you took at the pause.
const paused = await agent.run({ message: 'go' });
const beforeTheBreak = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
await agent.resume(paused.checkpoint, 'approved');
const afterTheBreak = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
// two cursors, two halves — `beforeTheBreak` still reads the pre-pause turnNothing about the strategy changes across a pause: both axes tile their log and both carry unique stops. It is the snapshot that split, not the cursor.
What it is not
- Not a second cursor. A strategy only says where the one cursor may rest.
Position, folds, marks and drilling stay with
timeTravel. - Not a recorder. Nothing is emitted at run time; marks live in the cursor and never appear in a recording.
- Not a re-walk. A milestone that never committed gets no stop. A cursor that stops where no evidence exists is telling a story, not reading one.
Runnable end-to-end: examples/observability/23-time-travel-milestones.ts.
Replay a saved run
Save a run as three things — snapshot, events, structure — then show it in Why Lens or Flow Lens. Nothing re-runs, no model is called.
File a bug with the run attached
A bug report IS the evidence. describeBugReport() measures the run into selectable units so a human can consent; exportBugReport() bundles what they kept as a real zip; githubBugReporter() commits it and files the issue.
