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:

  • ts is 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 original Instant is still on the object as .ts for anyone doing arithmetic — $b.ts - $a.ts is a Duration in seconds.

  • Undefined payload values are omitted. An AttemptFailed from a connection error has no error-status, so the key is absent rather than null, and a sink can use :exists and .defined interchangeably. 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 the AssistantMessage, carrying the session envelope id of the committed message. That id is the join key between the conversation and the tool-dispatched envelopes that follow it, which is why it is worth an event of its own; AssistantMessage keeps the payload and the commit role it has always had.

  • TurnDiscarded — every streamed scope that will never be followed by an AssistantMessage: 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).

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.