Theory · AI · Agent Runtime
从 earendil-works/pi 的 packages/agent 读懂一个生产级 Coding Agent 的运行时:双层循环 · 事件流 · 工具执行 · 人机协作 · 持久化恢复 —— minimal agent harness 的完整解剖
Agent 类 + agentLoop 双层 API;核心三文件(agent.ts 592 行 / agent-loop.ts 794 行 / types.ts 444 行)撑起全部运行时语义
错误编码为 stopReason=error 的 assistant 消息;UI 通过三层生命周期事件订阅;失败是数据,不是控制流
会话树 + 三存储不变式 + 效果三明治:进程随时被杀也能恢复,副作用绝不重复执行
Big Picture
作者 Mario Zechner(badlogic)· MIT · 100k+ stars(2026-09);"minimal agent harness"——每层独立 npm 包,分层即发布边界。
Core · 1/4
返回 EventStream<AgentEvent, AgentMessage[]>,for await 消费。事件顺序有保证,但不等待你的异步处理 settle——handler 慢不会拖住循环,但也做不了"工具预检前的屏障"。纯函数:context + config 进,事件流出,不持有状态。
for await (const event of agentLoop( [userMessage], context, config, signal, streamFn )) { console.log(event.type) }
拥有 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) |
Core · 2/4
| Message(pi-ai 定义) | 关键字段 |
|---|---|
| UserMessage | content: string | (Text|Image)[] + timestamp——文本与图片混排是公民参数 |
| AssistantMessage | content: (Text|Thinking|ToolCall)[] + api/provider/model/usage/stopReason + errorMessage?/deferred?/diagnostics?——自带溯源与结算 |
| ToolResultMessage | toolCallId/toolName + content + details(给 UI、不进 LLM)+ isError + usage? + addedToolNames? |
declare module "@earendil-works/pi-agent-core" { interface CustomAgentMessages { notification: { role: "notification"; text: string } } // declaration merging:编译期类型安全扩展 }
应用消息与 LLM 消息同存一个 transcript;convertToLlm 决定谁能见 LLM(默认只放行 user/assistant/toolResult)。
| StopReason | 循环行为 |
|---|---|
| stop | 自然停止 → 进入 follow-up 检查 |
| toolUse | 执行工具批,继续内层循环 |
| length | 输出被 token 上限截断 → 整批工具调用判失败 |
| error / aborted | 立即 turn_end + agent_end 退出 |
| deferred / pending | provider 侧延迟生成(DeferredHandle 轮询) |
Core · 3/4
Core · 4/4
Tools · 1/2
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) |
Tool not found)prepareArguments 兼容垫片(模型输出不规范时的 salvage)validateToolArguments schema 校验,失败 → 错误 toolResultbeforeToolCall 可 {block:true, reason} 拦截(权限闸门)Tools · 2/2
| 顺序语义 | 规则 |
|---|---|
| tool_execution_end | 按工具完成顺序发出(并行时先完成先报) |
| toolResult 消息事件 | 严格按 assistant 消息里的源顺序补发——LLM 看到的 transcript 顺序确定 |
| 混批规则 | 批内任一工具 executionMode=sequential → 整批退化为串行(写文件类工具的保护) |
| abort 响应 | 每个 toolCall 边界检查 signal.aborted,置位的批立即截断 |
并行工具写同一文件怎么办?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 上下文必须有序(源序)。
Control
| 钩子 | 时机 | 能力 |
|---|---|---|
| beforeToolCall | tool_execution_start 后、参数校验后、执行前 | {block:true, reason, terminate}——权限闸门;被拦的调用以错误 toolResult 呈现给 LLM |
| afterToolCall | 执行完成后、tool_execution_end 前 | 字段级覆盖 result(content/details/isError/usage/terminate),无深合并;审计与结果改写的挂点 |
| shouldStopAfterTurn | turn_end 后、队列轮询前、下次 LLM 调用前 | 返回 true → agent_end 优雅退出;不中止 provider 流、不取消运行中工具、不改 stopReason |
| prepareNextTurn | 下一轮 LLM 调用前 | 整体替换 context / model / thinkingLevel——轮间热切换模型、compaction 的官方挂点;prepare 完还会补拉一次 steering(防止压缩期间用户输入被饿死) |
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。
对照:MCP/LangChain 的 stop hooks 多为单工具即停——pi 的批语义更贴近"工具只是建议者"的定位。
Human-in-the-loop
| Steering(转向) | Follow-up(接力) | |
|---|---|---|
| 语义 | agent 正在干活时插话:"等等,改用方案 B" | agent 本该收工后追加:"接着做下一件事" |
| 检查时机 | 每个 turn 结束、工具批完成后(不跳过本轮工具) | 内层循环退出后:无 toolCall 且无 steering 时 |
| API | agent.steer(msg) | agent.followUp(msg) |
| QueueMode | 均为 "one-at-a-time"(默认,一次注一条)或 "all"(一次全注入) | |
| 驱动的循环 | 内层 while(有 steering → 继续当前工作流) | 外层 while(true)(follow-up → 从停止中"复活") |
从现有上下文恢复(如上次 provider 报错)。前置校验:末尾消息必须是 user 或 toolResult(assistant 结尾直接发起请求会被 provider 拒)。末尾恰好是 assistant 时:先 drain steering(跳过首次轮询)→ 再 drain follow-up → 都没有才 throw。
运行中再 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" 弹头元素。
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]})
continue() 可从错误现场直接重试全库一致的回调契约:convertToLlm / transformContext / getApiKey / shouldStopAfterTurn / 钩子们——注释里逐个写明 "must not throw or reject"。
Harness · 1/3
| Session 四部分 | 说明 |
|---|---|
| Entry tree(对话树) | entry = 消息/压缩/分支摘要/自定义条目,不可变、只追加;分支即线程,fork 并行工作且保留全史 |
| Facts | 可变、命名空间 KV(会话名、entry 标签、应用自定义) |
| Lanes(通道) | 树的命名游标,必有 main;持有自己的 leaf、模型配置、队列、至多一个 operation——Slack 线程、子 agent 各占一条 lane |
| Usage ledger | token/成本 append-only 台账 |
entries 对话树 · write-once,append-only registers 当前可变状态 · 命名空间类型化 KV,覆盖/删除 usage 成本历史 · append-only 行
每个 payload 必在三者之一,没有第三个地方。分支索引/全文搜索等投影可从三存储重建、不持有权威。原子事务(entry 插入 + usage 插入 + register 写,全有或全无、序列号严格递增)是唯一写原语——事务内不存在崩溃态。
每个 operation 的当前状态整体覆盖写在 op.state/{id} 一个寄存器里——状态是全量的(不依赖前一状态)。恢复不重放 journal、不从"缺了什么"推断位置:读一个寄存器、switch、继续。operation 结束的终止事务删掉全部 op.* 寄存器——没有待回收的死状态。
Harness · 2/3
Non-goals:exactly-once 外部效果做不到(crash 窗口必然存在)——策略显式化(never/safe);带副作用的 hooks 以 operation id 做幂等键。
Harness · 3/3
reserveTokens / keepRecentTokens,超限触发(shouldStopAfterTurn + prepareNextTurn 是官方挂点)<skill name location>…</skill>——名字+描述进列表,正文按需加载disable-model-invocation:只允许应用显式调用(lane.skill(name)),模型看不到agent-core 本身零 Node 依赖(node.ts 仅重导出);浏览器应用用 streamProxy(model, context, {authToken, proxyUrl}) 把 LLM 流量转发到自建代理——密钥不出服务端。SQLite 后端独立成包同理:核心包不携带原生依赖。
Interview QA
内层处理"模型 → 工具 → 插话"的工作流(条件:还有 toolCall 或 steering);外层只为 follow-up 队列存在——本该停止的 agent 被排队消息复活。两层分离让"停止"与"排队"两个语义互不纠缠,continue() 的恢复路径也更清晰。
预检(校验/钩子)串行收集执行闭包 → Promise.all 并发 → tool_execution_end 按完成序实时发(UI 不堵),toolResult 消息严格按 assistant 源序补发(LLM 上下文确定)。UI 可以乱、模型不能乱。
StreamFn 契约、convertToLlm、钩子、FileSystem/Shell 全部"must not throw":失败被编码成 stopReason=error 的消息或 Result<T,E> 值。控制流异常会穿透分层把状态机打散;值化的错误让 transcript 完整、UI 零特判、崩溃可恢复。与 Go 的 error 值语义同源。
输出被 token 上限截断时,流式 toolCall 参数经 best-effort JSON 补全可能"恰好合法但语义残缺"(比如 JSON 恰好闭合但少了一半参数)。宁可用错误 toolResult 让模型重发完整调用,也不执行可能错误的参数——LLM 系统里输入完整性无法事后验证,只能拒绝不确定的执行。
Interview QA · Harness
steering 在每个 turn 结束(工具批完成后)注入,驱动内层循环继续当前工作;follow-up 只在"无工具且无 steering"的停止点检查,驱动外层循环复活。都支持 one-at-a-time/all 两种消费模式。prompt() 运行中直接 throw——三条通道各司其职,无隐式行为。
every(finalized.terminate===true) 且非空才提前停:并行批里只要有一个工具还想继续,就必须让模型看到全部结果后自己决定。工具是建议者不是决策者;真要强制停用 shouldStopAfterTurn(turn 粒度)或 abort()(运行粒度),粒度分明。
每个外部效果包两次原子提交:意图 TX(预留结果/用量 ID + replay 声明)→ 执行 → 结算 TX。重启只读 op.state 一个全量寄存器 switch 继续而不重放日志。replay:never 的效果不重跑、在预留 ID 下补合成 interrupted 结果(对话每呼必应);replay:safe 的用持久化原参数重执行。ID 预留让"账目"与"效果"可对齐。
监听器按注册顺序 await,agent_end 之后监听器(如 flushSessionState 落盘)仍计入运行——prompt()/waitForIdle() 返回时一切已结算。低层 agentLoop 是观察性的(不等 handler),两层 API 的本质区别就在这道屏障。类比:HTTP handler 返回前 flush 日志 vs fire-and-forget。
Related
packages/agent/docs/harness.md(2942 行,存储/会话树/操作状态机/恢复的完整规范,本 deck P12-13 的出处)把 LLM 当不可靠的外部系统:用双层循环框住它的行为,用消息编码一切失败,用两次提交夹住一切副作用——agent 工程的其余部分都是产品化。
键盘操作:←→ 翻页 · T 换主题 · S 演讲者模式 · O 总览。