Agent Loop
The agent loop is the state machine at the heart of rho. It drives the conversation forward by sending messages to the model, executing tool calls, and feeding results back — tracking session phase and managing context utilization throughout.
State machine
┌──────────────────────────────────────────────┐
│ │
▼ │
Thinking ──► AwaitingApproval ──► ExecutingTool ──┘
│
▼
Idle (done)
- Thinking: send the session to the model and wait for a response. Stream timeouts (
first_token_timeout_secs,stream_idle_timeout_secs) convert hung streams into retryable errors. - AwaitingApproval: if a tool call requires human confirmation, ask the user. On denial, feed a synthetic "denied" result and return to Thinking. On redirect (
approved: falsewith amessage), inject the user's instructions as a new turn and return to Thinking — the tool batch is abandoned. - ExecutingTool: run the tool and append the result to the session.
- Idle: the model returned a text reply — the loop is done.
The run_loop function
#![allow(unused)] fn main() { pub async fn run_loop( session: &mut Session, message: &str, params: &LoopParams<'_>, ) -> Result<AgentResult> }
run_loop takes a Session and a LoopParams (bundle of LLM service, tool registry, config, cancellation token, approval gate, observer, and optional compaction client). A CollectingObserver is always active internally — it records tool call events into AgentResult.tool_calls automatically, so consumers don't need custom observers for structured output.
It returns Result<AgentResult>, which captures:
| Field | Type | Description |
|---|---|---|
reply | String | The model's final text reply |
iterations | u32 | Number of LLM round-trips |
usage | TokenUsage | Per-call token delta (input, output, cost, request count) |
tool_calls | Vec<ToolCallRecord> | Ordered tool call history with name, arguments, outcome, duration |
duration | Duration | Wall-clock time for the entire run_loop |
finish_reason | LoopFinishReason | Why the loop ended |
context_stats | ContextStats | Context window utilization snapshot |
The loop:
- Appends
messageas a user turn viasession.append_user_message(). - Enters the Thinking state and sends the session to the model via
session.send_current(). - Processes the model's response:
- Text reply → record phase as Conclusion, populate
AgentResultand return it (Idle). - Tool calls → for each call, check approval, execute, record phase transition, append the result, then loop back to Thinking.
- Text reply → record phase as Conclusion, populate
All tool calls in a single model response are executed sequentially. Each result is appended before the session is re-sent to the model. Parallel execution is a future optimisation.
Mid-turn steering
A SteeringSource can be provided via LoopParams.steering. At the tool-batch seam — after all tool calls in a batch have executed and before returning to Thinking — the loop drains any queued steering messages and injects each as a user message. This lets the user provide course corrections mid-turn without interrupting the active execution. In RPC mode, the concurrent reader task routes prompt messages with steer: true to a SteeringQueue that backs the SteeringSource.
Stream timeouts
The loop applies two configurable timeouts when consuming the model's stream:
first_token_timeout_secs(default 90) — maximum wait for the first stream event. Guards against hung requests where the server accepts the connection but never emits a token.- stream_idle_timeout_secs (default 60) — maximum gap between consecutive stream events once streaming has started. Guards against mid-stream stalls.
Both return a retryable ClientError::StreamTimeout so the existing exponential-backoff retry loop handles them. Set to 0 to disable either timeout. Compaction's LLM call always uses StreamTimeouts::none() (it has its own mechanical fallback).
finish_reason persistence
Every assistant message records an optional FinishReason (Stop, ToolCalls, Length, ContentFilter), serialized OpenAI-style (snake_case) and omitted when None for backward compatibility with existing session files. Terminal errors (e.g. retry budget exhausted) are recorded as Attached custom-state entries (kind rho.turn_error.v1) so silent failures leave a diagnostic trace on disk.
Phase tracking
The loop tracks the current SessionPhase (Exploration, Execution, Verification, Conclusion) and transitions on each tool execution:
- Reading files / listing directories → Exploration
- Editing files / writing files → Execution
- Running
cargo check/cargo test/cargo clippy→ Verification - Final text reply → Conclusion
Phase detection uses classify_tool_phase() with has_had_edits context for ambiguous tools. See Context Management for details.
Auto-compact
When context utilization crosses the auto_compact_threshold, the loop proactively compacts older entries using the configured compaction strategy:
- Mechanical (default) — deterministic, structured summaries with phase-aware narratives
- LLM (opt-in via
compaction_mode = "llm") — adds narrativenotesgenerated by the model
On LLM failure, falls back to mechanical compaction. See Context Management for details.
AgentObserver
The AgentObserver trait receives live events from the agent loop so that a REPL, TUI, or test harness can render progress as it happens.
#![allow(unused)] fn main() { pub trait AgentObserver: Send + Sync { async fn on_state_change(&self, _state: AgentState) {} async fn on_text_delta(&self, _delta: &str) {} async fn on_reasoning_delta(&self, _delta: &str) {} async fn on_tool_call(&self, _name: &str, _arguments: &str) {} async fn on_tool_result(&self, _name: &str, _result: &ToolResult) {} async fn on_tool_denied(&self, _name: &str) {} async fn on_approval_requested(&self, _tool_name: &str, _risk: ToolRisk) {} /// Called before a tool executes. Return Block to prevent execution. /// Used by extensions to implement approval gates and safety filters. /// Synchronous because it is a pure policy decision (no I/O). fn on_tool_call_intercept(&self, _name: &str, _args: &str) -> Option<InterceptResult> { None } /// A model response completed and its token usage was accumulated. /// Fires once per iteration that hit the model, carrying the per-iteration /// delta and a live context snapshot — useful for rendering a live /// context/cost gauge during long multi-iteration turns. async fn on_usage( &self, _iteration: u32, _usage: &IterationUsage, _context: &crate::session::ContextStats, ) { } } }
CollectingObserver
run_loop always creates a CollectingObserver internally — it records tool call events (name, arguments, outcome, duration) into ToolCallRecords that populate AgentResult.tool_calls. This is independent of the observer in LoopParams and requires no action from consumers. Any observer passed via LoopParams still receives all callbacks as before; the CollectingObserver runs alongside it.
When extensions are loaded, the CompositeObserver fans out every call to both the RPC observer and the extension DenoObservers. For on_tool_call_intercept, the first Block wins — if any extension blocks a tool call, execution is denied immediately.
The observer is called:
- At every state transition (
on_state_change) - As streaming deltas arrive (
on_text_delta,on_reasoning_delta) - Before and after each tool call (
on_tool_call,on_tool_result,on_tool_denied) - When approval is requested (
on_approval_requested)
Retry with backoff
Transient HTTP errors (503, 429, connection refused) are retried with exponential backoff. The retry budget is configurable via AgentConfig. If the budget is exhausted, the loop terminates with AgentResult.finish_reason = LoopFinishReason::RetryBudgetExhausted.
Cancellation
A CancellationToken is checked at the top of each loop iteration and between tool calls in a batch. When cancelled, the loop terminates with AgentResult.finish_reason = LoopFinishReason::Cancelled immediately — no pending tool execution is aborted mid-flight, but no new ones are started.
Iteration guard
The loop terminates after config.max_iterations tool-call rounds (default: 32). This prevents infinite loops from misbehaving models. Terminates with AgentResult.finish_reason = LoopFinishReason::MaxIterations.
Stuck-loop detection
If the model produces the same output stuck_loop_threshold times consecutively (default: 3), a nudge is injected prompting the model to try a different approach.
Session persistence
Since run_loop operates on a Session, all appends (user messages, tool results) are automatically flushed to the JSONL session file if persistence is enabled. If the process crashes mid-loop, the session file contains everything up to the last successful append — at most one entry is lost.