首页 / OpenHarness 源码课 / OH01: Agent Loop

OH01: Agent Loop OpenHarness · 对标 S01

S01: Agent Loop同一问题:模型如何反复调用工具直到停手;这里落在上游 PythonQueryEngine + run_query

权威路径:src/openharness/engine/query_engine.py(会话壳)· query.py(循环体)· stream_events.py(对流式 UI 输出的事件)。上游:HKUDS/OpenHarness · 本仓库快照:reference/rererence_harness/OpenHarness/ · 路径亦见 专题映射表

循环心智模型(与 S01 相同):User → LLM → Tool → tool_result → LLM,直至不再请求工具。

🎯 问题(与 S01 一致)

模型需要多轮在「推理」与「改世界」之间切换;Harness 负责把每轮 assistant 消息与 tool 结果写回同一条消息历史,再发起下一轮推理。没有这层循环,用户就要手动搬运工具输出。

🏗️ 两层拆分:QueryEngine vs run_query

OpenHarness 把状态与入口无状态循环体拆开,便于测试与复用:

  • QueryEngine:持有 _messagesCostTracker、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 轮数(防死循环)。

Python
# 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;下面引用关键片段位置便于你对照打开编辑器:

🔍 源码 2:run_query 主循环

每一轮开始先做 auto-compact(上下文超阈值则压缩),再 stream_message;流上产出文本 delta、重试状态等事件;完整后得到 final_message。若 final_message.tool_uses 为空则直接 return——等价于 S01 里「stop_reason 不再是 tool_use」的退出。

Python
# 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(...)展开版,与 S03S10 同一治理轴。

⚖️ 与课内叙事对照

维度 课内 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

🤔 思考题

  1. has_pending_continuation 与「工具结果已写好但尚未再调模型」的关系是什么?何时该调 continue_pending
  2. 多工具并发时,事件顺序与「模型看到的 tool_result 顺序」如何保证一致?
  3. 若把 max_turns 设为 None,循环仅靠什么条件结束?风险在哪?

📎 延伸阅读

OH01–12 目录 · OH 课纲 · OH02 · S01 · D01