Replay and resume
The runtime’s job is to make agent.run() resumable. After a crash,
restart the process pointed at the same journal, call agent.resume()
with the same session_id, and the run picks up where it left off
instead of starting over.
Resume in code
from loomflow import Agent
from loomflow.runtime import SqliteRuntime
agent = Agent(
"...",
model="claude-opus-4-7",
runtime=SqliteRuntime("./journal.db"),
)
# First run, interrupted by Ctrl-C / OOM / power outage:
result = await agent.run("complex task", session_id="my-task-2026-05-08")
# Later, after the process restarted: the same session_id picks up
# where the journal left off. Already-completed model calls and
# tool dispatches replay from the journal; only the unfinished
# work runs fresh.
result = await agent.resume("complex task", session_id="my-task-2026-05-08")Without a stored checkpoint, resume(prompt, session_id=...) runs
run(prompt, session_id=session_id) against the session’s last
run, replaying its journaled steps even if that run finished. Pass
the same prompt (and the same user_id) as the original run: journal
keys include a fingerprint of each step’s inputs, so a different
prompt changes the inputs and the steps run fresh. (When the agent was
built with Tuning(checkpoint=True) and a snapshot exists, resume()
restores it directly instead; see
Checkpoints.)
Calling run() again works too, as long as the first run crashed: a
run that raised (or whose process died) is still the session’s open
run, so running it again resumes it. Once a run has finished, a later
run() on the same session starts a new run and replays nothing. See
Runs and epochs.
What “replay” means
For each journaled step (model_call_5, tool_call_5_0,
persist_episode_3, …), the runtime stores the result under a key
built from the session id and user id, the run, the step name, and a
fingerprint of the step’s arguments. When a run is resumed:
- The agent loop calls
runtime.step("model_call_5", model.stream, ...). - The runtime looks up that session, name and input fingerprint. If
found, it returns the cached result without invoking
model.stream. - If not found, it calls
model.stream, stores the result, and returns it.
If the inputs differ (for example a new prompt on an old
session_id), the fingerprint differs and the step runs fresh. The
stale entry stays in the store until you prune it.
The same logic applies to runtime.stream_step(...) for streaming
steps: the chunks are stored as a list once the stream finishes and
replayed in order. A run that dies mid-stream replays that stream from
the start.
Runs and epochs
Replay is scoped to a run, not to the whole session. Each outermost run journals under the session’s current epoch, which advances when the run ends without raising. So:
- A finished run never replays into a later run. A second request on the same session, even an identical one, executes its steps fresh. (Before 0.17 a new request skipped any tool call identical to one made earlier in the session, so a second “charge 7” never charged.)
- A crashed run resumes. A run that raised, or whose process died, doesn’t advance the epoch. Running it again with the same session and inputs replays what already happened.
agent.resume()without a checkpoint continues the last run, finished or not.- Sub-runs journal under their parent. A run started inside
another run (a sub-agent, a debater, a team worker, an Agent node in
a
Workflow) keys its steps under the enclosing run, so resuming the outer run replays the sub-runs’ tool calls too. Two calls to one persistent worker within a run stay distinct. - Runs on one
(session, user)take turns. Within a process, a second concurrent run on the same session and user waits for the first to end instead of interleaving journal entries with it.
A run that returns an interrupted RunResult (it hit max_turns, a
budget cap or a guardrail) has finished: it returned normally, so the
epoch advances.
What gets re-executed on resume
Only steps that didn’t finish:
- Completed steps (entry exists in the journal): the cached result is returned instantly.
- Mid-flight steps (no journal entry; the process died during execution): re-executed.
- New steps (the conversation hadn’t reached them yet when the process crashed): executed normally, and their results journaled.
This means resume is idempotent at the step boundary. The journal entry is written only after a call returns, so if a tool wrote to a database and the process died before the entry was saved, the call runs again on resume. Delivery is at-least-once, not exactly-once.
Tool calls are journaled by position plus content: the ReAct
loop keys each one by its turn and slot (tool_call_<turn>_<slot>)
together with a hash of the tool name and arguments. A resumed run
replays the same call even when the model is asked again and issues
it under a different call_id. A retry in a later turn, or a
deliberate second identical call, is a different position, so it
runs.
Determinism contract
The agent loop assumes:
- Model calls are deterministic given the same input messages and
same
RunContext. Streamed chunks are journaled, so the model is effectively replayed verbatim. - Tool calls are deterministic given their arguments. This is the part you control. Pure functions are trivially safe; tools that hit external systems need either idempotency keys or acceptance that a re-run might double-fire. A tool whose result varies between identical calls (a clock, a counter, a live read) replays its recorded result when the run is resumed.
If a tool call writes to Stripe (say), the journal ensures the call only happens once as observed by the agent. But if Stripe received the request before the process died, the journal entry is missing and the resumed run re-fires. Pass an idempotency key to the external API from inside your tool.
runtime.step(name, fn, *args): the wrap point
Every external call inside an architecture goes through:
result = await deps.runtime.step(
f"my_call_{session.turns}",
my_async_callable,
arg1, arg2,
)The name is part of the journal key. Use a deterministic function of
the session state, such as f"model_call_{session.turns}" or
f"tool_call_{session.turns}_{slot}". The framework’s built-in
architectures all follow this pattern. Pass idempotency_key= to make
that key the whole journal key, so calls with the same key dedupe
regardless of their name.
For streaming calls, use runtime.stream_step(...):
chunks = []
async for chunk in deps.runtime.stream_step(
f"model_call_{session.turns}",
deps.model.stream,
messages,
):
chunks.append(chunk)
yield Event.model_chunk(session.id, chunk)Resume across deploys
When you ship a new version of your service, ongoing sessions resume across the deploy if and only if the step names and their inputs are stable. The framework’s built-in architectures take care of this. For custom architectures, don’t change the step name format between releases, or the journal entries from the old version won’t match.
Upgrading from 0.16 or earlier. Don’t count on resuming a run
that crashed under an earlier release; let in-flight runs finish
before you upgrade, or accept that their steps re-execute. Journal
values used to be pickled and are now tagged JSON: pickled rows are
skipped with a one-time warning unless you pass serializer="pickle"
(see Backends). Even then,
tool calls are now keyed by position plus content, so their old
entries don’t match, and the journal of a run with a user_id is
now stored per (session, user), so none of its old entries match.
Journal replay vs checkpoint restore
Replay is one of two resume mechanisms, and they answer different questions:
- Journal replay (this page) memoises steps. The resumed run re-walks the agent loop from the top, and each already-completed step returns its cached result instead of re-executing. Nothing is restored; the state is reconstructed by re-running the loop against the journal.
- Checkpoint restore (Checkpoints)
snapshots the transcript.
Tuning(checkpoint=True)writes the full message list + usage after every architecture pass, andagent.resume()loads the snapshot and continues directly: no replay walk, no memory re-seeding, prior turns never re-billed.
agent.resume() prefers the checkpoint path when a snapshot exists
and falls back to journal replay + memory rehydration when it doesn’t.
When to add checkpoints on top of the journal. Journal replay
already gives you crash recovery on a durable runtime. Turn on
Tuning(checkpoint=True) when you also want instant restores (no
replay walk), forking a session from an older point in time, or
inspectable JSON snapshots of the transcript.
Limitations
- Memory state isn’t journaled. The
Memorybackend is the source of truth for episodes, facts and blocks. The runtime journals only the agent loop’s external calls. - The journal is a log of side effects, not a snapshot of state.
Resuming with a very different
Agentconfiguration (different model, tools or memory) is undefined behaviour. - No DBOS or Temporal adapter ships with the framework. The
Runtimeprotocol (step,stream_step,session) is small enough to implement one yourself; otherwiseSqliteRuntimeandPostgresRuntimecover most production needs.
Choose stable session ids. A user-meaningful id (research-2026-05-08-acme,
onboarding-user-42) is the right shape. ULIDs and UUIDs work too;
the framework auto-generates one when you don’t pass session_id=.
For resumability, write the id to your own DB or queue alongside the
work item so you can call agent.resume(...) with it later.