花一天读完 OpenHarness:一个 11,733 行的 Agent Harness 到底长什么样
HKUDS 实验室刚刚开源了 OpenHarness,这是一个对标 Claude Code 的 Agent Harness 实现。本文是我花一天时间读完它核心架构的学习笔记,从 CLI 启动到 Agent Loop,完整走读 Phase 1。
写在前面#
我用 Claude Code 已经快半年了,它是我每天都在用的主力 Coding 工具。但有一个问题一直困扰我:我从来没有真正看到过它内部长什么样。
Claude Code 是 TypeScript 写的闭源产品,代码还做了混淆。作为一个想往 AI Agent 全栈方向发展的开发者,我很清楚光会”用”是不够的——我需要理解一个生产级 Agent 到底是怎么造出来的。
前几天 HKUDS 实验室(就是做出 Nanobot 的那个香港大学团队)开源了 OpenHarness,一个用 Python 把 Claude Code 核心架构重写了一遍的项目。只有 11,733 行代码,却实现了 43 个工具、54 个命令,以及完整的 Agent Loop、权限系统、插件系统、多 Agent 协作。
这对我来说简直是天降甘霖。
什么是 Harness? 如果你看过 OpenAI 和 Anthropic 最近关于 Agent 的论文,应该知道一个共识:模型负责智能,Harness 负责一切其他。Harness 是包裹在 LLM 外面的完整基础设施——工具、记忆、权限、上下文、多 Agent 协作。用项目作者的话说:“The model is the agent. The code is the harness.”
这篇文章是我花一天时间啃完 OpenHarness 核心架构的学习笔记。我会带你走完整个 Phase 1:从你敲下 oh 命令的那一刻起,一直到 Agent Loop 的心脏。
为什么这个项目值得学?三个理由:
- 它足够小:11,733 行 Python vs Claude Code 的 512,664 行 TypeScript,44 倍精简
- 它足够完整:该有的都有,Agent Loop、Tools、Hooks、MCP、Plugins、Multi-Agent
- 它足够真实:不是教学玩具,是能实际用的生产级实现
好,上路。
一、14 个子系统:先看全貌#
打开 src/openharness/ 目录,你会看到整个项目切成 14 个子模块。我第一次看的时候有点懵——这么多东西,该从哪里开始?
花了一点时间把每个模块的 __init__.py 扫了一遍,我画出了这张结构图:
src/openharness/
│
├── cli.py ← 入口:Typer CLI
│
├── engine/ ← 🧠 核心中的核心:Agent Loop
│ ├── query_engine.py ← while True: stream → tool_use → execute → loop
│ ├── query.py ← 真正的循环实现
│ ├── messages.py ← 消息格式
│ ├── cost_tracker.py ← token 计费
│ └── stream_events.py ← 流式事件类型
│
├── tools/ ← 🔧 43 个 Tool(Bash, Read, Write, Glob...)
│ ├── base.py ← BaseTool + ToolRegistry
│ └── *_tool.py ← 每个文件一个 Tool 实现
│
├── permissions/ ← 🛡️ 权限检查(default/plan/full_auto)
├── hooks/ ← ⚡ 生命周期钩子(PreToolUse/PostToolUse)
│
├── prompts/ ← 📝 System Prompt 组装工厂
├── skills/ ← 📚 按需加载的 .md 知识文件
├── memory/ ← 🧠 跨会话持久记忆
├── plugins/ ← 🔌 插件系统
├── commands/ ← 💬 斜杠命令注册
├── mcp/ ← 🌐 Model Context Protocol Client
├── tasks/ ← 📋 后台任务管理
├── coordinator/ ← 🤝 多 Agent 编排
│
├── config/ ← ⚙️ 配置管理
├── state/ ← 状态存储
├── services/ ← 辅助服务
├── bridge/ ← Python ↔ React TUI 通信桥
├── ui/ ← UI 层入口
└── keybindings/ ← 快捷键配置txt这里有个关键的观察:这 14 个模块不是平级的。它们可以分成三层:
- 执行层:engine、tools、permissions、hooks —— Agent 怎么跑
- 知识层:prompts、skills、memory —— Agent 知道什么
- 扩展层:mcp、plugins、coordinator、tasks、commands —— Agent 怎么连接外部世界
如果你是第一次读这种项目,建议按执行层 → 知识层 → 扩展层的顺序读,主干抓住了其他都是挂件。
二、从 oh 到 Agent 准备就绪:完整启动链路#
我们从用户敲下 uv run oh 的那一刻开始,跟着数据走一遍。
入口:Typer CLI#
打开 src/openharness/cli.py,你会看到熟悉的 CLI 参数定义。这个项目用的是 Typer ↗——如果你写过 Python,可以把它理解成 Python 版的 yargs 或者 commander:
# cli.py:12-21
app = typer.Typer(
name="openharness",
help="Oh my Harness! An AI-powered coding assistant.",
invoke_without_command=True,
)python所有 CLI 参数都定义在 main() 函数里(cli.py:179-334),包括 -p/--print、--model、--permission-mode 等等。参数解析完后,代码会走到这里:
# cli.py:346-377
if print_mode is not None:
# 非交互模式
asyncio.run(run_print_mode(...))
return
# 交互模式
asyncio.run(run_repl(...))python两条路:交互模式(默认)和 print 模式(-p 参数)。Print 模式是单进程直出,适合脚本集成;交互模式会启动漂亮的 React TUI,这也是你日常使用时看到的界面。
让我困惑了很久的双进程架构#
读 ui/app.py:27-47 的时候我卡了一下:
async def run_repl(...) -> None:
if backend_only:
await run_backend_host(...)
return
exit_code = await launch_react_tui(...)python这里有个 backend_only 分支是什么鬼?我继续顺着代码摸,打开 ui/react_launcher.py:
# react_launcher.py:78-102
env["OPENHARNESS_FRONTEND_CONFIG"] = json.dumps({
"backend_command": build_backend_command(...), # ← python -m openharness --backend-only
"initial_prompt": prompt,
})
process = await asyncio.create_subprocess_exec(
npm, "exec", "--", "tsx", "src/index.tsx", ...
)python看到这里我一下子明白了:OpenHarness 交互模式下实际上跑了两个进程。
完整的启动链是这样的:
第 1 步:你敲 `oh`
→ Python 进程 A 启动
→ 它的工作只有一件事:启动 Node.js
第 2 步:Node.js 启动了
→ 用 React/Ink 渲染你看到的 TUI 界面
→ 但 Node.js 不会 AI 逻辑啊
→ 于是它反过来 spawn 了 Python 进程 B(--backend-only 模式)
第 3 步:Python 进程 B 启动
→ 这才是真正干活的后端
→ 进程 A 使命完成,退出txt最终只有两个进程在运行:**Node.js(UI)**和 Python B(Agent 引擎)。它们通过 stdin/stdout 的 JSON-lines 协议通信。
为什么要这样设计?#
这是整个启动流程里最有意思的架构决策。说白了就是语言生态的选择:
| 需求 | 最好的工具 |
|---|---|
| 复杂终端 UI(语法高亮、弹窗、动画) | React/Ink(Node.js 生态) |
| AI Agent 引擎(LLM SDK、asyncio、文件系统) | Python 生态 |
两个需求最好的工具在不同的语言里。与其在一个语言里凑合,不如让两个进程各做各最擅长的事,通过 JSON 通信。
这个思路你应该很熟悉——你写 Next.js 的时候,浏览器跑 React,服务器跑 Node.js,通过 HTTP 通信。OpenHarness 把 HTTP 换成了更简单的 stdin/stdout JSON-lines,因为它们跑在同一台机器、同一个终端里,不需要网络栈。
通信协议长什么样#
看 ui/protocol.py,前后端的消息契约用 Pydantic 模型定义得清清楚楚。
前端 → 后端(protocol.py:15-22):
class FrontendRequest(BaseModel):
type: Literal[
"submit_line", # 用户输入一行
"permission_response", # 权限弹窗的回答
"question_response", # 问题弹窗的回答
"list_sessions",
"shutdown",
]
line: str | None = None
allowed: bool | None = None
answer: str | None = Nonepython后端 → 前端(protocol.py:55-86):14 种事件类型,包括 assistant_delta(流式文本)、tool_started/tool_completed(工具生命周期)、modal_request(弹窗请求)等等。
这个协议的精髓是:每行一个 JSON 对象,读写就是 stdin/stdout。没有端口、没有握手、没有超时重试。当你调试的时候直接 tail 日志就能看到所有通信内容。
build_runtime():整个 Harness 的装配线#
后端进程 B 启动后的第一件事,就是调用 ui/runtime.py:89 的 build_runtime() 函数。这是整个项目最重要的一个函数,它把所有子系统装配成一个 RuntimeBundle:
# runtime.py:89-176 (简化版)
async def build_runtime(...) -> RuntimeBundle:
settings = load_settings().merge_cli_overrides(...)
plugins = load_plugins(settings, cwd)
resolved_api_client = AnthropicApiClient(
api_key=settings.resolve_api_key(),
base_url=settings.base_url,
)
mcp_manager = McpClientManager(load_mcp_server_configs(settings, plugins))
await mcp_manager.connect_all()
tool_registry = create_default_tool_registry(mcp_manager)
hook_executor = HookExecutor(...)
engine = QueryEngine(
api_client=resolved_api_client,
tool_registry=tool_registry,
permission_checker=PermissionChecker(settings.permission),
system_prompt=build_runtime_system_prompt(...),
hook_executor=hook_executor,
...
)
return RuntimeBundle(
api_client=resolved_api_client,
tool_registry=tool_registry,
hook_executor=hook_executor,
engine=engine,
...
)python注意看这些依赖是怎么组装的:
- 先加载 settings 和 plugins(数据配置)
- 用 settings 创建
AnthropicApiClient - 创建
McpClientManager并连接所有外部服务器 - 创建
ToolRegistry(把 43 个 Tool 都注册进去) - 创建
HookExecutor - 最后把上面所有东西作为参数传给
QueryEngine
这就是经典的依赖注入模式。QueryEngine 不自己创建任何依赖,全部从外面传进来。好处很直接:
- 测试时:可以传 mock 的
api_client和 mock 的tool_registry - 换 Kimi 时:只需要改
settings.base_url,QueryEngine一行代码都不用改 - 切模式时:headless/print/interactive 可以共用同一套核心
RuntimeBundle:所有依赖的容器#
看 runtime.py:35-48:
@dataclass
class RuntimeBundle:
api_client: SupportsStreamingMessages # LLM API 客户端
cwd: str # 工作目录
mcp_manager: McpClientManager # MCP 外部工具
tool_registry: ToolRegistry # 43 个 Tool
app_state: AppStateStore # UI 状态
hook_executor: HookExecutor # 生命周期 Hook
engine: QueryEngine # Agent Loop 引擎
commands: object # 斜杠命令
external_api_client: bool
session_id: str = ""python用你熟悉的 React 术语来类比:RuntimeBundle 就是把所有 Context Provider 打包成了一个对象。以后不管哪个函数需要用到哪个子系统,都只要拿到 bundle 就行。
这个模式比全局变量好太多——所有依赖关系都是显式的,测试时可以构造一个 mock bundle 来跑,完全不需要改业务代码。
三、Agent Loop:整个项目的心脏#
终于到核心了。所有 Harness 工程最关键的问题都归结到一件事:Agent Loop 是怎么跑的?
普通 Chatbot vs Agent 的本质区别#
用 Vercel AI SDK 写过聊天应用的人应该都知道,最简单的聊天流程是这样:
用户发消息 → 调 API → AI 回复 → 结束txt但 Agent 不一样。AI 可能说「我需要先读一下这个文件」,然后你把文件内容给它,它又说「好的,我现在要编辑第 42 行」,你执行完再把结果给它,它再说「完成了」。
一个用户消息可能触发多轮 AI ↔ Tool 的交互。
这个循环就是 Agent Loop。它的实现出乎意料地简单——只有 70 行代码,全部在 src/openharness/engine/query.py 里。
逐行走读 run_query#
我把关键部分贴出来,我们一段一段看:
# query.py:53-86
async def run_query(
context: QueryContext,
messages: list[ConversationMessage],
) -> AsyncIterator[tuple[StreamEvent, UsageSnapshot | None]]:
"""Run the conversation loop until the model stops requesting tools."""
for _ in range(context.max_turns):
final_message: ConversationMessage | None = None
usage = UsageSnapshot()
async for event in context.api_client.stream_message(
ApiMessageRequest(
model=context.model,
messages=messages,
system_prompt=context.system_prompt,
max_tokens=context.max_tokens,
tools=context.tool_registry.to_api_schema(),
)
):
if isinstance(event, ApiTextDeltaEvent):
yield AssistantTextDelta(text=event.text), None
continue
if isinstance(event, ApiMessageCompleteEvent):
final_message = event.message
usage = event.usage
if final_message is None:
raise RuntimeError("Model stream finished without a final message")
messages.append(final_message)
yield AssistantTurnComplete(message=final_message, usage=usage), usage
if not final_message.tool_uses:
returnpython这段代码有六个关键点:
① Turn 循环(第 58 行)
for _ in range(context.max_turns): # 默认 8 轮python安全兜底。防止 AI 陷入无限的 tool 调用死循环。
② 调 API 时把所有 tool 的 schema 一起传进去(第 68 行)
tools=context.tool_registry.to_api_schema()python这是让 AI “知道” 自己能做什么的关键。43 个工具的名字、描述、参数格式全部一次性告诉 AI,它才能决定在什么时候调用哪个。这对应 Anthropic API 里的 tools 参数。
③ 流式处理两种事件(第 71-77 行)
if isinstance(event, ApiTextDeltaEvent):
yield AssistantTextDelta(text=event.text), None # 打字增量
continue
if isinstance(event, ApiMessageCompleteEvent):
final_message = event.message # 完整消息
usage = event.usagepythonDelta 事件立刻 yield 出去让 UI 展示”正在打字”的效果,Complete 事件记录完整消息和 token 消耗。这跟 Vercel AI SDK 的 onToken + onFinish 是一个套路。
④ 整个 Agent 和 Chatbot 的分水岭(第 85-86 行)
if not final_message.tool_uses:
returnpython就这两行。 AI 的回复里如果没有 tool_use 请求,说明它觉得任务完成了,整个 Agent Loop 结束。如果有 tool_use,就继续往下走执行工具。
如果有人问你”Agent 和 Chatbot 的本质区别是什么”,指着这两行代码就够了。
单 tool vs 多 tool:两种执行策略#
继续往下看,query.py:88-118:
tool_calls = final_message.tool_uses
if len(tool_calls) == 1:
# Single tool: sequential (stream events immediately)
tc = tool_calls[0]
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
result = await _execute_tool_call(context, tc.name, tc.id, tc.input)
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
is_error=result.is_error,
), None
tool_results = [result]
else:
# Multiple tools: execute concurrently, emit events after
for tc in tool_calls:
yield ToolExecutionStarted(tool_name=tc.name, tool_input=tc.input), None
async def _run(tc):
return await _execute_tool_call(context, tc.name, tc.id, tc.input)
results = await asyncio.gather(*[_run(tc) for tc in tool_calls])
tool_results = list(results)
for tc, result in zip(tool_calls, tool_results):
yield ToolExecutionCompleted(
tool_name=tc.name,
output=result.content,
is_error=result.is_error,
), Nonepython这里有个很实用主义的设计:
- 单个 tool:流式事件优先,started 和 completed 一个一个来
- 多个 tool:并行速度优先,用
asyncio.gather同时跑
为什么要区分?性能和用户体验的平衡。
想象 AI 同时要求读 3 个文件:
- 顺序执行:100ms + 100ms + 100ms = 300ms
- 并行执行:max(100, 100, 100) ≈ 100ms
多 tool 并行能直接把延迟降到最慢那个 tool 的时间。这就是为什么 Claude Code 最近越来越喜欢让 AI 一次性调多个 tool——背后的并行机制就是 asyncio.gather,等价于你在 JS 里的 Promise.all。
但单 tool 就没必要用 asyncio.gather,反而会损失即时反馈——所以代码特意分成两个分支。
Tool 执行的安全链路#
每个 tool 在真正执行前,要经过一条完整的安全检查链。这在 query.py:124-211 的 _execute_tool_call 函数里:
AI 请求执行 tool
│
▼
① PreToolUse Hook
→ 例如 security-guidance 插件会检查危险命令
→ Hook 可以直接 block 掉这次执行
│
▼
② 找到 tool 实现
→ tool_registry.get(tool_name)
│
▼
③ 校验输入参数(用 Pydantic)
→ tool.input_model.model_validate(tool_input)
→ 类型不对直接报错
│
▼
④ 权限检查
→ permission_checker.evaluate(...)
→ 检查 mode(default/plan/full_auto)
→ 检查 path_rules(某些路径不允许)
→ 检查 denied_commands(某些命令不允许)
→ 如果需要确认 → 调 permission_prompt 弹窗
│
▼
⑤ 真正执行 tool
│
▼
⑥ PostToolUse Hook(记日志等)txt你在 Claude Code 里每次看到的 “Allow / Deny” 弹窗,就是第 ④ 步。这是代码里的实现(query.py:168-182):
decision = context.permission_checker.evaluate(
tool_name,
is_read_only=tool.is_read_only(parsed_input),
file_path=_file_path,
command=_command,
)
if not decision.allowed:
if decision.requires_confirmation and context.permission_prompt is not None:
confirmed = await context.permission_prompt(tool_name, decision.reason)
if not confirmed:
return ToolResultBlock(
tool_use_id=tool_use_id,
content=f"Permission denied for {tool_name}",
is_error=True,
)pythoncontext.permission_prompt 是一个异步回调函数。它在 print 模式是 no-op(全部允许),在交互模式则会通过 BackendEvent.modal_request 发送给 React 前端,前端渲染弹窗,用户点 Allow/Deny 后再通过 FrontendRequest.permission_response 把结果回传。
前面讲的双进程架构在这里体现得淋漓尽致——权限弹窗是一个跨进程的异步等待。
完整的一次 Agent 循环#
用一个具体例子串一下。假设你问 AI “读一下 README.md 然后总结”:
第 1 轮:
messages = [{ role: "user", text: "读一下 README.md 然后总结" }]
→ 调 API,传入所有 tool schema
→ AI 回复: "我来读一下" + tool_use: Read({ file_path: "README.md" })
→ tool_uses 非空,继续循环
→ 执行 Read tool:
① PreToolUse Hook 通过
② tool_registry.get("Read") 找到工具
③ Pydantic 校验 file_path
④ 权限检查(只读操作,通过)
⑤ 读文件
⑥ PostToolUse Hook
→ 把 tool 结果作为 user 消息追加
→ messages 现在有 3 条
第 2 轮:
→ 调 API
→ AI 回复: "这个 README 主要讲了三点:1... 2... 3..."
→ tool_uses 为空
→ return,循环结束txt这就是 Agent 的完整生命周期——两个 for 循环、一个 if 判断。但这两行 if not final_message.tool_uses: return 就是 Agent 的灵魂。
四、几个值得记住的设计决策#
读完 Phase 1,有几个设计决策我觉得特别值得记在脑子里。
决策 1:为什么用 RuntimeBundle 而不是全局变量?#
在一个会话生命周期里,很多东西(api client、tool registry、permission checker…)到处都要用。最偷懒的做法是做成模块级全局变量,哪个文件需要就 import。
但 OpenHarness 选择把它们打包成 RuntimeBundle 沿途传递。代价是函数签名变长,好处是依赖关系全部显式,而且支持同时跑多个 session。
这就是写生产级代码和玩具项目的区别。
决策 2:为什么单 tool 和多 tool 要分两个分支?#
本来完全可以统一用 asyncio.gather,单 tool 相当于 gather 一个元素。代码更简洁。
但 OpenHarness 特意分开,是因为单 tool 场景下即时反馈比并行更重要。用户点一次只执行一个工具时,希望看到 “started → (执行中) → completed” 的完整流。
这是一个不易察觉的用户体验决策,但反映了作者对细节的在意。
决策 3:为什么用 Pydantic 校验 tool 输入?#
你完全可以让 tool 自己在 execute 里写 if not isinstance(x, str): raise。但 OpenHarness 在 BaseTool 基类里规定了一个 input_model: type[BaseModel],每个 tool 必须提供一个 Pydantic 模型。
好处是多方面的:
- 自动生成 JSON Schema:告诉 LLM 的
tools参数可以直接从 Pydantic 模型生成 - 统一错误处理:
query.py:150-157里用一个 try/except 就能捕获所有 tool 的参数错误 - 类型安全:tool 的
execute方法拿到的就是一个强类型的对象,不是 dict
你在 TypeScript 里用 zod 做的事,这里用 Pydantic 做。
五、学习路径建议#
如果你想自己读这个项目,我推荐这个顺序:
第一天:主干(Phase 1)#
cli.py→ 看 CLI 参数怎么组织ui/app.py+ui/runtime.py→ 看启动链路ui/react_launcher.py+ui/backend_host.py→ 理解双进程架构ui/protocol.py→ 看前后端通信协议engine/query_engine.py+engine/query.py→ 重点,反复读engine/messages.py+engine/stream_events.py→ 数据结构
第二天:工具系统#
tools/base.py→ Tool 基类tools/__init__.py→ 注册表- 挑 3 个代表性的 tool 深入读:
tools/bash_tool.py(shell 执行)tools/file_edit_tool.py(文件编辑)tools/agent_tool.py(子 Agent 调用)
第三天:知识系统#
prompts/system_prompt.py→ System Prompt 怎么组装skills/registry.py→ Skills 怎么按需加载memory/manager.py→ 持久记忆怎么工作
第四天:扩展系统#
permissions/checker.py→ 权限检查细节hooks/executor.py→ Hook 执行器plugins/loader.py→ 插件发现和加载mcp/client.py→ MCP 协议
六、写在最后#
读完 Phase 1 之后,我对”Agent Harness” 这个概念的理解彻底变了。
以前觉得 Agent 是一个很神秘的东西。读完代码才发现——它就是一个 for 循环 + 一个 if 判断。神秘的部分全在模型里,Harness 干的都是工程活:给模型提供工具、检查权限、记录日志、管理会话、组装上下文。
这个认知对我来说很有价值。因为它意味着:如果你理解了 Harness 的结构,你就能自己搭一个。OpenAI 和 Anthropic 的论文里反复提到的那些概念——tool use、planning、reflection、memory、multi-agent——全部可以在 OpenHarness 的代码里找到对应实现。
我接下来会继续读 Phase 2-6,把剩下的 15 个 Topic 也啃完。等读完整个项目,我会再写一篇总结。
如果你也想学 Agent 开发,强烈推荐花几天时间读一下 OpenHarness。它不长,但够真实。
项目地址:HKUDS/OpenHarness ↗
关于作者
我是 Joye,AI Agent 全栈方向的开发者,日常用 TypeScript + Next.js + AI SDK 做实习项目。这是我的学习笔记系列的第一篇,后续会持续更新 OpenHarness 的其他 Phase。
如果这篇文章对你有帮助,欢迎在小红书找我交流。