青雲的博客

第四部:会话、快照与 Harness

从低层 Agent Loop 向上追到两条并列控制面,解释 AgentHarness 的 turn snapshot、保存点、pending write、Session tree、压缩与导航,再以崩溃切片划清会话持久化和运行恢复的边界。

Agent 与 AgentHarness 通过快照、保存点和 Session tree 管理会话状态与持久化边界。
展开阅读路线与实验入口

第三部已经把 runAgentLoop() 内的两层循环、消息队列、工具批次与停止条件讲完。只读这层会留下一个实际问题:循环返回的消息由谁保存,运行中切换模型何时生效,回到旧分支后模型又从哪条路径继续?

Pi 在 packages/agent 中保留了两个答案。稳定的 Agent 是带实时状态的 loop wrapper,pi-coding-agent 再用 AgentSession 给它接上产品级持久化、扩展、自动压缩与 I/O。与此同时,package 里新增了独立的 AgentHarness:它不包 Agent,而是直接驱动同一个 loop,并尝试把 Session、资源、Hook、保存点和结构操作收进一套更适合嵌入的 API。

本部不把这两条路径揉成一套“Pi 最终架构”。它们共享底层 loop 与消息类型,但高层 state owner 不同。第 19 章先把并列调用关系钉住,后五章才集中阅读 Harness,因为这里的 Session 接口更小,状态边界也更容易逐项验证。

六章按哪条线读

第 19 章从两个直接 call site 开始。Agent 自己还原 AgentStateAgentHarness 自己处理 Session append、Hook、phase 与 pending write。随后回到 pi-coding-agent 的 SDK factory,确认稳定 CLI 用的是前者加 AgentSession。这一步用来防止后面每一个 Harness 结论被误写成当前 CLI 已迁移行为。

第 20 章处理运行中配置。Harness 不让一个长 run 从头到尾只用一份配置,也不让 setter 修改正在飞行的 provider request。它在 prompt 开始构造 turn state;assistant response 与完整工具批次结束后,先到保存点,再构造下一份 state。一个 run 因此可以依次使用多份快照。

第 21 章把“修改已经生效”拆成 runtime getter、当前 request、下一 request 和 Session entry 四个观察面。model、thinking level、active tools 在 active phase 会先进入 pending queue,同时更新 Harness 最新字段;保存点按接收顺序写入 Session。resources 与 stream options 则只存在进程内。Hook 抛错也不会回滚已成功提交的 entry。

第 22 章进入 Session 数据模型。backend 保存 append log,parentId 形成树,leaf entry 持久记录 pointer 移动。全量 entries、active branch、context entries 和最终 AgentMessage projection 是四种不同读取。普通 custom entry 默认不会进入模型上下文,必须由 projector 显式转换。

第 23 章比较两个结构操作。compaction 在当前 branch 尾部追加摘要屏障,旧 entries 仍然保留;navigation 移动 active leaf,可把离开的分支压成一条 branch summary 接到新路径。两者都要求 Harness idle,但 navigation 的 leaf move 与 summary append 是两次写入,不能宣称全有或全无。

第 24 章最后做 crash audit。JSONL 与 SQLite 都能重开 Session tree;SQLite 还能让单次 entry 及其 materialized index 在事务内一致。当前 AgentHarness 的 phase、queue、pending write、provider attempt 和 tool-start intent 没有进入 storage。重开会话不会自动续跑未完成 operation,harness-v2.md 提出的 lane 与 operation log 仍是设计目标。

flowchart TB
  accTitle: 第四部状态与所有权地图
  accDescr: Agent 和 AgentHarness 并列调用低层循环;Harness 使用快照与保存点,把完成内容写入 Session tree;context projection、compaction 和 navigation 决定模型看到的路径;storage 不保存未完成运行。
  LOOP["runAgentLoop"]
  AGENT["Agent\nAgentState owner"] --> LOOP
  HARNESS["AgentHarness\noperation owner"] --> LOOP
  HARNESS --> SNAP["turn snapshot\ncurrent request"]
  HARNESS --> SAVE["save point\npending write order"]
  SAVE --> TREE["Session tree\nentries + parentId + leaf"]
  TREE --> VIEW["buildContext\nactive path projection"]
  TREE --> STRUCT["compaction / navigation"]
  TREE --> STORE["memory / JSONL / SQLite"]
  STORE -.-> GAP["no durable run intent\nno tool recovery record"]
  PRODUCT["pi-coding-agent\nAgentSession"] --> AGENT

图中的 STORE -.-> GAP 是本版的明确缺口,不是说 Session 没有持久价值。对话 entry、分支、leaf、摘要和部分配置都可以跨进程恢复。缺的是“这条 prompt 是否已经被接受为一个 operation、provider 请求走到哪一步、工具副作用能否安全重放”这一层 orchestration receipt。

读源码时坚持三个词

这一部会反复使用 runturnentry,三者不能互换。

run 从一次 prompt() 开始,可能经过多次 provider request、工具调用、steering 和 follow-up,直到 Harness 回到 idle。turn 是低层 loop 的一段 assistant response 加完整工具批次,结束时触发 turn_end 和保存点刷新。entry 是 Session tree 中一次成功追加的事实;它可能是消息,也可能是配置、摘要或 leaf 移动。

同样要区分 snapshot 与 durable state。turn state 是为了让一条 provider request 使用稳定输入;它不是 UI snapshot,也不自动落盘。Session entry 才经过 storage。save_point 事件说明 pending writes 已在当前流程里 flush,并不承诺 fsync、跨工具事务或 crash resume。

本部实验的边界

实验都锁定 v0.83.0 / 845d6ff1f6643aba440341cce877ce1c43ebbc39。Harness 的核心测试使用 faux provider,不需要真实 API key,适合验证模型切换时序、pending write 排序、compaction projection 与 JSONL reopen。它们不能证明真实 provider 的网络恢复,也不能证明 bash、HTTP 或文件工具具有幂等副作用。

固定证据 checkout 只读,只用于 git show、路径和行号核验,不在其中安装依赖、生成 model data 或运行会写入构建产物的命令。这个 tag 还有一个不显眼的发布前提:provider catalog JSON 所在的 packages/ai/src/providers/data/.gitignore 排除,普通 git checkout 与普通 git archive <commit> 都没有这些文件。直接从这种副本导入 AgentHarness 测试,会在 suite 加载阶段报缺少 amazon-bedrock.json,实际执行数是 0。

本部所有可执行实验统一使用 v0.83.0 官方 release source archive。发布脚本会把被忽略的 provider data 加入临时 git tree,release workflow 又从该 archive 离线构建 binaries,因此不需要在复现当天重新访问可变模型目录。固定制品为 https://github.com/earendil-works/pi/releases/download/v0.83.0/pi-0.83.0-source.tar.gz,SHA256 为 f225b87ec3b4825dd5b94e922a8629558addca31a1b4d2c206ae598a8e2692c0

进入本部前至少先核对源码身份:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"

test "$(git -C "$repo" rev-parse HEAD)" = \
  845d6ff1f6643aba440341cce877ce1c43ebbc39
test "$(git -C "$repo" describe --tags --exact-match HEAD)" = v0.83.0
test -z "$(git -C "$repo" status --porcelain)"

随后为可执行实验准备一次性 release tree。哈希不匹配就立即停止;安装和测试都发生在解压目录,不会改动上面的证据 checkout:

work="$(mktemp -d)"
archive="${PI_RELEASE_ARCHIVE:-$work/pi-0.83.0-source.tar.gz}"
url="https://github.com/earendil-works/pi/releases/download/v0.83.0/pi-0.83.0-source.tar.gz"
expected="f225b87ec3b4825dd5b94e922a8629558addca31a1b4d2c206ae598a8e2692c0"

test -f "$archive" || curl -fL "$url" -o "$archive"
if command -v sha256sum >/dev/null 2>&1; then
  actual="$(sha256sum "$archive" | awk '{print $1}')"
else
  actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
fi
test "$actual" = "$expected"

tar -xzf "$archive" -C "$work"
export PI_RELEASE_WORKDIR="$work"
export PI_RELEASE_SOURCE_DIR="$work/pi-0.83.0"
test -f "$PI_RELEASE_SOURCE_DIR/packages/ai/src/providers/data/.manifest.json"
(cd "$PI_RELEASE_SOURCE_DIR" && npm ci --ignore-scripts --no-audit --no-fund)

第 20、21、23 章的命令都只读取 PI_RELEASE_SOURCE_DIR。实验结束后删除 PI_RELEASE_WORKDIR 即可;不要把其中生成的 node_modules、coverage 或缓存当作源码证据。

最后一章会把当前实现与 Harness v2 design 放在同一张差异表里,但只把 src、schema 和可执行测试当作 shipped evidence。设计文档负责说明方向,不负责替实现背书。完成这层校准后,第五部再回到稳定 Coding Agent 的 AgentSession,看产品控制面如何补上 Harness 之外的自动压缩、重试、扩展与会话切换。

深入浅出 Pi 第四部:会话、快照与 Harness 第 19 章

`AgentHarness` 为什么没有包住 `Agent`

沿两条真实调用链比较 Agent 与 AgentHarness,解释它们为什么都直接驱动低层循环,以及 v0.83.0 的 Coding Agent 究竟选择了哪一条。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

第三部停在 runAgentLoop():它接收初始消息、上下文、循环配置、事件回调和流函数,然后负责模型与工具之间的往返。继续往上看时,仓库里出现两个都很像“Agent 运行时”的类:AgentAgentHarness。最容易画出的关系是 AgentHarness -> Agent -> runAgentLoop。类名也很配合这个猜法,Harness 听起来就像包在 Agent 外面的那层东西。

源码没有这条边。AgentAgentHarness 是两条并列路径,它们都直接调用低层循环。前者是带状态的通用 Agent 包装器;后者另外接管了 Session、运行阶段、资源快照、Hook、压缩和树导航。理解这个分叉很重要,因为 v0.83.0 的 pi 产品还在走前一条路径,而 AgentHarness 已经在同一个 package 中形成另一套尚未完成迁移的控制面。

两个高层对象,共用一个低层循环

Agent 的类注释把职责写得很克制:它持有当前 transcript,发出生命周期事件,执行工具,并提供 steering 与 follow-up 队列。字段也对应这份说明:MutableAgentState、listeners、两个 PendingMessageQueue,以及 stream、tool hook、session id 等循环配置。它没有一个 Session 字段,也没有负责 JSONL 或 SQLite 的 storage。

Agent.prompt() 最终进入 runPromptMessages()。后者通过 runWithLifecycle() 创建 abort controller 和 settlement promise,再把 createContextSnapshot()createLoopConfig() 以及 processEvents() 一起交给 runAgentLoop()。事件回来后,Agentmessage_end 等节点更新自己的 _state,然后等待订阅者。这里的状态还原发生在 Agent 内部。

AgentHarness.executeTurn() 没有创建 Agent。它自己建立 abort controller,把当轮状态存在局部变量 activeTurnState 中,再直接调用同一个 runAgentLoop()。事件回调改为 handleAgentEvent(),流函数改为 Harness 自己的 provider 包装,循环配置也由 Harness 把 Hook、队列与保存点接进去。

把两条路径压缩成图,会比继承或包含关系更准确:

flowchart TB
  accTitle: Agent 与 AgentHarness 的并列调用关系
  accDescr: Agent 与 AgentHarness 分别管理不同的高层状态,但都直接调用同一个 runAgentLoop;v0.83.0 的 coding-agent 选择 Agent 加 AgentSession。
  LOOP["runAgentLoop()"]
  AGENT["Agent\n实时 AgentState + queues + events"] --> LOOP
  HARNESS["AgentHarness\nSession + snapshots + hooks + structural ops"] --> LOOP
  PRODUCT["pi-coding-agent\nAgentSession"] --> AGENT
  PRODUCT -.->|"v0.83.0 未迁移"| HARNESS

这张图不表示两边功能对称。它只说明调用边。Agent 把 loop event 还原为内存态;AgentHarness 还把完成消息追加到 Session,在 turn 边界刷新 pending write,并暴露 compaction、navigateTree 等结构操作。它们共享底层执行算法,不共享高层 owner。

AgentHarness 多出来的不是一个漂亮 API

AgentHarness 构造函数直接接收 SessionModels、资源、工具、模型、thinking level 与队列模式。类字段里还有显式 phase、abort controller、pending session writes、三种消息队列和 Hook handlers。这个对象想解决的是“一个可嵌入运行时怎样保持会话顺序与扩展语义”,不只是把 Agent.prompt() 换个名字。

每次 prompt() 开始,它先确认 phase 为 idle,立刻切成 turn,创建 turn state,然后进入执行。结构操作 compact()navigateTree() 也要求 idle,并使用不同 phase。于是,是否能并发调用某个 API,不再靠调用方约定,而是由 Harness 自己的 operation lock 收口。

Agent 也阻止第二个 prompt 与当前 run 重叠,但它的主要 owner 是 _state。Harness 的主要 owner 则是“当前操作怎样读取并修改一个持久 Session”。这就是为什么 Harness 没必要再包住 Agent:如果让 Agent 同时拥有 transcript、队列、事件 reduction 和 abort,再让 Harness 重做 Session、队列、Hook 和保存点,两个对象会争夺同一组生命周期节点。直接复用纯循环,边界反而更清楚。

这里有一个容易忽略的代价。Agent 已有一套经过产品使用的状态 reducer;Harness 直接调用 loop 后,就必须自己处理失败消息、message_end 落盘、turn_end 保存点、agent_end settlement 和队列更新。v0.83.0 的代码确实这么做了,但文档仍把更广的 listener/hook reentrancy、auto-compaction 决策等列为未完工作。不能因为调用链更短,就断言迁移已经结束。

稳定的 pi 命令到底用谁

答案在 pi-coding-agent 的 SDK factory,而不在 package 的导出列表。createAgentSession() 先创建 Agent,为它配置 convertToLlm、ModelRuntime 流函数、provider hooks、context transform、队列模式与 transport。旧会话存在时,它直接把 existingSession.messages 写回 agent.state.messages;新会话则先记录模型与 thinking level。最后,factory 把这个 agent 交给 new AgentSession()

AgentSession 的文件头也把自己定义为 interactive、print、RPC 共用的会话抽象,负责 Agent 状态访问、自动持久化、模型设置、compaction、bash 与树操作。换句话说,当前 CLI 的高层控制面叫 AgentSession,里面组合的是 Agent。仓库同时导出 AgentHarness,只证明 embedders 可以试用这条新路径,不证明 pi 已经换芯。

可以用两次只读搜索复核,不需要启动模型:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"

git -C "$repo" grep -n 'return await runAgentLoop(' v0.83.0 -- \
  packages/agent/src/agent.ts \
  packages/agent/src/harness/agent-harness.ts

git -C "$repo" grep -n 'new Agent({' v0.83.0 -- \
  packages/coding-agent/src/core/sdk.ts
git -C "$repo" grep -n 'new AgentHarness' v0.83.0 -- \
  packages/coding-agent/src || true

第一组应在 agent.tsagent-harness.ts 各命中一次;第二组在 SDK factory 命中 new Agent;最后一条在 coding-agent/src 没有命中。这里的 || true 只是让“预期无结果”的搜索不打断脚本,不是掩盖错误。若前两组缺少命中,说明读到的已不是本书固定版本。

这条分叉怎样影响后面的阅读

接下来五章主要分析 packages/agent/src/harness,因为它把快照、Session tree、compaction 和存储接口放在一个较小的边界里,适合把 Agent 运行时的状态问题讲透。但每个结论都会标明适用范围:Harness 中已经实现的行为,不自动等于 pi-coding-agent 的行为;Harness 设计文档里的未来行为,也不自动等于 Harness 当前实现。

最先要拆的是“快照”。AgentHarness 运行一条 prompt 时,并非从构造开始到结束都使用同一套模型、工具和资源;它在每个低层 assistant turn 前后建立边界。运行中修改配置为何不会污染已经发出的请求,又为什么能影响同一 run 的下一次请求,要看 createTurnState() 与保存点怎样配合。第 20 章从这里继续。