Lesson 02 · Harness Engineering

The Conversation Loop

How Hermes turns one user message into repeated model calls, tool dispatch, recovery, compression, and a final answer

Your tangible win

After this lesson, you should be able to trace a Hermes turn from memory: append user → build API copy → call model → validate tool calls → execute tools → append tool results → repeat until text.

Primary sources

This lesson is grounded in Hermes source: agent/conversation_loop.py, agent/error_classifier.py, agent/retry_utils.py, and agent/context_compressor.py. External references: Hermes architecture docs, OpenAI function calling flow, and Anthropic prompt caching.

The loop spine

The agent loop is not magic. It is a bounded state machine. Each model call either produces tool calls or final text. Tool calls keep the loop alive; final text exits.

flowchart TB U["User message"] P["Stable prompt"] M["Model call"] T["Tool batch"] A["Append results"] F["Final answer"] U --> P --> M --> T --> A --> M M --> F style P fill:#dbeafe,stroke:#2563eb,stroke-width:3px style M fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style T fill:#f0fdf4,stroke:#16a34a,stroke-width:2px
Figure 1: The whole loop. Model output decides the path: tool calls continue; plain text exits.

The real loop guard in Hermes is two budgets: a per-agent max_iterations and a shared iteration_budget. There is also a one-turn grace path for graceful summaries.

# agent/conversation_loop.py, around lines 675-700
while (api_call_count < agent.max_iterations
       and agent.iteration_budget.remaining > 0) \
       or agent._budget_grace_call:

    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

Design point: budget is consumed at the API-call boundary, not at the tool boundary. The expensive thing is another model step.

Stable prompt, changing user context

Hermes protects prompt caching by building the system prompt once per session and replaying it byte-stably. Runtime context from memory/plugins is injected into the current user message copy, not into the system prompt.

flowchart TB S["System prompt"] C["Cached bytes"] U2["User message"] E["Ephemeral context"] API["API payload"] S --> C --> API U2 --> E --> API style S fill:#dbeafe,stroke:#2563eb,stroke-width:3px style E fill:#faf5ff,stroke:#9333ea,stroke-width:2px style API fill:#fef9c3,stroke:#ca8a04,stroke-width:2px
Figure 2: Stable prompt, ephemeral user-context. This keeps Anthropic/OpenAI-compatible cache prefixes reusable.
# agent/conversation_loop.py, around lines 482-495
if agent._cached_system_prompt is None:
    _restore_or_build_system_prompt(agent, system_message, conversation_history)

active_system_prompt = agent._cached_system_prompt

# around lines 566-575
# Plugin context is appended to the current user message,
# never the system prompt. This preserves the prompt cache prefix.
Invariant

If you are building your own harness, treat the system prompt like a compiled artifact. Dynamic information belongs in a user-turn context block unless you intentionally want to invalidate caching.

Message role rhythm

Tool calling has a strict transcript rhythm. The assistant asks for tools; the harness answers each tool call with a tool message; then the model sees those results on the next call.

flowchart TB SYS["system"] USER["user"] ACALL["assistant tool_calls"] TOOL["tool results"] AFINAL["assistant answer"] SYS --> USER --> ACALL --> TOOL --> AFINAL style ACALL fill:#fef9c3,stroke:#ca8a04,stroke-width:2px style TOOL fill:#f0fdf4,stroke:#16a34a,stroke-width:2px style SYS fill:#dbeafe,stroke:#2563eb,stroke-width:2px
Figure 3: Tool-call transcript rhythm. Bad role order causes provider errors or silent empty responses.

Hermes repairs malformed history before each API call and fills missing tool results on errors. That is defensive harness engineering: never let a malformed transcript poison future turns.

Tool validation before execution

The model can hallucinate tool names or broken JSON. Hermes validates before dispatch. Invalid names are repaired or returned to the model as tool errors; broken JSON is retried before recovery.

# agent/conversation_loop.py, around lines 3286-3518
if assistant_message.tool_calls:
    for tc in assistant_message.tool_calls:
        if tc.function.name not in agent.valid_tool_names:
            repaired = agent._repair_tool_call(tc.function.name)
            if repaired:
                tc.function.name = repaired

    # validate JSON arguments...
    assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)
    messages.append(assistant_msg)
    agent._execute_tool_calls(assistant_message, messages, task_id, api_call_count)
    continue
Subtle point

The assistant tool-call message is appended before executing tools. The tool results then reference the assistant's tool call IDs. This preserves the API transcript contract.

Recovery is part of the loop

A production harness is mostly edge cases. Hermes treats empty responses, provider errors, context overflow, and budget exhaustion as loop states rather than surprises.

flowchart TB V["Validate"] R["Retry"] N["Nudge"] C["Compress"] B["Fallback"] S2["Summarize"] V --> R --> N --> C --> B --> S2 style V fill:#dbeafe,stroke:#2563eb,stroke-width:2px style C fill:#faf5ff,stroke:#9333ea,stroke-width:2px style B fill:#fef9c3,stroke:#ca8a04,stroke-width:2px
Figure 4: Recovery ladder. The best harnesses turn provider/model weirdness into explicit branches.
# Empty after tools → add a synthetic nudge, then continue
# agent/conversation_loop.py, around lines 3675-3740
if _prior_was_tool and not agent._post_tool_empty_retried:
    agent._post_tool_empty_retried = True
    messages.append({
        "role": "user",
        "content": "You just executed tool calls but returned an empty response...",
    })
    continue

API failures go through agent/error_classifier.py, which classifies errors as auth, billing, rate limit, overload, timeout, context overflow, payload too large, model not found, format error, and more. Retry delay uses jittered exponential backoff from agent/retry_utils.py to avoid synchronized retry storms.

# agent/retry_utils.py
def jittered_backoff(attempt, base_delay=5.0, max_delay=120.0):
    delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
    jitter = rng.uniform(0, 0.5 * delay)
    return delay + jitter

Compression happens inside the loop

Long sessions do not just fail at the context window. Hermes estimates request size before calls and also checks after tool execution. If the context is too large, it compresses the middle while protecting the beginning and recent tail.

# agent/conversation_loop.py, around lines 3563-3600
_real_tokens = agent.context_compressor.last_prompt_tokens

if agent.compression_enabled and _compressor.should_compress(_real_tokens):
    messages, active_system_prompt = agent._compress_context(
        messages,
        system_message,
        approx_tokens=agent.context_compressor.last_prompt_tokens,
        task_id=effective_task_id,
    )
Why middle compression?

The first messages contain task setup and invariants. The last messages contain live state. The middle is the safest region to summarize.

Interactive trace

Tap each state. Your job is to feel where the transcript mutates.

Retrieval check

When the model returns tool calls, what should the harness do next?
Execute tools before model
Append calls, run tools
Compress only after final
Persist only tool output

What to remember