Agent Main Loop
Responsibility
run_conversation is the heart of Hermes: given a user message, it repeatedly calls the model + tools until the iteration budget is exhausted or the task completes. It coordinates context construction, tool dispatch, stop-condition handling, and fallback on failure.
The file is ~5800 lines and dense — this page covers only the main thread; details go to sub-pages.
Key Files
run_conversation entry:588-720— function signature, init, pre-loop setupwhile main loop:721-760— conditionapi_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0, with grace callAPI call retry sub-loop:1227-1280—while retry_count < max_retriesbuild_turn_context— per-turn context assemblyprompt_builder— system prompt assemblymoa_loop— Mixture-of-Agents aggregation pathcontext_compressor— compresses history when threshold hitIterationBudget— budget accounting core
Data Flow
run_agent.py AIAgentis a thin forwarder; actual dependency injection lives ininit_agent.- The caller passes
user_message;run_conversationfirst runsbuild_turn_contextto assemble the turn context (api_messages). - Enters the
whilemain loop: interrupt check → decrement budget (iteration_budget.consume()) → build/sanitizeapi_messages→ pre-compression → drain/steertext → MoA aggregation → enter the API call retry sub-loop. - After the model responds, validate the response and handle
finish_reason; on failure, callagent._try_activate_fallback(). - When the compression threshold is hit, run
context_compressorto reclaim space. - When the loop exits (budget exhausted / task complete / interrupted), the final message is returned and delivered by the gateway (see Gateway).
The main loop's real shape is at agent/conversation_loop.py:721-740:
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
agent._checkpoint_mgr.new_turn()
if agent._interrupt_requested:
interrupted = True
_turn_exit_reason = "interrupted_by_user"
break
api_call_count += 1
if agent._budget_grace_call:
agent._budget_grace_call = False
elif not agent.iteration_budget.consume():
_turn_exit_reason = "budget_exhausted"
breakThe or agent._budget_grace_call tacked onto the loop condition is the pivot for "give it one more shot after the budget hits zero": the flag is cleared the moment this turn starts, so "one shot" really is one.
API call retry isn't recursive — it's an inner while retry_count < max_retries. Here's the opening of agent/conversation_loop.py:1227-1245:
while retry_count < max_retries:
if agent.provider == "nous":
try:
from agent.nous_rate_guard import nous_rate_limit_remaining
if nous_rate_limit_remaining() is not None and nous_rate_limit_remaining() > 0:
if agent._try_activate_fallback():
active_system_prompt = _sync_failover_system_message(
agent, api_messages, active_system_prompt)
retry_count = 0
continue
return {"final_response": "⏳ rate-limited, no fallback.",
"messages": messages, "completed": False, "failed": True}
except Exception:
pass # Never let rate guard break the agent loopAfter switching providers, retry_count = 0 retries from a clean state. The outermost except Exception: pass is deliberate: the guard is an optimization and must never end up breaking the main loop.
Design Motivation
Why a sub-loop for retries instead of recursion? Recursion in Python eats call stack (depth-limited), and a single failure's state scatters across multiple stack frames, making cleanup hard. With while retry_count < max_retries, all retry state (retry_count, compression_attempts) is local variables in one frame, and on a fallback switch a single retry_count = 0 resets the whole thing.
Why the grace call? The budget is a hard ceiling, but sometimes the model is one or two tool calls away from finishing — blocking outright would throw away work already done. _budget_grace_call is set by the inner loop when it sees "almost there"; the outer or lets that turn run, but the flag is cleared as soon as the loop enters — "one shot" really is one.
Why an iteration_budget object instead of a plain counter? The budget is accessed across threads (sub-agents, compression refunds); IterationBudget wraps consume/refund with a lock, giving thread safety and letting the compressor call refund() to give back iteration credit after pruning old messages. A plain int can't do either.
Boundaries and Failure
- Budget exhausted mid-flight: Before exiting,
_persist_session(messages, conversation_history)flushes the current session to disk;_turn_exit_reasonis set to"budget_exhausted"so the caller can tell whether this was a clean end or a truncation. - Tool call exceptions: Errors from the tool side are caught by the inner retry; after
max_retriesit goes toagent._try_activate_fallback(). When the fallback chain is empty it returns afailed: Trueresult dict instead of raising — the gateway above only sees a structured failure. - Rate-limit guard itself errors: If the
nous_rate_guardimport fails or its call raises, theexcept Exceptionswallows it; the guard degrades to "no-op" and the main loop keeps going on the original path. The comment puts it bluntly: "Never let rate guard break the agent loop". - OAuth pool infinite refresh:
_auth_pool_refresh_countsis reset at the start of the turn, so a single entry's OAuth pool can't be infinitely "refreshed" bytry_refresh_current()on persistent 401s (the bottom-line fix for#26080).
Summary
The main loop only orchestrates: turn driving, budget control, retry and fallback. The real capability comes from tools and skills; multi-platform delivery is handled by the gateway; self-improvement is driven by the learning loop.
For the official Chinese intro, see README.zh-CN and website/i18n/zh-Hans/.