【π】agent 记忆的形状:append-only 树、压缩与分支

27 分钟

上一篇 讲完了 packages/aipackages/agent。两层合在一起能完成"从一句用户输入到 agent idle"的完整循环,但一旦 agent_end 发出,一切记忆就消失了:没人知道这次对话发生过什么、工具跑出什么结果、token 用在了哪里。AgentContext 是一次性的,Agent 也是。

一个能 work 的 coding agent 不是这样的。它要跑几个小时,随时可能被 Ctrl+C 打断,第二天打开终端要能接着聊;对话长到塞不下 context window 了,要能压缩;用户跑偏了,要能回到半小时前新开一个分支方向,分支切换不能把之前的 context 丢掉;不同项目有不同的规则,要能扩展。

packages/coding-agent 就是围绕这些"有状态"的需求构建的应用层。它包裹 Agent,加上 session 管理、compaction、extension 系统、tool 定义等功能,最终暴露给 Run Mode(交互式 TUI / Print / RPC)用。

三层分离:Run Mode → AgentSession → Agent

先看整体结构。packages/coding-agent 内部也分了三层:

Run Modes(interactive / print / rpc)     ← I/O 与渲染

AgentSession(会话逻辑)                    ← 本篇的核心
  ├─ Agent(packages/agent)                ← 无状态循环
  ├─ SessionManager(append-only 文件树)
  ├─ ExtensionRunner(扩展系统)
  └─ ResourceLoader(skill / prompt / context 文件)

Run Mode 只做 I/O:InteractiveMode 跑 TUI,PrintMode 一次性打印结果,RpcMode 对外提供 RPC 接口。它们都持有同一个 AgentSession 实例,差别只在怎么把事件渲染出来、怎么对接用户的输入。

AgentSession聚合层,也是这一篇的主角。它持有 Agent + SessionManager + ExtensionRunner,负责:

  • prompt 输入的展开(slash command,包括 命令、skill 和 template 展开)
  • 自动 compaction 的触发与执行
  • agent.state.messagesSessionManager 文件之间的同步(在 compaction / resume / navigateTree 时)
  • 接收 AgentEvent,分发给持久化、扩展、UI 三条路径
  • auto-retry、model 切换、API key 管理、thinking level 管理

最底下是 Agent,就是上一篇讲的那个无状态循环。它不知道 session、不知道 compaction、不知道 skill,它只负责执行一次完整的无状态的 agent run,并在对应的位置调用外层传入的 callback 和传出事件。

三层分离的意义在于:Agent 可以被任何上层复用(chat agent、data agent、coding agent 都行),AgentSession 可以被任何 Run Mode 复用。这一篇只看中间那一层。

用户的输入在系统中是怎么流转的

从用户按下回车开始,看一次输入是怎么在系统中是怎么流转的。AgentSession.prompt() 是入口:

async prompt(text: string, options?: PromptOptions): Promise<void> {
    const expandPromptTemplates = options?.expandPromptTemplates ?? true;

    // 1. Slash command 检查(/ 开头的可能是 extension 注册的命令)
    if (expandPromptTemplates && text.startsWith("/")) {
        const handled = await this._tryExecuteExtensionCommand(text);
        if (handled) return;
    }

    // 2. Extension input 事件(可拦截、可变换输入)
    let currentText = text;
    let currentImages = options?.images;
    if (this._extensionRunner?.hasHandlers("input")) {
        const inputResult = await this._extensionRunner.emitInput(
            currentText, currentImages, options?.source ?? "interactive",
        );
        if (inputResult.action === "handled") return;
        if (inputResult.action === "transform") {
            currentText = inputResult.text;
            currentImages = inputResult.images ?? currentImages;
        }
    }

    // 3. Skill / prompt template 展开
    let expandedText = currentText;
    if (expandPromptTemplates) {
        expandedText = this._expandSkillCommand(expandedText);
        expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);
    }

    // 4. Compaction 预检(发现要塞满了,先压缩再发)
    const lastAssistant = this._findLastAssistantMessage();
    if (lastAssistant) {
        await this._checkCompaction(lastAssistant, false);
    }

    // 5. 交给 Agent,等它跑完或等重试完成
    await this.agent.prompt(messages);
    await this.waitForRetry();
}

这几步对应着整篇文章的几个主题:

  • 第 1 步extension 系统:slash command 可能是某个扩展注册的,优先走扩展。
  • 第 3 步skill / prompt template 机制/skill:xxx/templateName args 要展开成真实文本。
  • 第 4 步compaction:事前预防,发现将满就先清空间。
  • 第 5 步之后,事件回调会把消息写进 session 文件、转发给扩展、触发 auto-retry 或 auto-compaction。

在讲这四步之前,先看 Agent 启动时就设置的另一份重要输入:system prompt。

System Prompt 的拼装

Agent 跑起来之前需要一个 systemPrompt 字符串。systemPrompt 不是每个 turn 跑的时候拼接生成的,是在 session 初始化的时候就拼好的,之后基本稳定(除非切模型或手动 reload)。

buildSystemPrompt() 有两条分支,判断的条件是有没有 customPrompt,即自定义系统提示词。

分支 A:用户提供了 SYSTEM.mdResourceLoader 会在 {cwd}/.pi/SYSTEM.md~/.pi/agent/SYSTEM.md 里查找,找到就直接用作主体,后面追加 append section、project context、skills、date + cwd。

分支 B:走内置默认 prompt。按固定顺序拼接,大致是这样:

You are an expert coding assistant operating inside pi, a coding agent harness.
You help users by reading files, executing commands, editing code, and writing new files.

Available tools:
- read: [snippet]
- bash: [snippet]
- edit: [snippet]
- write: [snippet]
...

Guidelines:
- Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)
- Be concise
- Show file paths clearly
...

Pi documentation (read only when the user asks about pi itself ...):
- Main documentation: ...
- Additional docs: ...
...

[appendSystemPrompt, if any]

# Project Context

## /path/to/AGENTS.md

[file content]

The following skills provide specialized instructions for specific tasks. Use the read tool to load a skill's file when the task matches its description.
<available_skills>
  <skill>
    <name>...</name>
    <description>...</description>
    <location>...</location>
  </skill>
  ...
</available_skills>

Current date: 2026-04-11
Current working directory: /Users/shixy/projects/xxx

几个细节值得留意。

  • Guidelines 是动态生成的。比如"优先用 grep/find/ls 而不是 bash"这条,只有在同时激活 bashgrep/find/ls 这组工具时才会出现;如果只激活了 bash,换成"用 bash 做 ls、rg、find"。prompt 会随着激活的 tools 调整建议。

  • Project Context 的查找链是逐级向上的loadProjectContextFiles() 从当前工作目录逐级向上查 AGENTS.md / CLAUDE.md,再加上全局 ~/.pi/agent/AGENTS.md。这个设计对 monorepo 场景特别友好:在子包里启动,父包的 AGENTS.md 自动能被读到。

  • Skill 在系统提示词里只是个索引,只有在正文需要的时候才加载,progressive disclosure 的实现很简单,但是思想很重要。

Skill 和 Prompt Template:两种截然不同的 "Slash Command"

这两个机制在 UI 层看起来都像"我敲一段简短的命令,系统帮我展开成完整的指令",但它们的工作方式完全不同。

先看 skill。它的身份是工具索引,进 system prompt:

export function formatSkillsForPrompt(skills: Skill[]): string {
    const visibleSkills = skills.filter((s) => !s.disableModelInvocation);
    if (visibleSkills.length === 0) return "";

    const lines = [
        "\n\nThe following skills provide specialized instructions for specific tasks.",
        "Use the read tool to load a skill's file when the task matches its description.",
        ...
        "<available_skills>",
    ];

    for (const skill of visibleSkills) {
        lines.push("  <skill>");
        lines.push(`    <name>${escapeXml(skill.name)}</name>`);
        lines.push(`    <description>${escapeXml(skill.description)}</description>`);
        lines.push(`    <location>${escapeXml(skill.filePath)}</location>`);
        lines.push("  </skill>");
    }
    lines.push("</available_skills>");
    return lines.join("\n");
}

注意 XML block 里没有 skill 的正文,只有 name、description、location 三个字段。LLM 看到的是一份"目录":这里有 20 个技能可以用,每个是干嘛的、存在哪。当用户说"帮我生成一张封面图",LLM 从 description 里判断 cover-generator 这个 skill 相关,就自己发 read 工具把 location 指向的 SKILL.md 读出来,按正文执行。

这就是 Anthropic 讲的 "progressive disclosure"(正文按需加载,不把几百 K 的技能文档塞进每次请求):20 个 skill 只需要多出几百个 token 的索引成本。

用户也可以主动召唤一个 skill:输入 /skill:cover-generator 时,AgentSession_expandSkillCommand() 里把这个 skill 的 name、location、baseDir 前缀和正文一起拼成一段 <skill> XML 文本,作为本次 user message 送给 LLM。这是上面 AgentSession.prompt() 第三步的一部分。

disableModelInvocation=true 的 skill 不会进 <available_skills> XML block,只能通过 /skill:xxx 显式调用。LLM 根本看不到它的存在。

和 skill 不同,prompt template 就是可执行的文本替换,只是模版替换,是不进 system prompt的。

扫描路径是 ~/.pi/agent/prompts/{cwd}/.pi/prompts/,每个文件是一段 markdown,带 bash 风格的参数占位:

---
name: review
description: 审查一个 PR 的改动
---

请你帮我审查 PR #$1 的改动。重点关注:

- $@ 提到的文件
- 是否有新增的测试
- 代码风格与项目一致性

用户输入 /review 1234 src/auth.ts src/session.tsexpandPromptTemplate() 按名字匹配到这个模板,把 $1 替换成 1234$@ 替换成 src/auth.ts src/session.ts,展开后直接作为一段普通的 user message 进入对话。LLM 不知道这是一个模板的展开结果,它看到的就是一段正常的用户消息。

参数替换语法:

语法含义
$1, $2第 1、2 个 positional arg
$@, $ARGUMENTS所有 args 拼接
${@:N}第 N 个起的所有 args
${@:N:L}第 N 个起的 L 个 args

Append-Only Entry 树:session 文件的底层结构

Compaction、resume、分支切换这些看起来复杂的功能,都建立在一个非常简单的数据结构上,一棵 append-only 的 entry(记录) 树。

Session 文件是 JSONL,一行一个 entry。每个 entry 有三个必须字段:

export interface SessionEntryBase {
    type: string;
    id: string;
    parentId: string | null;
    timestamp: string;
}

parentId 指向父 entry,从 root 开始一直往下长,形成一棵树。leafIdSessionManager 里一个独立的指针,指向"当前活跃末端"。

entry 有 9 种 type

export type SessionEntry =
    | SessionMessageEntry        // message:user / assistant / toolResult / custom,传到 Agent 的消息
    | ThinkingLevelChangeEntry   // thinking_level_change:切换 thinking level
    | ModelChangeEntry           // model_change:切换模型
    | CompactionEntry            // compaction:压缩后的 summary entry
    | BranchSummaryEntry         // branch_summary:被放弃分支的 summary
    | CustomEntry                // custom:扩展私有数据,不进 LLM context
    | CustomMessageEntry         // custom_message:扩展注入的 LLM 可见消息
    | LabelEntry                 // label:用户贴的书签
    | SessionInfoEntry;          // session_info:session 元数据

这个设计的核心在于所有写操作都是追加,没有"修改"没有"删除",任何历史都永远在 JSONL 里,这就是记忆的基础。

核心的写入就是这 5 行:

private _appendEntry(entry: SessionEntry): void {
    // 更新内存数组
    this.fileEntries.push(entry);
    this.byId.set(entry.id, entry);
    this.leafId = entry.id;
    this._persist(entry);
}

一次对话在磁盘上长这样:

msg1 → asst1 → msg2 → asst2 → msg3 → asst3

                                    leafId

每条消息都指向前一条,形成一条单链。leafId 永远指着链的末端。buildSessionContext() 需要重建消息列表时,从 leafId 沿着 parentId 回溯至 root,反向得到完整的消息序列(下文会具体讲解 context 怎么构建)。

Branch:只是移动指针

现在假设用户跑到 asst3 发现走偏了,想回到 asst1 重新开始。/tree slash command 打开消息树选择器,用户选中 asst1AgentSession.navigateTree() 被调用,最后落到 SessionManager.branch() 进行分支切换:

branch(branchFromId: string): void {
    if (!this.byId.has(branchFromId)) {
        throw new Error(`Entry ${branchFromId} not found`);
    }
    this.leafId = branchFromId;
}

什么都不删,只是把 leafId 指回 asst1。之后用户再输入新消息时,新 msg4parentId = asst1,这样就从 asst1 这里长出了一个全新分支。

msg1 → asst1 → msg2 → asst2 → msg3 → asst3

         └───→ msg4 → asst4

                      leafId

"回退"这个操作的成本接近于零。老分支的所有历史、工具调用结果、甚至 compaction 记录都完整保留,只是不在当前 leaf → root 的路径上。如果用户后悔了想回到老分支,再 /tree 一次就行,很优雅。

可选的分支摘要。有时候用户不想完全丢弃被放弃的分支,想让新分支"记得"之前发生过什么。navigateTree() 支持 summarize 选项,这时候会先用 LLM 把被放弃分支总结成一段文本,写入一个 BranchSummaryEntry,然后再切 leaf:

branchWithSummary(branchFromId: string | null, summary: string, ...): string {
    if (branchFromId !== null && !this.byId.has(branchFromId)) { ... }
    this.leafId = branchFromId;
    const entry: BranchSummaryEntry = {
        type: "branch_summary",
        id: generateId(this.byId),
        parentId: branchFromId,
        timestamp: new Date().toISOString(),
        fromId: branchFromId ?? "root",
        summary,
        ...
    };
    this._appendEntry(entry);
    return entry.id;
}

BranchSummaryEntry 在消息重建时经过两步转换:buildSessionContext() 先将它转为 BranchSummaryMessagerole: "branchSummary"),随后 convertToLlm() 再将其映射为一条 role: "user" 的消息,并附上 "The following is a summary of a branch that this conversation came back from: ..." 的前缀,让 LLM 知道这一段上下文是从哪来的。

Resume:从 leaf 往回走

Resume 能恢复持久化到文件的内容,比如 完整消息历史、compaction 的 summary、分支摘要、model 和 thinking level 的偏好,但是会丢失 流式中间状态(部分生成的 assistant message)、运行时 extension 的内部状态(extension 会重新初始化并收到 reason: "resume"session_start 事件)、UI 状态。

用户启动时加 --resume 或跑 /resumeSessionManager.open() 会把 JSONL 逐行读进来,_buildIndex() 构建 byId map,leafId 设成最后一条 entry 的 id。然后 buildSessionContext()leafId 沿 parentId 回溯到 root,把一路上的 entry 转成 AgentMessage[],然后赋值给 agent.state.messages,这样 agent 的记忆就恢复了。

两个消息存储的关系

Agent 内部有 state.messages 数组保存运行时对话历史,SessionManager 把同样的历史写进 JSONL 文件(这就是两套存储)。读到这里你是不是觉得很熟悉,这不就是缓存和数据库的同步问题吗?

pi 的答案是,不进行实时同步,在 同一个事件 驱动下进行两路更新。

Agent 在内部循环里收到 message_end 事件时,会把这条消息 push 进 agent.state.messages。同一个事件也会回调到 AgentSession._processAgentEvent()

private async _processAgentEvent(event: AgentEvent): Promise<void> {
    // ... queue processing

    // Emit to extensions first
    await this._emitExtensionEvent(event);

    // Notify all listeners
    this._emit(event);

    // Handle session persistence
    if (event.type === "message_end") {
        if (event.message.role === "custom") {
            this.sessionManager.appendCustomMessageEntry(/* ... */);
        } else if (
            event.message.role === "user" ||
            event.message.role === "assistant" ||
            event.message.role === "toolResult"
        ) {
            this.sessionManager.appendMessage(event.message);
        }
        // ... track last assistant for auto-compaction
    }

    // After agent_end: auto-retry / auto-compaction
    if (event.type === "agent_end" && this._lastAssistantMessage) {
        // ...
    }
}

AgentSession 作为 Agent 的 listener,在同一个事件上调 sessionManager.appendMessage()。也就是说:一次消息完成同时触发了两处写入(Agent 内部数组 + JSONL 文件)。两个存储独立增长,但因为是同一个事件源驱动的,它们在"append 新消息"这个动作上在一般情况下始终是一致的。

那么什么时候需要 强制对齐 呢?答案是,只有在"运行时状态要被外部改变"时:

  • Compaction 完成后:新的运行时上下文需要从压缩后的消息进行重建。
  • Resume 时:运行时状态本来就是空的,要整个从 JSONL 重建。
  • navigateTree 后:分支切换意味着运行时状态的消息列表已经对应不上新 leaf 了。

这三种场景都走同一段逻辑,从 JSONL 按当前 leafId 重建一遍,整个替换。

const sessionContext = this.sessionManager.buildSessionContext();
this.agent.state.messages = sessionContext.messages;

这个设计的好处在于:append 是高频的(每条消息都发生),需要是简单的;同步是低频的(compaction / resume / rewind 才发生),可以简单粗暴地直接 "整个替换",无需考虑增量 diff。这和缓存的维护逻辑是一致的。

Compaction:长对话压缩

一个 session 跑一天,消息越来越多,总会碰到超出 context window 的时候。Compaction 是经典的解决方案,也是 packages/coding-agent 使用的方案。

它有两个触发时机,分别对应预防和补救两种策略。

Threshold(事前预防)。在用户按回车后、消息正式发给 LLM 之前,AgentSession.prompt() 会做一次检查,估算当前 context 占了多少 token,如果大于 contextWindow - reserveTokens(预留 token 数),就先压缩一轮然后再跑无状态的 agent run。这样就有效地避免了用户消耗了 token 但是 context 超出限制导致失败的情况,而且还不会产生会污染对话的 error assistant message。在用户的消息还没发出去的时候就进行压缩,压缩完继续正常的流程,也不需要自动重试的复杂逻辑。

export function shouldCompact(
    contextTokens: number,
    contextWindow: number,
    settings: CompactionSettings,
): boolean {
    if (!settings.enabled) return false;
    return contextTokens > contextWindow - settings.reserveTokens;
}

Overflow(事后补救)。有时候估算没拦住(可能是最后一个 tool result 特别大,或者 token 估算本身有偏差),LLM 直接返回了一个 context overflow error。这时候用户的消息已经发出去了,如果只压缩历史然后什么都不做就会让用户看到一个错误,体验上是不可接受的,这个错误消息也会污染上下文。

所以 overflow 走的是另一条路:去除错误的 assistant message 后进行 context 压缩,然后自动用 continue() 重新发一次,把压缩后的消息列表当新 context 重新问 LLM。

两者的区别通过 willRetry 这一个 bool 区分:

private async _runAutoCompaction(
    reason: "overflow" | "threshold",
    willRetry: boolean,
): Promise<void> {
    // ... 准备、生成 summary、append 到 session

    this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension);
    const sessionContext = this.sessionManager.buildSessionContext();
    this.agent.state.messages = sessionContext.messages;   // 对齐两个存储

    this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });

    if (willRetry) {
        // overflow:需要清掉最后那条 error assistant,然后 continue
        const messages = this.agent.state.messages;
        const lastMsg = messages[messages.length - 1];
        if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
            this.agent.state.messages = messages.slice(0, -1);
        }
        setTimeout(() => { this.agent.continue().catch(() => {}); }, 100);
    } else if (this.agent.hasQueuedMessages()) {
        // threshold:如果压缩期间有 follow-up 在排队,也继续
        setTimeout(() => { this.agent.continue().catch(() => {}); }, 100);
    }
}

willRetry = true 的路径里有一个小细节:它先把最后那条 stopReason === "error" 的 assistant message 从 state.messages 里 slice 掉。逻辑上这等同于"将那次失败视为未发生"。下一轮 continue 时,LLM 看到的是干净的压缩后历史加上那条原始的用户消息,就像那次报错从来没发生过。

CompactionEntry:两个 id 各管一事

context 的压缩结果写入一个 CompactionEntry

export interface CompactionEntry<T = unknown> extends SessionEntryBase {
    type: "compaction";
    summary: string;
    firstKeptEntryId: string;
    tokensBefore: number;
    details?: T;          // extension 可以在这里塞结构化数据
    fromHook?: boolean;   // extension 生成的还是 pi 自己生成的
}

注意这里有两个 id 概念:

  • parentId(继承自 SessionEntryBase):树结构维度。compaction entry 在 session 树里挂在哪个 entry 下。
  • firstKeptEntryId:内容维度。summary 替代了"从哪条 entry 往前"的历史。

压缩要挑一个"切割点",把旧历史裁剪掉进行压缩。切割点不是随便选的,findCutPoint 的逻辑是,从最新的 entry 往回走,计算 message token 估算值累积到 keepRecentTokens(默认 20000)后,返回最近的一个 valid cut point 的索引,prepareCompaction 再通过这个索引拿到对应 entry 的 id,这就是 firstKeptEntryId

valid cut point 最重要的是不能切在 tool result 上,tool result 必须紧跟着它对应的 tool call,切开的话 LLM 就看不懂了。其余 message role(user、assistant、bashExecution、custom、branchSummary、compactionSummary)以及 branch_summary / custom_message 两种 entry type(它们在 LLM 侧会被转成 user message)都是 valid cut point。

要压缩的内容就是这个 cut point 到之前的 summarize entry 或者直接到 root 的内容。这就确保压缩了 context 但是「最近 20K token 的消息是完整保留的」。

事情没那么简单:Split Turn

所谓 turn,就是从一条 user message 到下一条 user message 之间的完整人机交互周期:可能只有 user → assistant,也可能中间穿插着 tool_call → tool_result 的循环。

有一种边界情况:挑到的 cut point 落在一条 assistant message 上,但那条 assistant 本身属于一个未完成的 turn。cut 在 assistant 上时,assistant 和它后面的 tool_result 都会被保留(tool_result 必须跟着 tool_call,而 tool_call 在 assistant 里),但发起这个 turn 的 user message 被压缩掉了。LLM 看到一段 assistant 的工具调用和结果,却不知道用户当时问了什么,上下文断裂。

turn 结构(user → 下一条 user 之间的完整周期):
[user] → [assistant + tool_call] → [tool_result] → [assistant]

cut point 在 assistant 时:
[user] → | [assistant + tool_call] → [tool_result] → [assistant]
  ↑ 被压缩       ↑ 被保留,但失去了 user 上下文

pi-mono 的处理是把被切的 turn 单独摘要一次:把 turn 的前半段作为 turnPrefixMessages 独立生成 summary,前半段 summary 和主 summary 并行调用 LLM 生成,最后合并写入 CompactionEntry。

具体来说,messagesToSummarize 的范围取决于是不是 split turn:

const historyEnd = cutPoint.isSplitTurn
    ? cutPoint.turnStartIndex            // split:总结到 turn 起点之前
    : cutPoint.firstKeptEntryIndex;      // 正常:直接到 cut 点之前

split turn 情况下,messagesToSummarize 只包含老历史(boundaryStartturnStartIndex),被切 turn 的前半段是单独的 turnPrefixMessagesturnStartIndexfirstKeptEntryIndex)。两段内容通过 Promise.all 并行调用 LLM 分别生成 summary,最后拼接写入 CompactionEntry.summary,turn 前半段的摘要会以 "Turn Context (split turn)" 为标题附在主摘要之后,保障被切 turn 后半段的上下文完整性。

Compaction:以替换代替追加

compaction 表达的不是"在历史末尾再加一条压缩消息",而是替换。老消息在当前 leaf → root 的路径上被 CompactionEntry 覆盖了,buildSessionContext() 重建时不会再读到它们。

压缩前的树:

msg1 → asst1 → msg2 → asst2 → msg3 → asst3

                                       leafId

压缩后的树:

msg1 → asst1 → msg2 → asst2 → msg3 → asst3 → compaction_entry
                                 ↑                     ↑
                          firstKeptEntryId            leafId

LLM 看到的消息序列:

[compaction_summary, msg3, asst3]

  summary 替代了 msg1~asst2

appendCompaction 把 compaction entry 追加为当前 leaf(asst3)的子节点,leafId 移动到 compaction_entry。buildSessionContext() 从 compaction_entry 沿 parentId 回溯到 root,得到完整路径 [msg1, asst1, ..., asst3, compaction_entry],然后只取 firstKeptEntryId(msg3)开始到 compaction entry 之前的消息,再加上 summary,拼成 LLM 实际看到的消息序列。

注意,msg1 ~ asst2 这些 entry 仍然在 JSONL 文件里,也仍然在 leaf → root 的路径上,只是 buildSessionContext() 在重建消息列表时跳过了它们。和 branch 一样,append-only 树永远不真正删东西。这就让 compaction 和 branch 这两个看起来不相关的功能,在底层用的是同一套机制。

如果一个 session 跑得够久,compaction 就会触发不止一次。第二次压缩时,messagesToSummarize 只包含上次压缩之后到新 cut point 之前的消息,如果只对这段消息从零生成 summary,第一次 summary 里记录的早期历史就丢了。所以 generateSummary() 会检查是否存在 previousSummary:如果有,会把旧 summary 作为 <previous-summary> 传给 LLM,要求它在旧 summary 的基础上融入新消息的内容,而不是从零重写。这样每次 compaction 都是增量更新,早期历史不会随着反复压缩逐渐丢失。

Extension 系统:事件驱动的扩展点

现在剩下最后一块,怎么让 packages/coding-agent 可扩展,不把所有功能都硬编码。

pi-mono 的方案是一套事件驱动的扩展系统。Extension 的类型签名极其简单:

export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;

就是一个接收 ExtensionAPI 对象的函数。Extension 在这个函数里注册 hook、注册工具、注册命令,然后返回。运行时 ExtensionRunner 加载它,把 ExtensionAPI 传进去。

为什么选事件总线而不是少量大 hook? 假设只有 2~3 个大 hook,扩展收到通知后需要自己判断"这次 tool_call 是不是我关心的"。随着扩展数增加,每个扩展都要维护一套过滤逻辑,状态也散在各处。事件驱动的做法把"扩展自己维护过滤/状态"的负担挪到了框架里:框架负责把事件精准地投递给订阅了它的扩展,扩展只管处理自己关心的那类事件,逻辑内聚。

Extension 的加载来源有两类:自动扫描目录(项目本地的 cwd/.pi/extensions/ 加上全局的 ~/.pi/agent/extensions/)和显式配置路径(settings.json 与 --extension flag 汇入同一路径列表)。每一处都可以放两种东西:

  • 直接是 .ts / .js 文件(用 jiti 运行时编译 .ts
  • 一个目录,里面有 index.ts / index.js,或者有带 "pi.extensions" 字段的 package.json

ExtensionAPI 接口是扩展能做的事情的总集。完整的 ExtensionAPI 接口看起来像这样(节选):

export interface ExtensionAPI {
    // 事件订阅(on(),共 25+ 种事件)
    on(event: string, handler: ExtensionHandler<...>): void;

    // 注册能力(registerX() / sendX() 系列)
    registerTool<TParams>(tool: ToolDefinition<TParams, ...>): void;
    registerCommand(name: string, options: Omit<RegisteredCommand, "name" | "sourceInfo">): void;
    // ... sendX / appendEntry / setModel / setThinkingLevel 等主动操作
}

看起来东西很多,但逻辑上只有两类:订阅事件on)和 注册能力registerX / sendX)。

拦截、变换、阻止

大部分事件的 handler 是可以返回值的(ExtensionHandler<Event, Result>),Result 的形状决定了扩展能影响什么。举几个有代表性的例子:

  • input 事件:返回 { action: "handled" } 表示"我吃掉了这次输入,别往下走了";返回 { action: "transform", text, images } 表示"我改写了输入"。这让扩展可以实现自己的 slash command 或者输入预处理(比如自动粘贴剪贴板)。

  • tool_call 事件:在工具实际执行前触发。扩展可以修改参数,也可以直接阻止执行并返回一个 fake result。比如一个"安全审查"扩展可以在 bash 要跑 rm -rf 时拦住并要求用户确认。

  • tool_result 事件:工具执行完、结果回到 LLM 之前触发。扩展可以修改结果,典型用途是"给 read 工具读出来的代码加行号",或者"把超长的 tool result 截断并加个摘要"。

  • context 事件:每次 LLM 请求发出前触发,messages 字段是实际要送出去的消息数组。扩展可以修改这个数组,典型用途是"注入一个提醒 message,让 LLM 记住某条最新的状态"。

  • before_agent_start 事件:在 Agent 启动前触发,扩展可以返回一个新的 systemPrompt 或一批要插入 context 的消息。

  • session_before_compact / session_before_tree 事件:扩展可以返回 { cancel: true } 来阻止一次 compaction 或 tree 切换;也可以返回自己生成的 summary,让 AgentSession 用扩展的结果而不是默认的 LLM 调用结果。

  • resources_discover 事件:扩展可以返回额外的 skill / prompt / theme 路径。这是一条"反向注入"路径:extension 被加载后,可以把自己带的 skill 和 prompt 注册回 ResourceLoader,重新合并路径并触发一次 reload。

所有这些接口拼在一起,构成了一套"不改核心代码就能改变 coding agent 行为"的通道。Extension 通过订阅事件介入关键节点,通过 registerX 添加能力,通过 appendEntry / CustomEntry 持久化自己的状态(下次 resume 时 extension 可以扫描自己关心的 customType 重建状态)。核心代码永远不需要知道有哪些扩展在跑。

这和 packages/agent 那 7 个向上委托的 callback 本质是一回事,只是从函数参数扩到了事件总线,但是设计原则是一致的。

总结:优雅的逻辑抽象

到这里,让我们复盘一下 pi 的设计。

packages/ai 管一次 LLM 调用。provider 的差异在这层消化掉,上层拿到的是统一的流式事件。packages/agent 管一个完整的 agent run:双重 while 循环驱动 turn 和 follow-up,tool call 默认并发启动、顺序回收,7 个 callback 把权限的决定权交给上层。这两层都是无状态的,跑完就没了。

packages/coding-agent 是有状态的。append-only entry 树让 resume、branch、compaction 都变成了 "移动 leafId" 这一个简单操作;compaction 在 threshold 和 overflow 两个时机触发,用 update prompt 做增量更新避免信息丢失;extension 系统通过 事件总线 实现了 "不改核心代码就能改变行为"。

三层之间的边界很干净,ai 不知道 agent 的存在,agent 不知道 coding-agent 的存在。每一层只通过 callback 或事件把决定权向上交出去,职责单一/知识最小,自己不做不属于自己的事。这让 aiagent 可以被复用到任何其他 agent 产品里,有状态的复杂性全部隔离在最上面一层。

目录