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 viaasyncio.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:
- 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. - Mid-tool: tools that accept
ctx.cancel_eventcan short-circuit (thebashtool 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 recoverableErrorEvent. 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 asToolResultMessage(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 witherror=None).
Concurrency model¶
- Single asyncio event loop per process.
- No threads (except in library code we don't control —
asyncio.to_threadis 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/.