附录 F · pi Harness 代码地图
为什么需要一张地图
前面各章,你几乎在每个模式里都“用 pi 实现”了一遍:ReAct 循环(第 3 章)、上下文压缩(第 9 章)、会话持久化(第 12 章)、Hooks 与事件(第 8 章)、运行时分层(第 23 章)……你大概已经默认 pi 就是“那个帮你跑 Agent 的东西”。但你大概率从没真的打开过 packages/agent/src/harness/ 这个目录,看它内部到底长什么样。
这一附录就是给你一张地图。它不引入任何新概念,只做一件事:把前面散落在各章的 pi 片段,按真实的源码目录拼成一张完整的引擎剖面图。读完你再回头看“Agent = Model + Scaffolding + Harness”(第 1 章),会清楚地知道——Harness 这一层,在 pi 里具体是哪些文件、各自管什么。
一句话定位:pi 把“狭义 Harness(执行引擎)”显式抽成了一个
harness/模块,和 Scaffolding(system-prompt.ts)、运行时入口(agent.ts)严格分离。这张地图,就是harness/模块的导览。
第 2 章的 10 条判别清单,落到 pi 的代码上
第 2 章给了你一张“判别 Agent 有没有 Harness”的 10 条清单。要先说明:它不是某个标准组织的条文,而是一张从 Hugging Face Agent 术语表与真实开源 harness(pi、OpenAI Agents SDK、Claude Agent SDK、LangGraph)的共性能力中提炼的“判别透镜”。下面把它逐条映射到 pi 的真实代码——你就知道 pi 在 Harness 这一层到底做了什么:
| # | 判别维度(第 2 章) | pi 的代码落点 |
|---|---|---|
| 1 | 执行循环 | agent-loop.ts 的 runAgentLoop(双层循环) |
| 2 | 工具执行 | agent-loop.ts 的 executeToolCallsParallel/Sequential + before/after 钩子 |
| 3 | 状态持久化 | session/ 的会话树(id/parentId,可分支、JSONL 落盘) |
| 4 | 循环控制 / 停止 | agent-harness.ts 的阶段机 + shouldStopAfterTurn + abort() |
| 5 | 异常兜底 | agent-loop.ts:工具异常→createErrorToolResult 回填,循环继续 |
| 6 | 上下文工程 | compaction/ 的 token 估算 + LLM 结构化摘要 |
| 7 | 可观察性 | agent-harness.ts 的 subscribe/on + 全链路事件流 |
| 8 | 权限 / 安全边界 | 无内置(诚实边界),靠 env/nodejs.ts + 容器化(第 28 章) |
| 9 | 扩展机制 | skills.ts + 生命周期 Hooks(第 8 章)+ 四个扩展面(第 35 章) |
| 10 | 动态切换 | agent-harness.ts 的 setModel / setThinkingLevel / setTools |
后面每节,就是这张表里每一行的代码展开。
总览:harness/ 目录在哪儿、装了什么
pi 的运行时在 packages/agent。剥开入口文件,真正的执行引擎全在 packages/agent/src/harness/ 下:
packages/agent/src/
├── agent.ts / agent-loop.ts / node.ts / proxy.ts / index.ts # 基础运行入口
└── harness/ ← 狭义 Harness 实现层
├── agent-harness.ts # 核心类 AgentHarness(编排器)
├── agent-loop.ts # 执行循环 runAgentLoop(模型↔工具的真正驱动)
├── system-prompt.ts # Scaffolding(系统提示,模型可见层)
├── prompt-templates.ts # 提示模板
├── skills.ts # 技能加载与调度
├── messages.ts / types.ts # 消息结构与类型契约
├── compaction/ # 上下文压缩(Context Engineering 的执行侧)
│ ├── compaction.ts # token 估算 + 切点 + LLM 结构化摘要
│ ├── branch-summarization.ts
│ └── utils.ts
├── env/nodejs.ts # 执行环境(文件系统 / Shell)
└── session/ # 状态管理与持久化
├── session.ts # Session 类(分支树、buildContext)
├── jsonl-storage.ts # JSONL 落盘存储
└── ...
记住这条主线,后面每节对应一个文件:编排器管全局 → 循环管一轮 → 会话管状态 → 压缩管上下文 → 类型管契约。
编排器:AgentHarness(agent-harness.ts)
AgentHarness 是 harness 的总指挥,本质是一个带阶段机和消息队列的状态机。它不自己跑循环,而是把循环委托给 runAgentLoop,自己负责“什么时候能跑、跑的时候谁能插话、跑完怎么落盘”。它直接对应判别清单的第 4、7、10 项。
几个关键设计:
- 阶段机
phase:"idle" | "turn" | "compaction" | "branch_summary" | "retry"。除idle外不接受新的prompt(),以此防止并发冲突——你不能同时发起两轮(第 4 项:循环控制)。 - 运行中插话队列:
steerQueue(运行中指导)、followUpQueue(追加后续)、nextTurnQueue(下一轮预排)。模型跑着的时候你打字进去,会被安全排队,而不是打乱循环。 - 延迟写
pendingSessionWrites:运行中产生的所有状态变更(消息、模型切换、工具增减)先入队,在turn_end/agent_end/finally里统一 flush 到存储——保证“要么全写、要么不写”。 - 动态切换:
setModel()/setThinkingLevel()/setTools()/setActiveTools()在空闲时直写、运行时进延迟写队列(第 10 项)。
它暴露的方法也印证了 Harness 该管的事:prompt()/skill()/promptFromTemplate()(发起)、abort()(中止)、compact()(压缩)、navigateTree()(分支导航)、subscribe()/on()(订阅与钩子,第 7 项:可观察性)。
这一层对应第 1 章说的:Harness 决定“行动如何被真正执行”,而 Scaffolding 决定“该按什么规则行动”。
AgentHarness管执行,system-prompt.ts管规则,二者在同一目录并列但职责分明。
执行循环:runAgentLoop(agent-loop.ts)
第 3 章你看过一个简化的 while 骨架。真实代码在 agent-loop.ts 的 runAgentLoop,结构是双层循环——它对应判别清单的第 1、2、5 项:
- 外层
while:处理followUp消息——如果模型本该停了,但队列里还有后续指令,就带着新消息再转一轮。 - 内层
while:处理工具调用与steer消息——每轮先流式拿模型回复,有toolCall就执行并回填 Observation,没有就准备结束。
单轮内部的关键步骤:
// 伪代码骨架(省略流式/事件细节)
while (hasToolCalls || pendingMessages.length) {
const message = await streamAssistantResponse(ctx, config); // Thought + Action
if (message.stopReason === "error" || "aborted") { end(); return; }
const toolCalls = message.content.filter(c => c.type === "toolCall");
if (toolCalls.length) {
const results = await executeToolCalls(ctx, message, config); // Observation
hasMoreToolCalls = !results.terminate;
}
if (await config.shouldStopAfterTurn?.({ message, results, ctx })) { end(); return; }
pendingMessages = await config.getSteeringMessages?.() ?? []; // 运行中插话
}
比第 3 章骨架多出来的工程细节,正是“能上生产的 Harness”和“玩具循环”的分水岭(对应第 2、5 项):
- 并行 / 串行:工具按
executionMode或全局toolExecution决定executeToolCallsParallel还是Sequential(第 2 项:工具执行)。 - 钩子拦截:
beforeToolCall可阻断(block)或改写参数;afterToolCall可修补结果、标记terminate。这就是第 8 章 Hooks 的真正落点。 - 永不崩溃:工具找不到 / 参数非法 / 执行抛错 → 生成
createErrorToolResult回填,循环继续,而不是整个 Agent 挂掉(第 5 项:异常兜底)。
状态与会话树:session/(session.ts)
第 12 章讲过会话格式与持久化,但没展开 pi 怎么存。答案是一棵树:Session 把每条 entry 存成 { id, parentId, ... },顺着 parentId 连回根(对应第 3 项:状态持久化)。
这意味着会话不是一条线性时间线,而是一个可分支的树:
getBranch(fromId?)沿getPathToRoot(leafId)取回从根到某个叶子的路径。buildContext()把这条路径重建成当前上下文——所以你能从任意历史节点“分叉”出新对话,旧分支不丢。moveTo(entryId, summary)把叶子指针移到别处,并写入一条branch_summary记录为什么分叉。
落盘在 jsonl-storage.ts,用 JSONL 追加写;运行时变更先进 pendingSessionWrites,由编排器在合适时机统一 flush(呼应上面的“延迟写”)。
会话树是 pi 能做“RPC 远程驱动 + 后台长时 Agent”(第 24、33 章)的底层支撑:远程端操作的不是一段文本,而是一棵可寻址、可恢复、可回放的会话树。
上下文压缩:compaction/(compaction.ts)
第 9 章讲过 Compaction 的概念。这里看 compaction.ts 怎么落地(对应第 6 项:上下文工程):
- token 估算:
estimateTokens用“字符数 ÷ 4”的启发式(图片按 4800 字符计),estimateContextTokens再结合模型返回的usage修正。 - 触发条件:
shouldCompact(contextTokens, contextWindow, settings)当contextTokens > contextWindow - reserveTokens时触发,默认reserveTokens = 16384、keepRecentTokens = 20000。 - 切点选择:
findCutPoint从末尾向前累加 token 到“保留最近 20k”预算内,选一个不拆散中间状态的切点。 - LLM 结构化摘要:
generateSummary调模型按固定模板产出——## Goal / ## Constraints / ## Progress(Done/In Progress/Blocked)/ ## Key Decisions / ## Next Steps / ## Critical Context,并要求保留确切文件路径、函数名、错误信息。已有旧摘要时走UPDATE_SUMMARIZATION_PROMPT做增量更新,而不是从头写。
压缩后只保留“摘要 + 最近历史”,写一条 compaction entry;buildContext 重建时自动把摘要注入上下文顶部。长任务因此不会撑爆窗口,且关键信息(含读改写过的文件清单)被保留。
类型契约:types.ts
types.ts 是 harness 的“接口说明书”,值得单独记一笔,因为它体现了 pi 对“可靠”的执念(也支撑第 5、7 项的健壮性):
Result<T, E>模式:文件系统 / Shell / 执行环境的方法一律返回{ ok, value } | { ok: false, error },不靠抛异常传递可控错误——调用方必须处理err分支。- 六个稳定错误类:
FileError/ExecutionError/CompactionError/BranchSummaryError/SessionError/AgentHarnessError,各自带明确ErrorCode枚举。 - 执行环境接口:
FileSystem、Shell、ExecutionEnv(= FileSystem + Shell)把“怎么碰文件、怎么跑命令”抽象成接口——换平台只换实现,harness 内核不动。
这正是第 23 章“依赖倒置、换一层不动其他层”在类型层面的体现:harness 只依赖接口,不依赖某家文件系统或 shell 的实现。
用这张地图回看全书
拼起来看,前面学的每个模式,都能在 harness/ 里指到具体位置:
| 你学过的模式 | 在 harness/ 的落点 |
|---|---|
| ReAct 循环(第 3 章) | agent-loop.ts 的 runAgentLoop |
| 工具调用与并行/串行(第 4 章) | agent-loop.ts 的 executeToolCallsParallel/Sequential |
| Hooks 与事件(第 8 章) | agent-harness.ts 的 subscribe/on + 循环里的 before/after 钩子 |
| 上下文工程(第 9 章) | compaction/compaction.ts |
| 会话树与持久化(第 12 章) | session/session.ts + jsonl-storage.ts |
| 运行时分层(第 23 章) | packages/agent 整体,harness/ 是运行时层内部 |
| 在其上构建(第 35 章) | AgentHarness 暴露的四个扩展面之外的“引擎本身” |
回看第 1 章那句话——“你学的是模式,不是 pi 的 API”——现在你既懂了模式,也看清了模式在一个真实 harness 里长什么样。换到 Claude Agent SDK、LangGraph 或任何还没诞生的 harness,你都能照着这张地图,快速找到“它的执行循环、会话存储、上下文压缩分别在哪”。
更实际的做法:拿第 2 章 5 家对比表里任意一家 harness,照这 10 条逐项去找对应物——你会发现都能找到,只是内置与否、暴露程度不同。这恰好印证了“模式相同、实现各异”。
动手看看
打开 packages/agent/src/harness/,按这张地图走一遍:
- 读
agent-harness.ts的phase字段和三个队列,确认“运行中插话”是怎么不打乱循环的(对应判别第 4 项)。 - 读
agent-loop.ts的runAgentLoop,对照第 3 章骨架,标出beforeToolCall/afterToolCall两个钩子点(第 2、7 项)。 - 读
session/session.ts的buildContext,画一条从 leaf 回溯到 root 的路径,体会“会话树”为何能分支(第 3 项)。 - 读
compaction/compaction.ts的SUMMARIZATION_PROMPT,看它要求保留哪些“硬信息”(第 6 项)。
走完这四步,你就不再只是“用过 pi”,而是“读过 pi 的引擎”。
小结
- pi 把狭义 Harness 显式抽成
packages/agent/src/harness/,与 Scaffolding(system-prompt.ts)严格分离——这是“Agent = Model + Scaffolding + Harness”在代码层的直接印证。 - 第 2 章的 10 条判别清单,逐条都能落到 pi 代码:执行循环(
runAgentLoop)、工具执行(并行/串行+钩子)、会话树(session/)、阶段机与停止(AgentHarness)、异常兜底(createErrorToolResult)、上下文压缩(compaction/)、可观察性(subscribe/on)、扩展(skills+hooks)、动态切换(setModel等)。 - 编排器
AgentHarness用阶段机(idle/turn/compaction/branch_summary/retry)+ 运行中插话队列 + 延迟写,保证“何时能跑、谁可插话、怎么落盘”都受控。 - 执行循环
runAgentLoop是双层循环:外层处理 follow-up、内层处理工具调用与 steer;并行/串行、钩子拦截、异常兜底都在这一层,比第 3 章骨架多了“能上生产”的工程细节。 - 会话树
session/把对话存成可分支的{id, parentId}树,buildContext沿根重建——这是 RPC 远程驱动与后台长时 Agent 的底层支撑。 - 上下文压缩
compaction/用 token 估算 + 切点 + LLM 结构化摘要(保留文件路径/函数名/错误),让长任务不爆窗口且关键信息不丢。 - 类型契约
types.ts用Result模式、六个稳定错误类、执行环境接口,把“可靠”写进类型系统——也是分层与依赖倒置的体现。 - 这张地图把全书散落的 pi 片段拼成完整引擎剖面:你既懂了模式,也看清了模式在真实 harness 里的位置,换任何框架都能照图索骥。