为什么要把「一次对话」拆到函数级
上篇文章中我跑通了第一个插件(/hello 命令),但那只是「会用」。这次的目标是「懂」:当我在 pi 里敲下一行「123 + 456 等于几」,到我看到回答,这中间到底发生了什么?
本文把这条链路从入口拆到工具执行,每一环都标注源码文件:行号。配合一张时序图和一张工具调用图,读完你也能闭着眼画出 pi 的运行时骨架。
环境与版本
- 仓库:github.com/earendil-wo...
- 版本:0.84.1
- 关键目录:
packages/coding-agent(会话/资源/扩展)、packages/agent(模型循环)
一次交互的完整调用栈
在 packages/coding-agent/src/core/agent-session.ts 里,从 sendUserMessage 一路跟下去,我得到了这条链路:
scss
用户输入
→ sendUserMessage(agent-session.ts:1481)
→ prompt(agent-session.ts:1116)
→ _runAgentPrompt(agent-session.ts:1063)
├─ await this.agent.prompt(messages)(agent-session.ts:1066) ← 跨包桥
│ → runPromptMessages(packages/agent/src/agent.ts:409)
│ ├─ createContextSnapshot(agent.ts:437) ← AgentContext 快照
│ └─ runWithLifecycle(agent.ts:413/486) ← 包生命周期事件
│ → runAgentLoop(packages/agent/src/agent-loop.ts:95)
│ → runLoop(agent-loop.ts:155) ← 模型/工具循环
├─ while _handlePostAgentRun()(agent-session.ts:1077) ← 重试/follow-up
│ └─ _checkCompaction(1098/1962) ← 上下文压缩检查
└─ finally:
├─ _flushPendingBashMessages(2862)
└─ _emitAgentSettled(1073/596) ← 发 agent_settled 事件
用时序图看更直观:
最重要的架构认知:两个包的职责分界
这条链路里最值钱的一个发现是 agent-session.ts:1066 的 await this.agent.prompt(messages)------它是 coding-agent 到 agent 的跨包桥:
@earendil-works/pi-coding-agent(packages/coding-agent):管会话、资源加载、扩展注册、UI。它是「壳」。@earendil-works/pi-agent(packages/agent):管模型循环本身。它是「核」。
你注册的工具、写的扩展,都活在壳里;但真正循环调模型、发现 tool_call、执行工具的逻辑,在核里。理解这个分界,后面看任何 pi 扩展的代码都不会迷路。
ExtensionAPI 全貌:一张图数完 9 组能力
M0 我只会 registerCommand 和 registerTool。这轮把 ExtensionAPI 接口(packages/coding-agent/src/core/extensions/types.ts:1198-1437)通读了一遍,源码按注释分了 9 个区块:
| # | 分组 | 代表 API | 触发者 |
|---|---|---|---|
| 1 | 事件订阅 | on("tool_call") 等 31 个事件 |
pi 运行时 |
| 2 | 工具注册 | registerTool |
LLM |
| 3 | 命令/快捷键/标志 | registerCommand、registerShortcut、registerFlag |
用户 |
| 4 | 消息渲染 | registerMessageRenderer、registerMarkdownTransformer |
pi 渲染层 |
| 5 | 动作 | sendMessage、sendUserMessage、appendEntry |
扩展主动 |
| 6 | 会话元数据 | getCommands、getAllTools、setActiveTools、exec |
扩展主动 |
| 7 | 模型与思考级别 | setModel、setThinkingLevel |
扩展主动 |
| 8 | Provider | registerProvider、unregisterProvider |
扩展主动 |
| 9 | 共享事件总线 | events: EventBus |
任意 |
最容易踩的坑 :ctx.ui.xxx 不在 ExtensionAPI 上------它属于 ExtensionContext(handler 收到的 ctx 参数)。「注册 API」和「上下文工具」是两个层面的东西,别混。
事件订阅里最容易被忽略的一点 :ExtensionHandler<E, R> 的第二个泛型 R 决定你能不能拦截。tool_call、input、context 有 R,handler 可以返回 {block: true} 中断流程;agent_start 这类没有 R,只能「听」不能「拦」。
registerTool 深潜:工具是怎么被调起来的
注册:只存不干
registerTool 的实现(loader.ts:264)和 M0 学的所有注册方法一样------把工具定义塞进 extension.tools Map。真正干活在 agent 包。
执行:按名字 find
模型返回 tool_call 后,runLoop 把它交给 executeToolCalls,进入 prepareToolCall(agent-loop.ts:600)。核心是这一行:
typescript
const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
if (!tool) {
return {
kind: "immediate",
result: createErrorToolResult(`Tool ${toolCall.name} not found`),
isError: true,
};
}
工具执行的第一个动作不是执行,而是 find ------从 currentContext.tools 数组里按名字找。找不到直接返回一个 error 工具结果,不执行任何东西。
完整链路:
实验:让 LLM 真的调一次工具
理论链路的终点是实弹。我用 registerTool 写了个加法工具:
typescript
// .pi/extensions/add-tool.ts
import { Type } from "@earendil-works/pi-ai";
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
const addTool = defineTool({
name: "add",
label: "Add",
description: "当用户问加法/算术问题时使用,计算两个数字之和",
parameters: Type.Object({
a: Type.Number({ description: "第一个数字" }),
b: Type.Number({ description: "第二个数字" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
if (typeof params.a !== "number" || typeof params.b !== "number") {
return { content: [{ type: "text", text: "请输入两个数字" }] };
}
const result = params.a + params.b;
return {
content: [{ type: "text", text: `${params.a} + ${params.b} = ${result}` }],
details: { a: params.a, b: params.b, result },
};
},
});
export default function (pi: ExtensionAPI) {
pi.registerTool(addTool);
}
在 pi 里实际运行:
ini
> 123 + 456 等于几
The user is asking a simple arithmetic question: 123 + 456 = ?
I have an add tool available. Let me use it.
add
123 + 456 = 579
123 + 456 = 579
注意观察:模型主动想起了 add 工具(description 起作用了),发起 tool_call,拿到结果后继续生成最终回答------这就是这篇文章链路的一次实弹演练。
总结与展望
M1 的三个核心认知:
- 两个包的职责分界 :coding-agent 是壳(会话/资源/扩展),agent 是核(模型循环)。跨包桥在
agent-session.ts:1066。 - ExtensionAPI 是 9 组能力的注册面:工具/命令/事件/渲染/动作/会话元数据/模型/Provider/事件总线,触发者各不相同。
- 工具的完整生命周期:注册只存注册表 → 模型发起 tool_call → 按 name find → 执行 → toolResult 回传 → 下一轮循环。
下一步计划:基于本文的 API 地图,开始分析官方 examples/extensions/ 里的社区插件(permission-gate、git-checkpoint、todo),归纳真实插件怎么组合这些 API、提炼可复用的设计模式。
如果你也在研究 Agent,欢迎在评论区交流。