KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

02 · 主循环:回调注入式事件流与 90 轮预算 — keel 龙骨

hermes-agent 的主循环住在一个近 8000 行的模块里,但循环本体只有一行 while。真正值得看的不是那行循环,而是它周围的四件事:前置的 turn context 构建、把「还能不能再来一轮」拆成三个独立变量、把「这一轮为什么结束」做成一个贯穿全流程的诊断字符串,以及用 19 个回调替代事件总线的宿主接入方式。

hermes-agent 的主循环住在一个近 8000 行的模块里,但循环本体只有一行 while。真正值得看的不是那行循环,而是它周围的四件事:前置的 turn context 构建、把「还能不能再来一轮」拆成三个独立变量、把「这一轮为什么结束」做成一个贯穿全流程的诊断字符串,以及用 19 个回调替代事件总线的宿主接入方式。

这一章回答:

  1. run_conversation() 之前先做什么,为什么必须先做;
  2. 循环条件里的三个变量各自防的是什么;
  3. 一轮内部按什么顺序做七件事,哪些是必须串行的;
  4. 8 路工具并发怎么被「起始顺序门」与「授权门」收住;
  5. 为什么这里没有事件总线,而是一堆 *_callback。

一、入口函数与前置闸门

主循环的入口是 agent/conversation_loop.py:1691: def run_conversation(...)。它不是从零开始跑的——真正的准备发生在 build_turn_context()(agent/turn_context.py,在 conversation_loop.py:44 被导入,在 :1793 被调用):

_ctx = build_turn_context(
    ...
    effective_task_id=effective_task_id,
    should_review_memory=_should_review_memory,
)

注释(conversation_loop.py:1790)说明它是从内联代码里抽出来的,并且会就地修改 agent 对象。也就是说:turn context 构建不是「准备一份数据给循环用」,而是「把本轮所需状态灌进 agent 实例」。这一步之后,while 才有资格开始。

同一段还有两个分支值得注意:api_mode == "codex_app_server"(conversation_loop.py:1900)会提前把控制权交给另一个 runtime;上下文引擎的选择在 _apply_context_engine_selection()(conversation_loop.py:1555)里做,且被注释标为 fail-open。

二、循环条件:三个变量

agent/conversation_loop.py:1909 就是那个 while:

while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:

三个变量各管一件事:

IterationBudget 有一个容易误读的地方:它的模块 docstring 写着「父 agent 预算上限来自 max_iterations(default 500)、子 agent 来自 delegation.max_iterations(default 50)」。500 是过时描述——实际默认值是 90,而子 agent 的 50 在 docstring 里没有对应的当前默认值可核。两个数字都与 execute_code 的 refund() 有关:程序化工具调用的轮次会被退还,不占预算。

三、一轮的七件事

while 体内按固定顺序执行:

  1. drain redirect(conversation_loop.py:1910):_redirect_text = agent._drain_pending_redirect()。若有,则 _apply_active_turn_redirect(...) 把用户中途的纠正并入 original_user_message(拼成 User correction during the turn: ...),并立刻 agent._persist_session(...)。
  2. interrupt 检查(:1924):if agent._interrupt_requested: → _turn_exit_reason = "interrupted_by_user" → break。
  3. 计数与消费(:1931、:1940):api_call_count += 1,随后 elif not agent.iteration_budget.consume(): 走 budget_exhausted 退出。
  4. step_callback(:1947-1970):向宿主发「第 N 步」事件。它在这里做了一件有点重的事——反向扫描 messages,把上一批 assistant tool_calls 与其 tool 结果按 tool_call_id 配对,组成 prev_tools 一并回传。整段被 try/except 包住,失败只 logger.debug。
  5. 技能 nudge 计数(:1976-1978):仅当 _skill_nudge_interval > 0 且 "skill_manage" in agent.valid_tool_names 时 _iters_since_skill += 1。
  6. API 调用(api_messages 构建在 :2316 一带):按 api_mode 分派到不同协议路径(chat_completions / codex_responses / anthropic_messages / bedrock_converse 等在文件内反复出现分支)。
  7. 工具执行或收尾:有 tool_calls 则进入工具执行,无则收尾。工具调用的唯一入口是 conversation_loop.py:7204:
agent._execute_tool_calls(assistant_message, messages, effective_task_id, api_call_count)

注意这个调用的位置——_execute_tool_calls 定义在 run_agent.py(:8168),但调用点在 conversation_loop.py:7204。把这两处认成同一文件会读错。

四、退出原因是诊断字符串

本轮为什么结束,被记在局部变量 _turn_exit_reason 上,初值是 "unknown"(conversation_loop.py:1864),在 15 个以上站点被覆写,最后随 _turn_exit_reason=_turn_exit_reason(:8258)交给 agent/turn_finalizer.py。

可核实的字面量包括:

值 位置 含义
interrupted_by_user conversation_loop.py:1926 轮间检测到中断标志
budget_exhausted :1941 consume() 返回 False
interrupted_during_api_call :6430 API 调用期间被中断
all_retries_exhausted_no_response :6505 重试耗尽仍无响应
ollama_runtime_context_too_small :2458 运行时上下文过小
compaction_handoff_not_actionable :2639 / :6450 / :7344 压缩交接不可执行
session_persistence_failed :7181 / :7210 落库失败
guardrail_halt :7217 熔断器判定停止
partial_stream_recovery :7449 流式半途恢复
fallback_prior_turn_content :7480 回退到上一轮内容
empty_response_exhausted :7760 空响应重试用尽
text_response(finish_reason=…) :8142 正常文本收尾(模板族)
local_processing_error(…) :8232 本地处理异常
error_near_max_iterations(…) :8235 逼近上限时的错误
max_iterations_reached(n/N) turn_finalizer.py:175 / :182 撞到轮次上限

看得出设计意图:这是一个给人和给日志看的诊断串,而不是给程序分支用的枚举。turn_finalizer.py:153 只对 "unknown" / "budget_exhausted" 做特判,:226 用 startswith("text_response(") 判断是否正常收尾——模式匹配,而非穷举。

五、8 路并发与两道门

_execute_tool_calls(run_agent.py:8168)是个分派器,把批次交给两个实现之一:_execute_tool_calls_concurrent(run_agent.py:8293)或 _execute_tool_calls_sequential(run_agent.py:8298)。

并发侧的 worker 上限是 _MAX_TOOL_WORKERS = 8,两处各定义一次并互相镜像:run_agent.py:265 与 agent/tool_executor.py:96(后者注释:「Mirrors the constant in run_agent for tests/imports that look here」——为测试与导入便利而重复)。底层是 concurrent.futures。

真正值得注意的是 agent/tool_executor.py 顶部那几个超时常量,它们对应两道门:

_START_ORDER_GATE_TIMEOUT_S = 120.0        # 起始顺序门
_AUTHORIZATION_GATE_LOCK_TIMEOUT_S = 360.0 # 授权门串行锁兜底

六、没有事件总线,只有 19 个回调

AIAgent.__init__ 接受约 19 个回调参数(run_agent.py:465-484):tool_progress_callback / tool_start_callback / tool_complete_callback / thinking_callback / reasoning_callback / clarify_callback / read_terminal_callback / read_preview_callback / read_window_below_callback / setup_mcp_callback / step_callback / stream_delta_callback / interim_assistant_callback / tool_gen_callback / status_callback / notice_callback / notice_clear_callback / event_callback / reaction_callback。

它们在 agent/agent_init.py:816-830 被逐个挂到 agent 实例上:

agent.tool_progress_callback = tool_progress_callback
agent.tool_start_callback = tool_start_callback
...
agent.step_callback = step_callback
agent.stream_delta_callback = stream_delta_callback

含义很直接:事件不是被广播的,而是被注入的。内核不认识「宿主的类型」,只是在若干固定位置检查 if agent.step_callback is not None: 然后调用。因此 CLI / gateway / ACP / TUI 各宿主必须自己把回调接到自己的渲染或传输层,内核不做适配。step_callback 那段 try/except 是这个约定的必然结果——回调是宿主的代码,内核不能假设它不抛。

除此之外还有一道熔断:agent/tool_guardrails.py:273: class ToolCallGuardrailController,配套 ToolCallGuardrailConfig(:64)、LoopCapConfig(:140)、ToolCallSignature(:177)、ToolGuardrailDecision(:194)。触发后对应 guardrail_halt 退出原因。

代码地图

机制 位置 要点
主循环入口 agent/conversation_loop.py: run_conversation 第 1691 行;返回前把 _turn_exit_reason 交给 finalizer
前置闸门 agent/turn_context.py: build_turn_context 在 conversation_loop.py:44 导入、:1793 调用;就地修改 agent
循环条件 agent/conversation_loop.py: 1909 max_iterations + iteration_budget.remaining + _budget_grace_call
预算对象 agent/iteration_budget.py: IterationBudget 第 17 行;consume() / refund() / remaining,全线程安全
宽限一轮 agent/conversation_loop.py: 1938-1939 进入即置 False,本轮跳过 consume(),下轮自然退出
默认轮次上限 run_agent.py: max_iterations 第 446 行,默认 90;agent_init.py:504 同默认、:623 赋值
redirect 排空 agent/conversation_loop.py: 1910 agent._drain_pending_redirect(),用户中途纠正并入本轮
中断检查 agent/conversation_loop.py: 1924 _interrupt_requested → interrupted_by_user
step 事件 agent/conversation_loop.py: 1947-1970 反向配对上一批 tool_calls 与结果,异常只 debug
技能 nudge 计数 agent/conversation_loop.py: 1976-1978 _iters_since_skill += 1,受 _skill_nudge_interval 门控
工具执行唯一入口 agent/conversation_loop.py: 7204 调用 agent._execute_tool_calls(...)
工具分派 run_agent.py: _execute_tool_calls 第 8168 行;并发 :8293,串行 :8298
worker 上限 run_agent.py: _MAX_TOOL_WORKERS 第 265 行,值 8;agent/tool_executor.py:96 镜像同一常量
起始顺序门 agent/tool_executor.py: _START_ORDER_GATE_TIMEOUT_S 第 105 行,120.0 s,防乱序 worker 永久饿死批次
授权门兜底 agent/tool_executor.py: _AUTHORIZATION_GATE_LOCK_TIMEOUT_S 第 112 行,360.0 s;实际值由 tools/approval.py: human_wait_ceiling 推导
回调注入点 agent/agent_init.py: 816-830 19 个 *_callback 逐项挂到 agent 实例
熔断控制器 agent/tool_guardrails.py: ToolCallGuardrailController 第 273 行;触发对应 guardrail_halt 退出
退出原因收口 agent/turn_finalizer.py: _turn_exit_reason :175 / :182 生成 max_iterations_reached;:153、:226 做模式特判

关键取舍

把 max_iterations(90)与 IterationBudget 并存,代价是两个上限可能不一致。
max_iterations 是每轮的迭代次数闸,iteration_budget 是跨轮共享的计数器(默认上限同为 90),循环条件对两者取与。加上 _budget_grace_call 的宽限一轮,实际最多可能多跑一轮。收益是子 agent 可以有独立预算而不影响父 agent(iteration_budget.py docstring 明说「父 + 子总迭代数可以超过父的上限」);代价是「到底能跑几轮」要靠读三个变量和一处 elif 才能算出来。

IterationBudget 的 docstring 停留在 500,而实际默认是 90。
这是一个真实的文档漂移:数字从 500 调到 90 时,模块说明没跟着改。代价很小(不影响行为),但它说明这类「默认值写在两处」的设计天然容易腐烂——正确来源永远是 run_agent.py:446。

退出原因用字符串而不是枚举,代价是只能靠模式匹配消费。
_turn_exit_reason 有十几个字面量、还有三个 f-string 模板族。好处是诊断串能携带参数(text_response(finish_reason=stop)、max_iterations_reached(90/90)),日志里一眼可读;代价是下游只能用 startswith("text_response(") 这种脆弱方式判断「是否正常收尾」,新加一个模板族就会漏判。

两道门的超时都取「很长但有限」,代价是慢路径会真的慢 120–360 秒。
起始顺序门 120 s、授权门 360 s。注释里的权衡写得很清楚:上界必须覆盖「人真的在点审批」的时长,否则会误杀合法等待;但也不能无限,否则一个卡住的分派会饿死整批。代价是当真的出现挂死时,用户要等最长 6 分钟才看到恢复。

用 19 个回调而不是事件总线,代价是宿主必须自己接线且回调异常要各自兜。
内核只在固定点位 if callback is not None 后调用,不做类型适配、不做广播、不做顺序保证。收益是内核零依赖宿主、零事件类型定义;代价是每加一种事件就要在 AIAgent.__init__ 签名上再加一个参数(签名已经排到 run_agent.py:484),且每个调用点都得自带 try/except 与 logger.debug。

自测题

  1. _budget_grace_call 为什么必须在循环体开头就置回 False,而不是在 consume() 失败时才置?如果改成后者,会多跑几轮、或者少跑几轮?
  2. conversation_loop.py:1909 用了 and 连接两个上限再用 or 连接宽限。请写出四种组合(max_iterations 够/不够 × remaining 够/不够)下循环的实际行为,并指出哪一种组合下宽限标志必然已被消费。
  3. IterationBudget.refund() 会把 _used 减一。请问这个 API 的存在使 conversation_loop.py 的循环条件在什么情况下不能被当作「上限」来推理?
  4. 起始顺序门与授权门都出现在 tool_executor.py 的并发路径上,但 _execute_tool_calls_sequential 并不需要它们。请解释这两道门分别解决的是「并发带来的哪一类问题」,以及串行路径为什么天然免疫。
  5. 假设你要给某个新宿主(比如一个语音助手)接入进度事件。按本章的回调机制,你最少需要接哪几个回调才能让用户知道「正在调工具」与「正在思考」?为什么内核不直接提供一个 agent.emit(event) 接口?

进入 keel 阅读