Skip to content

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 inside MessageUpdate.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 --thinking since 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; see RetryEvent below).

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 ProviderError at the LLM client boundary and surfaced to the loop as a recoverable ErrorEvent. The basic frontend prints (press 'r' to retry) and waits. Under RPC these are {"type":"error","recoverable":true} without an id field. The RPC control error message carries id; an Agent ErrorEvent does not.
  • Tool errors (any exception raised in tool.execute) are caught by the loop and wrapped as ToolResultMessage(is_error=True). The LLM sees the error and decides whether to retry, give up, or try a different approach. No ErrorEvent fires. 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