LLM::Agent::Revival
NAME
LLM::Agent::Revival - bring an interrupted run back from its own transcript
SYNOPSIS
use LLM::Agent::Revival;
# Is there anything to revive here? Reads the file, writes nothing.
my %found = LLM::Agent::Revival.inspect(
path => $child-path, max-revivals => 2,
);
say %found<reason> unless %found<revivable>; # 'finished', 'revival-cap', ...
# Yes: repair the interrupted call, tell the run what happened, and hand
# back everything needed to start it again as ITSELF.
my %revived = LLM::Agent::Revival.revive(
path => $child-path, agent-id => 'researcher-1', max-revivals => 2,
);
if %revived<ok> {
my $loop = LLM::Agent::Loop.new(
:@backends, :$provider, session => %revived<session>,
);
my $run = $loop.run([|%revived<messages>]);
# ...and the composer takes it back under its ORIGINAL id, so the
# parent's outstanding `task_wait` finds it rather than a stranger.
$composer.revive-child(
agent-id => %revived<agent-id>,
agent-type => %revived<agent-type>,
label => %revived<label>,
call-id => $spawn-envelope<call-id>,
run => $run,
session-path => %revived<path>.Str,
);
}
DESCRIPTION
A process that dies does not stop being a conversation. Its transcript is
on disk, complete to the last line that returned (see
LLM::Agent::Session), and everything needed to carry it on is in
there: the messages, the tool calls that were in flight, and ā for a
child of a delegating agent ā the session-meta its host wrote when it
started it.
This class is the engine half of picking that up: given one
transcript, it decides whether there is a run to revive, repairs what the
interruption left open, tells the run what happened to it, and hands back
the pieces. It deliberately does not scan a directory, ask a human, or
build a loop. Which transcripts to look at is an application's question
(a child lives in <session>.agents/ only because an app put it
there), whether to spend the tokens is a human's ā a crash that
reproduces would otherwise revive the same four agents for ever ā and how
a run is wired is the app's own, exactly as it is for a first spawn.
The three things an interruption leaves behind
A tool call that was in flight
tool-dispatched with no tool-settled means the call was handed over
and the process died before it heard back (or heard back and died before
it could say so). It is answered as a failure, in the conversation,
with a message that says what happened and what to do about it:
The previous session ended while this tool call was in flight, so it was
interrupted. Whether it took effect is unknown, and it has NOT been
retried: check the state of anything it was changing ā a file it was
writing may have been left half-written ā before assuming either way.
It is never replayed, and the idempotency the dispatch envelope
records does not change that. A read-only call looks safe to run again
right up until the moment the thing it read has been changed by the half
of the batch that did land; more to the point, a model that is told "this
may have half-happened" can go and look, and a replay heuristic cannot.
One honest failure covers every case, and the model decides.
The operation itself settles failed with reason => 'interrupted' .
Two other shapes settle without a message, because the conversation
already accounts for them:
| What the transcript has | Outcome | Reason |
|---|---|
| dispatch, no settle, no tool message | failed | interrupted |
| dispatch, no settle, a tool message | outcome-unknown | completed-unpersisted |
| dispatch for a call no turn still has| outcome-unknown | orphaned |
The second is the loop's write order paying off (the tool message is written before the settle, so a crash between them is distinguishable from a call still running); the third is a compaction having spliced the turn away since.
A run that does not know any of this
Every revived run is told, in the conversation, immediately before it starts thinking again:
[background event] Automated notification from the system, not the user
speaking. The previous session ended while this run was working, and the
run has been resumed: everything above is its own conversation, up to the
moment it stopped. Work it had not finished is still unfinished.
It opens with the same marker and the same first sentence as
LLM::Agent::Subagents' BACKGROUND-FRAME, and for the same reason: a
model that has learned what [background event] means from one producer
must not have to learn it again from another, and a harness notification
a model has not been taught about is a notification it treats as
injected content. It is an ordinary user turn carrying
injected => 'revival' in its transcript extras, which is the
durable marker the loop's own background deliverables use ā so a second
pass over the file can tell a run that has already been told from one
that has not.
A parent that is still waiting
Nothing in here touches the parent. Its task_wait calls stay
pending rather than being settled as unknown, which is what
LLM::Agent::Loop's resume-tool-calls exists to make possible: the
joins are answered by the children that are starting again, under the
call ids they were made with. See that class's "the three states of a
tool-call tail".
What counts as revivable
A transcript whose last message is an assistant turn asking for no tools is a run that finished: it said its piece, and reviving it would start a second run that had nothing to do. Everything else ā a turn with tool calls nothing answered, a tail of tool results the model never got to read, a user turn that was still being thought about ā is a run that was interrupted mid-work, and is revivable.
The cap, and why it is a parameter
max-revivals bounds how many times one transcript may be revived, and
it is counted from the transcript itself: every revival appends a
run-revived envelope, so the count survives the process that made it.
A revival past the cap is refused with reason => 'revival-cap' and
nothing is written.
It is a parameter rather than a constant because the policy belongs to
the application: a harness that asks a human every time can afford a
generous cap, an unattended one cannot, and a crash that reproduces
exactly is otherwise an unbounded bill with nothing but a human's
attention between it and the card. max-revivals => 0 refuses every
revival, which is how a host turns the whole mechanism off without
special-casing it.
Nothing throws
inspect never writes and never throws: a transcript that cannot be
read is a fact to report ( reason => 'unreadable' ), the same
posture LLM::Agent::Session's peek takes, and for the same reason ā
the caller is a repair, reading somebody else's file, possibly while it
is still being written.
revive throws only for a caller that asked for something impossible (a
path and a session at once, neither, or a negative cap). Everything about
the transcript's own state ā finished, capped, unreadable ā comes back as
ok => False with a reason, because none of those are programmer
errors and all of them have to be shown to a human.
SEE ALSO
LLM::Agent::Session (the transcript and its crash repair),
LLM::Agent::Loop (resume-tool-calls, the pending-again seed state),
LLM::Agent::Subagents (revive-child, which takes a revived run back
under its original id).
one
toolmessage per interrupted call, carryingINTERRUPTED-MESSAGEandis-error => True;one settle per open dispatch (
failed/interrupted,outcome-unknown/completed-unpersisted, oroutcome-unknown/orphanedā see the module Pod);the resume notice, as a
userturn markedinjected => 'revival';one
run-revivedenvelope, which is what the cap counts.