Skip to content

Agent Main Loop

源码版本v2026.7.20

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

Data Flow

  1. run_agent.py AIAgent is a thin forwarder; actual dependency injection lives in init_agent.
  2. The caller passes user_message; run_conversation first runs build_turn_context to assemble the turn context (api_messages).
  3. Enters the while main loop: interrupt check → decrement budget (iteration_budget.consume()) → build/sanitize api_messages → pre-compression → drain /steer text → MoA aggregation → enter the API call retry sub-loop.
  4. After the model responds, validate the response and handle finish_reason; on failure, call agent._try_activate_fallback().
  5. When the compression threshold is hit, run context_compressor to reclaim space.
  6. 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:

python
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"
        break

The 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:

python
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 loop

After 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_reason is 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_retries it goes to agent._try_activate_fallback(). When the fallback chain is empty it returns a failed: True result dict instead of raising — the gateway above only sees a structured failure.
  • Rate-limit guard itself errors: If the nous_rate_guard import fails or its call raises, the except Exception swallows 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_counts is reset at the start of the turn, so a single entry's OAuth pool can't be infinitely "refreshed" by try_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/.

Unofficial community learning site. Content based on the MIT-licensed NousResearch/hermes-agent source.