QueryEngine.submit_message()
接收并记住用户目标
包装 ConversationMessage,清理历史,写入 task_focus_state,并追加到 _messages。
USER_PROMPT_SUBMIT hook 可以在入口观察或修改行为。
role: "user" · goal remembered路径说明:工具顺序执行,事件可以立即向外产出。
SOURCE NOTE · AGENT ENGINE
第一篇沿着一条消息画出了 Query Loop 的地图;这一篇继续走进 Engine,观察每一轮怎样开始、被拦截、压缩、执行,并最终结束。
第一篇学习笔记回答的是“一条消息经过哪些模块”。继续往下读,我更想知道的是:一次用户输入进入 submit_message() 之后,run_query() 到底怎样管理它的完整生命?
这个问题不能只看一次模型调用。真正的 Agent Loop 还包括消息协议、上下文预算、流式事件、工具并发、权限拦截和失败恢复。它们共同决定了一次运行何时继续、何时改变方向,以及什么才算结束。
INTERACTIVE TRACE · ENGINE LIFECYCLE
选择一条运行路径,再逐步检查状态变化与拦截边界。所有数据都只是本地演示。
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,但当前目标仍可以被重新注入。
run_query() 是独立函数,而不是 QueryEngine 的方法。Engine 通过 QueryContext 把这次运行所需的依赖一次性交给它:API Client、ToolRegistry、PermissionChecker、工作目录、模型、System Prompt、轮次上限,以及 Hook 和用户确认回调。
@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 的运行协议。
在每次模型请求前,Engine 会用本地启发式估算消息 token。这里不是精确 tokenizer:文本大致按四字符一个 token 计算,再乘安全系数;图片则使用固定预算。
超过阈值后,压缩分两层:
如果 API 仍返回 Prompt Too Long,Engine 会进入 reactive compact:强制压缩后重试,必要时继续丢弃最旧的 prompt rounds。
模型输出 ToolUseBlock,只代表它提出了一个候选动作。真正执行之前,动作还要穿过固定链路:
PRE_TOOL_USE hook
→ PermissionChecker
→ tool.execute()
→ POST_TOOL_USE hook
PRE_TOOL_USE 可以阻断,例如禁止写入某类文件。PermissionChecker 可以直接拒绝,也可以调用确认回调询问用户。tool.execute() 才产生真实的文件、命令或网络影响。POST_TOOL_USE 用于完成后的通知、日志与审计,不再逆转已经发生的动作。这条链说明 Harness 的核心价值不是“帮模型调用函数”,而是把模型的不确定决定放进可治理的执行路径。
只有一个 tool_use 时,Engine 顺序执行,并可以立即产出对应事件。多个工具调用则通过 asyncio.gather() 并发运行:
raw_results = await asyncio.gather(
*[_run(tool_call) for tool_call in tool_calls],
return_exceptions=True,
)return_exceptions=True 是这里的关键。一个工具抛异常不会取消其他工具,Engine 可以等所有分支都留下结果,再统一回灌对话。
并发提升吞吐,也引出新的工程约束:并行工具是否会修改同一个文件?权限确认能否同时出现?结果事件应该按完成顺序还是调用顺序展示?gather() 解决的是并发等待,不会自动解决共享状态冲突。
user 消息执行结束后,Engine 把结果组装为 ToolResultBlock,记录 carryover metadata,并以 role="user" 追加到历史。下一轮模型请求由此获得真实世界的新证据。
这里的 user 不是说人又说了一句话,而是协议层把“模型之外的输入”统一放在 user 侧。这样 assistant 只能提出 tool_use,不能自己制造一个成功的 tool_result 来证明动作已经发生。
tool_metadata:不会整包发给模型的会话黑板messages 是模型能看到的对话,tool_metadata 则主要服务于 Engine、工具和 compact。它可以携带两类完全不同的东西:
task_focus_state、已读文件、已调用 skills、异步 Agent 状态等跨轮工作记忆。其中任务焦点可以记录当前目标、最近目标、活跃产物、已验证状态和下一步。它不会自动等于长期记忆,但能在一次长任务被压缩后帮助 Loop 找回当前方向。
读完这条完整生命周期,我不再把 Agent Loop 理解成简单的“LLM → Tool → LLM”。更准确的结构是:
协议化消息
→ 有预算的上下文
→ 可观察的流式判断
→ 有权限边界的执行
→ 可恢复的新证据
→ 下一轮判断
模型负责提出内容与动作,Engine 负责赋予消息身份、控制循环、实施权限、保存证据和处理上下文退化。所谓 Agent 的连续性,不只来自模型“记得多少”,还来自 Runtime 在每一轮之间保留了哪些可靠状态。
下一步我会继续沿着这条生命周期下钻:权限模式如何组合规则与人工确认,Hooks 如何改变执行路径,以及长任务中的 task focus 怎样被工具更新。