有部分ai辅助生成

总览:

最近在看 earendil-works/pi 这个 coding agent 项目。它最值得学习的地方不是 TypeScript 语法,也不是简单的“调用 bash / read / write 工具”,而是两个更底层的架构设计:

  1. Extension 插件系统:让 Agent Runtime 可以被外部模块扩展。

  2. 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,但随着功能变多,主循环会越来越臃肿。

比如你想加:

  • 工具调用前审批

  • 禁止模型修改 .envnode_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:保护 .envnode_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 = [];

但这样会有问题:

  1. Agent 重启后状态丢失

  2. session 切换后状态丢失

  3. fork 分支后状态可能不正确

  4. 不同 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 会话元信息

这里最值得注意的是 customcustom_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,都会自然很多。


参考源码

  1. Extension 能力总览
    packages/coding-agent/docs/extensions.md
    重点:L249-L267

  2. Extension 写法
    packages/coding-agent/docs/extensions.md
    重点:L371-L390

  3. tool_call 拦截工具调用
    packages/coding-agent/docs/extensions.md
    重点:L790-L804

  4. registerTool 注册自定义工具
    packages/coding-agent/docs/extensions.md
    重点:L309-L323L1255-L1264

  5. Extension 状态管理
    packages/coding-agent/docs/extensions.md
    重点:L1590-L1618

  6. Session JSONL 格式
    packages/coding-agent/docs/session-format.md
    重点:L242-L245

  7. Entry Base:id / parentId
    packages/coding-agent/docs/session-format.md
    重点:L387-L396

  8. CustomEntry / CustomMessageEntry
    packages/coding-agent/docs/session-format.md
    重点:L443-L460

  9. SessionManager append-only tree
    packages/coding-agent/src/core/session-manager.ts
    重点:L3485-L3500L3813-L3856

  10. buildSessionContext
    packages/coding-agent/src/core/session-manager.ts
    重点:L2774-L2791

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐