← 返回文章

SOURCE NOTE · AGENT ENGINE

OpenHarness 源码阅读(二):Agent Loop 的完整生命周期

第一篇沿着一条消息画出了 Query Loop 的地图;这一篇继续走进 Engine,观察每一轮怎样开始、被拦截、压缩、执行,并最终结束。

第一篇学习笔记回答的是“一条消息经过哪些模块”。继续往下读,我更想知道的是:一次用户输入进入 submit_message() 之后,run_query() 到底怎样管理它的完整生命?

这个问题不能只看一次模型调用。真正的 Agent Loop 还包括消息协议、上下文预算、流式事件、工具并发、权限拦截和失败恢复。它们共同决定了一次运行何时继续、何时改变方向,以及什么才算结束。

INTERACTIVE TRACE · ENGINE LIFECYCLE

这一次 Agent Loop 会怎样结束?

选择一条运行路径,再逐步检查状态变化与拦截边界。所有数据都只是本地演示。

01 / 09
STAGE 01单工具

QueryEngine.submit_message()

接收并记住用户目标

包装 ConversationMessage,清理历史,写入 task_focus_state,并追加到 _messages。

可改变这一步的机制

USER_PROMPT_SUBMIT hook 可以在入口观察或修改行为。

role: "user" · goal remembered

路径说明:工具顺序执行,事件可以立即向外产出。

入口先做的事:把“输入”变成运行状态

QueryEngine.submit_message() 不只是把文本传给模型。它先把输入包装为 ConversationMessage,清理当前历史,追加到 _messages,并触发 USER_PROMPT_SUBMIT hook。

同时,remember_user_goal() 会把这次输入的压缩版本写入 tool_metadata["task_focus_state"]。这是一份与聊天记录并行的侧车状态:旧消息将来可能被 compact,但当前目标仍可以被重新注入。

QueryContext:让主循环依赖显式化

run_query() 是独立函数,而不是 QueryEngine 的方法。Engine 通过 QueryContext 把这次运行所需的依赖一次性交给它:API Client、ToolRegistry、PermissionChecker、工作目录、模型、System Prompt、轮次上限,以及 Hook 和用户确认回调。

query-context.py · simplifiedpython
@dataclass
class QueryContext:
  api_client: SupportsStreamingMessages
  tool_registry: ToolRegistry
  permission_checker: PermissionChecker
  cwd: Path
  model: str
  system_prompt: str
  max_tokens: int
  max_turns: int | None
  permission_prompt: PermissionPrompt
  ask_user_prompt: AskUserPrompt
  hook_executor: HookExecutor
  tool_metadata: dict[str, object]

我把它理解为一次运行的能力边界。模型并不知道怎样自己创建权限或扩大工具集;这些能力在进入循环前已经由宿主代码装配完成。

run_query() 不是一条直线

主循环以 turn_count < max_turns 为硬边界。每轮先检查上下文预算,再发起流式模型请求。模型返回纯文本时,Engine 触发 STOP hook 并结束;返回 tool_use 时,则执行工具、追加结果,再回到下一轮。

因此,“模型回答完成”与“Agent 任务完成”并不是同一个状态。只要还有工具动作和新证据,Loop 就仍在运行。

流式响应也是结构化事件

API Client 不只是吐出一段字符串。文本增量会形成 AssistantTextDelta,完整一轮结束后再形成 AssistantTurnComplete。工具执行也有自己的开始、更新和结束事件。

这让 UI 可以持续更新,也让运行过程可以被观察,而不必等到最终答案才知道中间发生了什么。聊天文本是面向人的叙事,流事件则是 Engine 的运行协议。

Auto-Compact:先便宜地减,再昂贵地总结

在每次模型请求前,Engine 会用本地启发式估算消息 token。这里不是精确 tokenizer:文本大致按四字符一个 token 计算,再乘安全系数;图片则使用固定预算。

超过阈值后,压缩分两层:

  1. Microcompact:不调用 LLM,清掉较老、体积较大的工具结果正文,但保留消息结构和 tool use id。
  2. Full compact:仍超限时,才让 LLM 总结较老的消息,并保留较新的原文。

如果 API 仍返回 Prompt Too Long,Engine 会进入 reactive compact:强制压缩后重试,必要时继续丢弃最旧的 prompt rounds。

工具动作要穿过四道边界

模型输出 ToolUseBlock,只代表它提出了一个候选动作。真正执行之前,动作还要穿过固定链路:

PRE_TOOL_USE hook
  → PermissionChecker
  → tool.execute()
  → POST_TOOL_USE hook

这条链说明 Harness 的核心价值不是“帮模型调用函数”,而是把模型的不确定决定放进可治理的执行路径。

单工具与多工具走不同节奏

只有一个 tool_use 时,Engine 顺序执行,并可以立即产出对应事件。多个工具调用则通过 asyncio.gather() 并发运行:

parallel-tool-execution.py · simplifiedpython
raw_results = await asyncio.gather(
  *[_run(tool_call) for tool_call in tool_calls],
  return_exceptions=True,
)

return_exceptions=True 是这里的关键。一个工具抛异常不会取消其他工具,Engine 可以等所有分支都留下结果,再统一回灌对话。

并发提升吞吐,也引出新的工程约束:并行工具是否会修改同一个文件?权限确认能否同时出现?结果事件应该按完成顺序还是调用顺序展示?gather() 解决的是并发等待,不会自动解决共享状态冲突。

ToolResult 为什么再次成为 user 消息

执行结束后,Engine 把结果组装为 ToolResultBlock,记录 carryover metadata,并以 role="user" 追加到历史。下一轮模型请求由此获得真实世界的新证据。

这里的 user 不是说人又说了一句话,而是协议层把“模型之外的输入”统一放在 user 侧。这样 assistant 只能提出 tool_use,不能自己制造一个成功的 tool_result 来证明动作已经发生。

tool_metadata:不会整包发给模型的会话黑板

messages 是模型能看到的对话,tool_metadata 则主要服务于 Engine、工具和 compact。它可以携带两类完全不同的东西:

其中任务焦点可以记录当前目标、最近目标、活跃产物、已验证状态和下一步。它不会自动等于长期记忆,但能在一次长任务被压缩后帮助 Loop 找回当前方向。

我对 Engine 的第二层理解

读完这条完整生命周期,我不再把 Agent Loop 理解成简单的“LLM → Tool → LLM”。更准确的结构是:

协议化消息
  → 有预算的上下文
  → 可观察的流式判断
  → 有权限边界的执行
  → 可恢复的新证据
  → 下一轮判断

模型负责提出内容与动作,Engine 负责赋予消息身份、控制循环、实施权限、保存证据和处理上下文退化。所谓 Agent 的连续性,不只来自模型“记得多少”,还来自 Runtime 在每一轮之间保留了哪些可靠状态。

下一步我会继续沿着这条生命周期下钻:权限模式如何组合规则与人工确认,Hooks 如何改变执行路径,以及长任务中的 task focus 怎样被工具更新。