3 · 主循环 ★

agent/conversation_loop.py,8,676 行。核心函数 run_conversation 从第 1,834 行开始。

3.1 循环入口:三重预算闸门

while (api_call_count < agent.max_iterations              # 调用次数没超上限
       and agent.iteration_budget.remaining > 0)          # 迭代预算还有余额
      or agent._budget_grace_call:                        # 或者:还有一次"宽限调用"

hermes-agent/agent/conversation_loop.py:2029

三个条件的分工:

条件作用
api_call_count < max_iterations硬性的调用次数上限。防失控
iteration_budget.remaining > 0可配置的迭代预算。比上限更灵活,可以按任务重要性分配
_budget_grace_call宽限调用。预算用完时不硬切断,再给一次机会

宽限调用的实现

if agent._budget_grace_call:
    agent._budget_grace_call = False               # ★ 消费掉标记,下轮必退出
elif not agent.iteration_budget.consume():         # 尝试扣一次预算,扣不动了
    _turn_exit_reason = "budget_exhausted"
    if not agent.quiet_mode:
        agent._safe_print(f"\n⚠️  Iteration budget exhausted "
                          f"({agent.iteration_budget.used}/{agent.iteration_budget.max_total} "
                          f"iterations used)")
    break
软着陆比硬切断好在哪

硬切断:预算撞线 → 立刻 break → 用户看到一个半成品和一句「预算用完了」。

软着陆:预算撞线 → 再给一次调用(此时模型知道自己该收尾了)→ 用户看到「我已经完成了 A 和 B,C 还差最后一步,当前进度是……」。

同样的成本上限,体验差距很大。而且那句总结对用户下一步怎么做非常有价值。

3.2 每轮的准备工作:build_turn_context

循环开始前有一大段「每轮序章」,源码把它抽到了 agent/turn_context.py

「All once-per-turn setup — stdio guarding, retry-counter resets, user message sanitization, todo/nudge hydration, system-prompt restore-or-build, preflight compression, the pre_llm_call plugin hook, external-memory prefetch, and crash-resilience persistence — lives in build_turn_context. It mutates agent exactly as the inline code did and returns the locals the loop below reads back.」

译:所有每轮只做一次的准备工作 —— 标准输入输出守卫、重试计数器重置、用户消息净化、待办与提醒的填充、系统提示词的恢复或构建、预检压缩、pre_llm_call 插件钩子、外部记忆预取、崩溃恢复持久化 —— 全部在 build_turn_context 里。它以和原来内联代码完全相同的方式修改 agent 对象,并返回下面循环要读取的局部变量。

逐项解释这九件事:

准备工作为什么需要
标准输入输出守卫防止工具执行时的输出污染智能体自己的输出流。终端界面下这会导致画面错乱
重试计数器重置新一轮开始,上一轮的重试次数清零
用户消息净化清理用户输入里可能干扰模型的内容
待办与提醒填充把待办清单和「该做某事了」的提醒注入上下文
系统提示词恢复或构建如果已缓存就复用(保护提示词缓存),否则重新组装
预检压缩发请求前先粗略估算 token 数,超了就先压缩
pre_llm_call 插件钩子让插件在调模型前插入内容
外部记忆预取提前从记忆服务拉取相关内容(见第 8 章)
崩溃恢复持久化先把状态写盘,这样进程被杀也能恢复

系统提示词的「恢复或构建」

def _restore_or_build_system_prompt(agent, system_message, conversation_history)
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool
def _ensure_cached_system_prompt_static(agent, system_message=None) -> None

这三个函数的存在说明:系统提示词是被缓存并跨轮次复用的,而且有一个「校验它是否还和当前运行时配置一致」的检查。

为什么要校验?因为运行时配置可能变了 —— 用户换了模型、启用了新工具集、编辑了 SOUL.md。这时缓存的提示词就过期了,必须重建。但如果没变,就绝不能重建 —— 重建可能产生不同的字节,破坏提示词缓存。

3.3 中途插话:/steer

这是 Hermes 一个很有特色的能力:模型正在思考时,用户可以插一句话,而且这句话在本轮就生效。

两个难点

难点为什么难
不能破坏角色交替 接口要求消息按「用户 → 模型 → 用户 → 模型」交替。如果模型正在等工具结果,你插一条用户消息进去,就打断了「工具调用 → 工具结果」的配对,请求会被拒绝
不能破坏提示词缓存 往对话中间插入新消息 = 改变了上下文的中段 = 从插入点往后的缓存全部失效

解法:追加到最新一条工具结果消息的末尾

_pre_api_steer = agent._drain_pending_steer()          # 取出待处理的插话
if _pre_api_steer:
    _injected = False
    for _si in range(len(messages) - 1, -1, -1):       # 从最后一条往前找
        _sm = messages[_si]
        if isinstance(_sm, dict) and _sm.get("role") == "tool":   # 找到最近的工具消息
            from agent.prompt_builder import format_steer_marker
            marker = format_steer_marker(_pre_api_steer)
            existing = _sm.get("content", "")
            if isinstance(existing, str):
                _sm["content"] = existing + marker      # ★ 追加,不新增消息
            else:
                # 多模态内容块 —— 追加一个文本块
                try:
                    blocks = list(existing) if existing else []
                    blocks.append({"type": "text", "text": marker})
                    _sm["content"] = blocks
                except Exception:
                    pass
            _injected = True
            break
    if not _injected:
        # 还没有任何工具消息(第一轮)—— 放回队列,
        # 等下一批工具结果出现时再注入。
        # 注入用户消息会破坏角色交替,而现在没有工具输出可以搭载
        agent._pending_steer = _pre_api_steer

hermes-agent/agent/conversation_loop.py · 调用接口前排空插话

可以搬走的做法:预留一个「带外信号注入点」

往对话里塞一条新消息 = 破坏缓存前缀 + 可能破坏角色交替。
往「最后一条消息的末尾」追加 = 只让最后一小段缓存失效,前面全部命中。

这个「最新工具结果消息的尾部」槽位,在 Hermes 里被复用了至少三次
· /steer 用户中途插话
· 墙上时钟预算用到 80% 时的「请开始收尾」提醒(下一节)
· 待办事项的提示

固定预留一个注入点,让所有带外信号都从这里进 —— 这样你只需要保证一个地方的缓存安全性,而不是每加一个功能就重新想一遍。

还有一个更强的:重定向

_redirect_text = agent._drain_pending_redirect()
if _redirect_text:
    _apply_active_turn_redirect(agent, messages, _redirect_text)
    if isinstance(original_user_message, str):
        original_user_message = (
            f"{original_user_message}\n\n"
            f"User correction during the turn: {_redirect_text}"     # 用户在本轮中的更正
        )
    agent._persist_session(messages, conversation_history)

「重定向」比「插话」更强:它会修改「原始用户消息」的记录,把更正内容附加上去。这样即使后面发生压缩,这条更正也会被保留在摘要的依据里 —— 因为它成了用户请求的一部分。

3.4 墙上时钟预算的收尾提醒

if getattr(agent, "run_budget_seconds", None):
    _maybe_inject_run_budget_wrapup(agent, messages)

对应的函数注释说明了机制:一次性的 —— 当运行预算(--run-budget)激活且已消耗 80% 时,提醒模型开始收尾、用手头已有的状态交付。走和 /steer 相同的缓存安全通道(追加到最新的工具结果上);没设预算时完全休眠。

注意这是「时间预算」而不是「token 预算」。

两者防的是不同的问题:
· token 预算 防的是花太多钱
· 时间预算 防的是用户等太久

一个跑在聊天软件里的智能体,用户的耐心是有限的。跑了 10 分钟还没回复,用户已经走开了。所以「到 80% 时提醒收尾」比「到 100% 时切断」有意义得多。

3.5 中断检查

if agent._interrupt_requested:
    interrupted = True
    _turn_exit_reason = "interrupted_by_user"
    if not agent.quiet_mode:
        agent._safe_print("\n⚡ Breaking out of tool loop due to interrupt...")
    break

Hermes 用的是标记位轮询:中断请求设置一个布尔标记,循环每轮开头检查一次。

还有一个更细的中断类型 —— 审查任务的输入预算耗尽:

if _review_input_budget_exhausted(agent):
    _turn_exit_reason = "review_input_budget_exhausted"
    if not agent.quiet_mode:
        agent._safe_print(
            f"\n⏹️  Review input budget exhausted "
            f"({int(agent.session_input_tokens):,} tokens) — stopping "
            f"the review tool loop before the next provider call.")
    break

注释解释了它的定位:「为分离出去的后台审查任务准备的聚合输入预算:压缩限制的是单次请求,而这个限制的是整个审查任务。它在两次迭代之间触发 —— 跨越预算线的那次请求已经完成(它的工具写入已经落地),然后工具循环在下一次调用模型之前停下,和迭代预算的退出方式一致。」

「在两次迭代之间退出」这个时机选择很讲究。

不在请求中途退 —— 那会留下未完成的工具调用(第 4 章会讲这个问题)。
不在工具执行中途退 —— 那会留下写了一半的文件。
在「一轮完整结束、下一轮开始之前」退出,是唯一能保证状态一致的时机。

3.6 步骤回调:给网关的观测点

if agent.step_callback is not None:
    try:
        prev_tools = []
        # 从后往前找最近一条带工具调用的模型消息
        for _idx, _m in enumerate(reversed(messages)):
            if _m.get("role") == "assistant" and _m.get("tool_calls"):
                _fwd_start = len(messages) - _idx
                _results_by_id = {}
                # 收集紧随其后的所有工具结果
                for _tm in messages[_fwd_start:]:
                    if _tm.get("role") != "tool": break
                    _tcid = _tm.get("tool_call_id")
                    if _tcid: _results_by_id[_tcid] = _tm.get("content", "")
                prev_tools = [
                    { "name":      tc["function"]["name"],
                      "result":    _results_by_id.get(tc.get("id")),
                      "arguments": tc["function"].get("arguments") }
                    for tc in _m["tool_calls"] if isinstance(tc, dict)
                ]
                break
        agent.step_callback(api_call_count, prev_tools)
    except Exception as _step_err:
        logger.debug("step_callback error (iteration %s): %s", api_call_count, _step_err)

这个回调让网关能实时知道「智能体走到第几步了、上一步用了哪些工具、结果是什么」,用于向聊天窗口推送进度。

注意整段被 try/except 包住,而且失败只记 debug 级日志。这是正确的:观测代码绝不能影响主流程。如果 step_callback 因为网络问题抛异常,智能体不该因此停止工作。

3.7 循环里的其他状态

从循环开头那一大段注释可以看出跨轮次维护的状态:

状态作用
compression_attempts 压缩尝试计数。注释:「一个解析出来的每轮压缩尝试上限,被所有消费它的地方共享:调用接口前的压力闸门、溢出/413 重试处理器、以及工具调用后的压缩闸门。这个计数器是连续「未验证/无效」尝试的兜底:一次完成的压缩只有在后续接口响应报告提示词已低于阈值后才会重新武装它。」
配置项 compression.max_attempts,默认 3
_outer_loop_errors 本轮外层循环异常总数,有上限 _MAX_OUTER_LOOP_ERRORS
_persistence_failed
_persistence_failure_cause
持久化失败标记与原因(locked 锁竞争 / disk 磁盘 / unknown)。每轮重置,防止上一轮的诊断泄露到这一轮
_pending_verification_answer
_response_was_previewed
被验证闸门扣住的待定回答,以及它是否已经作为中间内容流式发给用户过
MoA 引导保留 如果调用接口前的压缩在「多智能体建议」产出之后触发,保留那些临时输出并在下一轮迭代时重新挂到压缩后的记录上 —— 避免第二次顾问扇出
凭据刷新计数 见第 11 章。防止「持续 401 让单条目凭据池永远刷新成功」的自旋
_turn_usage 本轮用量,转发给上下文引擎的 on_turn_complete() 钩子。没走到响应就为 None,让钩子收到 None 而不是上一轮的过期数据

那个「重新武装」的机制值得展开

压缩尝试计数器的语义不是"总共压缩过几次", 而是"连续多少次压缩之后仍然没验证有效"。 第 1 次压缩 → 计数 1 接口响应回来,提示词仍然超阈值 → 计数保持 1(没有重新武装) 第 2 次压缩 → 计数 2 接口响应回来,提示词低于阈值了 → ★ 重新武装,计数归 0 如果连续 3 次压缩都没能把提示词降下来 → 判定压缩无效,停止尝试 ★ 关键在于"验证":不是压缩执行完就算成功, 而是要等下一次接口响应确认"确实降下来了"才算。

这比「压缩了就算数」严格得多,而且是必要的。

因为压缩可能是无效的:如果超长的内容全在「保护窗口」里(比如最后 6 条消息里有一个巨大的文件内容),那么压缩掉前面的部分完全不解决问题。

如果不验证,系统会陷入「压缩 → 还是超 → 再压缩 → 已经没东西可压了 → 还是超」的循环。用「验证后才重新武装」的计数器,3 次之后就会放弃并报错,而不是无限尝试。

3.8 循环退出原因

已经在代码里见到的 _turn_exit_reason 取值:

退出原因含义
interrupted_by_user用户中断
budget_exhausted迭代预算耗尽
review_input_budget_exhausted审查任务的聚合输入预算耗尽

这个字段和第 3.6 节的步骤回调一样,是纯粹为了可观测而存在的 —— 它不参与业务逻辑,但让「这个智能体为什么停了」变成一个可以直接查询的数据字段,而不需要去翻日志推断。

3.9 一个可选的旁路:Codex 应用服务运行时

# 可选的加入式运行时:如果 api_mode == codex_app_server,
# 把这一轮交给 codex 应用服务子进程处理(终端操作、文件操作、打补丁
# 全部在 Codex 内部完成)。默认的 Hermes 路径被完全绕过。

这是一个很彻底的扩展点:整个主循环可以被一个外部子进程接管。

它的存在说明了 Hermes 的一个立场:连「智能体循环」本身都不是必须由自己实现的。如果用户想用另一套智能体运行时(这里是 OpenAI 的 Codex),Hermes 可以退化成一个「网关 + 会话管理 + 记忆」的壳。

3 · The Main Loop ★

agent/conversation_loop.py, 8,676 lines. The core function, run_conversation, starts at line 1,834.

3.1 The Loop Entry: A Triple Budget Gate

while (api_call_count < agent.max_iterations              # call count under the hard cap
       and agent.iteration_budget.remaining > 0)          # iteration budget still has balance
      or agent._budget_grace_call:                        # or: one "grace call" left

hermes-agent/agent/conversation_loop.py:2029

What each of the three conditions does:

ConditionPurpose
api_call_count < max_iterationsA hard cap on the number of calls. Prevents runaway loops
iteration_budget.remaining > 0A configurable iteration budget. More flexible than the cap; can be allotted by task importance
_budget_grace_callThe grace call. When the budget runs out, don't cut off hard; give it one more chance

How the grace call is implemented

if agent._budget_grace_call:
    agent._budget_grace_call = False               # ★ consume the flag; the next iteration must exit
elif not agent.iteration_budget.consume():         # try to deduct one unit of budget; nothing left
    _turn_exit_reason = "budget_exhausted"
    if not agent.quiet_mode:
        agent._safe_print(f"\n⚠️  Iteration budget exhausted "
                          f"({agent.iteration_budget.used}/{agent.iteration_budget.max_total} "
                          f"iterations used)")
    break
Why a soft landing beats a hard cutoff

Hard cutoff: the budget hits the line → immediate break → the user sees a half-finished result and a “budget exhausted” message.

Soft landing: the budget hits the line → one more call (and the model knows it's time to wrap up) → the user sees “I've finished A and B, C is one step from done, here's where things stand…”.

Same cost ceiling, a very different experience. And that summary is extremely useful for deciding what the user does next.

3.2 Per-Turn Preparation: build_turn_context

Before the loop starts there is a long “per-turn prologue,” which the source pulls out into agent/turn_context.py:

“All once-per-turn setup — stdio guarding, retry-counter resets, user message sanitization, todo/nudge hydration, system-prompt restore-or-build, preflight compression, the pre_llm_call plugin hook, external-memory prefetch, and crash-resilience persistence — lives in build_turn_context. It mutates agent exactly as the inline code did and returns the locals the loop below reads back.”

In plain terms: everything that happens exactly once per turn — guarding stdio, resetting retry counters, sanitizing the user message, hydrating todos and nudges, restoring or building the system prompt, pre-flight compaction, the pre_llm_call plugin hook, prefetching external memory, and persisting for crash recovery — lives in build_turn_context. It modifies the agent object exactly the way the old inline code did, and returns the local variables the loop below reads.

Taking those nine items one at a time:

Preparation stepWhy it's needed
Stdio guardingKeeps output from tool execution from polluting the agent's own output stream. In the terminal UI this would garble the display
Retry-counter resetsA new turn begins; the previous turn's retry count goes back to zero
User message sanitizationCleans anything out of the user's input that could interfere with the model
Todo and nudge hydrationInjects the todo list and “time to do X” reminders into the context
System prompt restore-or-buildReuse it if cached (protecting the prompt cache); otherwise reassemble it
Pre-flight compactionRoughly estimate the token count before sending; compact first if it's over
pre_llm_call plugin hookLets plugins insert content before the model is called
External memory prefetchPull relevant content from the memory service ahead of time (see Chapter 8)
Crash-resilience persistenceWrite state to disk first, so it can recover even if the process is killed

“Restore or build” for the system prompt

def _restore_or_build_system_prompt(agent, system_message, conversation_history)
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool
def _ensure_cached_system_prompt_static(agent, system_message=None) -> None

The existence of these three functions tells you: the system prompt is cached and reused across turns, and there is a check that “verifies it still matches the current runtime configuration.”

Why verify? Because the runtime configuration may have changed — the user switched models, enabled a new toolset, edited SOUL.md. Then the cached prompt is stale and must be rebuilt. But if nothing changed, it must not be rebuilt — rebuilding could produce different bytes and break the prompt cache.

3.3 Mid-Turn Interjection: /steer

This is one of Hermes's distinctive capabilities: while the model is thinking, the user can slip in a line, and that line takes effect within the current turn.

Two hard parts

Hard partWhy it's hard
Can't break role alternation The API requires messages to alternate “user → model → user → model.” If the model is waiting on a tool result and you insert a user message, you break the “tool call → tool result” pairing and the request gets rejected
Can't break the prompt cache Inserting a new message into the middle of the conversation = changing the middle of the context = every cached byte from the insertion point onward is invalidated

The solution: append to the end of the most recent tool-result message

_pre_api_steer = agent._drain_pending_steer()          # take the pending interjection
if _pre_api_steer:
    _injected = False
    for _si in range(len(messages) - 1, -1, -1):       # search backward from the last message
        _sm = messages[_si]
        if isinstance(_sm, dict) and _sm.get("role") == "tool":   # found the most recent tool message
            from agent.prompt_builder import format_steer_marker
            marker = format_steer_marker(_pre_api_steer)
            existing = _sm.get("content", "")
            if isinstance(existing, str):
                _sm["content"] = existing + marker      # ★ append; don't add a new message
            else:
                # multimodal content blocks — append a text block
                try:
                    blocks = list(existing) if existing else []
                    blocks.append({"type": "text", "text": marker})
                    _sm["content"] = blocks
                except Exception:
                    pass
            _injected = True
            break
    if not _injected:
        # no tool message yet (first iteration) — put it back in the queue
        # and inject it when the next batch of tool results appears.
        # Injecting a user message would break role alternation, and there's no tool output to ride on yet
        agent._pending_steer = _pre_api_steer

hermes-agent/agent/conversation_loop.py · draining interjections before the API call

A pattern you can take with you: reserve one “out-of-band signal injection point”

Stuffing a new message into the conversation = breaking the cache prefix + possibly breaking role alternation.
Appending to “the end of the last message” = only the final short segment of the cache is invalidated; everything before it still hits.

This “tail of the most recent tool-result message” slot is reused at least three times in Hermes:
· /steer, the user's mid-turn interjection
· The “please start wrapping up” reminder when the wall-clock budget hits 80% (next section)
· Todo-item hints

Reserve one fixed injection point and route every out-of-band signal through it — then you only have to guarantee cache safety in one place, instead of re-thinking it every time you add a feature.

And a stronger one: redirect

_redirect_text = agent._drain_pending_redirect()
if _redirect_text:
    _apply_active_turn_redirect(agent, messages, _redirect_text)
    if isinstance(original_user_message, str):
        original_user_message = (
            f"{original_user_message}\n\n"
            f"User correction during the turn: {_redirect_text}"     # the user's mid-turn correction
        )
    agent._persist_session(messages, conversation_history)

A “redirect” is stronger than an “interjection”: it modifies the record of the “original user message,” attaching the correction to it. That way, even if compaction happens later, the correction survives in the material the summary is built from — because it has become part of the user's request.

3.4 The Wall-Clock Budget Wrap-Up Reminder

if getattr(agent, "run_budget_seconds", None):
    _maybe_inject_run_budget_wrapup(agent, messages)

The function's comment explains the mechanism: one-shot — when a run budget (--run-budget) is active and 80% of it has been consumed, remind the model to start wrapping up and deliver with the state it already has. Uses the same cache-safe channel as /steer (appended to the latest tool result); fully dormant when no budget is set.

Note that this is a “time budget,” not a “token budget.”

The two guard against different problems:
· The token budget guards against spending too much money
· The time budget guards against making the user wait too long

For an agent running inside a chat app, the user's patience is finite. Ten minutes with no reply and the user has walked away. So “remind it to wrap up at 80%” is far more meaningful than “cut it off at 100%.”

3.5 The Interrupt Check

if agent._interrupt_requested:
    interrupted = True
    _turn_exit_reason = "interrupted_by_user"
    if not agent.quiet_mode:
        agent._safe_print("\n⚡ Breaking out of tool loop due to interrupt...")
    break

Hermes uses flag polling: an interrupt request sets a boolean flag, and the loop checks it once at the top of each iteration.

There is a finer-grained interrupt type too — the input budget of a review task running out:

if _review_input_budget_exhausted(agent):
    _turn_exit_reason = "review_input_budget_exhausted"
    if not agent.quiet_mode:
        agent._safe_print(
            f"\n⏹️  Review input budget exhausted "
            f"({int(agent.session_input_tokens):,} tokens) — stopping "
            f"the review tool loop before the next provider call.")
    break

The comment explains where it fits: “An aggregate input budget for detached background review tasks: compaction limits a single request, while this limits the whole review task. It fires between iterations — the request that crossed the budget line has already completed (its tool writes have landed), and then the tool loop stops before the next model call, matching how the iteration budget exits.”

The choice of “exit between iterations” is deliberate and careful.

Don't exit mid-request — that leaves an unfinished tool call behind (Chapter 4 covers this problem).
Don't exit mid-tool-execution — that leaves a half-written file behind.
Exiting “after one iteration fully completes and before the next begins” is the only moment that guarantees consistent state.

3.6 The Step Callback: An Observation Point for the Gateway

if agent.step_callback is not None:
    try:
        prev_tools = []
        # search backward for the most recent model message that carried tool calls
        for _idx, _m in enumerate(reversed(messages)):
            if _m.get("role") == "assistant" and _m.get("tool_calls"):
                _fwd_start = len(messages) - _idx
                _results_by_id = {}
                # collect all the tool results that immediately follow it
                for _tm in messages[_fwd_start:]:
                    if _tm.get("role") != "tool": break
                    _tcid = _tm.get("tool_call_id")
                    if _tcid: _results_by_id[_tcid] = _tm.get("content", "")
                prev_tools = [
                    { "name":      tc["function"]["name"],
                      "result":    _results_by_id.get(tc.get("id")),
                      "arguments": tc["function"].get("arguments") }
                    for tc in _m["tool_calls"] if isinstance(tc, dict)
                ]
                break
        agent.step_callback(api_call_count, prev_tools)
    except Exception as _step_err:
        logger.debug("step_callback error (iteration %s): %s", api_call_count, _step_err)

This callback lets the gateway know in real time “which step the agent is on, which tools the last step used, and what the results were,” so it can push progress to the chat window.

Notice the whole block is wrapped in try/except, and a failure is logged only at debug level. That is correct: observation code must never affect the main flow. If step_callback throws because of a network problem, the agent should not stop working over it.

3.7 Other State Inside the Loop

The long comment at the top of the loop reveals the state maintained across iterations:

StatePurpose
compression_attempts Compaction attempt counter. From the comment: “A resolved per-turn cap on compaction attempts, shared by everything that consumes it: the pre-API pressure gate, the overflow/413 retry handler, and the post-tool-call compaction gate. The counter is a backstop for consecutive ‘unverified/ineffective’ attempts: a completed compaction only re-arms it once a subsequent API response reports the prompt is back under the threshold.”
Config key compression.max_attempts, default 3
_outer_loop_errors Total outer-loop exceptions this turn, capped by _MAX_OUTER_LOOP_ERRORS
_persistence_failed
_persistence_failure_cause
Persistence failure flag and cause (locked lock contention / disk disk / unknown). Reset every turn so the previous turn's diagnosis doesn't leak into this one
_pending_verification_answer
_response_was_previewed
An answer held back by the verification gate, and whether it has already been streamed to the user as interim content
MoA bootstrap retention If pre-API compaction fires after the “multi-agent advisors” have produced output, keep those interim outputs and re-attach them to the compacted transcript on the next iteration — avoiding a second advisor fan-out
Credential refresh count See Chapter 11. Prevents the spin where “continuous 401s make a single-entry credential pool refresh ‘successfully’ forever”
_turn_usage This turn's usage, forwarded to the context engine's on_turn_complete() hook. None if no response was reached, so the hook receives None rather than stale data from the previous turn

That “re-arming” mechanism deserves a closer look

The semantics of the compaction attempt counter are not "how many times have we compacted in total," but "how many consecutive compactions have still not been verified effective." 1st compaction → count 1 API response comes back, prompt still over the threshold → count stays 1 (no re-arm) 2nd compaction → count 2 API response comes back, prompt now under the threshold → ★ re-arm, count resets to 0 If 3 consecutive compactions fail to bring the prompt down → compaction is judged ineffective; stop trying ★ The key word is "verified": a compaction doesn't count as a success when it finishes, only when the next API response confirms "it really did come down."

This is much stricter than “it compacted, so it counts,” and it is necessary.

Because compaction can be ineffective: if the oversized content is all inside the “protected window” (say, one of the last 6 messages contains a huge file), compacting away the earlier part solves nothing.

Without verification, the system falls into the loop “compact → still over → compact again → nothing left to compact → still over.” With a counter that only re-arms after verification, it gives up and reports an error after 3 attempts instead of trying forever.

3.8 Why the Loop Exited

The values of _turn_exit_reason we've already seen in the code:

Exit reasonMeaning
interrupted_by_userInterrupted by the user
budget_exhaustedIteration budget exhausted
review_input_budget_exhaustedThe review task's aggregate input budget exhausted

Like the step callback in section 3.6, this field exists purely for observability — it plays no part in business logic, but it turns “why did this agent stop” into a data field you can query directly, instead of something you infer by digging through logs.

3.9 An Optional Bypass: The Codex App Server Runtime

# Optional opt-in runtime: if api_mode == codex_app_server,
# hand this turn to the codex app server subprocess (terminal operations, file operations,
# and patching all happen inside Codex). The default Hermes path is bypassed entirely.

This is a very thorough extension point: the entire main loop can be taken over by an external subprocess.

Its existence reveals a Hermes stance: not even the “agent loop” itself has to be implemented in-house. If a user wants a different agent runtime (here, OpenAI's Codex), Hermes can shrink down to a shell of “gateway + session management + memory.”