Theory · AI · Agent Runtime

pi Agent:极简 Agent Runtime 源码解析

从 earendil-works/pi 的 packages/agent 读懂一个生产级 Coding Agent 的运行时:双层循环 · 事件流 · 工具执行 · 人机协作 · 持久化恢复 —— minimal agent harness 的完整解剖

Agent Runtime 核心

Agent 类 + agentLoop 双层 API;核心三文件(agent.ts 592 行 / agent-loop.ts 794 行 / types.ts 444 行)撑起全部运行时语义

一切皆消息、皆事件

错误编码为 stopReason=error 的 assistant 消息;UI 通过三层生命周期事件订阅;失败是数据,不是控制流

Harness 持久化

会话树 + 三存储不变式 + 效果三明治:进程随时被杀也能恢复,副作用绝不重复执行

pi 是 badlogic(Mario Zechner,libGDX 作者)公司 earendil-works 的开源项目(MIT,100k+ stars),口号是 minimal agent harness——"Adapt Pi to your workflows, not the other way around"。这份 deck 聚焦用户态最值得读的 packages/agent:上半部讲 runtime(Agent/agentLoop/工具/事件/队列),下半部讲 harness 持久化(三存储/效果三明治/崩溃恢复)。对 Go 后端面试的价值:它的并发控制、幂等、崩溃恢复设计跟数据库与分布式系统是同一套思想。

Big Picture

pi monorepo:四层分工,packages/agent 是发动机

pi monorepo 四层架构 pi 分四层:底层 pi-ai 统一 LLM API;其上 pi-agent-core 提供 Agent 与 agentLoop 运行时;再上是 harness 层(会话树、压缩、工具、技能);顶层是 pi-coding-agent CLI 与 pi-tui。packages/agent 包含 agent-core 与 harness 两层,右侧有 pi-telemetry 与 SQLite 会话后端两个独立包。 PACKAGES/AGENT · 本 deck 范围 L4 · PRODUCT L3 · HARNESS L2 · RUNTIME L1 · LLM API AgentLane API runAgentLoop StreamFn CLI pi-coding-agent extensions · skills · session TUI pi-tui 差分渲染终端 UI HARNESS AgentHarness(持久化运行时) 会话树 · lanes · compaction · skills · bash/read/edit/write RUNTIME pi-agent-core:Agent + agentLoop 工具执行 · 事件流 · steering/followUp · proxy PI-AI pi-ai:统一多 Provider LLM API Message · EventStream · Models · anthropic/openai/google… pi-telemetry 厂商中立遥测契约 session-backend-sqlite SQLite 会话后端(独立包) LEGEND 本 deck 焦点 monorepo 内包 独立发布 / 可选 依赖方向(上层依赖下层)

作者 Mario Zechner(badlogic)· MIT · 100k+ stars(2026-09);"minimal agent harness"——每层独立 npm 包,分层即发布边界。

先立全局:四层自下而上是 pi-ai(把 anthropic/openai/google 的消息与流差异归一成 Message + AssistantMessageEvent 协议)、pi-agent-core(无状态的 agentLoop + 有状态的 Agent 类)、harness(会话树/持久化/工具/skills,也在 packages/agent 里)、coding-agent + tui(产品)。读源码顺序建议自下而上。packages/agent 同时含 L2 与 L3,是本 deck 范围。

Core · 1/4

两层 API:agentLoop(低层流)与 Agent(有状态封装)

agentLoop / agentLoopContinue · 观察性流

返回 EventStream<AgentEvent, AgentMessage[]>for await 消费。事件顺序有保证,但不等待你的异步处理 settle——handler 慢不会拖住循环,但也做不了"工具预检前的屏障"。纯函数:context + config 进,事件流出,不持有状态。

for await (const event of agentLoop(
  [userMessage], context, config, signal, streamFn
)) { console.log(event.type) }

Agent 类 · 有状态 + 屏障语义

拥有 transcript 与运行时状态;subscribe() 监听器按注册顺序被 await,全部 settle 后运行才算结束——prompt()/waitForIdle() 等到的是"含收尾工作(如落盘)"的完成。agent_end 是最后一个事件,但不是 idle 的时刻。

const agent = new Agent({ initialState, streamFn });
agent.subscribe(async (event, signal) => { /* 可异步落盘 */ });
await agent.prompt("Hello!");  // 屏障:所有 listener settle 才返回
AgentState 字段语义与陷阱
systemPrompt / model / thinkingLevel每次 LLM 请求携带;运行中可直接改,影响"未来轮次"(配合 prepareNextTurn 还能轮间热切换)
tools / messages赋值时 setter 复制顶层数组slice())再存储——防外部持有引用并发改;但取出后原地改会直接影响 agent 状态
isStreaming / streamingMessage流式期间持有当前部分 assistant 消息;isStreaming 直到 awaited agent_end 监听器全部 settle 才 false
pendingToolCalls / errorMessage执行中工具 id 集合 / 最近一次失败信息;prompt() 在运行中再调用直接 throw(排队请用 steer/followUp)
为什么两层:低层流适合"只看不管"的场景(日志、转发),Agent 类适合 UI 与产品(需要落盘屏障、需要打断与排队)。这个取舍很像 HTTP client 的"裸连接 vs 带连接池的 Client"。面试可以类比:EventStream 是迭代器模式的拉模型,Agent 是在其上加了状态机与 barrier 的会话对象。

Core · 2/4

消息模型:应用层富类型,LLM 边界收敛

Message(pi-ai 定义)关键字段
UserMessagecontent: string | (Text|Image)[] + timestamp——文本与图片混排是公民参数
AssistantMessagecontent: (Text|Thinking|ToolCall)[] + api/provider/model/usage/stopReason + errorMessage?/deferred?/diagnostics?——自带溯源与结算
ToolResultMessagetoolCallId/toolName + content + details(给 UI、不进 LLM)+ isError + usage? + addedToolNames?

AgentMessage = Message ∪ 自定义类型

declare module "@earendil-works/pi-agent-core" {
  interface CustomAgentMessages {
    notification: { role: "notification"; text: string }
  }  // declaration merging:编译期类型安全扩展
}

应用消息与 LLM 消息同存一个 transcript;convertToLlm 决定谁能见 LLM(默认只放行 user/assistant/toolResult)。

每次 LLM 调用前的两段管道

AgentMessage[] transformContext() convertToLlm() Message[] → LLM
  • transformContext(可选):修剪旧消息、压缩、注入上下文——工作在 AgentMessage 层
  • convertToLlm(必选):过滤 UI 消息、把自定义类型翻译成 user 消息
  • 两者契约都禁止 throw:失败也要返回安全回退值
StopReason循环行为
stop自然停止 → 进入 follow-up 检查
toolUse执行工具批,继续内层循环
length输出被 token 上限截断 → 整批工具调用判失败
error / aborted立即 turn_end + agent_end 退出
deferred / pendingprovider 侧延迟生成(DeferredHandle 轮询)
两个易考细节:① details 字段是"给人和 UI 看的",LLM 只见 content——工具可以返回巨大结构化 details 而不烧 token;② StopReason 不只是 stop/toolUse 二元,length 的"截断即全批失败"是安全设计:流式 JSON 补全解析出的参数可能"恰好合法但语义残缺",宁可直接让模型重发。deferred 是 provider 批处理/延迟输出的句柄。

Core · 3/4

runLoop:双层 while,turn 是基本心跳

pi agent 主循环流程图 pi 的 runLoop 主循环:发出 agent_start 与 turn_start 后进入内层循环——注入排队消息、流式请求 LLM、若 stopReason 为 error 或 aborted 直接发 agent_end 退出;若有工具调用则执行工具批并发出 turn_end;shouldStopAfterTurn 为真则提前结束;否则轮询 steering 与 follow-up 队列,有消息则回到注入步骤,没有则发 agent_end 结束。 外层 while(true) · FOLLOW-UP 复活循环 内层 while(toolCalls || steering) · 工作循环 Yes · 注入下轮 No · 0 个 Yes · 早退 Yes · 优雅停 No Yes No No prompt() emit agent_start · turn_start 注入 pending 消息 message_start/end × N LLM 流式响应(一个 turn) transformContext → convertToLlm → StreamFn stopReason = error / aborted ? 有 toolCall ? 执行工具批 length 截断 → 整批 fail 重发 emit turn_end shouldStopAfterTurn ? 轮询 steering → follow-up QueueMode: all / one-at-a-time 有排队消息 ? agent_end LOOP INVARIANTS · turn = 一次 LLM 调用 + 工具批 · 循环开始也 poll steering · steering 不跳过本轮工具 · continue() 须末尾 user/toolResult · error/abort → 立即退出
对照源码讲:内层 while 的条件是 hasMoreToolCalls || pendingMessages.length>0;外层 while(true) 唯一的继续条件是 follow-up 队列非空。turn_end 之后 shouldStopAfterTurn 检查在 steering poll 之前——所以"上下文快满了想优雅停"不会被 steering 打断。prepareNextTurn 在 lastCompletedTurn 存在时、下一轮 LLM 调用前执行,可整体替换 context/model/thinkingLevel(compaction 的挂点)。图上橙色回边是队列驱动的"复活"路径。

Core · 4/4

事件流:三层生命周期,订阅即渲染

pi agent 一次 prompt 的事件流时序 使用方调用 prompt 后,Agent 循环发出 agent_start 与 turn_start,user 消息事件,向 LLM 流式请求并转发 message_start、message_update、message_end;模型产生工具调用后调用工具 execute,工具流式上报进度,结果以 tool_execution_end 与 toolResult 消息事件发出;随后发起下一轮请求直到模型停止,最后发出 turn_end 与 agent_end。 使用方 (UI) Agent / agentLoop LLM Provider Tool prompt("…") agent_start · turn_start message_start/end (user) streamSimple(model, context) start · text_delta ×N · done message_start · update ×N · end execute(toolCallId, args, signal, onUpdate) onUpdate → tool_execution_update AgentToolResult(throw → isError) tool_execution_end · message(toolResult) 下一轮请求 [循环 · 直到无 toolCall] turn_end {message, toolResults} agent_end {messages} 屏障:agent_end 是最后一个事件,但运行未结束——awaited subscribers(如落盘)全部 settle 后,waitForIdle() / prompt() 才返回 仅 assistant 消息有 message_update(流式 delta);user / toolResult 消息只有 start + end 一对
三层事件:agent(一次 prompt 运行)、turn(一次 LLM 调用+工具批)、message/tool_execution(消息与工具粒度)。UI 只需要 subscribe 一个入口就能渲染全部状态——"事件即 UI 协议"。streamAssistantResponse 的实现细节:partial assistant 消息在 context.messages 里原地替换(start 时 push,delta 时替换最后一个,done 时替换为 final),对外 emit 时浅拷贝 {...partialMessage} 防泄漏内部引用。

Tools · 1/2

工具:typebox 声明,错误靠抛,进度靠推

const tool: AgentTool<typeof params> = {
  name: "get_current_time",
  label: "当前时间",          // UI 显示名
  description: "获取指定时区当前时间",
  parameters: Type.Object({       // typebox → JSON Schema
    timezone: Type.String(),
  }),
  executionMode: "parallel",      // 可选:per-tool 覆盖
  async execute(toolCallId, params, signal, onUpdate) {
    onUpdate({ content: [{type:"text",text:"查询中…"}], details:{} })
    if (!valid(params.timezone)) throw new Error("bad tz")
    return { content: [{type:"text",text:now()}], details: { tz } }
  },
}
execute 契约设计意图
失败 = throw异常被循环捕获 → isError:true 的 toolResult 回给 LLM;禁止把错误文本当 content 返回(会污染语义、丢错误标记)
onUpdate 推进度工具执行中流式上报 partialResult → tool_execution_update;promise settle 后的迟到回调被 acceptingUpdates 忽略
content / details 分离content 给 LLM(文本+图片);details 给 UI/日志——大结构化结果不烧 token
addedToolNames工具结果可"解锁"新工具(原生 deferred tool loading 的 load point)

调用前防线(prepare 阶段,逐个串行)

  • 找不到工具 → 立即错误 toolResult(Tool not found
  • prepareArguments 兼容垫片(模型输出不规范时的 salvage)
  • validateToolArguments schema 校验,失败 → 错误 toolResult
  • beforeToolCall{block:true, reason} 拦截(权限闸门)
typebox 的好处:一份 Type.Object 同时给出 TS 静态类型(Static<T>)与 JSON Schema(给 LLM 的 parameters),声明即双用。错误设计是本页灵魂:错误从异常通道走,数据从返回值走,两条通道不混——所以 agent 永远不会把"工具失败了"误当成"工具成功返回了失败描述"。

Tools · 2/2

并行工具执行:预检串行、执行并发、消息归位

① 串行预检每个 toolCall ② 校验 + beforeToolCall + block 判定 ③ 允许的工具 Promise.all 并发 ④ afterToolCall 逐个 finalize ⑤ 按源顺序 emit toolResult 消息
顺序语义规则
tool_execution_end工具完成顺序发出(并行时先完成先报)
toolResult 消息事件严格按 assistant 消息里的源顺序补发——LLM 看到的 transcript 顺序确定
混批规则批内任一工具 executionMode=sequential → 整批退化为串行(写文件类工具的保护)
abort 响应每个 toolCall 边界检查 signal.aborted,置位的批立即截断

file-mutation-queue:同路径写串行化

并行工具写同一文件怎么办?harness 用 promise chain 互斥锁:以 canonicalPath 为 key,后续写请求 chain 到前一个 promise 之后;key 先经 absolutePath → canonicalPath 归一(软链/相对路径攻击面收敛)。WeakMap 按 ExecutionEnv 隔离,锁注册本身也串行(registration promise)。

// executeToolCallsParallel(简化)
const finalized = [];
for (const tc of toolCalls) {          // ① 预检串行
  emit({type:"tool_execution_start", …})
  const prep = await prepareToolCall(…)   // ② 校验/钩子/block
  if (prep.kind === "immediate") { … continue }
  finalized.push(async () => {           // ③ 延迟并发单元
    const r = await executePreparedToolCall(prep, …)
    const f = await finalizeExecutedToolCall(…)  // ④ afterToolCall
    emit({type:"tool_execution_end", …})      //   完成序
    return f
  })
}
const done = await Promise.all(finalized.map(f => f()))
for (const f of done)               // ⑤ 源顺序补发消息
  emitToolResultMessage(createToolResultMessage(f))

设计母题:并发做 IO,串行保语义——UI 事件允许乱序(完成序),LLM 上下文必须有序(源序)。

为什么预检必须串行:beforeToolCall 钩子可能做全局判定(如"当前是否允许写"),并发跑会引入竞态;且 emit tool_execution_start 的顺序即用户看到的启动顺序,串行预检让它稳定等于源顺序。执行阶段的并发单元是闭包数组 Promise.all——与 Go 的 errgroup 语义对照:每个单元内部自捕获错误转 isError,绝不 reject 出去打断兄弟单元。

Control

控制钩子全景与 terminate 的"全称才停"

钩子时机能力
beforeToolCalltool_execution_start 后、参数校验后、执行前{block:true, reason, terminate}——权限闸门;被拦的调用以错误 toolResult 呈现给 LLM
afterToolCall执行完成后、tool_execution_end 前字段级覆盖 result(content/details/isError/usage/terminate),无深合并;审计与结果改写的挂点
shouldStopAfterTurnturn_end 后、队列轮询前、下次 LLM 调用前返回 true → agent_end 优雅退出;不中止 provider 流、不取消运行中工具、不改 stopReason
prepareNextTurn下一轮 LLM 调用前整体替换 context / model / thinkingLevel——轮间热切换模型、compaction 的官方挂点;prepare 完还会补拉一次 steering(防止压缩期间用户输入被饿死)

terminate:批语义而非调用语义

function shouldTerminateToolBatch(calls) {
  return calls.length > 0 &&
    calls.every(c => c.result.terminate === true)
}   // 全部 terminate 才提前停;混合批正常继续

三个来源都可置 terminate:工具自身、被 block 的 beforeToolCall、afterToolCall 覆盖。runtime 仅把它当提示(hint)——跳过自动后续 LLM 调用,但 transcript 里仍是标准 tool result。

为什么"全称才停"?

  • 并行批里 A 工具说"任务完成"、B 工具说"还要继续"——语义上只有模型能看到全部结果后才有资格裁决
  • 任何单个工具都能单方面掐死循环 = 把控制权从模型手里抢走,破坏 agent 语义
  • 真想强制停:用 shouldStopAfterTurn(turn 粒度)或 abort()(运行粒度)

对照:MCP/LangChain 的 stop hooks 多为单工具即停——pi 的批语义更贴近"工具只是建议者"的定位。

面试高频追问:钩子们的执行顺序。一次工具调用的完整链:tool_execution_start → beforeToolCall → execute(+onUpdate→tool_execution_update) → afterToolCall → tool_execution_end → message(toolResult) → [批结束] turn_end → shouldStopAfterTurn → prepareNextTurn → 下轮。把这条链背下来,pi 的控制流就通了。

Human-in-the-loop

Steering 与 Follow-up:人在环上的两条注入通道

Steering(转向)Follow-up(接力)
语义agent 正在干活时插话:"等等,改用方案 B"agent 本该收工后追加:"接着做下一件事"
检查时机每个 turn 结束、工具批完成后(不跳过本轮工具内层循环退出后:无 toolCall 且无 steering 时
APIagent.steer(msg)agent.followUp(msg)
QueueMode均为 "one-at-a-time"(默认,一次注一条)或 "all"(一次全注入)
驱动的循环内层 while(有 steering → 继续当前工作流)外层 while(true)(follow-up → 从停止中"复活")

continue():不新增消息的重试入口

从现有上下文恢复(如上次 provider 报错)。前置校验:末尾消息必须是 user 或 toolResult(assistant 结尾直接发起请求会被 provider 拒)。末尾恰好是 assistant 时:先 drain steering(跳过首次轮询)→ 再 drain follow-up → 都没有才 throw

prompt() 不排队

运行中再 prompt() 直接 throw "Agent is already processing"——逼你显式选择语义:打断(abort)、插话(steer)还是排队(followUp)。API 设计上消灭隐式行为。

// 人机协作典型流(CLI 编码助手场景)
agent.subscribe(ev => { /* TUI 渲染 */ })

await agent.prompt("重构这个模块")  // 开始干活

// 用户在工具执行期间又输入了:
agent.steer({ role: "user", content: [{type:"text",
  text: "顺便把测试也补上"}], timestamp: Date.now() })
// → 本轮工具跑完 → 注入 → 下一轮 LLM 同时看到新指令

agent.followUp({ role: "user", …, text: "然后跑一遍 CI" })
// → agent 打算停时发现有接力 → 继续下一轮

队列实现仅 35 行(PendingMessageQueue):push / hasItems / drain(mode) / clear——drain 按 QueueMode 弹出,"one-at-a-time" 弹头元素。

这两个队列是 coding agent 交互体验的关键:Claude Code / pi CLI 里"任务跑着还能打字"就是 steering;"任务结束接着下一句"就是 follow-up。低层 config 里对应 getSteeringMessages/getFollowUpMessages 两个回调,Agent 类把它们接到自己的队列上——低层无状态、高层有状态,又一次分层。

Philosophy

失败是数据,不是控制流

防线机制
① StreamFn 契约请求/模型/运行时失败禁止 throw,必须编码进返回的流:AssistantMessageEvent{type:"error"} + 终态消息 stopReason:"error"|"aborted" + errorMessage
② 循环内错误降级工具 throw → isError toolResult;校验失败 → 错误 toolResult;钩子 throw → 该工具判错,循环不崩
③ Agent.handleRunFailure循环自身异常 → 合成一条 assistant 错误消息(EMPTY_USAGE)→ 补齐 message_start/end + turn_end + agent_end 完整事件序列
④ harness Result<T,E>FileSystem / Shell 全部方法永不 reject:期望内失败返回 {ok:false, error:FileError(code,…)},code 是稳定枚举
// agent.ts · handleRunFailure(简化)
const failureMessage = {
  role: "assistant",
  content: [{ type: "text", text: "" }],
  stopReason: aborted ? "aborted" : "error",
  errorMessage: err.message,      // 失败原因进消息
  usage: EMPTY_USAGE, …
}
await emit({type:"message_start",  message: failureMessage})
await emit({type:"message_end",    message: failureMessage})
await emit({type:"turn_end", message, toolResults: []})
await emit({type:"agent_end", messages: [failureMessage]})

为什么值得学

  • transcript 完整:错误也是一条消息 → 会话历史不缺角,continue() 可从错误现场直接重试
  • UI 零特判:渲染层不需要 try/catch 分支——错误和正常消息走同一条事件管道
  • 可持久化:错误进 entry tree,崩溃重启后现场仍在(见 P12-13)
  • 可观测:diagnostics 字段带脱敏的 provider 诊断信息

全库一致的回调契约:convertToLlm / transformContext / getApiKey / shouldStopAfterTurn / 钩子们——注释里逐个写明 "must not throw or reject"。

跟 Go 对照着讲最出彩:Go 用 error 返回值 + panic/recover 边界,pi 用 Result 返回值 + 异常只做"不可恢复信号"且在系统边界统一转译成数据。两者共同的洞见:控制流异常(control-flow exceptions)在长生命周期系统里会穿透抽象层,把状态机打散;把失败编码为值/消息,状态机永远闭合。Kafka 的错误编码进 record、PostgreSQL 的事务失败进 clog,同理。

Harness · 1/3

AgentHarness:把 agent 变成可恢复的持久系统

Session 四部分说明
Entry tree(对话树)entry = 消息/压缩/分支摘要/自定义条目,不可变、只追加;分支即线程,fork 并行工作且保留全史
Facts可变、命名空间 KV(会话名、entry 标签、应用自定义)
Lanes(通道)树的命名游标,必有 main;持有自己的 leaf、模型配置、队列、至多一个 operation——Slack 线程、子 agent 各占一条 lane
Usage ledgertoken/成本 append-only 台账

三存储不变式(一切设计的地基)

entries    对话树      · write-once,append-only
registers  当前可变状态 · 命名空间类型化 KV,覆盖/删除
usage      成本历史    · append-only 行

每个 payload 必在三者之一,没有第三个地方。分支索引/全文搜索等投影可从三存储重建、不持有权威。原子事务(entry 插入 + usage 插入 + register 写,全有或全无、序列号严格递增)是唯一写原语——事务内不存在崩溃态。

持久化程序计数器

每个 operation 的当前状态整体覆盖写在 op.state/{id} 一个寄存器里——状态是全量的(不依赖前一状态)。恢复不重放 journal、不从"缺了什么"推断位置:读一个寄存器、switch、继续。operation 结束的终止事务删掉全部 op.* 寄存器——没有待回收的死状态。

后端与边界

  • 后端:memory(测试)/ JSONL(文件)/ SQLite(独立包,fenced lease 保证单进程写)
  • Non-goals:exactly-once 外部效果、provider 流恢复、多写者、复制——单会话单进程,并行靠 lanes
这一页是"agent 界的数据库设计"。lane 是最容易被低估的抽象:Slack 里一个频道 400 条历史、每开一个 thread 就 createLane 锚定当前 leaf——多条 lane 在同一棵树上并行工作互不干扰,天然支持子 agent。对比 Claude Code 的 session 文件:pi 的树+lane 是结构化多线程,不是线性追加。

Harness · 2/3

效果三明治:把不确定性关进两次提交之间

效果三明治与崩溃恢复 Harness 每产生外部效果(provider 请求或工具调用)都包裹两次原子提交:先提交意图(预留结果 ID),执行外部效果,再提交结算。进程在任意两次提交间被杀,重启后读取 op.state 寄存器即可恢复:声明不可重放的效果补一条合成中断结果,声明可安全重放的只读效果用持久化参数重新执行。 Harness Storage (TX) Provider / Tool TX① 接受:user entry + op.meta + op.state=checkpoint TX② 意图:op.state=effect_pending(预留 entry/usage ID) UNCERTAIN WINDOW · 不确定窗口 provider 请求 / 工具执行(流式,非持久) 结果回来(可能已计费 / 副作用已发生) TX③ 结算:insert entry n2 + usage u1 + op.state=下一步 每个外部效果一对 TX · ID 在意图期铸好,结算期原位落账 CRASH RECOVERY · 任意两个 TX 之间被杀 进程重启 不确定窗口内死掉最棘手 读 op.state/{id} 一个寄存器 全量程序计数器 · 不重放 journal switch 状态继续 每个 tool call 先声明 replay 策略 replay:"never"(写副作用,如删文件) 不重跑 —— 在预留的结果 ID 下补一条合成"interrupted"错误结果,对话保持每呼必应,零重复执行 replay:"safe"(幂等只读,如查询) 用意图事务里持久化的原样参数重新执行 —— 代价是多一次调用,收益是结果确定性

Non-goals:exactly-once 外部效果做不到(crash 窗口必然存在)——策略显式化(never/safe);带副作用的 hooks 以 operation id 做幂等键。

这是全 deck 最"后端"的一页:两阶段提交的影子(intent 记录在先、settlement 在后),WAL 的影子(op.state 就是 checkpoint),幂等键的影子(预留 ID + replay 策略),Outbox 的影子(结果与状态同一事务落账)。worked example:删迁移文件的工具执行中被 kill——重启读到 effect_pending + replay:never,不重删,补 interrupted 结果,继续 call 1,"conversation stays coherent, nothing ran twice"。

Harness · 3/3

上下文工程:Compaction、Skills、能力接口

Compaction · 上下文压缩

  • 设置:reserveTokens / keepRecentTokens,超限触发(shouldStopAfterTurn + prepareNextTurn 是官方挂点)
  • 旧消息 → LLM 总结成 CompactionEntry(替换 provider 上下文,不改存储——entry 树永不删)
  • 压缩时追踪文件操作(readFiles / modifiedFiles),并从上一代 compaction 继承——摘要不丢"我改过哪些文件"
  • retainedTail:最近消息原样保留,只有更早的被总结
  • fork 分支另有 branch-summarization

Skills · 技能注入

  • SKILL.md + YAML frontmatter(name ≤64 / description ≤1024),遵循 agentskills.io 规范
  • 目录递归发现,遵守 .gitignore/.ignore
  • 系统提示注入 XML 块:<skill name location>…</skill>——名字+描述进列表,正文按需加载
  • disable-model-invocation:只允许应用显式调用(lane.skill(name)),模型看不到

ExecutionEnv · 能力接口

  • 内置工具(read/write/edit/bash)不直接碰 fs/child_process,全走 FileSystem + Shell 接口
  • 方法级 Result 错误码(not_found / permission_denied / timeout…),后端可替换:Node / 浏览器 / 沙箱
  • pi 不内置权限系统——官方姿势是容器化:Gondolin 微虚机(宿主认证+VM 内跑工具)/ Docker / OpenShell 策略沙箱
  • edit 工具带 diff 引擎(edit-diff.ts 500 行)与文件变更队列

浏览器与代理部署

agent-core 本身零 Node 依赖(node.ts 仅重导出);浏览器应用用 streamProxy(model, context, {authToken, proxyUrl}) 把 LLM 流量转发到自建代理——密钥不出服务端。SQLite 后端独立成包同理:核心包不携带原生依赖。

compaction 的精髓:改变的是"provider 看到什么",不是"存储里有什么"——所以压缩永远可逆、可审计、可重放。skills 是 prompt 工程的结构化:与其塞一个巨大 system prompt,不如给模型一张"技能目录"(name+description+location),用的时候才展开正文——跟懒加载一个道理。

Interview QA

高频 QA · Runtime:循环、并发、错误处理

Q1 · agent loop 为什么是双层 while?

工作循环复活循环停止语义分离

内层处理"模型 → 工具 → 插话"的工作流(条件:还有 toolCall 或 steering);外层只为 follow-up 队列存在——本该停止的 agent 被排队消息复活。两层分离让"停止"与"排队"两个语义互不纠缠,continue() 的恢复路径也更清晰。

Q2 · 工具并发执行,LLM 看到的顺序怎么保证?

两阶段完成序 vs 源序上下文确定性

预检(校验/钩子)串行收集执行闭包 → Promise.all 并发 → tool_execution_end 按完成序实时发(UI 不堵),toolResult 消息严格按 assistant 源序补发(LLM 上下文确定)。UI 可以乱、模型不能乱。

Q3 · 为什么全库回调都禁止 throw?

错误即数据状态机闭合统一处理面

StreamFn 契约、convertToLlm、钩子、FileSystem/Shell 全部"must not throw":失败被编码成 stopReason=error 的消息或 Result<T,E> 值。控制流异常会穿透分层把状态机打散;值化的错误让 transcript 完整、UI 零特判、崩溃可恢复。与 Go 的 error 值语义同源。

Q4 · stopReason=length 为什么整批工具判失败?

截断参数JSON salvage安全优先

输出被 token 上限截断时,流式 toolCall 参数经 best-effort JSON 补全可能"恰好合法但语义残缺"(比如 JSON 恰好闭合但少了一半参数)。宁可用错误 toolResult 让模型重发完整调用,也不执行可能错误的参数——LLM 系统里输入完整性无法事后验证,只能拒绝不确定的执行。

答题套路:每个问题先给"一句结论",再落到源码锚点(文件+函数),最后举一个数字或对照(如 Go errgroup)。前四题覆盖循环结构、并发、错误处理三大 runtime 主题。

Interview QA · Harness

高频 QA · Harness:人机协作与崩溃恢复

Q5 · steering 与 follow-up 的本质区别?

注入点内层 vs 外层插话 vs 接力

steering 在每个 turn 结束(工具批完成后)注入,驱动内层循环继续当前工作;follow-up 只在"无工具且无 steering"的停止点检查,驱动外层循环复活。都支持 one-at-a-time/all 两种消费模式。prompt() 运行中直接 throw——三条通道各司其职,无隐式行为。

Q6 · terminate 为什么批内全称才停?

批语义模型裁决权提示非命令

every(finalized.terminate===true) 且非空才提前停:并行批里只要有一个工具还想继续,就必须让模型看到全部结果后自己决定。工具是建议者不是决策者;真要强制停用 shouldStopAfterTurn(turn 粒度)或 abort()(运行粒度),粒度分明。

Q7 · 崩溃恢复怎么保证副作用零重复?

效果三明治op.statereplay 策略

每个外部效果包两次原子提交:意图 TX(预留结果/用量 ID + replay 声明)→ 执行 → 结算 TX。重启只读 op.state 一个全量寄存器 switch 继续而不重放日志。replay:never 的效果不重跑、在预留 ID 下补合成 interrupted 结果(对话每呼必应);replay:safe 的用持久化原参数重执行。ID 预留让"账目"与"效果"可对齐。

Q8 · Agent 类的 subscribe 为什么逐个 await?

屏障语义agent_end ≠ idle收尾入运行

监听器按注册顺序 await,agent_end 之后监听器(如 flushSessionState 落盘)仍计入运行——prompt()/waitForIdle() 返回时一切已结算。低层 agentLoop 是观察性的(不等 handler),两层 API 的本质区别就在这道屏障。类比:HTTP handler 返回前 flush 日志 vs fire-and-forget。

后四题覆盖人机协作与持久化恢复——把 pi 讲成「WAL + 两阶段提交 + 幂等键」的后端故事。八题合起来足够撑起一轮「讲一个你读过的开源架构」的深挖。

Related

相关知识点与参考资料

同库关联(思想同构)

参考资料(2026-09 阅读)

  • 源码:github.com/earendil-works/pi · packages/agent(main 分支,agent-core v0.84.4)
  • 实现规范:packages/agent/docs/harness.md(2942 行,存储/会话树/操作状态机/恢复的完整规范,本 deck P12-13 的出处)
  • 官网与文档:pi.dev(minimal agent harness 定位、extensions/skills 文档)
  • 核心源文件:agent.ts(592 行)/ agent-loop.ts(794 行)/ types.ts(444 行)/ harness/(session·compaction·tools·skills)

一句话带走

把 LLM 当不可靠的外部系统:用双层循环框住它的行为,用消息编码一切失败,用两次提交夹住一切副作用——agent 工程的其余部分都是产品化。

键盘操作: 翻页 · T 换主题 · S 演讲者模式 · O 总览。

源码阅读时点:2026-09-01,基于 main 分支(agent-core v0.84.4)。pi 迭代很快(5800+ commits),语义层面(双层循环/三存储/效果三明治)相对稳定,但字段与选项名可能随后续版本演进——引用时以当时源码为准。