EventBus Event Types and Payload Schemas¶
This document catalogs every event type emitted by little-loops subsystems. It is the primary reference for extension authors, external consumers (e.g. loop-viz), and internal development.
Related Documentation: - API Reference — EventBus and LLExtension — bus registration, transports, filter patterns - Architecture Overview — Event persistence patterns and FSM executor design
Wire Format¶
All events are emitted as flat Python dicts and serialized to JSON:
| Key | Type | Description |
|---|---|---|
event |
str |
Event type identifier (see tables below) |
ts |
str |
ISO 8601 timestamp, UTC |
| (payload fields) | varies | Type-specific fields documented per event |
When received by an LLExtension, the raw dict is wrapped into an LLEvent dataclass:
event.type # the "event" key
event.timestamp # the "ts" key
event.payload # all remaining keys as a dict
Hook intents — sibling type¶
LLEvent covers pub/sub bus events. Hook intents (PreCompact, SessionStart, PreToolUse, …) are request/response and use a sibling dataclass LLHookEvent, with handler responses modeled as LLHookResult. Adapters under hooks/adapters/<host>/ translate between each host's native hook protocol and these host-agnostic types; the dispatcher lives in little_loops.hooks.main_hooks and is invoked as python -m little_loops.hooks <intent>.
LLHookEvent fields¶
Source of truth: scripts/little_loops/hooks/types.py.
| Key | Type | Description |
|---|---|---|
host |
str |
Host agent identifier (e.g. "claude-code", "opencode", "codex"). Adapters set this; the CLI reads LL_HOOK_HOST (default "claude-code"). |
intent |
str |
Hook intent name matching the handler module (e.g. pre_compact, session_start). |
ts |
str |
ISO 8601 UTC timestamp. Field name differs from wire key: stored as timestamp on the dataclass, serialized as ts by to_dict(). from_dict() accepts either ts or timestamp. |
payload |
object |
Host-supplied event data. Schema is intent-specific (see per-intent notes below). |
session_id |
str (optional) |
Host session identifier. Omitted from the wire dict when None. |
cwd |
str (optional) |
Working directory the host was operating in. Omitted from the wire dict when None. |
LLHookResult fields¶
| Key | Type | Description |
|---|---|---|
exit_code |
int |
Always emitted. 0 = pass; 2 = block and surface feedback to the model. Non-Claude hosts map this to their own permit/deny semantics. |
feedback |
str (optional) |
Human-readable message. Claude Code writes this to stderr when exit_code == 2. Omitted from the wire dict when None. |
decision |
str (optional) |
Permission decision for permission-checking intents (allow / deny / ask). Omitted from the wire dict when None. |
data |
object |
Additional structured data returned to the host. Omitted from the wire dict when empty. |
stdout |
str (optional) |
Raw payload written to the host's stdout (e.g. SessionStart's merged config JSON). Omitted from the wire dict when None. |
Wire-format example¶
{
"host": "claude-code",
"intent": "pre_compact",
"ts": "2026-05-12T14:00:00Z",
"payload": {"transcript_path": "/tmp/session.jsonl"},
"cwd": "/Users/me/project"
}
Round-trip note: to_dict() emits the timestamp under the key ts; from_dict() accepts both ts and timestamp. A dict produced by to_dict() round-trips cleanly through from_dict().
Per-intent payload notes¶
pre_compact— reads exactly one payload key,transcript_path(falls back to""). Writes.ll/ll-precompact-state.json. ReturnsLLHookResult(exit_code=2, feedback=<line-budget-message>)to surface a context-budget warning to the model.session_start— reads no payload keys; operates viaPath.cwd(). ReturnsLLHookResult(exit_code=0, feedback=<stderr-lines>, stdout=<merged-config-json-or-None>).
Naming Conventions¶
| Namespace | Pattern | Source |
|---|---|---|
| FSM executor | bare names (loop_start, state_enter, …) |
fsm/executor.py |
| FSM persistence | bare names (loop_resume) |
fsm/persistence.py |
| StateManager | state.* |
state.py |
| Issue lifecycle | issue.* |
issue_lifecycle.py |
| Parallel orchestrator | parallel.* |
parallel/orchestrator.py |
Use these namespaces in event_filter patterns when registering observers:
# Subscribe only to FSM events
bus.register(callback, filter="state_*")
# Subscribe only to issue lifecycle events
bus.register(callback, filter="issue.*")
# Subscribe to multiple namespaces
bus.register(callback, filter=["issue.*", "parallel.*"])
Subsystem: FSM Executor¶
Source: little_loops.fsm.executor.FSMExecutor
Path: scripts/little_loops/fsm/executor.py
Flow: FSMExecutor._emit() → event_callback → EventBus.emit()
These events use bare names (no dot namespace) for historical compatibility.
loop_start¶
Emitted once at the very beginning of loop execution, before any state is entered.
| Field | Type | Description |
|---|---|---|
loop |
str |
Name of the FSM loop (from the loop YAML name field) |
Example:
state_enter¶
Emitted when the executor enters a state, before the state's action is executed.
| Field | Type | Description |
|---|---|---|
state |
str |
Name of the state being entered |
iteration |
int |
Current iteration count (1-based) |
Example:
route¶
Emitted when the executor selects the next state after an evaluation.
| Field | Type | Required | Description |
|---|---|---|---|
from |
str |
always | Source state name |
to |
str |
always | Destination state name |
reason |
str |
optional | "maintain" when the loop is in maintain mode; absent otherwise |
Example:
action_start¶
Emitted immediately before executing the current state's action.
| Field | Type | Description |
|---|---|---|
action |
str |
The resolved action string (interpolated prompt text or shell command) |
is_prompt |
bool |
true if the action is a Claude prompt; false if a shell command |
Example:
action_output¶
Emitted for each line of streaming output produced by the action. High-frequency event — may fire hundreds of times per state.
| Field | Type | Description |
|---|---|---|
line |
str |
A single line of output from the running action |
Example:
action_complete¶
Emitted after the action finishes, regardless of success or failure.
| Field | Type | Required | Description |
|---|---|---|---|
exit_code |
int |
always | Exit code of the action (0 = success) |
duration_ms |
int |
always | Wall-clock execution time in milliseconds |
output_preview |
str \| null |
always | Last 2 000 characters of the action's output; null if no output was produced |
is_prompt |
bool |
always | true for Claude prompt actions, false for shell commands |
session_jsonl |
str \| null |
prompt only | Absolute path to the Claude session JSONL file for this prompt run; null if path cannot be determined |
Example (shell command):
{
"event": "action_complete",
"ts": "...",
"exit_code": 0,
"duration_ms": 1234,
"output_preview": "Build succeeded",
"is_prompt": false
}
Example (Claude prompt):
{
"event": "action_complete",
"ts": "...",
"exit_code": 0,
"duration_ms": 45000,
"output_preview": "I have completed the task...",
"is_prompt": true,
"session_jsonl": "/Users/user/.claude/projects/.../abc123.jsonl"
}
action_error¶
Emitted when an action raises an unhandled exception that is routed to the state's on_error target. Only emitted when on_error is defined; if absent, the exception propagates to the top-level loop handler and terminates execution instead.
| Field | Type | Required | Description |
|---|---|---|---|
state |
str |
always | Name of the state whose action raised |
error |
str |
always | String representation of the raised exception |
route |
str |
always | Route taken in response (always "on_error") |
Example:
{
"event": "action_error",
"ts": "...",
"state": "fetch_data",
"error": "ConnectionError: timed out after 30s",
"route": "on_error"
}
evaluate¶
Emitted after the evaluator runs to determine the next routing decision.
| Field | Type | Description |
|---|---|---|
type |
str |
Evaluation type: "default" (exit-code based) or the custom type declared in the state's evaluate config (e.g. "llm") |
verdict |
str |
"pass" or "fail" |
| (detail fields) | varies | Additional evaluator-specific fields (e.g. score, reason for LLM evaluators) |
Example (default exit-code evaluation):
retry_exhausted¶
Emitted when a state exceeds its max_retries limit and the executor transitions to on_retry_exhausted.
| Field | Type | Description |
|---|---|---|
state |
str |
Name of the state that exhausted its retry budget |
retries |
int |
Number of retries that were attempted |
next |
str |
Name of the on_retry_exhausted target state |
Example:
rate_limit_exhausted¶
Emitted when the wall-clock rate-limit budget is spent across the short-burst and long-wait retry tiers and the executor transitions to on_rate_limit_exhausted (or on_error). See rate_limit_max_wait_seconds and rate_limit_long_wait_ladder on StateConfig for budget configuration.
| Field | Type | Description |
|---|---|---|
state |
str |
Name of the state that exhausted rate-limit retries |
retries |
int |
Total rate-limit retries attempted across both tiers (short_retries + long_retries) |
short_retries |
int |
Retries attempted in the short-burst tier (before entering long-wait) |
long_retries |
int |
Retries attempted in the long-wait tier (ladder-based) |
total_wait_seconds |
number |
Accumulated wall-clock seconds spent sleeping in rate-limit waits |
next |
str \| null |
Name of the on_rate_limit_exhausted target state, or null |
Example:
{"event": "rate_limit_exhausted", "ts": "...", "state": "implement", "retries": 7, "short_retries": 3, "long_retries": 4, "total_wait_seconds": 21600.0, "next": "halt"}
rate_limit_storm¶
Emitted when consecutive rate_limit_exhausted events across any states reach the storm threshold (3). The counter resets on any successful non-rate-limited state transition.
| Field | Type | Description |
|---|---|---|
state |
str |
Name of the state that triggered the storm threshold |
count |
int |
Consecutive rate_limit_exhausted count at emission time |
Example:
rate_limit_waiting¶
Emitted periodically by the FSM executor while sleeping between 429 retry attempts (both short-burst and long-wait tiers). Provides heartbeat visibility into in-progress waits so dashboards and analysis tooling can surface progress toward the wall-clock budget defined by rate_limit_max_wait_seconds.
| Field | Type | Description |
|---|---|---|
state |
str |
Name of the state currently retrying |
elapsed_seconds |
number |
Wall-clock seconds elapsed in the current sleep window |
next_attempt_at |
str |
ISO-8601 timestamp at which the next retry will fire |
total_waited_seconds |
number |
Accumulated wall-clock seconds across all 429 waits for this state |
budget_seconds |
number |
Configured rate_limit_max_wait_seconds budget |
tier |
str |
Current retry tier: "short" or "long" |
Example:
{"event": "rate_limit_waiting", "ts": "...", "state": "implement", "elapsed_seconds": 60.0, "next_attempt_at": "2026-04-17T12:34:56Z", "total_waited_seconds": 180.0, "budget_seconds": 21600, "tier": "short"}
throttle_warn¶
Emitted when a state's tool-call count reaches warn_max within a single state visit.
| Field | Type | Description |
|---|---|---|
state |
str |
State name where throttle warning was triggered |
count |
int |
Current tool-call count at time of emission |
normal_max |
int |
Configured normal_max threshold for this state |
warn_max |
int |
Configured warn_max threshold for this state |
hard_max |
int |
Configured hard_max threshold for this state |
Example:
{"event": "throttle_warn", "ts": "...", "state": "implement", "count": 8, "normal_max": 3, "warn_max": 8, "hard_max": 12}
throttle_hard¶
Emitted when a state's tool-call count reaches hard_max, triggering transition to on_throttle_hard.
| Field | Type | Description |
|---|---|---|
state |
str |
State name where hard throttle was triggered |
count |
int |
Current tool-call count at time of emission |
hard_max |
int |
Configured hard_max threshold for this state |
next |
str |
Target state (on_throttle_hard or on_error, or null) |
Example:
{"event": "throttle_hard", "ts": "...", "state": "implement", "count": 12, "hard_max": 12, "next": "throttle_recovery"}
throttle_stop¶
Emitted when a state's tool-call count exceeds hard_max with no on_throttle_hard target, causing a hard stop.
| Field | Type | Description |
|---|---|---|
state |
str |
State name where stop throttle was triggered |
count |
int |
Current tool-call count at time of emission |
hard_max |
int |
Configured hard_max threshold for this state |
Example:
learning_target_proven¶
Emitted when a target's learning-tests registry record is found with status='proven'. The state continues to the next target (or to on_yes when all targets are proven).
| Field | Type | Description |
|---|---|---|
state |
str |
State name executing the learning dispatch |
target |
str |
Target identifier (e.g. "Anthropic SDK streaming") |
learning_target_stale¶
Emitted when a target's registry record is missing or has status='stale', immediately before /ll:explore-api fires.
| Field | Type | Description |
|---|---|---|
state |
str |
State name executing the learning dispatch |
target |
str |
Target identifier |
cause |
str |
"missing" or "stale" |
learning_explore_invoked¶
Emitted just before the learning state invokes /ll:explore-api <target>. Pairs with action_start/action_complete from the underlying skill invocation.
| Field | Type | Description |
|---|---|---|
state |
str |
State name executing the learning dispatch |
target |
str |
Target identifier being explored |
attempt |
int |
Attempt number (1-based), capped by learning.max_retries |
learning_target_refuted¶
Emitted when a target's record has status='refuted'. Routes to on_blocked / on_no.
| Field | Type | Description |
|---|---|---|
state |
str |
State name executing the learning dispatch |
target |
str |
Target identifier |
learning_complete¶
Emitted when every target in a learning state has been proven. The state transitions via on_yes.
| Field | Type | Description |
|---|---|---|
state |
str |
State name executing the learning dispatch |
targets |
list[str] |
Targets that were all proven |
learning_blocked¶
Emitted when a learning state cannot advance: a target is refuted, or /ll:explore-api retries are exhausted without proving the target.
| Field | Type | Description |
|---|---|---|
state |
str |
State name executing the learning dispatch |
target |
str |
Target that blocked progress |
reason |
str |
"refuted" or "retries_exhausted" |
handoff_detected¶
Emitted when the executor detects a handoff signal in the action output, indicating the loop needs to be paused and resumed in a fresh session.
| Field | Type | Description |
|---|---|---|
state |
str |
Current state name when the handoff was detected |
iteration |
int |
Current iteration count |
continuation |
str |
The continuation prompt payload extracted from the handoff signal |
Example:
{
"event": "handoff_detected",
"ts": "...",
"state": "implement",
"iteration": 3,
"continuation": "Continue from: implement auth middleware..."
}
handoff_spawned¶
Emitted when the handoff handler spawns a new child process to continue the loop.
| Field | Type | Description |
|---|---|---|
pid |
int |
PID of the spawned child process |
state |
str |
Current state name at the time of spawning |
Example:
loop_complete¶
Emitted once when the executor finishes, regardless of how it terminated.
| Field | Type | Description |
|---|---|---|
final_state |
str |
Name of the state at termination. Usually the last state entered; when terminated_by="timeout" this may be a state that was routed to but never entered. Exception (BUG-1226): when that pending state is a shell action, the executor flushes it — emitting state_enter with flushed: true and running its action — before honoring the timeout, so state_enter for final_state is always emitted before loop_complete. Slash commands and sub-loops are not flushed. |
iterations |
int |
Total number of iterations completed |
terminated_by |
str |
Reason for termination: "signal" (OS signal), "error" (no valid transition or unhandled error), or the terminal state name |
Example:
{
"event": "loop_complete",
"ts": "...",
"final_state": "done",
"iterations": 5,
"terminated_by": "done"
}
Subsystem: FSM Persistence¶
Source: little_loops.fsm.persistence.PersistentExecutor
Path: scripts/little_loops/fsm/persistence.py
loop_resume¶
Emitted when a paused or interrupted loop is resumed. Occurs after the executor state is restored from disk, before execution continues.
| Field | Type | Required | Description |
|---|---|---|---|
loop |
str |
always | Name of the loop being resumed |
from_state |
str |
always | State to resume from (as saved in the state file) |
iteration |
int |
always | Iteration count at the time of resume |
from_handoff |
bool |
optional | true when resuming from a handoff_detected pause; absent otherwise |
continuation_prompt |
str |
optional | The continuation prompt (only present when from_handoff is true) |
Example (normal resume):
Example (handoff resume):
{
"event": "loop_resume",
"ts": "...",
"loop": "my-loop",
"from_state": "implement",
"iteration": 3,
"from_handoff": true,
"continuation_prompt": "Continue from: implement auth middleware..."
}
Transport behavior¶
loop_resume is emitted via EventBus.emit() and therefore fans out to every registered observer and every registered transport (FEAT-1322 / FEAT-1323). In ll-loop resume (cli/loop/lifecycle.py:cmd_resume), wire_transports() is called immediately after wire_extensions(), so transports configured under events.transports in ll-config.json see loop_resume for resumed runs the same way ll-loop run sees loop_start for fresh runs. Earlier builds wired transports only on cmd_run, which meant resumed loops bypassed the transport layer; that gap is closed by FEAT-1323. Teardown happens in a try/finally around the resume call: executor.close_transports() runs even on KeyboardInterrupt so any buffered loop_resume (and downstream) events are flushed before the process exits.
Subsystem: StateManager¶
Source: little_loops.state.StateManager
Path: scripts/little_loops/state.py
Flow: StateManager._emit() → EventBus.emit()
Filter pattern: "state.*"
These events track per-run issue processing state for ll-auto and ll-sprint.
state.issue_completed¶
Emitted when an issue is marked as completed in the sequential run state.
| Field | Type | Description |
|---|---|---|
issue_id |
str |
Issue identifier (e.g. "BUG-001") |
status |
str |
Always "completed" |
Example:
state.issue_failed¶
Emitted when an issue is marked as failed in the sequential run state.
| Field | Type | Description |
|---|---|---|
issue_id |
str |
Issue identifier |
reason |
str |
Human-readable failure reason |
status |
str |
Always "failed" |
Example:
{
"event": "state.issue_failed",
"ts": "...",
"issue_id": "BUG-002",
"reason": "Command exited with code 1",
"status": "failed"
}
Subsystem: Issue Lifecycle¶
Source: little_loops.issue_lifecycle
Path: scripts/little_loops/issue_lifecycle.py
Filter pattern: "issue.*"
These events are emitted by the standalone lifecycle functions used by ll-auto, ll-sprint, and ll-parallel.
issue.failure_captured¶
Emitted when a new bug issue is automatically created from a failed parent issue.
| Field | Type | Description |
|---|---|---|
issue_id |
str |
ID of the newly created bug issue |
file_path |
str |
Absolute path to the new bug issue file |
parent_issue_id |
str |
ID of the parent issue that triggered this capture |
Example:
{
"event": "issue.failure_captured",
"ts": "...",
"issue_id": "BUG-042",
"file_path": "/path/to/.issues/bugs/P1-BUG-042-....md",
"parent_issue_id": "ENH-025"
}
issue.closed¶
Emitted when an issue is closed without being implemented (e.g. invalid, duplicate, or already fixed).
| Field | Type | Description |
|---|---|---|
issue_id |
str |
Issue identifier |
file_path |
str |
Absolute path to the issue file in completed/ |
close_reason |
str |
Reason code, e.g. "already_fixed", "invalid_ref", "duplicate", "unknown" |
Example:
{
"event": "issue.closed",
"ts": "...",
"issue_id": "BUG-015",
"file_path": "/path/to/.issues/completed/P2-BUG-015-....md",
"close_reason": "already_fixed"
}
issue.completed¶
Emitted when an issue successfully completes its full lifecycle and is moved to completed/.
| Field | Type | Description |
|---|---|---|
issue_id |
str |
Issue identifier |
file_path |
str |
Absolute path to the issue file in completed/ |
Example:
{
"event": "issue.completed",
"ts": "...",
"issue_id": "ENH-025",
"file_path": "/path/to/.issues/completed/P3-ENH-025-....md"
}
issue.deferred¶
Emitted when an issue is moved to the deferred pool.
| Field | Type | Description |
|---|---|---|
issue_id |
str |
Issue identifier |
file_path |
str |
Absolute path to the issue file in deferred/ |
reason |
str |
Human-readable reason for deferral |
Example:
{
"event": "issue.deferred",
"ts": "...",
"issue_id": "FEAT-099",
"file_path": "/path/to/.issues/deferred/P2-FEAT-099-....md",
"reason": "Blocked on external dependency"
}
Subsystem: Parallel Orchestrator¶
Source: little_loops.parallel.orchestrator.Orchestrator
Path: scripts/little_loops/parallel/orchestrator.py
Filter pattern: "parallel.*"
parallel.worker_completed¶
Emitted when a parallel worker finishes processing an issue in its isolated git worktree.
| Field | Type | Description |
|---|---|---|
issue_id |
str |
Issue identifier processed by this worker |
worker_name |
str |
Name of the git worktree directory used by this worker |
status |
str |
"success" if the worker succeeded, "failure" otherwise |
duration_seconds |
float |
Wall-clock time in seconds for the entire worker run |
Example:
{
"event": "parallel.worker_completed",
"ts": "...",
"issue_id": "BUG-007",
"worker_name": "ll-worker-BUG-007-abc123",
"status": "success",
"duration_seconds": 142.7
}
Machine-Readable Schemas¶
Every event type listed in this document has a corresponding JSON Schema (draft-07) file committed to docs/reference/schemas/. These files can be used for programmatic validation, IDE autocomplete, and external tooling.
docs/reference/schemas/
├── action_complete.json
├── action_error.json
├── action_output.json
├── action_start.json
├── evaluate.json
├── handoff_detected.json
├── handoff_spawned.json
├── issue_closed.json
├── issue_completed.json
├── issue_deferred.json
├── issue_failure_captured.json
├── loop_complete.json
├── loop_resume.json
├── loop_start.json
├── parallel_worker_completed.json
├── rate_limit_exhausted.json
├── rate_limit_storm.json
├── rate_limit_waiting.json
├── retry_exhausted.json
├── route.json
├── state_enter.json
├── state_issue_completed.json
└── state_issue_failed.json
Naming Convention¶
Event type identifiers map to filenames by replacing dots with underscores:
| Event type | Schema file |
|---|---|
loop_start |
loop_start.json |
issue.completed |
issue_completed.json |
state.issue_completed |
state_issue_completed.json |
parallel.worker_completed |
parallel_worker_completed.json |
Schema Format¶
Each file is a self-contained JSON Schema (draft-07) object. All schemas set "additionalProperties": true so forward-compatible extensions to event payloads do not break validation. Example (loop_start.json):
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "little-loops://event-loop_start.json",
"title": "Loop Start",
"description": "Emitted when an FSM loop begins execution.",
"type": "object",
"required": ["event", "ts", "loop"],
"properties": {
"event": { "type": "string", "description": "Event type identifier" },
"ts": { "type": "string", "format": "date-time", "description": "ISO 8601 timestamp" },
"loop": { "type": "string", "description": "Loop name" }
},
"additionalProperties": true
}
Programmatic Validation¶
Use the jsonschema library to validate event dicts against the generated files:
import json
import jsonschema
from pathlib import Path
schema = json.loads(Path("docs/reference/schemas/loop_start.json").read_text())
event = {"event": "loop_start", "ts": "2026-04-04T12:00:00Z", "loop": "my-loop"}
jsonschema.validate(event, schema) # raises jsonschema.ValidationError on failure
To resolve a schema path from an event type at runtime:
def schema_path(event_type: str, base: Path) -> Path:
return base / f"{event_type.replace('.', '_')}.json"
Regenerating¶
To regenerate all schema files after adding or modifying an event type, run:
See ll-generate-schemas in the CLI reference and the schema maintenance workflow in CONTRIBUTING.md.
Quick Reference¶
| Event | Namespace | Source |
|---|---|---|
loop_start |
FSM | fsm/executor.py |
state_enter |
FSM | fsm/executor.py |
route |
FSM | fsm/executor.py |
action_start |
FSM | fsm/executor.py |
action_output |
FSM | fsm/executor.py |
action_complete |
FSM | fsm/executor.py |
action_error |
FSM | fsm/executor.py |
evaluate |
FSM | fsm/executor.py |
retry_exhausted |
FSM | fsm/executor.py |
rate_limit_exhausted |
FSM | fsm/executor.py |
rate_limit_storm |
FSM | fsm/executor.py |
rate_limit_waiting |
FSM | fsm/executor.py |
handoff_detected |
FSM | fsm/executor.py |
handoff_spawned |
FSM | fsm/executor.py |
loop_complete |
FSM | fsm/executor.py |
loop_resume |
FSM Persistence | fsm/persistence.py |
state.issue_completed |
StateManager | state.py |
state.issue_failed |
StateManager | state.py |
issue.failure_captured |
Issue Lifecycle | issue_lifecycle.py |
issue.closed |
Issue Lifecycle | issue_lifecycle.py |
issue.completed |
Issue Lifecycle | issue_lifecycle.py |
issue.deferred |
Issue Lifecycle | issue_lifecycle.py |
parallel.worker_completed |
Parallel | parallel/orchestrator.py |
OTel Transport Field Mapping¶
When OTelTransport is active (events.transports: ["otel"]), the following event fields are used to construct OpenTelemetry spans and span events. All other fields are serialized as span event attributes (str(value)).
Span-opening events¶
| Event | OTel action | Field used |
|---|---|---|
loop_start |
Opens root span (trace) | loop_name → span name |
loop_resume |
Closes all open spans; opens new root span | loop_name → span name |
state_enter |
Opens child span of loop span | state → span name |
action_start |
Opens grandchild span of state span | action → span name |
Span-closing events¶
| Event | OTel action | Field used |
|---|---|---|
action_complete |
Closes action span | — |
loop_complete |
Closes state + action spans; sets loop span status; closes loop span | outcome → status code ("error" / "failed" / "exhausted" → ERROR, all others → OK) |
Span event records¶
These events are added as OTel span events on the innermost open span (action > state > loop):
evaluate, route, retry_exhausted, handoff_detected, handoff_spawned, action_output
All fields except "event" are included as span event attributes (string-coerced).
Sub-loop events¶
Events with depth > 0 are no-ops. A single WARNING is logged per OTelTransport session. Full nested-trace support is out of scope.