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-mode write-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 hasWhat it means
an assistant turn, no dispatch linenever dispatched β€” it did not run
dispatch, no settleit 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 AttemptFailed events 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 .repair file 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 .repair file is deleted by the next load.

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-id that is sticky (sticky, sysprompt or depth β€” i.e. Message.is-sticky);

  • splice in one synthetic user message 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 β€” the IO::Path it was read from.

  • size β€” its size in bytes at the moment it was read.

  • meta β€” the session-meta payload, or an empty Hash.

  • envelopes β€” every line that parsed, in file order, in the shape events hands back.

  • messages β€” the message lines 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 a session-meta. The other keys are still there and still safe.

LLM::Agent v0.6.1

a streaming agent loop: tools, retry, fallback, a durable

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Digest::SHA256::Native:ver<1.0.0+>:auth<zef:bduggan>LLM::Chat:ver<0.10.0+>:auth<zef:apogee>MCP::Client:ver<0.5.0+>:auth<zef:apogee>JSONL:ver<0.1.6+>:auth<zef:apogee>JSON::Fast:ver<0.19+>:auth<cpan:TIMOTIMO>UUID::V4:ver<1.0.0+>:auth<zef:masukomi>

Test Dependencies

Provides

  • LLM::Agent
  • LLM::Agent::Artifacts
  • LLM::Agent::Canonical
  • LLM::Agent::Compactor
  • LLM::Agent::CompletionBus
  • LLM::Agent::Event
  • LLM::Agent::Loop
  • LLM::Agent::Prompt
  • LLM::Agent::RequestBudget
  • LLM::Agent::Run
  • LLM::Agent::RunContext
  • LLM::Agent::Session
  • LLM::Agent::Subagents
  • LLM::Agent::TokenCount
  • LLM::Agent::ToolOperation

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite β€” the markup and publishing tools behind this site.