Session
NAME
LLM::Agent::Session - a durable, resumable JSONL transcript of an agent run
SYNOPSIS
use LLM::Agent::Session;
# A new transcript. The file is created and the meta line written now.
my $session = LLM::Agent::Session.create(
path => "$*HOME/.local/state/sadna/2026-08-09-refactor.jsonl",
meta => { agent => 'sadna', model => 'kimi-k2', cwd => $*CWD.Str },
);
# The loop writes every message it is handed that this does not already
# have, in order β so a FIRST run appends nothing itself. (Appending the
# user turn here would put it in the file ahead of the system prompt, and
# the loop refuses a run that contradicts its transcript.)
my $loop = LLM::Agent::Loop.new(:@backends, :$provider, :$session);
await $loop.run([$system, $user-message]).result;
$session.close;
# ... a day later, in a new process:
my $resumed = LLM::Agent::Session.load(path => $path);
my @messages = $resumed.messages; # Message objects, compaction applied
my $policy = MCP::Client::Policy.new( # the human is not asked twice
:$provider, :&on-ask, grants => $resumed.grants,
);
my $next = user-message('and now the tests');
$resumed.append-message($next);
$loop = LLM::Agent::Loop.new(:@backends, provider => $policy, session => $resumed);
$loop.run([|@messages, $next]);
DESCRIPTION
An agent run is long, expensive and interruptible: the process is killed, the laptop sleeps, the model runs out of context, the human closes the terminal. A session is the answer to "and then what?" β one append-only JSONL file that is complete after every line, so a run can be picked up from wherever it stopped by a different process, on a different day.
It is deliberately not a database and not a cache. It is a transcript: things that happened, in the order they happened, each as one self-describing line.
Durability: one handle, flushed
create and load open the file once, in append mode, and hold a
single JSONL::Writer in handle mode with :flush for the life of the
session. Every append-* writes one line and flushes it to the OS
before returning, so a kill -9 one instruction later loses nothing
that a method call had already returned from.
What it deliberately does not do:
Never
JSONL::Editor, and never path-modewrite-all: both rewrite the file in place, and an in-place rewrite is a window in which a crash loses the whole transcript rather than the last line. (The crash repair below does rewrite β into a second file, swapped in by one atomic rename, which is precisely the window this avoids.)No
fsync. The line is out of the process and in the kernel; surviving a power cut as well as a crash costs a synchronous disk write per round, which is not a trade a transcript should make for you.
The envelope
Every line is the same four-key envelope with a per-type payload:
{"id":"6f1c...","payload":{...},"ts":"2026-08-09T13:10:08.542283Z","type":"message","v":1}
| Key | Meaning |
|---|---|
| v | Envelope version. 1. A line with any other version is fatal. |
| type | What the payload is; see the types below. |
| id | UUID v4, unique per line. Compaction refers to messages by it. |
| ts | When it was appended: ISO-8601 UTC, microseconds, Z suffix. |
| payload | The type's own data. |
The ts format is byte-identical to LLM::Agent::Event's to-hash,
so an event forwarded to a transcript needs no conversion. Keys are
serialised in sorted order (JSONL's default), which makes two transcripts
of the same run diffable.
type: session-meta
Always the first line, written by create. The payload is the :%meta
hash exactly as it was given β nothing is added, so whatever an app wants
to know when it finds this file six months later is entirely up to the
app. Read it back with .meta.
type: message
One committed conversation message.
| Key | From |
|---|---|
| role | $message.role |
| content | $message.content |
| tool-calls | $message.tool-calls, when there are any |
| tool-call-id | $message.tool-call-id, when defined |
| sticky | $message.sticky, when True |
| sysprompt | $message.sysprompt, when True |
| depth | $message.depth, when defined |
| ... | anything in :%extra |
:%extra is how the loop records the things that are worth reading but
are not part of what gets sent back to a model:
| Extra | On | What it is |
|---|---|---|
| reasoning | assistant | the thinking trace, when the model exposed one |
| finish-reason | assistant | how the provider said generation ended ('stop', 'tool_calls', β¦) |
| usage | assistant | what the provider billed for the turn |
| is-error | tool result | True when the tool answered with a failure |
finish-reason is the provider's own account of why the turn stopped,
kept because a transcript is the only place a truncation nobody caught
can still be seen afterwards. A turn only reaches the transcript once the
loop's clip gate has cleared it (see LLM::Agent::Loop), so the reason
recorded here is one the billed tokens agree with.
They are replay-visible β
events shows them β and dropped when rebuilding Messages, because a
Message has nowhere to put them and inventing somewhere would change what
the next request looks like.
A message key always wins over an extra of the same name: an extra cannot quietly rewrite the conversation.
type: grants
The whole grant snapshot as the policy reports it, every time it
changes β not a delta. Last line wins, which makes grants a single
lookup and makes a truncated transcript degrade to an older, smaller set
of permissions rather than a corrupt one. Feed it straight back:
< MCP::Client::Policy.new(:$provider, grants => $session.grants) >.
type: compaction
The record that turns N earlier messages into one summary: summary,
replaces-through-id (the envelope id of the last message it replaces),
tokens-before, tokens-after, fallback (True when the summarizer
could not be reached and the middle was hard-trimmed instead).
type: elision
The record that replaces the content of messages already in this
transcript with stubs, and changes nothing else about them:
< items => [ { id, stub }, ... ] >, where id is the envelope id of
the message being stubbed.
This is what LLM::Agent::Compactor's observation aging produces β old
tool results elided in place rather than summarized away, at a fraction of
the cost and with no risk to tool_calls pairing (see that class's Pod).
The message stays where it is, keeps its role, its tool_call_id and its
stickiness flags, and only its content is lighter.
Unlike a compaction, an elision adds and removes nothing: messages
has the same length afterwards, and message-ids is untouched. It is
therefore the one envelope that rewrites something the file already said,
which is exactly why it names ids rather than positions β and why an id it
cannot resolve is fatal, for the same reason a compaction's
replaces-through-id naming nothing is.
The original result is still in the file, on the message line it was
written on. A transcript is a record of what happened; the elision is a
record of what the model was shown afterwards, and reading the file tells
you both.
type: tool-dispatched / tool-settled
The two halves of one tool operation (see
LLM::Agent::ToolOperation): the moment a call was prepared for provider
dispatch, and the moment the loop found out β or gave up finding out β what
it did. tool-dispatched does not prove the provider received the call:
the envelope is deliberately durable immediately before the call crosses that
boundary, and a process death between those operations is indistinguishable
from one immediately after it. It therefore means βmay have reached the
providerβ, never βdefinitely ranβ.
tool-dispatched carries call-id (the model's tool_calls id),
tool, arguments (canonicalised), arguments-digest,
idempotency, run-id and round. Its envelope id is the
operation id, which is what tool-settled names and what a UI joins
on.
tool-settled carries op-id, outcome (completed, failed or
outcome-unknown), and optionally reason, duration (seconds),
result-digest, artifact and error.
artifact β < { file, digest, bytes, chars, elided-chars } > β is
there when the result was too big to live in the conversation and the
full bytes went to a file instead (see LLM::Agent::Artifacts).
file is a basename in the < <stem>.artifacts/ > directory
beside this transcript, never a path, because transcripts get moved. The
tool message holds the excerpt the model actually saw, and
result-digest is over that β so this transcript replays byte for
byte whether or not the artifact still exists.
Neither line is a conversation message, and neither touches messages:
the model's view of a tool call is the tool message the loop writes
beside these, exactly as before. What these add is the state of the
call, which is the thing a SIGKILL can leave in three different
places:
| What the file has | What it means |
|---|---|
| an assistant turn, no dispatch line | never dispatched β it did not run |
| dispatch, no settle | it was running when the process died |
| dispatch, no settle, but a tool message| it completed and the settle never landed |
That last row is why the loop writes the tool message before the
tool-settled envelope, and the ordering is load-bearing: it is what
makes "completed but unpersisted" a distinguishable state rather than one
that looks exactly like "still running".
An operation may be settled once. A settle naming an operation this transcript does not have β or one that has already settled β is refused before the line is written, for the same reason a compaction naming a message that is not there is.
Applications read the surviving state back with
pending-tool-operations and close it with resolve-tool-operation
(both below); the loop itself writes both lines as it goes.
type: run-context
What the run was told about the world β the LLM::Agent::RunContext the loop rendered into the request, one line per run that had one.
| Key | What it is |
|---|---|
| run-id | the run this context was rendered for |
| digest | the context's own digest; equal digests are equal contexts |
| facts | < [[key, value], ...] >, in the order they were rendered |
| sections | one record per section: < { name, digest, rendered? | rendered-in? } > |
The facts are lists, not an object, because their order is part of what was rendered and a JSON object has no order to preserve.
A run context is not a conversation message and never becomes one. It
is not in messages, not in message-ids, and a compaction cannot
touch it β which is the whole point of it living out here: the model is
told today's date, today's branch and today's AGENTS.md on every run,
without any of that fossilising in the conversation the seed check locks
down.
Sections are stored once
The volatile half of a context changes every run, so whole-blob deduplication would never fire. Per section it fires almost always: an identity block and a project instruction file are usually the same bytes for a hundred runs in a row.
So a section carries its rendered text only if no earlier
run-context envelope in this transcript already carries a section with
that digest. Otherwise it carries rendered-in: the envelope id of the
line that does. Two identical sections in one envelope resolve the same
way, the second pointing at its own line.
Read the body back with run-context-section($digest), which follows the
pointer for you β and which answers with an undefined Str when the
carrying line is not there any more. That is not an error: a crash-tail
repair can have removed it, and a transcript is a conversation first and
an audit trail second. Same posture as a missing artifact file (see
LLM::Agent::Artifacts) β replay never needs either.
What is NOT stored, and why
Token deltas. They are re-derivable from the committed message and would multiply the file size by the number of fragments.
Attempt telemetry. Which backend failed how many times is operational data with a different lifetime than a conversation; send the
AttemptFailedevents to a log.The contents of permission questions. A transcript that recorded every ask would record what the model was about to do to the user's files, in a file the user may well paste into a bug report. Only the resulting grants are kept.
Forwarded server logs. Same reason as telemetry.
Where the file should live. The caller passes a path. There is no XDG lookup, no default directory and no filename convention here β an app owns its own state layout.
Replay, and the one crash it tolerates
Before it decodes anything, load looks at the end of the file as
bytes. A crash mid-say leaves half a line there, and half a line
can stop in the middle of a multi-byte character β which is a file that
.lines cannot read at all, so anything that turns the file into
Strs first dies before :lenient ever gets a chance.
If that last line is not one whole JSON value, it is physically removed rather than merely skipped:
the good prefix of the file is copied verbatim β byte for byte, nothing re-serialised, because the part that survived is not this class's to rewrite β into a
.repairfile beside it;which is then
renamed over the original. The rename is the only mutation and it is atomic, so a crash during the repair leaves the original exactly as it was. A leftover.repairfile is deleted by the nextload.
The line that went is recorded in .warnings, so an app can say so.
Removing it is the whole point: skipping it in memory and then appending
after it would write the next line inside the garbage β a transcript
that loads, appends and closes without complaint, and then refuses to
load at all.
A malformed line is tolerated only as the very last one. If the line
before it is malformed too, load dies instead of repairing:
healing one line per load would eat a damaged file backwards, a line per
resume, and call the result a conversation. A malformed line anywhere
else means the file has been damaged in a way that no crash produces β a
bad merge, a concurrent writer, a truncated copy β and load dies for
the same reason. Silently skipping it would hand back a conversation
with a hole in it, and the hole would be invisible.
The repair runs before replay, so a transcript that then fails to load for some other reason has still lost its tail. That tail was garbage either way, and leaving it there would only mean repairing it on the load after the one that fixed the real problem.
What replay does not repair is a transcript whose last assistant turn
asked for tools that nothing answered β which is what a SIGKILL
between a tool call being committed and its result arriving leaves
behind. LLM::Agent::Loop closes those off itself when it is
cancelled, so the only way to get one is a process that died outright;
an app that wants to be bulletproof against that should check
pending-tool-operations (and the tail of messages) before resuming.
It is not repaired here because a silent repair is a silent change to a
conversation, and the only honest options β drop the turn or answer it
with a lie β are the app's to choose between. What this class does is
tell the app exactly what state each interrupted call was left in; see
pending-tool-operations.
Blank lines are ignored anywhere. An unknown type is preserved in
events and skipped by messages and grants, so a transcript
written by a newer LLM::Agent still replays as far as this one
understands it. An unknown v is fatal: the envelope itself is the one
thing that cannot be guessed at.
Reading one without opening it
load is for carrying a conversation on: it repairs the crash tail,
replays every envelope, and keeps an append handle. Two callers want
neither half of that.
A crash repair reading a child's transcript to find out whether the agent that was working when the lights went out ever finished. It has no business appending to somebody else's file, and a transcript it cannot parse is a fact to report rather than an exception to throw.
A viewer watching a transcript that is still being written. Repairing that file would truncate the line its own writer is halfway through, and a second append handle on one transcript is how a conversation ends up interleaved with itself.
peek is for those: < Session.peek(:$path) > reads the bytes, parses
what parses, and answers
< { path, size, meta, envelopes, messages, warnings, error? } >
without writing a byte. envelopes is every line that parsed, in the
shape events gives; messages is the message lines as Messages.
In file order, with no replay: a compaction is not applied, an elision
is not applied, and a malformed line is skipped with a warning rather
than being cut off the end of the file. load answers "what would the
model be sent next?"; peek answers "what does this file actually
say?", which is the question a repair and a viewer are both asking.
How compaction replays
messages is not "every message line". Applying a compaction means:
keep every entry up to and including
replaces-through-idthat is sticky (sticky,syspromptordepthβ i.e.Message.is-sticky);splice in one synthetic
usermessage carrying the summary, at the cut point, under the compaction envelope's own id;keep everything after the cut point untouched.
Later compactions run over that result, so they compose: a compaction may
name a previous compaction's summary as its replaces-through-id and
fold it into the new one. This is exactly the transformation
LLM::Agent::Compactor applies to the live conversation, which is what
makes messages after a resume equal the array the loop was working
with when it stopped.
A compaction whose replaces-through-id names nothing is fatal, for the
same reason a mid-file malformed line is.
...and how an elision replays beside it
An elision line replaces the content of the messages its items name,
by id, wherever they are in the conversation as it stands at that
point in the file. Both kinds of envelope are applied in file order,
which is what makes them compose in either direction:
an elision, then a compaction: the compaction summarizes a middle that already holds the stubs, which is precisely what the live run's summarizer was shown;
a compaction, then an elision: the elision may name a message the compaction kept β including, in principle, the summary itself, which replays under the compaction envelope's own id.
An elision naming an id the live message set does not have is fatal, with the same posture and for the same reason as a compaction naming nothing: the alternative is a resumed conversation that silently differs from the one the run was working with. Note that this is a statement about the messages that are still there β an id that a later compaction replaced is gone, so an elision must be written before it, which is the order LLM::Agent::Loop writes them in.
Ids, and why you may want them
message-ids is messages's parallel list of envelope ids.
LLM::Agent::Loop uses it to keep its own message-to-id map in step
across a resume, so a compaction that happens in the second process can
still name a message the first process wrote. Applications rarely need
it; one that wants to reference a specific turn later does.
Concurrency
Every method is safe to call from any thread β the file handle, the writer and the replayed state all live behind one lock. What that does not buy you is two processes appending to one file: use one session per file, which is also the only way the ids stay meaningful.
SEE ALSO
LLM::Agent::Loop (its main writer), LLM::Agent::Compactor (what
produces the compaction lines), JSONL::Writer (the :flush this
leans on).
pathβ theIO::Pathit was read from.sizeβ its size in bytes at the moment it was read.metaβ thesession-metapayload, or an empty Hash.envelopesβ every line that parsed, in file order, in the shapeeventshands back.messagesβ themessagelines as Messages, in file order.warningsβ one line per line that would not parse.errorβ present only when there is nothing to read at all: no file, an empty one, or a first line that is not asession-meta. The other keys are still there and still safe.