title: Events description: The Event union: what agent_loop yields, what frontends subscribe to, what RPC mode serializes verbatim on the wire.
Events¶
The agent loop is an async def agent_loop(...) -> AsyncIterator[Event]. Everything the loop does (starting a turn, streaming a delta, emitting a tool result, retrying a provider call) surfaces as one entry in this union. Frontends subscribe via agent.subscribe(callback); RPC mode serialises each event verbatim as one JSON line.
Two unions, one wire¶
There are actually two Pydantic discriminated unions in letscode.agent.events:
Event: the top-level union the loop yields.StreamEvent: nested insideMessageUpdate.stream_event, one per LLM stream chunk.
Both discriminate on a type: Literal["..."] field, so the wire form is event.model_dump(mode="json"). Dispatch by type in one table on the receiving side.
The Event union¶
agent_start · start of a full prompt run
turn_start · start of one LLM turn (may have multiple per prompt)
message_start · new AgentMessage entered the conversation
message_update · streaming delta (nests a StreamEvent)
message_end · AgentMessage finalised (safe to persist)
tool_execution_start
tool_execution_update · streaming tool progress
tool_execution_end · tool result assembled
turn_end
retry · provider retry scheduled (since v0.7)
error · recoverable or fatal
agent_end · terminal event for a prompt run
The union lives in src/letscode/agent/events.py; the exact field set for each type is defined there. Additive changes (new event type, new optional field) are non-breaking under the extension model versioning rule; a client MUST ignore unknown type values and unknown fields.
Sequence per turn¶
Every interactive prompt emits this sequence. Frontends subscribe and render.
agent_start
turn_start
message_start (user)
message_end
message_start (assistant)
message_update (streaming deltas: TextDelta / ToolCallStart / ...)
message_end
[tool_execution_start / _update / _end ...]
message_start (tool_result, per tool)
message_end
turn_end
[...next turn until stop_reason="end_turn"...]
agent_end
Retries and errors interleave freely. See below.
The StreamEvent union¶
MessageUpdate.stream_event is one of:
text_delta: one chunk of assistant text.thinking_delta: reasoning text (some providers stream this separately; opt-in via--thinkingsince v0.7).tool_call_start: assistant announced a tool call (id + name).tool_call_args_delta: arguments streaming in.tool_call_end: arguments complete.usage: token counts + cost inputs.stop: assistant stop reason.retry: provider retry scheduled before the stream started (since v0.7; seeRetryEventbelow).
The retry type is intentionally a member of both StreamEvent (so LLMClient.stream() can yield it in its async iterator) and Event (so the agent loop can unwrap it and pass it through as a top-level event, not wrapped in a MessageUpdate).
RetryEvent (since v0.7)¶
Provider transient failures (408 / 409 / 429 / 5xx or a network error) are retried inside LLMClient.stream() with an exponential backoff (1s → 10s cap, 2 retries by default). Before each retry, letscode emits a RetryEvent:
class RetryEvent(BaseModel):
type: Literal["retry"] = "retry"
attempt: int # 1-indexed; counts the upcoming attempt
total_attempts: int # max_retries + 1
delay_seconds: float
reason: str # "APIStatusError: HTTP 429: rate limited"
The basic frontend renders this as a subtle grey status line; RPC clients receive it verbatim as {"type":"retry",...}. The retry sleep is cancellable. Ctrl+C during backoff aborts immediately.
Prior to v0.7 the OpenAI SDK's built-in retry ran on by default and invisibly; v0.7 disables the SDK retry (max_retries=0) and rolls it into LLMClient so the user sees what happens.
Errors¶
Two failure kinds:
- Provider errors (rate-limited, timeout, network, server error) are wrapped as
ProviderErrorat the LLM client boundary and surfaced to the loop as a recoverableErrorEvent. The basic frontend prints(press 'r' to retry)and waits. Under RPC these are{"type":"error","recoverable":true}without anidfield. The RPC controlerrormessage carriesid; an AgentErrorEventdoes not. - Tool errors (any exception raised in
tool.execute) are caught by the loop and wrapped asToolResultMessage(is_error=True). The LLM sees the error and decides whether to retry, give up, or try a different approach. NoErrorEventfires. From the loop's perspective the tool call completed, just with an is-error result.
Cancellation is not an error. See Agent loop § Cancellation.
Custom message rendering¶
Plugins can inject CustomMessage(kind="...") entries into the transcript via letscode_transform_context. These survive session save/load but aren't visible to the LLM by default. The per-kind letscode_render_custom_message hook turns them into synthetic UserMessage / AssistantMessage instances at LLM-call time. That's how letscode-memory and the built-in compaction plugin coexist: each owns its own kind without shadowing the other.
Where the code lives¶
- Event models:
src/letscode/agent/events.py - Emitting side (loop):
src/letscode/agent/loop.py - Provider-retry logic:
src/letscode/llm/client.py - Subscribing side:
agent.subscribe(callback)insrc/letscode/agent/agent.py; the basic frontend is the canonical consumer. - Wire serialisation: RPC mode does
event.model_dump(mode="json")verbatim. No translation layer.