Sessions & checkpoints¶
A session preserves conversation across runs. A checkpoint preserves progress within one run for crash recovery and idempotency. They solve different problems and do not replace each other.
| If you need to… | Use | Key |
|---|---|---|
| Let the next Run remember earlier conversation | Session | session_id |
| Resume the same Run after a crash | Checkpoint | run_id |
| Preserve conversation and recover in-flight work | Both | Both keys |
Sessions¶
from lovia import Runner, SQLiteSession
session = SQLiteSession("chat.db")
await Runner.run(agent, "My project is called Atlas.", session=session, session_id="u1")
result = await Runner.run(agent, "What is my project called?", session=session, session_id="u1")
# "Atlas" — the second run loaded the first run's transcript as history
You control session_id — key it by user, thread, ticket, whatever your
product calls a conversation. The runner loads prior history at run start
and, when the run completes, appends the run's own entries as one
Segment (run id + entries + opaque per-run meta). Two implementations
ship: SQLiteSession(path, *, wal=False) and InMemorySession().
The contract¶
The Session protocol is four methods — segments(session_id),
load(session_id) (flat concatenation), append(session_id, entries, *,
run_id=None, meta=None), clear(session_id) — with two properties doing
the heavy lifting:
- Append-only. There is deliberately no
replace: a stored run is immutable. That's what lets a resumed run safely reload history, and what keeps run boundaries and per-runmeta(e.g. carried compaction state) consistent. - Idempotent by
run_id. Appending again under an already-storedrun_idis a no-op, so a replayed run never duplicates its entries.
The runner times each segment it appends: meta["timing"] is
{"started_at", "finished_at"} in epoch seconds, from the run's first start (a
resumed run keeps it, through the checkpoint's RunHead.started_at) to the
append. A session is thus also a timed run log; segments written before lovia
recorded timing have none.
Interrupted runs are not auto-appended — they live in their checkpoint.
Whether to record an abandoned partial as a finished segment is a caller
decision (the bundled web UI does this when a user stops a run); a finalized
partial must stay tool-consistent — lovia.transcript.drop_dangling_tool_calls
exists for exactly that.
Maintenance¶
The bundled stores (not the protocol) provide two sanctioned carve-outs from append-only.
Long-lived sessions accumulate huge old tool outputs — trim_tool_results
reclaims the space:
It truncates stored tool outputs older than the last keep_runs runs
(structure, order, and call_id pairing preserved; idempotent). Pair it
with a FileResultStore on the compaction
policy before relying on it — archived outputs stay recoverable via
recall_tool_result; un-archived ones are truncated for good.
rewind drops the transcript's tail — the primitive behind "edit that
message and resend" / "regenerate" (a linear, destructive undo; there is no
branching):
keep_entries counts over the flat load() view. Whole later runs are
deleted; a run the cut lands inside is truncated, kept tool-consistent (a
dangling tool call at the cut is dropped too), and loses its per-run meta —
carried context state computed after content that no longer exists must not
leak back into the next run. keep_entries=0 empties the session; a count at
or past the end is a no-op. If the rewound run has a live checkpoint, delete
it too — a later resume would replay the undone tail.
rewind is optional for a custom Session. Implement
async rewind(session_id, *, keep_entries) -> int with the semantics above
and the web UI offers edit-and-resend and regenerate on it; the
web layer checks for the method and hides both without it.
Checkpoints¶
For runs that must survive crashes — or be safely re-issued — add a checkpoint:
from uuid import uuid4
from lovia import CheckpointOptions, Runner, SQLiteCheckpointer
cp = SQLiteCheckpointer("runs.db")
result = await Runner.run(
agent,
"Migrate the report format.",
checkpoint=CheckpointOptions(cp, f"report-migration-{uuid4().hex}"),
)
The loop snapshots after the model turn and after every tool result, so
a crash loses at most the work in flight. A RunSnapshot holds the run's
own entries plus a small mutable head (RunHead): active agent name,
usage, turn count, status (running / interrupted / completed /
failed), the context policy's carried state, and the target of a handoff
that fired but has not been applied yet. Your deps object is not
snapshotted — you re-supply it when resuming.
run_id is an idempotency key¶
run_id is the checkpoint's sole, global key — unlike a session it is
not scoped, so keep it unique per checkpointer (a UUID, a job id). What
happens when the id already exists is the if_run_exists policy:
| Policy | Existing run... | No stored run... |
|---|---|---|
"resume" (default) |
continues it (replays it verbatim if already completed) | starts fresh |
"restart" |
discards it and starts fresh | starts fresh |
"fail" |
raises UserError |
starts fresh |
"resume_only" |
continues it | raises UserError |
So a crashed worker just re-issues the same call: an interrupted run resumes, a completed run replays its stored result without touching the model, and either way the answer comes back. Two subtleties:
- On resume, the new
inputis ignored — the transcript already carries the original.run_idis a per-run idempotency key, not a conversation key; for conversational continuity use a session. To continue a known run by id with no new input:
- A replay re-emits terminal events only. Hooks and output guardrails
ran on the original completion and are not re-run; usage folds into the
caller;
finish_reasonisNone(not persisted). Session persistence is re-applied — idempotently — which heals a crash that landed between checkpoint finalization and the session append.
CheckpointOptions also takes delete_on_success=True (drop the snapshot
once the run is in the session — for runs whose durable record is the
session; the delete comes after the session append, so a store error in
between still leaves a replayable snapshot) and resume_from= (rehydrate a
RunSnapshot you obtained yourself).
What resume actually does¶
Resuming rebuilds the run — system prompt re-rendered, session history reloaded, the snapshot's entries appended — and then drains pending tool calls: calls the crash left without results are re-executed (same turn number) before the loop continues. Completed results are never re-run; dangling calls are. Design your side-effecting tools for at-least-once, or make them idempotent.
Resume works across handoffs: the snapshot records the active agent by name, and the runner re-resolves it from the entry agent's handoff graph — so agent names must be unique across that graph (two distinct agents sharing a name is a hard error on resume). A transfer that fired but was not yet applied when the run stopped is recorded too, and the resume completes the switch before the target's first turn. Renaming or removing an agent breaks resume for its in-flight runs (hard error); completed-run replays degrade gracefully to the entry agent with a warning instead — finished work must not error on a deploy.
How the two stores relate¶
full transcript = session.load(session_id) + snapshot.entries
(all completed runs) (the one in-flight run)
On success the order is fixed: checkpoint finalized first, then the session append. A run can therefore never be both "persisted as done" and "still resumable" — the crash window between the two heals on the next replay, because the session append is idempotent.
Both SQLite stores accept wal=True (off by default): WAL journal mode
plus a busy timeout, for a database file shared with other writers —
several stores in one file, or a multi-process deployment.
Across lovia versions, each SQLite store records its schema version in a
lovia_schema table in its file and migrates an older database the first
time it opens it. A database that a newer lovia has migrated past what this
version knows is refused with an "upgrade lovia" error, never read with the
wrong schema. Stored entries are read leniently instead: fields this version
doesn't know are ignored, so what a newer lovia wrote still loads. Only an
unknown entry type is an error.
Sharp edges¶
- Reusing a completed
run_idsilently drops your new input — by design (replay). If you meant "next turn of the conversation", that's a session, not a checkpoint. - Tools re-execute on resume. Only calls with stored results are safe;
anything dangling runs again. Non-idempotent tools (charges, sends) need
their own dedup keyed by
ctx.run_id+ call arguments. run_idcollisions across sessions corrupt nothing but confuse everything — the checkpointer is global; two "runs" sharing an id are one run to it. Generate ids, don't compose them from user input.InMemory*stores don't survive the process — they exist for tests and ephemeral chat; pairingInMemoryCheckpointerwith "crash recovery" is a category error.
See also¶
- Core concepts — the mental model
- Context management — carried state and result stores
- Web server — how the bundled server wires Sessions
- Examples:
05_sessions.py,15_resume.py