Skip to Content
DocsRuntimeReplay 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.

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 — same session_id picks up # where the journal left off. Already-completed model calls and # tool dispatches replay from the journal; only the un-completed # work runs fresh. result = await agent.resume("complex task", session_id="my-task-2026-05-08")

Without a stored checkpoint, resume(prompt, session_id=...) is sugar for run(prompt, session_id=session_id). Pass the same prompt; the runtime keys on the session id, not the prompt. (When the agent was built with Tuning(checkpoint=True) and a snapshot exists, resume() restores it directly instead — see Checkpoints.)

What “replay” means

For each journaled step (model_call_5, tool_call_5_0, persist_episode_3, …), the runtime stores the result keyed by (session_id, step_name). On a re-run with the same session id:

  1. The agent loop calls runtime.step("model_call_5", model.stream, ...).
  2. The runtime looks up (session_id, "model_call_5"). If found, returns the cached result without invoking model.stream.
  3. If not found, calls model.stream, stores the result, returns it.

The same logic applies to runtime.stream_step(...) for streaming steps. The chunks are stored as a list and replayed in order.

What gets re-executed on resume

Only steps that didn’t finish:

  • Completed steps (entry exists in the journal) → cached result returned instantly.
  • Mid-flight steps (no journal entry; the process died during execution) → re-executed.
  • New steps (the model is mid-conversation when you crashed and restart, so it’ll need to make NEW model calls) → executed normally; results journaled.

This means resume is idempotent at the step boundary. If a tool call wrote to a database before crashing, the journal entry is missing (the framework writes the entry only after the call returns) and the call replays. For non-idempotent tool calls, you can wrap them with your own dedup key.

Determinism contract

The agent loop assumes:

  • Model calls are deterministic given the same input messages and same RunContext. Streamed chunks are journaled. 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.

If a tool call writes to Stripe (say), the framework’s 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. The resumed run re-fires. Idempotency keys at the tool layer are your friend.

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 the journal key. Use a deterministic function of the session state, f"model_call_{session.turns}", f"tool_call_{session.turns}_{slot}". The framework’s built-in architectures all follow this pattern.

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 are stable. The framework’s built-in architectures take care of this; for custom architectures, don’t change the step name format between releases , the journal entries from the old version won’t 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; 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; 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 degrades 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 / blocks. The runtime journals only the agent loop’s external calls.
  • The journal isn’t a snapshot of state. It’s a log of side effects. Resuming with a wildly different Agent configuration (different model, different tools, different memory) is undefined behaviour.
  • DBOS / Temporal adapters are coming. The runtime protocol is intentionally compatible; until they ship, 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 / queue alongside the work item so you can call agent.resume(...) with it later.

Last updated on