cli.py · Typer
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。
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 解析命令行参数,创建 Settings 和 QueryEngine,再启动 REPL。它处理的不只是一段文本,还包括这次会话应该使用哪些配置。
2. QueryEngine.submit_message():装配上下文
QueryEngine 维护 _messages,并持有工具注册表与权限检查器。收到用户消息后,它构建 QueryContext,再调用 run_query()。
3. run_query():控制多轮判断
在 turn_count < max_turns 的前提下,循环持续做四件事:
- 检查 token 是否超限,需要时自动压缩历史。
- 调用
api_client.stream_message()把当前消息发给 LLM。 - 收到
tool_use时执行工具,并把结果追加回消息。 - 收到纯文本时结束循环。
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,声明 name、description 和作为 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 结合 default、auto 和 plan 模式以及路径规则,决定工具是否可以执行。Hook 仍然可以在这个过程中拦截。
Hooks = 生命周期事件
HookExecutor 在 PRE_TOOL_USE、POST_TOOL_USE 和 STOP 等事件上运行扩展逻辑,为阻断执行或修改行为提供了固定接入点。
系统提示词 = 组装
prompts/system_prompt.py 将 base prompt、environment info、CLAUDE.md 内容与技能注入组装为最终的系统提示词。这说明 agent 得到的并不是一段固定文本,而是当前工作环境的动态投影。
这一阶段的理解
OpenHarness 的主循环本身并不神秘:装配上下文、请求模型、解析动作、执行工具,再把结果放回上下文。真正的工程量在这个循环周围:消息如何被限制,工具如何自描述,权限如何被检查,行为如何被 Hook 观测,以及多个 Provider 如何被统一成稳定接口。
这是我刚开始学习 OpenHarness 时画出的第一张地图。后续继续向各个模块下钻时,这条 Query Loop 会成为定位每个细节的坐标系。