Skip to content

Agent loop

The agent loop is the heart of letscode: an async def agent_loop(...) -> AsyncIterator[Event] that drives one or more turns until either the LLM emits stop_reason="end_turn" or the user aborts.

Event sequence

Every interactive turn 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

The Agent class wraps the loop with a state object, an event bus, steering / follow-up queues, and abort(). Frontends subscribe to events via agent.subscribe(callback).

Per-turn pipeline

flowchart LR
    State[AgentState.messages] --> SP[system_prompt hook]
    SP --> TC[transform_context hook]
    TC --> CL[convert_to_llm hook]
    CL --> Default[Default seam:<br/>per-CustomMessage<br/>render_custom_message]
    Default --> LLM[LLM stream]
    LLM --> Tools{Tool calls?}
    Tools -->|yes| BT[before_tool_call hook]
    BT --> Exec[tool.execute]
    Exec --> AT[after_tool_call hook]
    AT --> Tools
    Tools -->|no| End[turn_end]
    OnEvent[(on_event hook<br/>fires for every event<br/>throughout the pipeline)]

Tool execution

Tools declare their execution mode:

  • execution_mode="parallel" (default) — multiple parallel tool calls in one assistant message run concurrently via asyncio.gather.
  • execution_mode="sequential" — runs alone; the loop waits for the previous tool to finish before invoking.

Tool events fire in completion order (since v0.4)

tool_execution_end events fire in completion order — a fast read shows its panel as soon as it finishes, even if a slower bash was dispatched first. The persisted transcript keeps source order, so replay is deterministic. (Before v0.4 these were source-ordered.)

Cancellation

agent.abort() sets a shared asyncio.Event. The cancellation propagates two ways:

  1. Mid-stream: the LLM client's stream watcher closes the underlying httpx connection on cancel, which unblocks the consumer's __anext__ immediately rather than waiting for the next provider chunk. Post-cancel exceptions from the forced close are swallowed.
  2. Mid-tool: tools that accept ctx.cancel_event can short-circuit (the bash tool checks it between subprocess reads).

In the basic frontend, Ctrl+C calls agent.abort() on first press and exits on second press within a 2-second grace window. The SIGINT handler is installed per-agent-call (not once at startup) because prompt_toolkit.PromptSession.prompt_async removes signal handlers on its Application exit, which would otherwise clobber ours.

Retries and errors

  • Provider errors (rate limit, timeout, network, server error) are caught at the LLM client boundary, wrapped as ProviderError, and surfaced to the frontend as a recoverable ErrorEvent. The basic frontend prints (press 'r' to retry) and waits.
  • 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.
  • Cancellation is not an error. Aborting mid-stream produces a clean agent_end (potentially with error=None).

Concurrency model

  • Single asyncio event loop per process.
  • No threads (except in library code we don't control — asyncio.to_thread is used in tool bodies for blocking I/O).
  • Parallel tool execution via asyncio.gather.
  • Task groups for cancellation propagation (the stream watcher in the LLM client is one such).

Where the code lives

Module Purpose
letscode/agent/loop.py The async generator.
letscode/agent/agent.py Agent class wrapping the loop.
letscode/agent/state.py AgentState dataclass.
letscode/agent/events.py Event Pydantic models.
letscode/agent/messages.py Message types and discriminated unions.
letscode/agent/tools.py Tool Protocol + @tool decorator + ToolRegistry.
letscode/agent/hookspecs.py The ten pluggy hookspecs.
letscode/agent/compaction.py CompactionPolicy, should_compact, compact.
letscode/agent/execution_env.py ExecutionEnv Protocol + LocalEnv.

For event payloads and retry policy, the canonical reference is the source in src/letscode/agent/.