从 Pi 学 Coding Agent 架构:Extension 插件系统与 Tree Session 状态树
有部分ai辅助生成
总览:
最近在看 earendil-works/pi 这个 coding agent 项目。它最值得学习的地方不是 TypeScript 语法,也不是简单的“调用 bash / read / write 工具”,而是两个更底层的架构设计:
-
Extension 插件系统:让 Agent Runtime 可以被外部模块扩展。
-
Tree Session 状态树:让会话历史从线性 messages 升级成可分叉、可回放、可压缩的树。
这两个点对做 coding agent 很有启发:
Extension 解决“Agent 怎么扩展”,Tree Session 解决“Agent 的历史和状态怎么管理”。
1. 为什么普通 Agent Loop 不够用?
一个最小 Agent 通常长这样:
while not done:
call LLM
if tool_call:
execute tool
append result
这种写法适合 MVP,但随着功能变多,主循环会越来越臃肿。
比如你想加:
-
工具调用前审批
-
禁止模型修改
.env、node_modules -
执行危险命令前弹确认框
-
每轮对话后做 Git checkpoint
-
注册自定义工具
-
添加
/review、/summary这类 slash command -
保存 extension 自己的状态
-
对上下文做自定义压缩
如果这些都写进主 Agent Loop,最后代码会变成一大坨。
Pi 的思路是:
Agent Loop 保持核心流程;
额外能力通过 Extension 插进去。
也就是说,Agent Runtime 在关键节点发事件,外部 extension 通过 hook 监听、拦截、增强。
2. Extension:Agent Runtime 的插件系统
Pi 的 extension 本质上是一个模块,导出一个默认函数:
export default function (pi: ExtensionAPI) {
pi.on("event_name", async (event, ctx) => {
// 监听事件
});
pi.registerTool({
// 注册工具
});
pi.registerCommand("name", {
// 注册 slash command
});
}
不用纠结 TypeScript 语法,可以理解成:
extension = 一个函数
输入 = ExtensionAPI
能力 = 订阅事件 + 注册工具 + 注册命令 + 修改运行时行为
Pi 文档里明确提到 extension 可以:
| 能力 | 作用 |
|---|---|
pi.on() |
监听生命周期事件 |
pi.registerTool() |
注册 LLM 可调用工具 |
pi.registerCommand() |
注册 slash command |
ctx.ui |
和用户交互,比如 confirm / select / input |
pi.appendEntry() |
持久化 extension 状态 |
| tool interception | 拦截工具调用 |
官方例子包括:
-
Permission gates:权限门禁
-
Git checkpointing:自动 Git checkpoint
-
Path protection:保护
.env、node_modules -
Custom compaction:自定义上下文压缩
-
Conversation summaries:对话总结
-
CI / webhook integration:外部系统集成
所以 Pi 的 extension 不只是“工具注册器”,更像是:
Agent Runtime 的中间件系统。
源码建议看:
packages/coding-agent/docs/extensions.md
重点:L249-L267、L371-L390
3. 最关键的 Hook:tool_call
Pi 的 extension 生命周期里,最值得关注的是 tool_call。
它的触发时机是:
模型已经决定调用工具;
但工具还没有真正执行。
这意味着 extension 可以在工具执行前做拦截。
简化示例:
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash") {
const command = event.input.command;
if (command.includes("rm -rf")) {
return {
block: true,
reason: "危险命令,已拦截"
};
}
}
});
这个 hook 可以做很多事情:
| 场景 | 做法 |
|---|---|
| 阻止危险命令 | 拦截 bash 中的 rm -rf |
| 保护敏感文件 | 拦截对 .env 的 read/write |
| 保护依赖目录 | 禁止修改 node_modules |
| 人工审批 | 调用 ctx.ui.confirm() |
| 工具审计 | 记录每次工具调用 |
| 参数修正 | 修改 event.input |
对 coding agent 来说,tool_call 很像一个安全阀门。
它的价值在于:
安全策略不用写死在某个工具里,而是可以放在 runtime extension 层统一处理。
源码建议看:
packages/coding-agent/docs/extensions.md
重点:L790-L804
4. registerTool:工具不只是一个函数
Pi 的 extension 还可以注册自定义工具:
pi.registerTool({
name: "greet",
label: "Greet",
description: "Greet someone by name",
parameters: Type.Object({
name: Type.String({ description: "Name to greet" }),
}),
async execute(toolCallId, params, signal, onUpdate, ctx) {
return {
content: [{ type: "text", text: `Hello, ${params.name}!` }],
details: {},
};
},
});
这个设计说明,Pi 里的 tool 不是简单函数,而是一组结构化定义:
Tool = 元数据 + 参数 schema + 执行函数 + prompt 注入规则 + UI 渲染
普通 Agent 可能这样写:
tools = {
"read": read_file,
"bash": run_shell,
}
但 Pi 的工具更工程化。一个工具不仅要能执行,还要:
-
描述给模型看
-
定义参数 schema
-
控制是否进入 prompt
-
控制工具调用怎么展示
-
控制工具结果怎么展示
-
保存执行细节
这点对 coding agent 很重要。
因为工具不是普通函数,而是 LLM 和外部世界交互的边界。
源码建议看:
packages/coding-agent/docs/extensions.md
重点:L309-L323、L1255-L1264
5. Extension 状态为什么不能只放内存?
很多 extension 都是有状态的,比如:
-
todo list
-
webhook 状态
-
当前任务阶段
-
已处理文件列表
-
工具执行统计
最简单的写法是:
let items = [];
但这样会有问题:
-
Agent 重启后状态丢失
-
session 切换后状态丢失
-
fork 分支后状态可能不正确
-
不同 branch 的状态可能混在一起
Pi 的思路是:
extension 状态应该从当前 session branch 里恢复。
简化逻辑:
pi.on("session_start", async (_event, ctx) => {
items = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "toolResult") {
if (entry.message.toolName === "my_tool") {
items = entry.message.details?.items ?? [];
}
}
}
});
这说明 extension 状态不是和整个进程绑定,而是和当前 session branch 绑定。
这就引出了 Pi 的第二个亮点:Tree Session。
源码建议看:
packages/coding-agent/docs/extensions.md
重点:L1590-L1618
6. Tree Session:为什么不用线性 messages?
普通 Agent 会话通常是线性的:
messages = [
user,
assistant,
tool,
assistant,
user,
assistant
]
这种结构适合普通聊天,但不太适合 coding agent。
因为 coding agent 经常会:
-
试错
-
回退
-
从某个历史节点重新开始
-
fork 一个新方案
-
压缩旧上下文
-
给某个节点打 checkpoint
-
根据当前分支恢复 extension 状态
如果只用线性 messages,这些行为会很难表达。
Pi 的做法是:
每一行 JSONL = 一个 entry
每个 entry 有 id / parentId
整份 session = 一棵树
当前对话位置 = leaf
也就是说,它不是简单记录一条聊天历史,而是记录一棵可分叉的会话树。
源码建议看:
packages/coding-agent/docs/session-format.md
重点:L242-L245、L387-L396
7. JSONL + id / parentId
Pi 的 session 文件大概长这样:
{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path/to/project"}
{"type":"message","id":"a1","parentId":null,"timestamp":"...","message":{"role":"user","content":"Hello"}}
{"type":"message","id":"b2","parentId":"a1","timestamp":"...","message":{"role":"assistant","content":[{"type":"text","text":"Hi"}]}}
{"type":"message","id":"c3","parentId":"b2","timestamp":"...","message":{"role":"toolResult","toolName":"bash","content":[{"type":"text","text":"output"}]}}
核心字段是:
| 字段 | 含义 |
|---|---|
type |
entry 类型 |
id |
当前节点 ID |
parentId |
父节点 ID |
timestamp |
时间 |
message / data / summary |
具体载荷 |
其中最关键的是:
parentId
有了 parentId,session 就不是一条线,而是一棵树。
8. Session 不只是 Message
Pi 的 session entry 类型很多,不只有普通 message。
可以整理成:
| Entry 类型 | 作用 | 是否进入 LLM Context |
|---|---|---|
message |
普通 user / assistant / toolResult | 是 |
compaction |
压缩旧上下文后的 summary | 是 |
branch_summary |
切换分支时保留被放弃路径的信息 | 是 |
custom |
extension 持久化状态 | 否 |
custom_message |
extension 注入上下文 | 是 |
label |
给节点打标签 | 否 |
session_info |
会话元信息 | 否 |
这里最值得注意的是 custom 和 custom_message。
custom
custom 用来保存 extension 内部状态,不给模型看。
例如:
{"type":"custom","id":"h8","parentId":"g7","customType":"my-extension","data":{"count":42}}
适合保存:
-
todo 状态
-
webhook 触发时间
-
工具统计
-
extension 内部数据
custom_message
custom_message 用来给模型注入额外上下文,会进入 LLM context。
例如:
{"type":"custom_message","id":"i9","parentId":"h8","customType":"my-extension","content":"当前正在 review PR","display":true}
这个区分很重要:
Agent 内部状态 ≠ 模型应该看到的上下文
不是所有状态都应该塞进 prompt。
Pi 把“内部状态”和“模型上下文”分开了。
源码建议看:
packages/coding-agent/docs/session-format.md
重点:L408-L460
9. append-only tree:Session 怎么推进?
Pi 的 SessionManager 核心逻辑可以简化成:
private _appendEntry(entry: SessionEntry): void {
this.fileEntries.push(entry);
this.byId.set(entry.id, entry);
this.leafId = entry.id;
this._persist(entry);
}
它做了四件事:
1. 追加 entry
2. 更新 byId 索引
3. 更新当前 leaf
4. 持久化到 JSONL
追加 message 时,新节点的 parentId 会指向当前 leaf:
const entry = {
type: "message",
id: generateId(this.byId),
parentId: this.leafId,
timestamp: new Date().toISOString(),
message,
};
所以 session 是这样推进的:
当前 leaf
↓
append child
↓
child 成为新的 leaf
↓
继续 append
如果从旧节点继续 append,就自然形成了新分支。
例如:
A user
└── B assistant
└── C toolResult
├── D assistant: 方案 1
└── E assistant: 方案 2
这对 coding agent 很有用。
因为 agent 经常会尝试不同修改方案,Tree Session 可以保留这些分支,而不是把所有试错都混在一条线性历史里。
源码建议看:
packages/coding-agent/src/core/session-manager.ts
重点:L3485-L3500、L3813-L3856
10. buildSessionContext:树怎么变成模型上下文?
虽然 Pi 存储时用的是树,但发给 LLM 时,最终还是要变成线性 messages。
所以它需要一个转换过程:
Tree Session
↓
当前 leaf 所在路径
↓
线性 messages
↓
发给 LLM
也就是从当前 leaf 沿着 parentId 一直回溯到 root,再反转成当前 branch path。
伪代码:
path = []
current = leaf
while current:
path.unshift(current)
current = byId[current.parentId]
messages = buildMessagesFrom(path)
核心思想是:
存储结构是树;
模型输入是线性数组;
中间靠 buildSessionContext 做投影。
这点非常关键。
不是把整棵树都发给模型,而是只把当前 leaf 所在路径发给模型。
源码建议看:
packages/coding-agent/src/core/session-manager.ts
重点:L2774-L2791
11. 如果迁移到自己的 Python Agent,可以怎么做?
不用一上来完全照搬 Pi,可以先借鉴两个核心思想。
11.1 做一个最小 HookManager
class HookManager:
def __init__(self):
self.handlers = {}
def on(self, event_name, handler):
self.handlers.setdefault(event_name, []).append(handler)
async def emit(self, event_name, event):
results = []
for handler in self.handlers.get(event_name, []):
result = await handler(event)
results.append(result)
return results
第一阶段可以支持:
session_start
before_agent_start
context
tool_call
tool_result
turn_end
agent_end
最先做 tool_call 就够有价值,因为它可以马上支持:
-
危险命令拦截
-
文件路径保护
-
写入前审批
-
工具调用审计
11.2 把 messages 表升级成 entries 表
如果当前是线性表:
messages(
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
created_at TEXT
)
可以升级成:
entries(
id TEXT PRIMARY KEY,
session_id TEXT,
parent_id TEXT,
type TEXT,
payload JSON,
created_at TEXT
)
再加一个:
sessions(
id TEXT PRIMARY KEY,
leaf_id TEXT,
cwd TEXT,
created_at TEXT,
updated_at TEXT
)
核心字段只有四个:
id
parent_id
type
payload
这样就能模拟 Pi 的 Tree Session。
11.3 最小 append 逻辑
def append_entry(session_id, entry_type, payload):
session = get_session(session_id)
entry = {
"id": generate_id(),
"session_id": session_id,
"parent_id": session["leaf_id"],
"type": entry_type,
"payload": payload,
"created_at": now(),
}
insert_entry(entry)
update_session_leaf(session_id, entry["id"])
return entry["id"]
11.4 最小 build_context 逻辑
def build_context(session_id):
session = get_session(session_id)
current = get_entry(session["leaf_id"])
path = []
while current:
path.append(current)
current = get_entry(current["parent_id"]) if current["parent_id"] else None
path.reverse()
messages = []
for entry in path:
if entry["type"] == "message":
messages.append(entry["payload"])
elif entry["type"] == "custom_message":
messages.append({
"role": "user",
"content": entry["payload"]["content"],
})
elif entry["type"] == "compaction":
messages.append({
"role": "user",
"content": entry["payload"]["summary"],
})
return messages
这就是 Pi 的核心思想:
树状存储
↓
沿 leaf 回溯当前 branch
↓
转换成 LLM messages
12. 总结
Pi 最值得学习的不是 TypeScript,而是两个架构选择。
第一,Agent Runtime 不应该是封闭的 while loop,而应该暴露 extension hooks。
这样权限、安全、工具增强、UI、summary、webhook、CI trigger 都可以通过 extension 加进去,而不是堆在主循环里。
第二,Agent Session 不应该只是线性 messages,而应该是 append-only tree。
因为 coding agent 的真实使用方式天然包含试错、回退、分叉、压缩和状态恢复。线性 messages 很难表达这些行为。
一句话总结:
Extension 解决“Agent 怎么被扩展”;
Tree Session 解决“Agent 的历史和状态怎么被管理”。
如果要把 Pi 的思想迁移到自己的 Agent 项目里,最值得先做三个点:
1. 给 tool_call 加 hook
2. 给 session entry 增加 parent_id
3. 给 session 增加 leaf_id
这三个点做好之后,后面再做权限审批、Git checkpoint、自动 summary、branch/fork、工具审计、custom skill,都会自然很多。
参考源码
-
Extension 能力总览
packages/coding-agent/docs/extensions.md
重点:L249-L267 -
Extension 写法
packages/coding-agent/docs/extensions.md
重点:L371-L390 -
tool_call拦截工具调用packages/coding-agent/docs/extensions.md
重点:L790-L804 -
registerTool注册自定义工具packages/coding-agent/docs/extensions.md
重点:L309-L323、L1255-L1264 -
Extension 状态管理
packages/coding-agent/docs/extensions.md
重点:L1590-L1618 -
Session JSONL 格式
packages/coding-agent/docs/session-format.md
重点:L242-L245 -
Entry Base:
id / parentIdpackages/coding-agent/docs/session-format.md
重点:L387-L396 -
CustomEntry / CustomMessageEntry
packages/coding-agent/docs/session-format.md
重点:L443-L460 -
SessionManager append-only tree
packages/coding-agent/src/core/session-manager.ts
重点:L3485-L3500、L3813-L3856 -
buildSessionContextpackages/coding-agent/src/core/session-manager.ts
重点:L2774-L2791
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)