OH01: Agent Loop OpenHarness · 对标 S01
与 S01: Agent Loop 读同一问题:模型如何反复调用工具直到停手;这里落在上游 Python 的 QueryEngine + run_query。
权威路径:src/openharness/engine/query_engine.py(会话壳)· query.py(循环体)· stream_events.py(对流式 UI 输出的事件)。上游:HKUDS/OpenHarness · 本仓库快照:reference/rererence_harness/OpenHarness/ · 路径亦见 专题映射表。
🎯 问题(与 S01 一致)
模型需要多轮在「推理」与「改世界」之间切换;Harness 负责把每轮 assistant 消息与 tool 结果写回同一条消息历史,再发起下一轮推理。没有这层循环,用户就要手动搬运工具输出。
🏗️ 两层拆分:QueryEngine vs run_query
OpenHarness 把状态与入口和无状态循环体拆开,便于测试与复用:
QueryEngine:持有_messages、CostTracker、API client / registry / permission 等依赖;submit_message只负责追加用户句并调用run_query。run_query(context, messages):真正的 while 循环;接受同一份messages列表并在原列表上追加,与 S01 伪代码一致。
🔍 源码 1:QueryEngine 外壳
类文档字符串即设计意图:「拥有会话历史与 tool-aware 模型循环」。构造器注入 API、工具注册表、权限、工作目录、模型与 system prompt;默认 max_turns=8 限制单次用户提交内的 agent 轮数(防死循环)。
# reference/.../src/openharness/engine/query_engine.py(节选)
class QueryEngine:
"""Owns conversation history and the tool-aware model loop."""
def __init__(..., max_turns: int | None = 8, ...):
...
self._messages: list[ConversationMessage] = []
self._cost_tracker = CostTracker()
async def submit_message(self, prompt: str) -> AsyncIterator[StreamEvent]:
self._messages.append(ConversationMessage.from_user_text(prompt))
context = QueryContext(...)
async for event, usage in run_query(context, self._messages):
if usage is not None:
self._cost_tracker.add(usage)
yield event
完整定义见上游文件开头至 continue_pending;下面引用关键片段位置便于你对照打开编辑器:
reference/rererence_harness/OpenHarness/src/openharness/engine/query_engine.py · 类与 submit_message
🔍 源码 2:run_query 主循环
每一轮开始先做 auto-compact(上下文超阈值则压缩),再 stream_message;流上产出文本 delta、重试状态等事件;完整后得到 final_message。若 final_message.tool_uses 为空则直接 return——等价于 S01 里「stop_reason 不再是 tool_use」的退出。
# reference/.../src/openharness/engine/query.py(节选)
async def run_query(context, messages):
...
turn_count = 0
while context.max_turns is None or turn_count < context.max_turns:
turn_count += 1
messages, was_compacted = await auto_compact_if_needed(...)
async for event in context.api_client.stream_message(ApiMessageRequest(...)):
if isinstance(event, ApiTextDeltaEvent):
yield AssistantTextDelta(text=event.text), None
...
if isinstance(event, ApiMessageCompleteEvent):
final_message = event.message
usage = event.usage
messages.append(final_message)
yield AssistantTurnComplete(message=final_message, usage=usage), usage
if not final_message.tool_uses:
return
# 执行 tool_uses → ToolResultBlock → messages.append(user with results)
...
多工具:仅 1 个 tool 时顺序执行并立刻 yield 事件;多个时 asyncio.gather 并发,再按序补全完成事件。最后统一 messages.append(ConversationMessage(role="user", content=tool_results)),进入下一轮 while。
🔍 源码 3:单次工具 _execute_tool_call
在循环之内,每条工具调用走:PreToolUse Hook → 取 tool → 校验输入 → 权限策略(可弹确认)→ execute → PostToolUse Hook。这是 S01 里被折叠成 executeTool(...) 的展开版,与 S03、S10 同一治理轴。
⚖️ 与课内叙事对照
| 维度 | 课内 S01 | OpenHarness |
|---|---|---|
| 循环位置 | QueryEngine 式大对象内 while |
QueryEngine 薄壳 + run_query 纯循环 |
| 模型调用 | 常写作一次性 messages.create 返回 |
stream_message + 事件流(UI 可先刷字) |
| 退出条件 | stop_reason != tool_use |
not final_message.tool_uses(语义对齐) |
| 上下文长度 | S05 另讲压缩 | 每轮开头 auto_compact_if_needed(与 S05 同题) |
| 轮数安全 | 课内伪代码常省略 | max_turns 显式封顶,超限抛 MaxTurnsExceeded |
🤔 思考题
has_pending_continuation与「工具结果已写好但尚未再调模型」的关系是什么?何时该调continue_pending?- 多工具并发时,事件顺序与「模型看到的 tool_result 顺序」如何保证一致?
- 若把
max_turns设为None,循环仅靠什么条件结束?风险在哪?
📎 延伸阅读
OH01–12 目录 · OH 课纲 · OH02 · S01 · D01