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 可以退化成一个「网关 + 会话管理 + 记忆」的壳。