The agent loop is the heartbeat of every autonomous agent. It drives the cycle of model call → tool execution → result injection → next model call, repeating until the model signals it is done or a termination condition fires. The design of this loop determines everything that matters in production: how the agent handles transient API errors, how it recovers from context overflow, how it knows when to stop, and how a post-mortem can reconstruct exactly what happened and why. Get the loop right and you get a resilient, debuggable agent. Get it wrong and you get an infinite retry spiral or a silent hang.
Claude Code's loop lives in queryLoop()
inside src/query.ts
. It is an async generator that runs while (true)
and yields events as they happen — text deltas, tool results, error messages — rather than buffering everything and returning at the end.
Every iteration of the loop reads from and writes to a State
object:
// src/query.ts — State carries mutable context between loop iterations.
// The `transition` field is the key: it records WHY the previous iteration
// continued, so the next one can make informed recovery decisions.
let state: State = {
messages: params.messages,
toolUseContext: params.toolUseContext,
maxOutputTokensOverride: params.maxOutputTokensOverride,
autoCompactTracking: undefined,
stopHookActive: undefined,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
turnCount: 1,
pendingToolUseSummary: undefined,
transition: undefined, // undefined on first iteration; named reason on all subsequent
}
The transition
field is what separates a naive retry loop from a state machine. When the loop continues, it stamps the reason onto state.transition
. The next iteration reads that reason before deciding what to do. This prevents infinite loops: if transition.reason === 'collapse_drain_retry'
, the next iteration skips the collapse drain and tries the next recovery strategy.
The loop has exactly seven places where it can continue
to the next iteration. Each one stamps a named reason:
// Continue site 1 — context collapse drained staged collapses
// Cheapest recovery: archives old messages locally, no API call needed.
state = { messages: drained.messages, ..., transition: { reason: 'collapse_drain_retry', committed: drained.committed } }
continue
// Continue site 2 — reactive compact summarized old history via API
// More expensive: calls a fast model to summarize, then retries.
state = { messages: postCompactMessages, ..., transition: { reason: 'reactive_compact_retry' } }
continue
// Continue site 3 — escalated max output tokens to 64K
// Single-shot: retries the same request with a higher token ceiling.
state = { messages: messagesForQuery, ..., transition: { reason: 'max_output_tokens_escalate' } }
continue
// Continue site 4 — multi-turn recovery after hitting output token limit
// Injects a "resume without recap" user message and keeps going.
state = {
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
maxOutputTokensRecoveryCount: maxOutputTokensRecoveryCount + 1,
transition: { reason: 'max_output_tokens_recovery', attempt: maxOutputTokensRecoveryCount + 1 },
}
continue
// Continue site 5 — stop hook blocked continuation
// A post-turn hook returned blocking errors; inject them and retry.
state = {
messages: [...messagesForQuery, ...assistantMessages, ...stopHookResult.blockingErrors],
stopHookActive: true,
transition: { reason: 'stop_hook_blocking' },
}
continue
// Continue site 6 — token budget pressure message injected
// Warns the model it is approaching its budget; asks it to wrap up.
state = {
messages: [...messagesForQuery, ...assistantMessages, createUserMessage({...})],
transition: { reason: 'token_budget_continuation' },
}
continue
// Continue site 7 — normal next turn after tool execution
// The happy path: tools ran, results appended, loop continues.
state = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
turnCount: nextTurnCount,
maxOutputTokensRecoveryCount: 0,
hasAttemptedReactiveCompact: false,
transition: { reason: 'next_turn' },
}
continue
The loop yields events as they arrive from the API. The caller sees text appear in real time:
// src/query.ts — yield each streaming event immediately rather than buffering.
// This is what makes Claude Code feel responsive: the user sees output
// character-by-character, not in one big dump at the end.
for await (const message of deps.callModel({...})) {
if (!withheld) {
yield yieldMessage // push to caller immediately
}
// ... collect tool_use blocks for execution
}
The design choice here is deliberate: async generators compose naturally. The submitMessage
method in QueryEngine
is also an async generator, so the entire call stack from API response to UI update is non-blocking and incremental.
Hermes's loop lives in run_conversation()
inside run_agent.py
. It is synchronous — it returns a final string when done, not a stream. The loop condition is a double gate: iteration counter AND budget.
# run_agent.py — the main while loop.
# Two conditions must both be true to continue:
# 1. api_call_count < self.max_iterations (hard ceiling from config)
# 2. self.iteration_budget.remaining > 0 (shared budget across parent + subagents)
while api_call_count < self.max_iterations and self.iteration_budget.remaining > 0:
# Check for user interrupt before each iteration
if self._interrupt_requested:
interrupted = True
break
api_call_count += 1
# consume() is the actual gate — thread-safe, returns False if exhausted.
# This is the canonical check; the while condition is a fast pre-filter.
if not self.iteration_budget.consume():
self._safe_print(f"⚠️ Iteration budget exhausted ({self.iteration_budget.used}/{self.iteration_budget.max_total})")
break
The IterationBudget
class is a thread-safe counter that can be shared across a parent agent and its subagents:
# run_agent.py — IterationBudget is the shared resource that prevents
# runaway loops across the entire agent tree (parent + all children).
class IterationBudget:
def __init__(self, max_total: int):
self.max_total = max_total
self._used = 0
self._lock = threading.Lock() # thread-safe for concurrent subagents
def consume(self) -> bool:
"""Try to consume one iteration. Returns True if allowed, False if exhausted."""
with self._lock:
if self._used >= self.max_total:
return False
self._used += 1
return True
def refund(self) -> None:
"""Return one iteration (used for programmatic tool calls that don't count)."""
with self._lock:
if self._used > 0:
self._used -= 1
@property
def remaining(self) -> int:
with self._lock:
return max(0, self.max_total - self._used)
Each run_conversation()
call resets the budget at the start of the turn:
# run_agent.py — budget resets per conversation turn, not per session.
# This prevents subagent usage from a previous turn eating into the next one.
self.iteration_budget = IterationBudget(self.max_iterations)
Inside the loop, each API call is wrapped in its own while retry_count < max_retries
inner loop:
# run_agent.py — inner retry loop for transient API failures.
# Separate from the outer agent loop: retries are transparent to the
# conversation; the iteration counter only increments on success.
retry_count = 0
max_retries = 3
while retry_count < max_retries:
try:
response = self._interruptible_streaming_api_call(api_kwargs, on_first_delta=_stop_spinner)
break # success — exit retry loop, continue agent loop
except RateLimitError as e:
# 429: back off and retry on the same iteration
wait = min(2 ** retry_count * 5, 60)
time.sleep(wait)
retry_count += 1
except Exception as e:
# Non-retryable: surface to outer loop
raise
The ContextCompressor
fires before the API call when token usage crosses 50% of the model's context window:
# run_agent.py — preflight compression check before entering the main loop.
# Handles the case where a loaded session already exceeds the threshold
# before the first API call of the new turn.
if self.compression_enabled and self.context_compressor.should_compress_preflight(messages):
messages, active_system_prompt = self._compress_context(messages, ...)