Skip to Content
RuntimeReplay and resume

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:

  1. The agent loop calls runtime.step("model_call_5", model.stream, ...).
  2. The runtime looks up that session, name and input fingerprint. If found, it returns the cached result without invoking model.stream.
  3. 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, and agent.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 Memory backend 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 Agent configuration (different model, tools or memory) is undefined behaviour.
  • No DBOS or Temporal adapter ships with the framework. The Runtime protocol (step, stream_step, session) is small enough to implement one yourself; otherwise SqliteRuntime and PostgresRuntime cover 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.

Last updated on