The agent loop drives a multi-turn conversation between an LLM and a set of tools. It streams assistant responses, dispatches the tool calls they request, injects steering / follow-up messages from the host application, and gives plugins and the embedder structured hook points to observe and shape every turn.
The loop solves the problem of running an LLM agent as a long-lived, cancellable, hook-extensible process — instead of one prompt-and-reply, the loop owns turn boundaries, tool dispatch ordering, cancellation propagation, and between-turn context manipulation (compaction, model swap, steering injection).
A single user input opens one outer iteration. The outer iteration runs inner turns until the assistant produces a turn with no tool calls AND no pending messages remain. After that it polls get_followup_messages; if any are produced they become the next iteration's pending messages and the outer loop continues. When follow-ups are empty the loop emits AgentEnd and exits.
When the assistant tries to stop, poll_finalization_follow_up (in run.rs) consults a fixed priority order and returns at most one gate's follow-up per finalization:
- Hook — caller-supplied
get_followup_messages(e.g. the/planreviewer loop). - Verifier — cheap, signal-based: code was edited but no build/test ran, or the last build/test failed. One nudge per run.
- Critic — bounded LLM judgment of completeness from the transcript. Once per run, opt-in via
critic_provider. - Code reviewer — diff-aware LLM review of correctness (dirge-iyf5). On a run that left uncommitted changes it captures the diff and runs a two-pass review (review → verify/dedupe). How it engages is set by
code_review(off|advisory|blocking, defaultadvisory). In advisory mode the whole capture+review runs detached in the background after finalization and surfaces all findings (high/critical included) as one non-blocking<system>notice — it never blocks the turn and never re-enters the loop, so a tight debug loop isn't held up. In blocking mode it runs synchronously on the finalization path and splits findings by severity: high/critical re-enter the loop as a blocking[code-review]nudge (fix or justify), medium/low surface as a non-blocking advisory; it persists across finalizations bounded byMAX_REVIEW_REACT. off disarms it entirely. Reuses thecritic_providerjudge — same opt-in as the critic, off with no cost otherwise. Prompt craft and the finding/verdict model are ported from roborev. Run it manually any time with/code-review. - Goal gate — user-defined
--goalstop condition, judged by the same provider, bounded byMAX_GOAL_REACT. - Todo nudge — unfinished todos, bounded by
MAX_TODO_NUDGES.
Three further signals sit alongside these rather than in the gate chain — tiered verification, a stall/turn-budget progress monitor, and the safe-state abort rung that replaces the rung-2 recovery checkpoint when it fires. All are off by default; see failure-ladder.md.
Because only one gate fires per finalization, lower-priority gates are deferred a turn (intentional: a red build surfaces the verifier nudge now; the critic and reviewer run at the next finalization once it's green). All gates fail open — an errored judge never blocks finalization.
Each inner iteration is one assistant turn:
- Drain pending steering messages into the context, emitting
MessageStart/MessageEndfor each. - Call
stream_assistant_responseto produce the next assistant message via the configuredStreamFn. - If the stop reason is
ErrororAborted, emitTurnEnd+AgentEndand exit. - Collect tool-call blocks from the assistant message. If any exist, dispatch them via
execute_tool_callsand append the results to the context. - Emit
TurnEndcarrying the assistant message and the batch of tool results. - Run between-turn hooks:
prepare_next_turn(optional context snapshot),should_stop_after_turn(early exit),get_steering_messages(queue messages for the next inner turn). - Continue while either the last turn had tool calls or pending messages exist.
Hooks live on LoopConfig and fire at fixed points in the loop. Each is an optional async closure; the loop runs the default behaviour when a hook is None.
| Hook | Fires | Returns |
|---|---|---|
convert_to_llm |
Before each stream call, on context.messages |
Filtered Vec<Value> of messages the LLM should see (drops custom roles) |
transform_context |
Before convert_to_llm, once per turn |
Mutated Context (e.g. compaction, system-prompt rewrite) |
before_tool_call |
Per tool call, before dispatch | BeforeToolCallResult — pass, mutate args, or block with synthetic result |
after_tool_call |
Per tool call, after dispatch | AfterToolCallResult — pass, replace result, mark batch terminating |
prepare_next_turn |
After each turn (post-tools, pre-stop-check) | Optional snapshot: new Context, new model, new thinking level |
should_stop_after_turn |
After prepare_next_turn, every turn |
bool; true exits the inner loop after the current turn |
get_steering_messages |
End of every inner turn | Vec<LoopMessage> queued into pending for the next inner turn |
get_followup_messages |
End of every outer iteration | Vec<LoopMessage> that reopens the outer loop if non-empty |
get_api_key |
Per stream call, given provider name | Option<String> override for the request |
Plugins reach these hooks through plugin_hooks.rs factories that bridge Janet plugin slots (on-tool-start, on-tool-end, etc.) into the corresponding hook closure.
stream_assistant_response is the single path from context to assistant message:
- Apply
transform_contextif set, producing the context the LLM will see this turn. - Apply
convert_to_llmto filtercontext.messagesto LLM-visible roles (user,assistant,toolResult,system). - Build
StreamOptionswith per-call API key, thinking level, headers, metadata, request timeout, and the sharedAbortSignal. - Call the configured
StreamFnto obtain an async stream ofStreamEvents. - Consume the stream: text deltas accumulate into a text block, tool-call deltas accumulate into tool-call blocks, reasoning deltas into thinking blocks. Each is committed on the matching
*Endevent. - Emit
MessageStartat the start andMessageEndat the close, carrying the fully-assembledAssistantMessagewith itsstop_reason.
The rig adapter (rig_stream.rs + rig_stream_factory.rs) supplies a StreamFn that wraps a rig::CompletionModel. Per-provider parameter shapes (Anthropic thinking, OpenAI-family reasoning.effort, Gemini thinking_config, generic reasoning_level fallback) are packed in rig_stream_factory::build_provider_additional_params.
retry.rs::retrying_stream_fn wraps a StreamFn to auto-retry transient network and rate-limit errors. Retry only fires before any text or tool-call delta commits; once content has streamed, an error passes through and the loop exits.
Compaction runs at the turn boundary, after each assistant response. run.rs calls context_manager::decide_after_usage with the provider's prompt_tokens; the decision engine and mechanism live in src/agent/agent_loop/context_manager.rs (decision) and src/agent/compression.rs (token estimator, per-result caps, summarizer) — those modules are the canonical reference. In summary:
- Input: the provider's
prompt_tokensfrom the usage report. When a provider reports no usage, the decision isNone(carry on) — no fold is attempted. - Budget ladder — thresholds are fractions of the model's context window (
ctx_max), in ascending pressure:0.60— tighten the per-tool-result cap to head off overflow before a fold is needed.0.75(HISTORY_FOLD_THRESHOLD) — normal fold: older history folds into a structured summary, keeping a 20% token tail.0.78(HISTORY_FOLD_AGGRESSIVE_THRESHOLD) — the normal fold didn't buy enough headroom, so halve the tail (10%).0.80(FORCE_SUMMARY_THRESHOLD) — exit the turn with a final summary (defense in depth).0.90(TURN_START_FOLD_THRESHOLD) — a turn-start local estimate, before the first API call (catches a terminal prior turn, a session restore, or a huge paste).
- Fold = prune + summarize: oversized tool results are capped and tool output pruned without an LLM call, then the conversation middle is summarized via the configured
summarization_provider(or the main model if none is set). An explicit/compressforces the pass regardless of ratio; reactive overflow recovery uses the same route, falling back to prune-only emergency compaction when no safe summarizer is configured. - Snip override: a pre-send snip that frees ≥10% of the window suppresses a normal fold; aggressive and force-summary folds always proceed.
What survives a fold, beyond the summary itself:
- The user's own turns, verbatim. Summarizers paraphrase, and paraphrase is where stated constraints go soft ("use ESM not CJS" becomes "discussed module format"). The folded window's user messages are appended to the summary block unedited, newest-first under a shared budget, carried across successive folds, and any elision is declared with a pointer at
session_search. They sit inside the[CONTEXT COMPACTION — REFERENCE ONLY]block so the model reads them as standing context, not as new requests. - Exactly one compaction block. A prior fold's marker is a
systemturn and the head cut snaps forward to a user turn, so the old marker lands in the protected head and is never folded. Each fold therefore supersedes it rather than stacking a second one — otherwise the model accumulates blocks that each declare a different## Active Task. - A breadcrumb to the raw turns. Folded turns are still in the session DB. The summary prefix says so and names
session_search, and the live session is excluded from that search by exact id (not by lineage root) so the pre-fold half of the current conversation stays reachable. - A validated summary, or none.
validate_summaryrequires at least two template sections with non-placeholder bodies. A stub fails, the caller keeps the pruned context, and a persistently bad summarizer trips the circuit breaker into prune-only mode — never a silent trade of real history for## Active Task\nNone.
See features.md for the user-facing summary.
execute_tool_calls dispatches the batch of tool calls in a single assistant message. The batch runs sequentially if either:
LoopConfig.tool_executionisToolExecutionMode::Sequential, or- Any tool in the batch declares
execution_mode() == Some(ToolExecutionMode::Sequential).
Otherwise the batch runs in parallel via futures::join_all. Read-only tools (read, grep, list_dir, find_files) leave the default Parallel; mutating tools (write, edit, bash, apply_patch) declare Sequential so a batch containing any of them serializes.
In parallel mode, tool_execution_end events emit in completion order but the resulting ToolResultMessage items appear in source order in the context. Each tool dispatch threads through:
prepare_tool_call— argument schema validation, appliestool_input_repairif validation fails.before_tool_callhook — may mutate args, block with a synthetic result, or pass.execute_prepared_tool_call— runs the tool future inside atokio::select!against the abort signal; a cancel returns anabortederror result within ~50ms.after_tool_callhook — may replace the result or set the batch'sterminateflag.finalize_executed_tool_call— emitsToolExecutionEndand builds theToolResultMessage.
If every result in the batch is marked terminating, the inner loop exits after the current turn.
A StormBreaker (src/agent/agent_loop/storm.rs) tracks recent (tool_name, args) pairs in a sliding window and suppresses a call once it has been issued identically too many times (default: the 3rd identical call). This catches non-progressing loops — an agent re-reading the same file or re-running the same failing command — without relying on the model to notice it's stuck.
The intervention is deliberately not a bare "don't repeat yourself". Research on agent loops (and dirge's own experience) shows that simply telling a model to try again tends to reinforce the same failing chain of reasoning — the degeneration-of-thought / mental-set problem. So on the first all-suppressed turn the loop fabricates a tool result carrying a reflect-then-pivot prompt (run.rs, the guard_text in the storm-suppression branch) that forces genuine divergence:
- State what the call was trying to achieve and why it isn't working.
- Name the assumption that might be wrong, and what the earlier results actually show.
- Propose 2–3 fundamentally different approaches — a different tool, entry point, or interpretation — and pick one.
- Proceed with that approach; or, if nothing can work with the available tools, say so plainly instead of retrying.
This gives the model one structured shot to self-correct (turn_self_corrected). If it keeps producing only suppressed calls afterward, the inner loop exits rather than spinning. The outermost backstop is the max_turns cap (see config.md), which stops the run and surfaces a <system> notice.
An opt-in, per-task workflow (ported from vix) that splits a complex request into separate, context-isolated phases instead of one long single-agent run. It is an explicit command, not a forced mode — regular chat is untouched, and the user decides which tasks warrant it. Gated by phased_workflow_enabled (default off; see config.md). The logic lives under src/agent/plan/: workflow.rs (phase prompts + verdict parsing + the shared next_review_step policy) and runtime.rs (the runner-drain glue + reviewer fork); the entry is src/ui/slash/cmd_plan.rs and the reviewer loop runs in src/ui/run_handlers/plan_review.rs.
/plan <request> runs four phases:
- Explore — a read-only fork (
READONLY_PHASE_TOOLS: read/grep/glob/lsp/semantic navigation, no mutation) that builds a frugal understanding of the codebase and emits a structured findings report. A genuine context reset: it starts from a frozen transcript snapshot. - Plan — a second read-only fork. The only thing carried over from Explore is its findings report (true context reset between phases, vix's
fork_fromdiscipline). It produces a specificity-forced implementation plan against a quality rubric.- Optional approval gate (
phased_workflow_plan_approval, default off): before Implement, the plan is shown and you're prompted [a]pprove / [e]dit / [c]ancel.edittakes free-form feedback and re-runs the Plan fork with it (carrying the prior plan + findings), then asks again — so you can steer the plan before any code is touched.cancelends the workflow. With the gate off, Implement launches immediately (yolo).
- Optional approval gate (
- Implement — the plan seeds a normal streamed agent turn through the main UI loop, so edits, build/test runs, permission prompts, and interjection all behave exactly as in a regular run. You watch it work.
- Review — after the implement turn completes, a write-disabled reviewer fork (
REVIEWER_TOOLS: read/grep +bash, but nowrite/edit/apply_patch) independently runs the code and emits a machine-parsed JSON verdict. ADONEverdict ends the workflow;NEEDS_FIXfeeds the reviewer's punch-list back into another implement turn. Bounded byphased_workflow_max_review_cycles(default 2).
The review gate is asymmetrically cautious: anything that isn't a parseable DONE is treated as not-done, so an ambiguous or malformed verdict triggers another fix cycle rather than shipping — a false DONE ships a broken result, a false NEEDS_FIX only costs one retry. The reviewer loop is driven event-by-event across Done events (in src/ui/run_handlers/done.rs) because the implement turn streams through the UI loop and can't be awaited inline; the same per-step policy (next_review_step) backs the headless run_review_loop so both paths stay in lockstep.
A single AbortSignal is shared end-to-end:
- The outer loop checks the signal between turns via the stream's stop-reason path.
- The stream wrapper polls the signal between chunks and emits an
Errorevent mid-stream when triggered. - Tool execution races the tool future against
wait_for_canceland returns an aborted result on signal.
In-flight HTTP requests are not cancelled (rig configures the HTTP client at construction); the loop simply stops reading. The server-side stream is dropped when the connection closes.
The agent loop lives in src/agent/agent_loop/:
| File | Role |
|---|---|
run.rs |
run_loop / run_agent_loop / run_agent_loop_continue — the outer/inner loop |
stream.rs |
stream_assistant_response and the StreamFn trait |
tools.rs |
execute_tool_calls dispatcher; sequential and parallel paths; prepare_tool_call, execute_prepared_tool_call, finalize_executed_tool_call |
hooks.rs |
All hook function-type aliases and TurnHookContext |
types.rs |
Context, LoopConfig, TurnUpdate, ThinkingLevel, ThinkingBudgets, ToolExecutionMode, QueueMode |
message.rs |
LoopMessage, AssistantMessage, ToolResultMessage, UserMessage, ContentBlock, StreamEvent, LoopEvent |
result.rs |
LoopToolResult, BeforeToolCallResult, AfterToolCallResult |
tool.rs |
LoopTool trait, AbortSignal, LoopToolUpdate |
bridge.rs |
LoopEvent → AgentEvent translation for UI / ACP consumers |
integration.rs |
spawn_loop_runner composition into a LoopRunner |
rig_stream.rs |
wrap_rig_stream adapter from rig::StreamingCompletionResponse to StreamEvent |
rig_stream_factory.rs |
rig_stream_fn_from_model_with_provider and per-provider reasoning mapping |
rig_tool.rs |
RigToolAdapter — wraps rig::ToolDyn as LoopTool |
retry.rs |
retrying_stream_fn — transient-error recovery around a StreamFn |
steering.rs |
steering_from_queue — shared queue → GetSteeringMessagesFn |
plugin_hooks.rs |
Factories that adapt Janet plugin hooks to the loop hook surface |
tool_input_repair/ |
Validate-then-repair pass for malformed tool-call arguments |
context_manager.rs |
Compaction policy and dispatch for transform_context |
Every provider::AnyAgent::spawn_runner call composes the loop the same way:
spawn_runner(prompt, history) -> AgentRunner
tool_defs = loop_tools → rig tool definitions
inner_stream_fn = build_stream_fn(tool_defs)
stream_fn = retrying_stream_fn(inner_stream_fn, RecoveryPolicy::default())
cfg = LoopSpawnConfig { stream_fn, system_prompt, history, prompt, tools, plugin_mgr, provider_name, ... }
spawn_loop_runner(cfg).into_agent_runner()
The headless non-streaming path (runner::run_print) and history conversion (runner::convert_history) bypass the loop and stay in runner.rs.