← 返回首页

一个最简 Coding Agent 的完整解剖——结合 pi 的架构与真实会话数据

希望了解最小化的coding agent是什么样的。于是整理了 pi(@earendil-works/pi-coding-agent)的源码和它留在本机的真实会话数据(~/.pi/agent/sessions/ 下的 sessions.jsonl)。这篇把两样东西合在一起:用源码结构讲架构,用会话数据讲这架机器实际转起来是什么样子。文中的会话片段都取自真实运行记录,路径、ID、仓库名做了泛化,结构原样保留。

自勉:

应该通过一手信息、而不是别人/ai总结的信息学习。

先整体说清楚:一个 Coding Agent 必不可少的几部分

在拆细节之前,先用几段话把"必不可少"讲透,后面各节只是把这几段展开。

第一,是一个消息模型和围绕它的主循环。模型本身是无状态的,它每轮看到的"世界"就是你发给它的全部消息。所谓 agent,就是由你来维护这份消息列表、替模型跑命令和改文件、再把结果喂回去的那个程序。主循环的伪代码只有十几行:调用 LLM,如果回复里有工具调用就执行并把结果追加进历史,没有就结束本轮。会话历史不是"聊天记录"这么简单,它是 agent 唯一的工作记忆,后面的一切——持久化、恢复、分支、压缩——都是围绕它长的。

第二,是工具层。工具是模型的手和脚,每个工具本质上就是三样东西:一个 JSON Schema 参数声明(给模型看的说明书)、一个执行函数、一段给 system prompt 用的使用说明。对 coding agent 来说,read/bash/edit/write 四个就够干活了,其中 bash 是灵魂——任何你没做成专用工具的能力,模型都能通过 bash 完成,它的通用性正是它的价值。

第三,是Provider 层。内部只使用你自己的统一消息格式,由各个 provider 负责把它翻译成不同模型 API 的请求、再把流式响应拼回来。主循环不应该知道自己面前坐着的是哪个模型。这层还藏着两个容易被低估的东西:模型元数据注册表(上下文窗口、最大输出、每模型的兼容性开关),和重试策略——哪些错误该重试、退避多久、什么时候该把失败交还给用户,自己从零写 agent 时最先被真实世界教育的地方就在这里。

第四,是会话管理与上下文经营。会话要落盘(append-only JSONL 是被验证过的好格式)、要能恢复、要在逼近上下文窗口时压缩。尤其是压缩:例如可以把前面的历史总结成摘要替换掉旧消息,本质是在"会话续航"和"细节永久丢失"之间做交易,摘要保留关键事实比泛泛经过描述重要得多。

第五,是System Prompt 与权限安全。前者决定模型"是谁":工作环境、工具惯例、项目级指令(AGENTS.md)都从这里注入,而且前缀保持稳定才能命中 prompt caching。后者不是可选项:一个能随意执行 shell 命令的程序,需要目录信任、工具拦截点这两道防线加输出截断这半个防线,还要防文件内容里藏的提示注入。

把这五块拼起来,就是一个完整的、最小的、能用的 coding agent。下面这张分层图是全文的地图,每一层都能点开看细节,后面两节还会用同样风格的图拆开消息模型和主循环。

架构图Coding Agent 分层总览:核心很小,设施环绕
前端层(可整体替换)tui · print · rpc · sdk
只订阅事件做渲染,不含任何业务逻辑——所以同一个核心能配四种完全不同的前端。
四种运行模式
交互式 TUI(日常使用)、pi -p 脚本打印模式(管道 / CI)、RPC 模式(进程集成)、SDK 模式(嵌入别的应用)。核心循环一行不改。
订阅事件
事件总线event-bus
核心只负责喊"发生了一件事",谁来听、怎么呈现是订阅者的事。
真实事件流
agent_start → (turn_startmessage_start/message_update/message_endtool_execution_start/tool_execution_endturn_end)× N → agent_endagent_settled。steering(转向消息)不打断循环,排队插入。
驱动 / 被驱动
核心循环agent-session50 行以内
调 LLM → 解析回复 → 执行工具 → 结果回填 → 直到没有 toolCall。整个 agent 的心脏。
伪代码
while true:
    resp = LLM(system + messages + tools)
    append(resp)
    if not resp.toolCalls: return
    for call in resp.toolCalls:
        append(execute(call))
调用设施
设施层(核心循环依赖的全部)
消息模型与会话管理 · Provider 翻译 · 工具注册表 · 权限 · System Prompt 拼装。
pi 对应模块
消息 / 会话:messages · session-manager(JSONL 落盘、压缩)|Provider:pi-ai(协议适配)· model-registry(模型目录)· http-dispatcher(undici 全局配置)|工具:tools/bash.js · tools/truncate.js(2000 行 / 50KB 截断)|安全:trust-manager(目录信任)|提示词:system-prompt
系统调用 / HTTP
外部世界
模型 API(OpenAI / Anthropic 协议,流式返回)· 文件系统 · shell。虚线意味着:边界之外,agent 只能通过工具触达。
自上而下每层只依赖下一层;换前端不动核心,换模型只动 Provider。

一、消息模型:一切设施的基石

先看一张真实会话的消息链——下面每一个节点都是 JSONL 里的一行,节点间的连线靠 parentId,工具结果靠 toolCallId 找回它的调用方。红色那条是一次真实的 bash 失败,注意它如何作为普通消息流回模型:

架构图一次真实会话的消息链(sessions.jsonl 逐行对应)
session 头type: "session" · version: 3
会话 ID、时间戳、工作目录(cwd)。之后每一行都是一条消息,永不改写前面的行。
原始 JSON
{ "type": "session", "version": 3,
  "id": "<uuid>", "timestamp": "...",
  "cwd": "<项目路径>" }
userid: a1ec175e · parentId: …
用户输入,或注入的项目上下文(<file name="…"> 块)。
assistantstopReason: "toolUse" · 9476 in / 129 out
一条消息 = 块列表:thinking + text + 三个并发的 toolCall。模型一口气要三个文件。
content 块结构
[ { "type": "thinking", "thinking": "Let me read the full doc…" },
  { "type": "text", "text": "I'll start by reading…" },
  { "type": "toolCall", "id": "call_f0ntdnyb", "name": "read",
    "arguments": { "path": "…/BUG_REPORT.md" } },
  … 共 3 个 toolCall ]
另有 api / provider / model / usage 元数据随行记录——会话文件顺手就是成本账本。
toolResulttoolCallId: call_f0ntdnyb · isError: false
read 的文件内容,截断后喂回。三个 toolCall 各自对应一条 toolResult。
toolResultisError: true · bash
真实失败记录:cd: <路径>: No such file or directory,exit code 1。
为什么失败不需要特殊处理
报错信息作为普通 toolResult 喂回,模型下一轮看到 isError: true 和 stderr,自己纠正路径重试。纠错是模型的事,不是循环的事。
assistantstopReason: 无 toolCall
没有工具调用的回复 = 本轮结束,控制权交还用户。
id/parentId 构成一棵树:从任意节点分叉复制,就得到"会话分支"。

先定义数据结构,因为它决定了后面所有东西的形态。pi 的会话文件是 append-only 的 JSONL,每发生一件事追加一行 JSON,永不改写前面的行。第一行是会话头:

{
  "type": "session",
  "version": 3,
  "id": "<uuid>",
  "timestamp": "2026-08-10T11:02:43.340Z",
  "cwd": "<项目路径>"
}

之后每行是一条消息记录。消息分三种角色:userassistanttoolResult。每条记录有 idparentId,指向它前面那条消息——注意这不是简单的链表,而是树:从历史某一点分叉出多个分支,就得到了"会话分支"能力,parentId 就是分支的挂载点。 Assistant 消息的 content 是块(block)列表,一条消息可以同时装下思考、文字和多个工具调用。下面这条是我翻出来的真实记录(脱敏后),一次模型回复里带了一段 thinking、一段文字、三个并发的 read 调用:

{
  "type": "message",
  "id": "82d4737d",
  "parentId": "a1ec175e",
  "message": {
    "role": "assistant",
    "content": [
      { "type": "thinking", "thinking": "Let me read the full documentation and patch..." },
      { "type": "text", "text": "I'll start by reading the full bug report and patch..." },
      { "type": "toolCall", "id": "call_f0ntdnyb", "name": "read",
        "arguments": { "path": "<项目路径>/docs/BUG_REPORT.md" } },
      { "type": "toolCall", "id": "call_8xzoc1ip", "name": "read",
        "arguments": { "path": "<项目路径>/docs/fix.patch" } },
      { "type": "toolCall", "id": "call_1sky8mqk", "name": "read",
        "arguments": { "path": "<项目路径>/repro/main.py" } }
    ],
    "api": "openai-completions",
    "provider": "ollama-local",
    "model": "glm-5.2:cloud",
    "usage": { "input": 9476, "output": 129, "totalTokens": 9605 },
    "stopReason": "toolUse"
  }
}

值得注意的细节有三个。stopReasontoolUse,这是主循环判断"继续跑工具还是收尾输出"的依据;usage 记录了每轮的真实 token 消耗,会话文件顺手就是一份成本账本;三个 toolCall 共享同一个 id 前缀空间但各有独立 ID,因为每个调用都要各自对应一条结果。

工具结果用 toolCallId 关联回调用方,并且带 isError 标记。真实的失败记录长这样:

{
  "type": "message",
  "id": "5c06ba21",
  "parentId": "8b1c78e9",
  "message": {
    "role": "toolResult",
    "toolCallId": "call_q2hiox7w",
    "toolName": "bash",
    "isError": true,
    "content": [
      { "type": "text",
        "text": "/usr/bin/bash: line 1: cd: <路径>: No such file or directory\n\nCommand exited with code 1" }
    ]
  }
}

这条记录本身就说明了一件事:工具失败不需要代码层面的特殊处理。报错信息作为普通的 toolResult 喂回给模型,模型下一轮自己看到 isError: true 和 stderr,自己纠正路径重试。整个会话恢复的实现就是逐行读回这个文件、重建消息数组,程序崩了文件里已有的部分仍然完整可用。这个鲁棒性是 append-only 换来的。

我统计了本机全部会话的工具调用分布,一个有意思的观察:有一次 54 次 bash 的长会话,bash 占比压倒性多数;纯代码任务里 readbash 是绝对主力,edit/write 反而稀少——因为大量轮次花在"读代码、跑命令、验证"上,真正落笔修改的时刻比想象中少。这印证了工具设计里"bash 是灵魂"的判断。

二、主循环:整个 agent 的心脏

有了消息模型,主循环就是几十行的事。下面是我按同样结构写的教学版——它也是接下来读 pi 源码的对照基准,正文里那几条“教学版与真实实现的差异”都源于它:

async function runLoop(session: Session, provider: Provider) {
  while (true) {
    const response = await provider.chat({
      system: buildSystemPrompt(session),
      messages: session.messages,
      tools: registry.schemas(),
    });

    session.append({
      role: "assistant",
      content: response.blocks,   // thinking + text + toolCall
    });

    const calls = response.blocks.filter(b => b.type === "toolCall");
    if (calls.length === 0) return;   // stopReason 不是 toolUse,本轮结束

    const results = await Promise.all(calls.map(call =>
      registry.execute(call.name, call.arguments)
    ));
    for (const r of results) session.append(r);   // role: "toolResult"
  }
}

而 pi 的真实实现分布在三个包里:外层 agent-session.js(会话编排:重试、压缩、扩展事件转发)、中间的 @earendil-works/pi-agent-coreagent.js 的 Agent 类 + agent-loop.js 的循环引擎,两个文件合计不到一千行)、底层 pi-ai(协议适配与流式 HTTP)。下面两张图里的每段代码都标了行号并链到 GitHub 源码(pi monorepo 的 packages/agent/src/,版本 v0.84.0,与本机安装的编译产物逐行核对过),点击即可对照原文。

先交代几个只有读代码才能确认的结构事实。

循环是双层的。外层 while (true)(agent-loop.js:170)只为一件事存在:内层停下后检查 follow-up 队列(:263),有就 continue 回内层,没有才 break 走到唯一的正常出口 agent_end:278)。内层 while (hasMoreToolCalls || pendingMessages.length > 0):174)才是主引擎——退出条件是"没有更多工具调用、也没有排队的 steering 消息"。hasMoreToolCalls 由工具批次结果的 terminate 标志驱动(:224),而 terminate 是全体投票:shouldTerminateToolBatch:582-584)要求这批工具结果全部声明 terminate === true 才终止整个 agent,一个没投就继续跑。

agent_end 有三个出口:207-210:248-259:277-278):stopReason 为 error/aborted 时立即收尾;shouldStopAfterTurn 钩子叫停;follow-up 耗尽自然退出。此外 Agent.handleRunFailureagent.ts:507-507)在循环本身抛异常时合成第四个 agent_end——保证订阅者永远能看到配对的开始/结束事件。

工具默认并行、结果按原顺序回填(修正我早先版本的错误判断):executeToolCalls:411-427)只有配置显式 toolExecution: "sequential"、或这批里存在标记 executionMode === "sequential" 的工具时才走串行路径,否则默认并行——准备阶段逐个过校验和 beforeToolCall 钩子,把可执行的调用包成惰性 thunk 推进数组(:530-543),Promise.all 同时点火后按 toolCall 原始顺序生成 toolResult 消息(:544-558),tool_use/tool_result 配对顺序永远稳定。串行路径的唯一额外行为是每个工具跑完检查 signal.aborted 就 break(:548-550)。

abort 是合作式的。内层 while 顶部不查 signal.aborted;signal 一路传进流式调用(打断 HTTP)和工具执行器,检查点分布在 beforeToolCall 钩子返回后(:412、:633)和串行批次的每个工具之间。ESC 不是"循环顶部的 if",而是每个 await 点上的礼貌询问。

turn 之间可以换模型prepareNextTurn 钩子(:234-247)每个 turn 结束都被调用,返回的快照可以整体替换 context、换 model、调 thinkingLevel——这就是 pi 会话中途 /model 切模型不掉线的原因。

这些事实对应的代码都在下面两张图里。第一张按抽象层拆,层与层之间用什么连接(谁 await 谁、返回什么、事件往哪边流)直接标在蓝色连接条上;右侧按钮聚焦某一层,其余层变暗但连接仍在:

架构图主循环分层状态机:pi-agent-core 四层 + 层间真实连接方式(蓝条 = 向下调用,绿条 = 向上事件)
L1 Agent 会话层agent-session.js + agent.js:一次用户提交到 agent_settled
prompt() / steer() / followUp() / abort()
对外 API。prompt 拒绝并发(activeRun 检查);steer/followUp 只入队不打断
代码agent.ts:346-385
async prompt(input, images) {
    if (this.activeRun) {
        throw new Error("Agent is already processing a prompt. " +
            "Use steer() or followUp() to queue messages...");
    }
    const messages = this.normalizePromptInput(input, images);
    await this.runPromptMessages(messages);
}
async continue() {
    if (this.activeRun) throw new Error("Agent is already processing...");
    const lastMessage = this._state.messages[
        this._state.messages.length - 1];
    if (lastMessage.role === "assistant") {
        const queuedSteering = this.steeringQueue.drain();
        if (queuedSteering.length > 0) {
            await this.runPromptMessages(queuedSteering,
                { skipInitialSteeringPoll: true });  // 刚drain过,跳过下次轮询防重复
            return;
        }
        ...
    }
    await this.runContinuation();
}
steer(message) { this.steeringQueue.enqueue(message); }
followUp(message) { this.followUpQueue.enqueue(message); }
skipInitialSteeringPollagent.ts:442 / agent.ts:470-476)防同一条 steering 被注入两遍。

调用:runPromptMessages → runWithLifecycle(signal) → await runAgentLoop(...)(agent.ts:405-419)|返回:newMessages[],异常经 handleRunFailure(agent.ts:507-507)转成合成的 agent_end 事件
createLoopConfig:把 Agent 的队列变成循环的钩子
steering/followUp 队列的 drain 函数在这里注入循环(agent.ts:441-480
事件流(唯一向上的通道):processEvents 把 agent_start / turn_start / message_* / tool_execution_* / turn_end / agent_end 逐个 emit 给 AgentSession 的订阅者(外层再转发给扩展和 TUI)。事件与调用同一条 emit 链,顺序即发生顺序。
L2 Turn 循环层agent-loop.ts:163-278 runLoop:双层 while,一个 turn = 一次 LLM 调用 + 一个工具批次
外层 while(true):follow-up 续跑:85, :163-170
内层停下后捞 follow-up 队列,有 → continue 回内层;没有 → break,唯一的正常出口 emit agent_end(:172)
进入内层:while (hasMoreToolCalls || pendingMessages.length > 0)(:88)——循环条件本身就是"工具结果驱动回环"的形式化
内层循环体:turn 的完整生命周期:174-261
turn_start → 注入 pending steering → 流式回复 → stopReason 分支 → 工具批次 → turn_end → prepareNextTurn / shouldStopAfterTurn → 捞 steering → 下一圈
内层循环原文(省略号处见源码)agent-loop.ts:174-261
while (hasMoreToolCalls || pendingMessages.length > 0) {
    if (!firstTurn) { await emit({ type: "turn_start" }); }
    else { firstTurn = false; }
    if (pendingMessages.length > 0) {          // :96 注入 steering
        for (const message of pendingMessages) {
            await emit({ type: "message_start", message });
            await emit({ type: "message_end", message });
            currentContext.messages.push(message);
            newMessages.push(message);
        }
        pendingMessages = [];
    }
    const message = await streamAssistantResponse(
        currentContext, config, signal, emit, streamFunction);  // :106
    newMessages.push(message);
    if (message.stopReason === "error" || message.stopReason === "aborted") {
        await emit({ type: "turn_end", message, toolResults: [] });  // :109
        await emit({ type: "agent_end", messages: newMessages });    // :110
        return;                                                      // :111 出口1
    }
    const toolCalls = message.content.filter((c) => c.type === "toolCall"); // :114
    ...
    if (toolCalls.length > 0) {
        const executedToolBatch = message.stopReason === "length"
            ? await failToolCallsFromTruncatedMessage(toolCalls, emit)  // :122
            : await executeToolCalls(currentContext, message,
                                     config, signal, emit);             // :123
        hasMoreToolCalls = !executedToolBatch.terminate;                // :125
        for (const result of toolResults) {
            currentContext.messages.push(result);
            newMessages.push(result);
        }
    }
    await emit({ type: "turn_end", message, toolResults });   // :131
    const nextTurnSnapshot =
        await config.prepareNextTurn?.(nextTurnContext);      // :138 可换模型
    if (await config.shouldStopAfterTurn?.({...})) {          // :151 出口2
        await emit({ type: "agent_end", messages: newMessages });
        return;
    }
    pendingMessages =
        (await config.getSteeringMessages?.()) || [];         // :160 捞steering
}
三个 agent_end 出口::110(error/aborted)、:157(钩子叫停)、:172(follow-up 耗尽)。

调用:await streamAssistantResponse(context, config, signal, emit, streamFn)(:106)|返回:一条完整的 AssistantMessage(含 stopReason 和 usage),partial 消息已在流式过程中被原位 push 进 context
streamAssistantResponse:AgentMessage → Message 的翻译边界:281-379
调 LLM 前三步变换 + 每次调用现解析 API key(过期 token 靠这个续命)
翻译边界的四步agent-loop.ts:281-379
let messages = context.messages;
if (config.transformContext) {                    // :181 可选变换
    messages = await config.transformContext(
        messages, signal);        // AgentMessage[] → AgentMessage[]
}
const llmMessages = await config.convertToLlm(
    messages);                                     // :185 → LLM Message[]
const llmContext = { systemPrompt, messages: llmMessages, tools };
const resolvedApiKey =
    (config.getApiKey                              // :193 每次调用现解析
        ? await config.getApiKey(config.model.provider)
        : undefined) || config.apiKey;
const response = await streamFunction(
    config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal });
文件头注释点明设计意图:"Agent loop that works with AgentMessage throughout. Transforms to Message[] only at the LLM call boundary."——会话格式与协议格式只在这一点交汇。

向下:消费 pi-ai 的 AssistantMessageEvent 流(start / text_delta / thinking_delta / toolcall_delta / done)|向上:每收到一个块事件就 emit message_update(:221-226),TUI 逐字渲染靠这条通道;partial 消息被原位替换(:220),done/error 时换上最终消息(:228-241)
流事件 → 消息的组装 switch:316-359
start 建 partial 并 emit message_start;七种块事件更新 partial 并 emit message_update;done/error 取 response.result() 收尾 emit message_end

调用:stopReason 含 toolCall 时 await executeToolCalls(context, message, ...)(:123)|返回:{ messages: 按原始顺序的 ToolResultMessage[], terminate: 全体投票结果 }——shouldTerminateToolBatch(:377-379)要求全部工具结果声明 terminate===true 才终止
L4 Tool 执行层agent-loop.ts:381-792:一个工具批次的完整流水线
策略分岔:默认并行,显式才串行:287-294
config.toolExecution === "sequential" 或批次里有 sequential 工具 → 串行;否则并行
分岔原文agent-loop.ts:411-426
const hasSequentialToolCall = toolCalls.some((tc) =>
    currentContext.tools?.find((t) => t.name === tc.name)
        ?.executionMode === "sequential");
if (config.toolExecution === "sequential" || hasSequentialToolCall) {
    return executeToolCallsSequential(...);
}
return executeToolCallsParallel(...);
两条路径共用:prepareToolCall(:393-448)——找工具 → prepareArguments 预处理(edit 的 JSON 字符串兼容就在这)→ validateToolArguments schema 校验 → beforeToolCall 钩子(权限拦截点,:405-426)→ abort 检查(:412/:427)。任何一步失败返回 { kind: "immediate", isError: true },不执行、直接变错误结果。
并行路径:thunk 数组 + Promise.all:489-580
准备循环里同步结果直接收、可执行的包成惰性 thunk(:355-360);同时点火后按 toolCall 原始顺序生成 toolResult
并行执行原文agent-loop.ts:544-558
const orderedFinalizedCalls = await Promise.all(
    finalizedCalls.map((entry) =>
        typeof entry === "function" ? entry() : Promise.resolve(entry)));
const messages = [];
for (const finalized of orderedFinalizedCalls) {
    const toolResultMessage = createToolResultMessage(finalized);
    await emitToolResultMessage(toolResultMessage, emit);
    messages.push(toolResultMessage);
}
顺序稳定性:map 的数组下标 = toolCall 原始顺序,Promise.all 不改位置。
单个工具内部:execute + 流式上报 + afterToolCall 改写:449-514
tool.execute 第四参是 onUpdate 回调——bash 的 100ms 节流输出从这里流出去(:453-463);跑完过 afterToolCall 钩子,可改写 content/details/usage/isError/terminate(:480-513)
层聚焦
蓝 = 向下调用(await + 返回值),绿 = 向上事件流(emit);层间没有别的连接方式。行号指 GitHub 上 packages/agent/src 的源码行(v0.84.0),徽章可点击直达。

第二张图按"目的"演进:假如你只想做消息问答,引擎只需要什么?加上工具调用要多哪些件?错误处理和 steering 又是叠在哪里的?每个阶段的虚线框就是相对上一阶段的新增件:

第二张图按"目的"把整个 runLoop 的零件全部摊开。不只是"问答 → 工具 → 错误处理"三步——原架构里没有多余的东西,图里每个阶段补上的零件都对应源码里真实存在的机制:会话上下文变换(transformContext)、每次调用现解析 API key、截断消息的工具批处理(length 分支)、工具结果的 afterToolCall 改写、terminate 全体投票、turn 间换模型(prepareNextTurn)。看完这张图,agent-loop.js 里就没有"没讲到的角落"了:

架构图按目的演进:runLoop 的全部零件(①→④ 每步只加新件,虚线框 = 本阶段新增)
只做问答:一个 turn 就够——上下文变换、调 LLM、收回消息、结束。连 while 都不转(hasMoreToolCalls=false 且无 pending,:88 条件为假)。但即便这个最小形态,源码里也有两件容易被忽略的必需品:AgentMessage→Message 的翻译边界,和每次调用现解析 API key。
transformContext(可选):181-183
调用 LLM 前最后机会变换 AgentMessage[](压缩历史、注入提示都可以挂这)
convertToLlm :185
convertToLlm → llmContext:185-191
AgentMessage[] 翻译成协议 Message[]——会话格式与协议格式唯一的交汇点
getApiKey :193
streamFunction(model, ctx, opts):194-198
每次调用都 await getApiKey 现解析——OAuth token 过期靠这个无缝续命,不必重启会话
这四步的原文
let messages = context.messages;
if (config.transformContext)
    messages = await config.transformContext(messages, signal);
const llmMessages = await config.convertToLlm(messages);
const resolvedApiKey = (config.getApiKey
    ? await config.getApiKey(config.model.provider)
    : undefined) || config.apiKey;
const response = await streamFunction(config.model, llmContext,
    { ...config, apiKey: resolvedApiKey, signal });
message_end :240
stopReason === "stop" → 结束
turn_end → follow-up 检查(空)→ agent_end(:172)
加工具调用:while 开始真正转。新增三件——toolCall 过滤、工具批次执行(默认并行 + 顺序回填)、terminate 投票。前两件是回环,第三件是"某个工具可以叫停整个 agent"的民主机制。
streamAssistantResponse 返回
stopReason 现在可能是 toolUse,content 里有 1..N 个 toolCall 块
filter :114
executeToolCalls:策略分岔:287-294
默认并行(Promise.all + thunk),sequential 工具在场才串行
新增的核心几行:114-129
const toolCalls = message.content
    .filter((c) => c.type === "toolCall");
const toolResults = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
    const executedToolBatch = await executeToolCalls(
        currentContext, message, config, signal, emit);
    toolResults.push(...executedToolBatch.messages);
    hasMoreToolCalls = !executedToolBatch.terminate;  // :125
    for (const result of toolResults) {
        currentContext.messages.push(result);
        newMessages.push(result);
    }
}
结果回填 :126-129
shouldTerminateToolBatch:全体投票:582-584
全部工具结果都声明 terminate===true 才终止 agent;一个没投就继续。自定义 exit 类工具就靠它
工具内部的完整流水线:393-514
prepareToolCall(:393-448):找工具 → prepareArguments(edit 的 JSON 字符串兼容)→ schema 校验 → beforeToolCall 钩子(权限拦截点)→ abort 检查。executePreparedToolCall(:449-479):执行 + onUpdate 回调流式上报(bash 的 100ms 节流输出)。finalizeExecutedToolCall(:480-514):afterToolCall 钩子可改写 content/details/usage/isError/terminate——扩展改写工具结果的官方通道。
下一圈
带工具结果的上下文再进 LLM
tool_use/tool_result 配对顺序由 :365-371 的 map 下标保证
TOOL RESULT 回环
加错误处理与截断:全部长在固定分支上,循环骨架不动。三种 stopReason 特殊值 + 一个合成事件出口,覆盖"模型出错、用户中断、输出截断、循环本身崩了"四种失败模式。
error / aborted 分支:108-111+新增
turn_end(带空 toolResults)+ agent_end 立即返回。重试不在这层做——外层 agent-session 的 _prepareRetry(agent-session.ts:764 区段)决定要不要 continue() 重来。
stopReason
length 分支:截断的 toolCall 不执行:121-122, :263-283+新增
输出被 max_tokens 截断时,流式 JSON 补救解析器可能拼出"能通过校验但悄悄不完整"的参数——全部标为错误结果喂回去,让模型自己重发。
failToolCallsFromTruncatedMessage 原文:381-408
async function failToolCallsFromTruncatedMessage(toolCalls, emit) {
    const messages = [];
    for (const toolCall of toolCalls) {
        await emit({ type: "tool_execution_start", ... });
        const finalized = {
            toolCall,
            result: createErrorToolResult(
                `Tool call "${toolCall.name}" was not executed: ` +
                `the response hit the output token limit, so its ` +
                `arguments may be truncated. Re-issue the tool call ` +
                `with complete arguments.`),
            isError: true,
        };
        ...
    }
    return { messages, terminate: false };
}
注释原文点破了动机:"a truncated message can yield tool calls whose arguments parse and validate but are silently incomplete. None of them are safe to execute."
循环抛异常
handleRunFailure:合成 agent_endagent.ts:482-505+新增
runWithLifecycle 的 catch:循环本身崩了也要 emit 配对的 agent_end(内含错误消息),订阅者永远看到完整的开始/结束对。
每个 await 点
abort:合作式检查点:412/:427/:323/:350+新增
signal 传进流式调用(打断 HTTP)和每个工具;beforeToolCall 返回后、串行批次每个工具之间都查一次。不是循环顶部的 if,是每个 await 点的礼貌询问。
加 steering、换模型、续跑:runLoop 的最后一批挂件。到这一步,agent-loop.js 的 548 行就全部出现在图上了——没有剩余的"边角代码"。
steering:turn 间隙插话:83, :96-104, :160+新增
循环开头捞一次(用户等待时打的字不丢);每个 turn 结束再捞一次(:160),下个 turn 开头作为 user 消息注入。不打断正在跑的工具批次。
steering 的完整路径
入队:AgentSession._queueSteer(agent-session.ts:1016 区段)→ agent.steer()(agent.ts:283)→ steeringQueue。消费:createLoopConfig 的 getSteeringMessages(agent.ts:470-476)→ runLoop :168/:258:182-190 注入。UI 侧 _steeringMessages 数组跟踪未送达消息,message_start 确认送达后移除(agent-session.js)。continue() 在 assistant 结尾时优先 drain 队列(agent.ts:363-383)。
turn_end 之后
prepareNextTurn:turn 间换模型:138-150+新增
每个 turn 结束调用,返回快照可替换 context、换 model、调 thinkingLevel("off" 映射为 undefined)。会话中途 /model 切换不掉线的原因。
换模型的原文:138-150
const nextTurnSnapshot =
    await config.prepareNextTurn?.(nextTurnContext);
if (nextTurnSnapshot) {
    currentContext = nextTurnSnapshot.context ?? currentContext;
    config = {
        ...config,
        model: nextTurnSnapshot.model ?? config.model,
        reasoning: nextTurnSnapshot.thinkingLevel === undefined
            ? config.reasoning
            : nextTurnSnapshot.thinkingLevel === "off"
                ? undefined
                : nextTurnSnapshot.thinkingLevel,
    };
}
内层退出后
外层 while + follow-up 续跑:85, :163-170+新增
agent 本要停时捞 follow-up 队列,有 → pendingMessages = followUpMessages; continue(:164-167)回内层。外层 while(true) 存在的全部理由。
更外层
AgentSession 的善后方向盘agent-session.js:744-790
_runAgentPrompt:while (await _handlePostAgentRun()) agent.continue()。_handlePostAgentRun 按优先级:可重试错误 → _prepareRetry(2s/4s/8s 退避);_checkCompaction(16384 reserveTokens 阈值触发压缩);hasQueuedMessages。这一层不在 agent-loop.js 里,但每个 runLoop 的使用者都要回答"然后呢"。
外层方向盘原文agent-session.js:744-790
async _runAgentPrompt(messages) {
    this._isAgentRunActive = true;
    try {
        await this.agent.prompt(messages);
        while (await this._handlePostAgentRun()) {
            await this.agent.continue();
        }
    } finally {
        this._systemPromptOverride = undefined;
        this._flushPendingBashMessages();
        await this._emitAgentSettled();
    }
}
async _handlePostAgentRun() {
    const msg = this._lastAssistantMessage;
    this._lastAssistantMessage = undefined;
    if (!msg) return false;
    if (this._isRetryableError(msg)
        && (await this._prepareRetry(msg))) return true;
    if (msg.stopReason === "error" && this._retryAttempt > 0) {
        this._emit({ type: "auto_retry_end", success: false, ... });
        this._retryAttempt = 0;
    }
    if (await this._checkCompaction(msg)) return true;
    return this.agent.hasQueuedMessages();
}
①翻译边界与 key 解析是问答就有的必需品;②回环 + terminate 投票;③四种失败模式各有专属分支;④全部挂件就位后 agent-loop.js 548 行完整覆盖。阶段④最外层跳出 agent-loop.js,指向使用者(AgentSession)的善后循环。

朴素的同步实现能跑,但很快你会发现什么都做不了:想按 ESC 中断?想边生成边流式显示?想在模型跑工具的半路上插一句话?这些都需要把"发生了一件事"和"如何呈现这件事"解耦。pi 的解法是事件总线(event-bus.js)——它的实现朴素到只有几十行:直接包了一个 Node.js 的 EventEmitteremit 同步分发,唯一加的料是把每个 handler 包进 try/catch,保证一个订阅者抛错不会炸掉整个进程:

on: (channel, handler) => {
    const safeHandler = async (data) => {
        try {
            await handler(data);
        } catch (err) {
            console.error(`Event handler error (${channel}):`, err);
        }
    };
    emitter.on(channel, safeHandler);
    return () => emitter.off(channel, safeHandler);   // 返回退订函数
},

事件名没有枚举约束,就是字符串 channel;但生命周期事件的命名是稳定的:agent_start →(turn_startmessage_start/message_update/message_endtool_execution_start/tool_execution_endturn_end)× N → agent_endagent_settled。TUI、日志、扩展系统全部订阅这一条流,所以同一个核心能配四种完全不同的前端(交互式 TUI、pi -p 脚本打印、RPC 进程集成、SDK 嵌入),核心一行不改。

上一节那条真实的 bash 报错记录其实展示了同一个哲学——杀掉一个跑了一半的任务常常比让它带着错误信息跑完更贵,把纠错机会留给模型自己往往更划算。

三、Provider 层:挡住模型 API 差异的墙

看上面 assistant 消息里的 "api": "openai-completions", "provider": "ollama-local", "model": "glm-5.2:cloud" 这几个字段——同一套统一消息格式,跑在 OpenAI 协议上、经由 ollama 代理接到云模型。这就是 Provider 层存在的意义:主循环只认统一格式,provider 负责"翻译出去"(统一格式 → 对方 API 请求)和"翻译回来"(流式响应 → 统一格式的 assistant 消息)。

pi 的这层不在主包里,而是拆成了独立依赖 @earendil-works/pi-ai,内部再分三层:API 层(每个线上协议一个适配器模块,各自导出统一签名的 stream/streamSimple)、Provider 层(auth + baseUrl + 模型目录,把调用委托给 API 适配器)、Model 层(一个值对象决定自己走哪个适配器)。支持的协议列表本身就是一份业界现状清单:

export type KnownApi = "openai-completions" | "mistral-conversations"
  | "openai-responses" | "azure-openai-responses" | "openai-codex-responses"
  | "anthropic-messages" | "bedrock-converse-stream"
  | "google-generative-ai" | "google-vertex" | "pi-messages";

stream() 的签名是"同步返回一个事件流对象、内部异步推事件"——调用方拿到 AssistantMessageEventStream 就能立刻开始渲染,token 到一点拼一点。流上的事件协议是所有适配器共享的:先 start,再按 contentIndex 发块级的 text_start/text_delta/text_endthinking_*toolcall_*,终态只能是 doneerror。这个协议就是 TUI 能逐字渲染、又不必关心底下是哪家模型的原因。

统一消息格式本身(pi-ai/types.d.ts,节选)长这样,注意 thinkingSignature 这种字段——它存 Anthropic 的加密思考签名,回放历史时必须原样带回,否则 API 会拒绝:

export interface ThinkingContent {
  type: "thinking"; thinking: string;
  thinkingSignature?: string; redacted?: boolean;
}
export interface ToolCall {
  type: "toolCall"; id: string; name: string;
  arguments: Record<string, any>; thoughtSignature?: string;
}
export interface AssistantMessage {
  role: "assistant";
  content: (TextContent | ThinkingContent | ToolCall)[];
  api: Api; provider: ProviderId; model: string;
  usage: Usage; stopReason: StopReason; timestamp: number;
}
export type StopReason = "pending" | "stop" | "length" | "toolUse"
                       | "error" | "aborted" | "deferred";

往下看两个主流协议各自怎么适配的,这两段是理解"翻译层到底在翻什么"的关键。

OpenAI chat.completions 方向。请求组装时工具定义转成 {type: "function", function: {name, description, parameters, strict}} 数组,strict 字段只在对方支持时才带("Some reject unknown fields"——pi 源码里的原注释,兼容性防御无处不在)。流式回来时最难的是 delta.tool_calls 的聚合:OpenAI 把一次工具调用拆成多个增量片段发过来,pi 用"index 优先、id 兜底"的双重索引把它们拼回块:

const toolCallBlocksByIndex = new Map();  // key: toolCall.index
const toolCallBlocksById   = new Map();   // key: toolCall.id

let block = streamIndex !== undefined
    ? toolCallBlocksByIndex.get(streamIndex) : undefined;
if (!block && toolCall.id) block = toolCallBlocksById.get(toolCall.id);
if (!block) { block = { type: "toolCall", id: toolCall.id || "", name,
                        arguments: {}, partialArgs: "", streamIndex };
              blocks.push(block); }

// 参数是流式 JSON 片段,边拼边做增量解析
if (toolCall.function?.arguments) {
    block.partialArgs = (block.partialArgs ?? "") + toolCall.function.arguments;
    block.arguments = parseStreamingJson(block.partialArgs);
}

为什么需要双重索引:不是所有实现都老老实实填 index,有些代理只给 id,有些只给 index。块结束时 finishBlock 会做最终解析并 delete block.partialArgs——会话文件里只留干净的 arguments,流式期间的脚手架字段全部拆掉。

Anthropic Messages 方向。映射规则里最有代表性的是 tool_result:pi 内部每条工具结果是独立的 role: "toolResult" 消息,而 Anthropic 协议要求工具结果必须作为 user 角色消息里的 content block 出现,所以适配器要把连续的 toolResult 合并成一条 user 消息:

// 内部: [assistant(tool_use), toolResult, toolResult]
// 协议: [assistant(content: [tool_use × 2]),
//        user(content: [{type:"tool_result", tool_use_id, content, is_error} × 2])]

还有一类映射是缓存打点。Anthropic 的 prompt caching 靠在消息里插 cache_control 标记,pi 的打点位置是精心选的:system prompt 第一块、最后一个工具定义、最后一条 user 消息的最后一个 block——正好是"稳定前缀"的三个端点。而 OpenAI 方向不用手动打点,靠 prompt_cache_key(会话 ID)让服务端自动关联缓存。thinking 块的映射还有个细节:没有 signature 的 thinking(比如中断后的残块)会被降级成普通 text,因为 Anthropic 拒绝无签名的 thinking 回放。

模型注册表是这层的另一半(pi-ai/providers/data/*.json,35 家厂商,自动生成)。每条登记长这样,主循环靠 contextWindow 决定何时压缩,靠 cost 五元组算钱:

"claude-haiku-4-5": {
  "id": "claude-haiku-4-5", "api": "anthropic-messages",
  "provider": "anthropic", "baseUrl": "https://api.anthropic.com",
  "reasoning": true, "input": ["text", "image"],
  "cost": { "input": 1, "output": 5, "cacheRead": 0.1, "cacheWrite": 1.25 },
  "contextWindow": 200000, "maxTokens": 64000
}

个性差异不写死在代码里,而是塞进每个模型的 compat 字段(20 多项开关:supportsStoremaxTokensFieldmax_tokens 还是 max_completion_tokens、thinking 格式十一种变体……),按 provider 名和 baseUrl 关键字自动探测。这是"支持得太泛会陷入兼容性泥潭"的一个解法:协议只有几种,怪癖全部数据化。

最后是重试,pi 做了两层,参数都值得抄。HTTP 层(provider-retry.js,注释明说"镜像 OpenAI/Anthropic SDK 的 pinned 策略"):408/409/429/5xx 和网络错误可重试,退避是 min(0.5 × 2^n, 8) 秒再乘 (1 - random() × 0.25) 的抖动(0.5s/1s/2s/4s/8s 封顶);服务器通过 Retry-After 要求等超过 60 秒时直接失败上抛,不无限耗着。agent 层(retryAssistantCall):对整条失败的 assistant 消息重试,默认 3 次、2s/4s/8s 退避,用两条大正则分类——配额类错误(insufficient_quotabillingavailable balance……)绝不重试,过载类(overloaded429stream ended before message_stop……)重试。还有个实现细节:pi 显式传 maxRetries: 0 关掉官方 SDK 的内建重试,因为它自己的退避 sleep 能被 AbortSignal 打断,SDK 的不能——用户按 ESC 时重试等待必须立刻让路。

四、System Prompt:模型"是谁"

pi 的 system-prompt.js 只有 109 行,拼装结构是固定的四段:基础指令("You are an expert coding assistant operating inside pi, a coding agent harness...")+ 工具清单(每个工具的 description 拼成 - read: ... 列表,没有 snippet 的自定义工具不列)+ 使用守则("Be concise"、"Show file paths clearly",以及条件性的"用 bash 做 ls/rg/find"——只在没装那三个专用工具时才出现)+ 一段"问 pi 自身用法时读哪个文档"的路由说明。

项目级指令的注入格式是这样的(resource-loader.js 会按 AGENTS.override.md > AGENTS.md > CLAUDE.md 的优先级,从工作目录逐级向上收集):

<project_context>

Project-specific instructions and guidelines:

<project_instructions path="/path/to/AGENTS.md">
...文件原文...
</project_instructions>

</project_context>

给上下文块套上明确的 XML 边界是个务实的小技巧:模型更容易把它当成"参考资料"而不是"必须立刻执行的指令",一定程度上抑制文件内容里的提示注入。skills 的注入同理,用 <available_skills><skill><name> 的结构列出名字和描述,模型需要时自己用 read 去加载全文——按需注入而不是全量塞进来。

有意思的是 pi 在这里反而不搞模板系统:没有 {{cwd}} 占位符,工作目录就是一行纯字符串拼接 prompt += "\nCurrent working directory: " + cwd.replace(/\\/g, "/")(Windows 反斜杠归一化)。真正的缓存考量在 Provider 层解决(上一节的 prompt_cache_keycache_control 打点),system prompt 只要内容确定就行。

一个很实际的考量仍然成立:system prompt 和对话前缀逐字节稳定,才能命中 provider 的 prompt caching,长会话能省大笔费用和延迟。pi 的做法值得注意——它把"会变的东西"(项目上下文、skills 列表)都放在 prompt 尾部拼接,"不变的基础指令"始终在头部,缓存前缀的断裂面被压到最小。

五、工具层:四个工具就能干活

pi 内置七个工具:readbasheditwritegrepfindls。真正不可或缺的是前四个,后三个是便利性优化(省 token、输出更结构化)。每个工具三件套:TypeBox 写的 JSON Schema(给模型)、执行函数(给循环)、description 文案(进 system prompt 的工具清单)。description 不是装饰,是运行时行为的一部分——工具描述里直接引用了截断常量,模型因此"知道"输出会被截到多长、该什么时候主动分段。

read 的截断与续读。常量在 truncate.js 里:DEFAULT_MAX_LINES = 2000 行、DEFAULT_MAX_BYTES = 50 * 1024(50KB),先到者为准。截断不是默默砍掉就完事,而是给模型一个可执行的下一步——这是工具设计里非常值得学的一笔:

if (truncation.truncatedBy === "lines") {
    outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} ` +
        `of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
} else {
    outputText += `\n\n[Showing lines ... (${formatSize(DEFAULT_MAX_BYTES)} limit). ` +
        `Use offset=${nextOffset} to continue.]`;
}

更极端的边界也有兜底:单行就超过 50KB 时(比如 minified JS),read 会直接放弃并指路——"Use bash: sed -n '120p' file | head -c 51200"。工具没有说"失败了,你自己想办法",而是每次拒绝都附带替代方案,模型循环才不会卡死。

bash 的超时与流式回传。超时参数是可选的(schema 里明写 "no default timeout"),实现是 setTimeout 到点杀整棵进程树——spawn 时用 detached: true 建独立进程组就是为了这一刻能连带子孙进程一起杀,而不是只杀外壳留下孤儿:

if (timeoutMs !== undefined) {
    timeoutHandle = setTimeout(() => {
        timedOut = true;
        if (child.pid) killProcessTree(child.pid);
    }, timeoutMs);
}
// 进程退出后检查 timedOut 标志
if (timedOut) throw new Error(`timeout:${timeout}`);

输出回传走 OutputAccumulator + 100ms 节流:stdout/stderr 的每个 data 块先进滚动缓冲区,凑够 100ms 才给 TUI 发一次快照(BASH_UPDATE_THROTTLE_MS = 100),超过 50KB 自动开始写临时文件、结束时把完整输出路径附在结果里。这里还有个容易忽略的细节——pi 用 getShellConfig(options?.shellPath) 决定用哪个 shell,我本机就在 ~/.pi/agent/settings.json 里配了 shellPath 指向 Git Bash,Windows 默认 shell 跑不了多少正经命令,这个配置点是 Windows 用户的第一个必改项。

edit 的匹配与容错。参数 schema 的原文把约束写得明明白白:

const replaceEditSchema = Type.Object({
    oldText: Type.String({
        description: "Exact text for one targeted replacement. It must be " +
            "unique in the original file and must not overlap with any other " +
            "edits[].oldText in the same call.",
    }),
    newText: Type.String({ description: "Replacement text for this targeted edit." }),
});

edits 数组一次可以带多组替换,但都对着"原始文件"匹配而不是增量应用——两处改动挨得近就要求合并成一条。还有一个专门伺候模型的预处理函数,它处理的是真实世界里模型不守规矩的情况(源码注释点名了 Opus 4.6 和 GLM-5.1):

function prepareEditArguments(input) {
    const args = input;
    // Some models (Opus 4.6, GLM-5.1) send edits as a JSON string instead of an array
    if (typeof args.edits === "string") {
        try {
            const parsed = JSON.parse(args.edits);
            if (Array.isArray(parsed)) args.edits = parsed;
        } catch { }
    }
    // 旧格式 top-level oldText/newText 兼容
    ...
}

要澄清一个流传很广的说法:pi 并没有实现"必须先 read 才能 edit"的强校验edit.js 的执行路径就是 access 检查 → 读文件 → applyEditsToNormalizedContent 应用编辑 → 写回,全程没有查询"这个文件之前 read 过没有"的状态(某些别的 agent 比如 ZCode 有这个机制,但 pi 选择靠 oldText 必须逐字符命中的约束本身来兜底——没读过文件就编不出唯一的 oldText)。它有的是另一个保护:withFileMutationQueue(absolutePath, ...) 把同一文件的并发变更串行化,防止两个工具调用交错写坏文件。

edit 的参数格式本身是个经典权衡。行号定位省 token 但模型数行号经常数错;精确字符串替换多花一点 token 换高得多的成功率。下面是真实会话里的一次 edit 调用(脱敏,节选),它在改一个第三方库的 bug,newText 里那段英文注释是模型在解释自己为什么这么改(PEFT 的 inference_mode 继承问题会导致优化器为空、训练静默失效)——工具调用的参数本身就是推理过程的载体:

{
  "type": "toolCall",
  "id": "call_drb9ajpd",
  "name": "edit",
  "arguments": {
    "path": "<site-packages>/trainer/grpo_trainer.py",
    "edits": [
      {
        "oldText": "                model.add_adapter(\"ref\", default_config)\n",
        "newText": "                model.add_adapter(\"ref\", default_config)\n                for name, param in model.named_parameters():\n                    ...\n"
      }
    ]
  }
}

六、会话管理:持久化、恢复与压缩

append-only JSONL 带来的能力链前面说过:持久化 → 恢复 → 分支(parentId 树从某点复制出新文件,/fork 出的会话头还会带 parentSession 字段指向源文件)。真正难的是压缩(compaction)。

pi 的自动触发条件是一个具体的公式:contextTokens > contextWindow - reserveTokens,其中 reserveTokens 默认 16384(给模型回复预留的空间),keepRecentTokens 默认 20000(最近一段不动、只压缩更早的部分)。压缩发生时会话文件里追加一个 CompactionEntry,老消息不从文件里删——它们只是"不再发给 LLM":

压缩前:
  entry:  0     1     2     3      4     5     6      7      8     9
        ┌─────┬─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
        └─────┴─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┘
                └────────┬───────┘ └──────────────┬──────────────┘
               被摘要的部分              保留的部分(从 firstKeptEntryId 起)

压缩后:末尾追加一个 cmp 条目;下次加载时 LLM 看到
        系统提示 + summary + 从 firstKeptEntryId 开始的消息

CompactionEntry 的结构(session-manager.ts):

interface CompactionEntry {
  type: "compaction";
  id: string; parentId: string; timestamp: number;
  summary: string;
  firstKeptEntryId: string;   // 保留消息的起点
  tokensBefore: number;       // 被替换掉的压缩前上下文量
  usage?: Usage;              // 生成摘要这次调用的开销
}

摘要不是"把对话总结一下"这么随意,pi 用一个结构化模板约束输出(Goal / Constraints / Progress 的 Done/In Progress/Blocked / Key Decisions / Next Steps / Critical Context),末尾还强制带上两个机器可读的清单:<read-files>(读过的文件路径)和 <modified-files>(改过的文件)——这两个清单会跨多次压缩累积传递,是模型在丢失细节后仍能"知道去哪里找"的生命线。生成摘要前,对话先被 serializeConversation() 拍平成 [User]: ... / [Assistant tool calls]: read(path="foo.ts") 的文本,其中工具结果截到 2000 字符。

重复压缩的边界处理很见功力:第二次压缩的摘要范围从上一次的 firstKeptEntryId 开始算,而不是从上一个压缩条目开始——这样上次幸存下来的消息这次也会被纳入摘要,不会出现"既没被摘要、又没被保留"的幽灵区间。

除了阈值触发的 compaction,还有手动 /compact [instructions](可以给摘要加聚焦指令,enabled: false 也照样能用),以及一套独立的分支摘要机制:/tree 导航到另一条分支时,pi 会提议把"你正在离开的那条分支"(从旧叶子回到共同祖先的路径)总结成 BranchSummaryEntry 追加到新位置——三段式(找共同祖先 → 沿路径收集 → 按 token 预算从最新优先纳入)和 compaction 同构,等于把"压缩"复用成了"换脑子"。

本质都是同一个取舍:压缩保住了会话的续航,代价是细节的永久丢失,只能靠结构化摘要(尤其是文件清单和关键决策)缓解。会话文件里逐条记录的 usage 在这里派上第二个用场:tokensBefore 加上逐条 usage 就能监控 token 增长曲线,决定阈值调松还是调紧。

七、权限与安全:信任要分层

先说一个会颠覆直觉的事实:pi 没有内置的 permissions/allow 规则系统,也没有沙箱(settings.json 里根本不存在 permissions.allow 这类字段,官方 security.md 明说 "It is not a sandbox")。它的安全模型比传闻的简单得多,也诚实得多——两条防线,各管一段。

第一条是目录信任trust-manager.js)。存储文件 ~/.pi/agent/trust.json 的结构简单到极致:{ "规范化绝对路径": true | false },值只允许布尔。查找不是 glob 也不是前缀匹配,而是从当前目录逐级向上找最近的已登记祖先

let currentDir = normalizeCwd(cwd);
while (true) {
    const value = data[currentDir];
    if (value === true || value === false)
        return { path: currentDir, decision: value };
    const parentDir = dirname(currentDir);
    if (parentDir === currentDir) return null;
    currentDir = parentDir;
}

这个设计防的是一个具体攻击面:恶意仓库在 .pi/ 下塞 settings、extensions、skills、SYSTEM.md,在仓库里放嘱托型的 AGENTS.md——不信任的目录里这些资源一概不加载。多进程并发写 trust.json 用 proper-lockfile 文件锁串行化。值得注意的边界:AGENTS.md 这类 context 文件不受信任门控、总是加载——因为它们只是作为"参考资料"进上下文,真正的执行通道(工具)在另一道防线上。

第二条是工具确认,但它整个外包给了扩展系统agent.beforeToolCall 钩子在每次工具执行前触发,若任何扩展注册了 tool_call handler,就发出 {type: "tool_call", toolName, toolCallId, input} 事件;扩展返回 { block: true, reason: "..." } 可以拦截,还可以原地改写 event.input 实现参数纠正。弹窗能力由 UI API 的 ui.confirm(title, message) 提供,官方示例 permission-gate.ts 演示的就是"拦危险命令 + 弹确认"的完整实现。换句话说,pi 核心只提供拦截点,确认策略(哪些命令要问、记住哪些答案)全部是扩展的事——这和它"功能不进核心"的一贯哲学完全一致。

第三条算半个:输出防护靠截断而非过滤。read/bash 的 2000 行 / 50KB 截断保护的是上下文不被撑爆;对提示注入(read 的文件内容里藏着"忽略之前的指令"),pi 的缓解就是上一节说的 XML 边界包裹 + 工具调用对用户完全可见。它不假装能防住,把最终监督权留给屏幕前的人。真要硬隔离,文档指路给 OS 级方案(容器、micro-VM)。

一个能随意执行 shell 命令和改文件的程序,安全设计不是可选项——但 pi 的答案是把"必须进核心的"压到最少(信任表 + 拦截点),其余交给可审计的扩展层。这比堆一整套 permission DSL 更容易验证正确性。

八、扩展机制:决定上限的取舍

pi 最有意思的取舍在这里。它的扩展系统允许 TypeScript 代码订阅事件总线、增删工具、改写 system prompt、拦截工具调用(上一节的 tool_call 拦截点就是给扩展用的),功能上几乎无所不能——然后它把 subagent、plan mode 这些别家做进核心的东西都留给了扩展或第三方包。理由是:核心里的每个功能都是所有用户必须背负的复杂度,而扩展里的功能只属于需要它的人。pi 的 skills 机制(把常用工作流写成 Markdown 说明书,启动时只把 name/description 以 <available_skills> 结构注入、模型需要时自己 read 全文)是同一哲学的延续——很多"功能"其实只是一段 prompt,根本不需要代码。

对一个 mini agent,我的建议是照抄这个分层:核心只做"循环 + 消息 + provider + 基础工具 + 事件",subagent、计划模式想清楚接口留好,先不实现。

最小可行版本的骨架

收敛成可以动手的目录结构(对照 pi 的 dist/core 模块名,方便阅读它的源码):

miniagent/
├── src/
│   ├── index.ts          # 入口:解析参数,创建 AgentSession
│   ├── loop.ts           # 主循环(agent-session 的最简版)
│   ├── messages.ts       # 消息与内容块定义
│   ├── provider/
│   │   ├── types.ts      # Provider 接口 + 模型元数据
│   │   └── openai.ts     # 具体 provider:翻译 + 流式解析 + 重试
│   ├── tools/
│   │   ├── read.ts bash.ts edit.ts write.ts
│   │   └── index.ts      # 注册表:schema + 执行器 + 说明文案
│   ├── session.ts        # JSONL 读写、恢复、压缩
│   ├── permissions.ts    # 目录信任 + 工具确认
│   ├── events.ts         # 事件总线
│   └── ui.ts             # 订阅事件渲染终端(可先退化为 console.log)
└── AGENTS.md

主循环五十行以内,剩下的工作量都在重试策略、输出截断、缓存友好、权限收敛这些不起眼的设施上。这也印证了一句话:coding agent 的"最小核心"一个下午能写完,耐用的 agent 是由细节垒出来的。

最后收拢几组关键权衡

数据格式选 append-only 还是可变结构,pi 选前者换鲁棒性;edit 选行号还是字符串匹配,选后者换正确率;功能放核心还是放扩展,pi 一律往外推,保持核心可信可审计;bash 的通用性和风险是一体两面,靠分层信任调和;compaction 换续航但丢细节,只能靠更好的摘要缓解。这些权衡没有标准答案,但 pi 的参考答案是一致自洽的:核心永远选简单、耐用、可解释的那个选项,把复杂性留给边缘的扩展层。这也是参考 pi 学 mini agent 最该带走的一条设计原则。

附:文中所有会话片段取自 pi 在本机留下的真实 sessions.jsonl,可参考 pi 文档中的 session-formatSDK 说明对照阅读。

评论