Event
NAME
LLM::Agent::Event - the typed events an agent run publishes
SYNOPSIS
use LLM::Agent::Event;
# One Supply, every kind of event. Dispatch on the class...
react whenever $run.events -> $event {
given $event {
when LLM::Agent::Event::Token { print $event.text }
when LLM::Agent::Event::ToolCall { note "-> {$event.name}" }
when LLM::Agent::Event::RunFailed { note $event.error }
}
}
# ...or on the stable kind string, which is what a transcript stores.
react whenever $run.events -> $event {
$log-file.say: to-json($event.to-hash);
last if $event.is-terminal;
}
DESCRIPTION
An agent run is a long, failure-prone, resumable thing, and every consumer
wants a different slice of it: a TUI wants tokens and tool calls, a logger
wants everything as plain data, a test wants the exact ordering, a metrics
sink wants only the attempt records. Rather than a hook per concern, a run
publishes one Supply of these objects and each consumer filters it.
Every event carries the moment it was created (.ts, an Instant), a
stable .kind string, and knows how to flatten itself into plain data
(.to-hash).
The envelope: run-id and seq
An event that has been published by a LLM::Agent::Run also carries the envelope the Run stamped on it:
| Field | What it is |
|---|---|
| run-id | the id of the Run that published it |
| seq | its position in that run's publication order, 0-based |
Both are stamped at publication, on a clone, inside the Run's one
critical section ā so seq is the total order of the run's events even
though a token stream, a tool thread, an asker and a server's log hook all
emit from threads of their own. It is contiguous (0 .. N with no gaps),
and the terminal event is always the last one, N.
What it is not is a promise about which event is seq 0: a log
notification from an MCP server can legitimately beat the driver's own
RunStarted into the mailbox. Order is a fact about publication, not
about causality.
An event you built yourself and never handed to a Run has neither field,
and to-hash omits both ā which is why a hand-built event still
serialises exactly as it did before there was an envelope.
The attempt-framing contract
This is the part worth reading twice, because it is what makes a mid-stream failure replayable rather than corrupting.
A single round of the loop may talk to the backend several times: the
first attempt can die after streaming 400 tokens, and the retry starts
again from nothing. Those 400 tokens were already emitted as Token
events, and they cannot be un-emitted ā a Supply has no undo.
So the framing events are the contract:
| Event | What a consumer must do |
|---|---|
| AttemptStarted | open a fresh token scope; nothing before it belongs here |
| Token | append to the CURRENT scope |
| AttemptFailed | discard every Token since the last AttemptStarted |
| AttemptSucceeded | commit the scope; the AssistantMessage that follows is authoritative |
A consumer that ignores this renders a doubled reply the first time a
backend 500s halfway through a sentence. A consumer that honours it shows
the text rewinding, which is what actually happened. The
AssistantMessage event that follows a success carries the committed
text, so a consumer that does not want live tokens at all can simply
ignore Token and render only AssistantMessage.
The transcript is never in doubt: only committed messages are written to a session, so nothing a failed attempt streamed can ever be replayed as if the model had said it.
Exactly one terminal
A run emits exactly one of RunCompleted, RunFailed or
RunCancelled, and the Supply is done immediately afterwards. The
Supply is never quit: a failure is data (a RunFailed event and a
kept result Promise), not an exception thrown at whoever happened to be
tapping. $event.is-terminal is the test; LLM::Agent::Event::TERMINAL-KINDS
is the same answer for code working from kind strings.
to-hash: plain data only
.to-hash returns kind, ts, the envelope (run-id and seq,
when the event has been published), and the event's payload keys,
containing nothing but Str / Int / Num / Rat / Bool /
Hash / List ā it round-trips through to-json unchanged, which is
what the JSONL transcript and any log sink need.
Two rules make that predictable:
tsis an ISO-8601 UTC string with microsecond precision (2026-08-09T13:10:08.542283Z), not a number. It matches the session envelope's timestamp format exactly, so forwarding an event to a transcript needs no conversion, and it stays readable when a human greps the file. The originalInstantis still on the object as.tsfor anyone doing arithmetic ā$b.ts - $a.tsis aDurationin seconds.Undefined payload values are omitted. An
AttemptFailedfrom a connection error has noerror-status, so the key is absent rather than null, and a sink can use:existsand.definedinterchangeably. The exception is a key whose value is a container that is present but empty (usage,attempts): those are always emitted, so "the provider reported no usage" is an empty hash rather than a missing key.
Naming: why these are not is exported
The classes are plain global names under LLM::Agent::Event::, declared
without is export ā exactly like LLM::Chat::Retry::Exceptions, and
for the same reason. is export on a nested-name class exports its
leaf name too, so an is exported LLM::Agent::Event::Log would
put a bare Log into the importer's scope and collide with every other
module that has an opinion about what Log means. use
LLM::Agent::Event; here gives you the fully-qualified names and nothing
else.
THE TAXONOMY
Every row below also carries the two envelope fields run-id? and
seq? ā see /The envelope: run-id and seq ā so they are not repeated
per class.
| Class | kind | Payload |
|---|
| RunStarted | run-started | message-count, context-digest? |
| RoundStarted | round-started | round, tokens? |
| AttemptStarted | attempt-started | round, attempt, backend-index, model |
| Token | token | text, round?, attempt? |
| AttemptFailed | attempt-failed | round, attempt, backend-index, model?, error, error-class?, error-status?, disposition, backoff?, usage? |
| AttemptSucceeded | attempt-succeeded | round, attempt, backend-index, model-used?, finish-reason?, usage, latency-ms? |
| AssistantMessage | assistant-message | message, reasoning?, round? |
| TurnCommitted | turn-committed | message-id?, round? |
| ToolCall | tool-call | id, name, arguments?, round? |
| ToolStarted | tool-started | id, name, round? |
| ToolProgress | tool-progress | id, progress, total?, message?, round? |
| ToolResult | tool-result | id, name?, content, is-error, artifact?, round? |
| ToolAbandoned | tool-abandoned | id, name, reason, dispatched, round? |
| Subagent | subagent | agent-id, agent-type, label?, call-id?, inner |
| BackgroundOpStarted | background-op-started | op-id, op-kind, label?, call-id?, round? |
| BackgroundOpSettled | background-op-settled | op-id, op-kind, collected, round? |
| BackgroundOpDelivered | background-op-delivered | op-kind, op-id?, message-id?, round? |
| RunParked | run-parked | outstanding, ops, round? |
| RunResumed | run-resumed | reason, parked-seconds?, round? |
| AskPending | ask-pending | request, tool? |
| AskAnswered | ask-answered | request, answer? |
| Log | log | level, logger?, data |
| LimitReached | limit-reached | limit, count?, max? |
| TurnDiscarded | turn-discarded | reason, round? |
| CompactionStarted | compaction-started | tokens-before?, budget?, message-count?, round? |
| CompactionDone | compaction-done | tokens-before?, tokens-after?, dropped?, summary?, fallback, round? |
| RunCompleted | run-completed | final, rounds?, message-count? |
| RunFailed | run-failed | error, attempts, round?, reason? |
| RunCancelled | run-cancelled | stage?, round? |
A ? marks an optional attribute ā one whose key is absent from
to-hash when it was not supplied.
Turns: committed, and discarded
AttemptSucceeded says the transport worked ā and, since the loop's
usage-clip gate, that the completion was not billed at the backend's
max_tokens either, so an answer the provider quietly cut off never
reaches one (see LLM::Agent::Loop). It does not say the model's turn
is part of the conversation, and the two really do come apart: a turn
that asks for tools when a limit has been reached is discarded whole,
tokens and all.
So a turn ends in exactly one of two events:
TurnCommittedā beside theAssistantMessage, carrying the session envelope id of the committed message. That id is the join key between the conversation and thetool-dispatchedenvelopes that follow it, which is why it is worth an event of its own;AssistantMessagekeeps the payload and the commit role it has always had.TurnDiscardedā every streamed scope that will never be followed by anAssistantMessage: a limit discarded it (reason ='limit'>), the backend chain ran out ('failed'), or the run was cancelled mid-flight ('cancelled').
The invariant a consumer may rely on: an AttemptSucceeded that is
never followed by an AssistantMessage in the same round is always
followed by a TurnDiscarded. A UI that renders streamed text can
therefore always retract it on a signal rather than leaving it on screen
until the next turn overwrites it.
A tool call, event by event
Five events frame one tool call, and they answer five different questions:
| Event | The question it answers |
|---|---|
| ToolCall | what did the model ask for? (it is proposed) |
| ToolStarted | it has been handed to the provider (it is dispatched) |
| ToolProgress | it is still running, and here is how far |
| ToolResult | it answered ā with a result, or with an error |
| ToolAbandoned | it will not answer; here is what we know about whether it ran |
ToolResult's content is what the model was given, which for a
result bigger than < request-budget.max-observation-size > is an
excerpt rather than the whole thing ā the same excerpt that went into the
conversation and the transcript, with the full bytes in the file
artifact names. A consumer rendering a tool result is therefore
rendering exactly what the model saw, and artifact is how it offers to
show the rest. See LLM::Agent::Artifacts.
ToolAbandoned is the one to read twice. < dispatched => False >
means the call was never handed to the provider and is known not to
have run. < dispatched => True > means it was, and then the loop
stopped waiting ā the outcome is genuinely unknown, not failed.
Nothing invents a ToolResult for either case: a result event would
tell a consumer the tool answered, and it did not.
round is 1-based. attempt is 1-based within a round and counts
across backends: attempt 3 may be the first attempt against the second
backend. disposition is one of retry-same, advance or abort ā
the buckets of LLM::Chat::Retry's classify-error. reason is the
named-failure key described on RunFailed, and is present only for the
failures that have a name.
Background work, and the run that waits for it
A background tool call answers twice: once immediately, with an acknowledgement saying the work has started, and once later with what it produced. Five events frame that, and between them they answer the two questions a consumer of a background run actually has ā what is still owed, and why is nothing happening.
| Event | The question it answers |
|---|---|
| BackgroundOpStarted | something was acknowledged, and an answer is owed for it |
| BackgroundOpSettled | that answer exists now |
| BackgroundOpDelivered | and the model has been shown it, as a turn |
| RunParked | the model went quiet with work outstanding; here is what |
| RunResumed | ...and here is what woke it |
BackgroundOpSettled's collected is the one to read twice. False
is the ordinary case: the answer went onto the completion bus and a
BackgroundOpDelivered follows it at the next round boundary. True
means somebody joined the operation synchronously ā a task_wait that
was already parked on the child when it settled ā so the model has the
answer as an ordinary tool result and no delivered event is coming.
Exactly one of the two presentations happens, and collected is which.
A RunParked is not a failure and not a stall. The model finished
its turn, the harness has work outstanding that it promised to report,
and the run is waiting rather than ending ā which is the whole of
LLM::Agent::Loop's Park, don't end. ops is the inventory, in the
order the operations were opened, and it is what a UI renders as "waiting
on". It is always emitted, empty or not, for the same reason usage is:
"nothing outstanding" and "the inventory was not reported" are different
facts.
RunResumed's reason says what ended the park ā completion
(something landed), steer (the user said something), cancelled, or
idle (nothing arrived for park-idle-timeout, and the run gives up
honestly rather than waiting for ever). parked-seconds is how long it
waited, and it is the number that is subtracted from the run's
wall-clock spend: a run that spent an hour parked on a child spent an
hour waiting, not working, and a max-wall-clock that counted it would
kill runs for being patient.
Every one of the five is non-terminal. A park is a pause, a resume is the end of a pause, and a background operation settling is not the run ending any more than a tool result is.
Subagent: a child run's event, carried on the parent's stream
A run that spawns another run (LLM::Agent::Subagents) has two event
streams to reconcile, and merging them naively would be a disaster: two
runs' seq numbers interleaved in one order, two RunCompleteds on a
Supply that promises exactly one terminal, a consumer unable to tell
whose Token it is rendering.
So a child's events are not merged ā they are wrapped. Each one is
flattened with .to-hash and carried as the inner payload of a
Subagent event published on the parent's stream, which stamps it
with the parent's run-id and the next parent seq like any other
event. Nothing about the parent's envelope contract moves:
| Layer | run-id / seq | kind |
|---|---|---|
| outer | the PARENT's | always subagent, never terminal |
| inner | the CHILD's, as stamped | whatever the child emitted |
inner is plain data, not an Event object ā it has already been
through to-hash, so a Subagent event serialises to JSON in one
step like every other event, and a consumer reads < $e.inner<kind> >
to dispatch on what the child did.
Two consequences worth stating out loud. A Subagent whose
< inner<kind> > is run-completed is not terminal: the child
ended, the parent did not, and is-terminal stays False so a consumer
that stops tapping on a terminal does not stop halfway through the
parent's run. And agent-id ā not the inner run-id ā is the key to
group by when several children are running at once: it is short, it is
what the transcript's subagent-spawned envelope records, and it is
stable across a child that had to be restarted.
call-id is the other join, and it points the other way: it is the
provider's id for the task tool call that started this child, the
same string the ToolCall, ToolResult and ToolAbandoned events
for that call carry as id. It is constant for the child's whole life ā
every wrapper for one agent carries the same one ā which is what lets a
consumer put a delegation's tool card and its agent card together instead
of drawing the same act twice:
# One card, not two: the child's first event claims the tool card the
# `task` call already put on screen.
$card-for{$e.call-id} //= agent-card($e.agent-id);
It is optional. A composer forwarding a child it did not start from a
tool call has none to give, and an event replayed from a transcript
written before the key existed has none either; both leave it undefined
rather than inventing a call that never happened, and a consumer that
finds no call-id falls back to a card of its own.
SEE ALSO
LLM::Agent::Run (the handle that carries the Supply), LLM::Chat::Retry
(where disposition comes from).