【π】agent 记忆的形状:append-only 树、压缩与分支
上一篇 讲完了 packages/ai 和 packages/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.messages与SessionManager文件之间的同步(在 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.md。ResourceLoader 会在 {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"这条,只有在同时激活
bash和grep/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.ts,expandPromptTemplate() 按名字匹配到这个模板,把 $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 开始一直往下长,形成一棵树。leafId 是 SessionManager 里一个独立的指针,指向"当前活跃末端"。
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 打开消息树选择器,用户选中 asst1,AgentSession.navigateTree() 被调用,最后落到 SessionManager.branch() 进行分支切换:
branch(branchFromId: string): void {
if (!this.byId.has(branchFromId)) {
throw new Error(`Entry ${branchFromId} not found`);
}
this.leafId = branchFromId;
}
什么都不删,只是把 leafId 指回 asst1。之后用户再输入新消息时,新 msg4 的 parentId = 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() 先将它转为 BranchSummaryMessage(role: "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 或跑 /resume,SessionManager.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 只包含老历史(boundaryStart → turnStartIndex),被切 turn 的前半段是单独的 turnPrefixMessages(turnStartIndex → firstKeptEntryIndex)。两段内容通过 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 或事件把决定权向上交出去,职责单一/知识最小,自己不做不属于自己的事。这让 ai 和 agent 可以被复用到任何其他 agent 产品里,有状态的复杂性全部隔离在最上面一层。