← 返回文章

SOURCE NOTE · AGENT RUNTIME

OpenHarness 源码阅读(一):从 CLI 到工具执行循环

Agent 不只是一次模型请求。这篇笔记从一条用户消息出发,跟踪它如何被装配、流式发送,并在工具调用与文本回复之间循环。

OpenHarness 是一个 AI agent harness。它包在 LLM 外面,负责管理对话、执行工具、控制权限、加载技能,以及协调多个 agent。刚开始读源码时,我先不追每个类的实现,而是先回答一个问题:从用户按下回车,到屏幕上出现回复,一条消息到底经过了什么?

先看核心循环

用户输入 → CLI → QueryEngine → API Client → LLM
                ↑                              ↓
                └── ToolRegistry ← tool_calls ─┘

QueryEngine 是这条链路的中心。它一边持有对话历史、工具注册表和权限检查器,一边把当前上下文交给 run_query()。模型如果返回纯文本,这一轮就结束;如果返回 tool_use,Runtime 就执行工具、追加结果,再进入下一轮模型判断。

INTERACTIVE TRACE · LOCAL SIMULATION

一句话如何穿过 OpenHarness?

输入不会离开浏览器;这里演示模块职责与数据形态,不会调用真实 LLM。

READY

cli.py · Typer

接收用户输入

CLI 解析当前会话参数,并把文本交给 QueryEngine。

QueryEngine.submit_message()

装配查询上下文

写入对话历史,带上工具注册表、权限检查器与当前设置。

run_query()

进入 Turn 循环

检查最大轮数与 auto-compact,再准备本轮模型请求。

API Client

统一流式请求

把内部消息格式交给所选 Provider,并持续接收模型事件。

LLM

选择下一步动作

为了演示完整链路,这次模拟返回一个 tool_use。

ToolRegistry

解析工具请求

根据 name 查找 BaseTool,并使用 Pydantic input_model 校验参数。

Hooks + PermissionChecker

在执行前设置边界

先触发 PRE_TOOL_USE,再根据当前 Mode 与规则评估权限。

BaseTool.execute()

执行工具并回传证据

工具返回 ToolResult,POST_TOOL_USE 随后触发,结果被追加到消息。

run_query() · Turn 2

携带工具结果再次请求

循环把 tool_result 交回 API Client,让模型基于新证据组织回复。

LLM → QueryEngine

提交最终响应

纯文本结束本次循环,并把 assistant 消息保存到历史。

从 CLI 到 LLM:一条消息的路径

1. cli.py:建立运行环境

CLI 使用 Typer 解析命令行参数,创建 SettingsQueryEngine,再启动 REPL。它处理的不只是一段文本,还包括这次会话应该使用哪些配置。

2. QueryEngine.submit_message():装配上下文

QueryEngine 维护 _messages,并持有工具注册表与权限检查器。收到用户消息后,它构建 QueryContext,再调用 run_query()

3. run_query():控制多轮判断

turn_count < max_turns 的前提下,循环持续做四件事:

  1. 检查 token 是否超限,需要时自动压缩历史。
  2. 调用 api_client.stream_message() 把当前消息发给 LLM。
  3. 收到 tool_use 时执行工具,并把结果追加回消息。
  4. 收到纯文本时结束循环。

API Client 层将 Anthropic、OpenAI、Copilot 和 Codex 等 Provider 收敛为统一的流式接口。上层只需要处理流式事件,而不必在主循环里重复每个 Provider 的差异。

工具调用不是直接执行

当 LLM 返回工具调用时,_execute_tool_call() 还要经过一条完整的执行链:

HookExecutor.pre_tool_use
  → PermissionChecker.evaluate
  → tool.execute(parsed_input)
  → HookExecutor.post_tool_use

这条链路把“模型建议做什么”和“系统允许什么真正发生”分开了。LLM 只是生成候选动作;运行时仍然可以在工具执行前拦截,也能在执行后记录和修改后续行为。

五个值得记住的设计模式

工具 = BaseTool + Pydantic Model

每个工具继承 BaseTool,声明 namedescription 和作为 Pydantic 模型的 input_model,再实现异步 execute()。注册到 ToolRegistry 后,工具可以根据输入模型自动生成 JSON Schema。

class MyTool(BaseTool):
    name = "my_tool"
    description = "Does something useful"
    input_model = MyInput

    async def execute(self, arguments: MyInput, context: ToolExecutionContext) -> ToolResult:
        return ToolResult(output="done")

配置 = 多层合并

设置按 CLI 参数 > 环境变量 > ~/.openharness/settings.json > 默认值 的优先级合并,并在 config/settings.py 中使用 Pydantic 模型定义。

权限 = Checker + Mode

PermissionChecker 结合 defaultautoplan 模式以及路径规则,决定工具是否可以执行。Hook 仍然可以在这个过程中拦截。

Hooks = 生命周期事件

HookExecutorPRE_TOOL_USEPOST_TOOL_USESTOP 等事件上运行扩展逻辑,为阻断执行或修改行为提供了固定接入点。

系统提示词 = 组装

prompts/system_prompt.py 将 base prompt、environment info、CLAUDE.md 内容与技能注入组装为最终的系统提示词。这说明 agent 得到的并不是一段固定文本,而是当前工作环境的动态投影。

这一阶段的理解

OpenHarness 的主循环本身并不神秘:装配上下文、请求模型、解析动作、执行工具,再把结果放回上下文。真正的工程量在这个循环周围:消息如何被限制,工具如何自描述,权限如何被检查,行为如何被 Hook 观测,以及多个 Provider 如何被统一成稳定接口。

这是我刚开始学习 OpenHarness 时画出的第一张地图。后续继续向各个模块下钻时,这条 Query Loop 会成为定位每个细节的坐标系。