深入 pi-agent 内核:完整追踪一次 CLI 交互的完整链路

为什么要把「一次对话」拆到函数级

上篇文章中我跑通了第一个插件(/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 事件

用时序图看更直观:

sequenceDiagram participant U as 用户 participant S as AgentSession participant A as agent 包 participant M as Model / LLM participant R as Tool Runner participant T as Tool U->>S: 输入「123 + 456 等于几」 S->>S: prompt() 组装上下文/系统提示词 S->>A: agent.prompt(messages) 跨包桥 A->>M: 请求(messages + tools 定义) M-->>A: tool_call("add", {a:123, b:456}) A->>R: executeToolCalls R->>T: execute({a:123, b:456}) T-->>R: {content: "123 + 456 = 579"} R-->>A: toolResult 消息 A->>M: 追加结果,再次请求 M-->>A: 最终文本 A-->>S: agent_settled S-->>U: 渲染输出

最重要的架构认知:两个包的职责分界

这条链路里最值钱的一个发现是 agent-session.ts:1066await this.agent.prompt(messages)------它是 coding-agent 到 agent 的跨包桥

  • @earendil-works/pi-coding-agentpackages/coding-agent):管会话、资源加载、扩展注册、UI。它是「壳」。
  • @earendil-works/pi-agentpackages/agent):管模型循环本身。它是「核」。

你注册的工具、写的扩展,都活在壳里;但真正循环调模型、发现 tool_call、执行工具的逻辑,在核里。理解这个分界,后面看任何 pi 扩展的代码都不会迷路。

ExtensionAPI 全貌:一张图数完 9 组能力

M0 我只会 registerCommandregisterTool。这轮把 ExtensionAPI 接口(packages/coding-agent/src/core/extensions/types.ts:1198-1437)通读了一遍,源码按注释分了 9 个区块:

# 分组 代表 API 触发者
1 事件订阅 on("tool_call") 等 31 个事件 pi 运行时
2 工具注册 registerTool LLM
3 命令/快捷键/标志 registerCommandregisterShortcutregisterFlag 用户
4 消息渲染 registerMessageRendererregisterMarkdownTransformer pi 渲染层
5 动作 sendMessagesendUserMessageappendEntry 扩展主动
6 会话元数据 getCommandsgetAllToolssetActiveToolsexec 扩展主动
7 模型与思考级别 setModelsetThinkingLevel 扩展主动
8 Provider registerProviderunregisterProvider 扩展主动
9 共享事件总线 events: EventBus 任意

最容易踩的坑ctx.ui.xxx 不在 ExtensionAPI 上------它属于 ExtensionContext(handler 收到的 ctx 参数)。「注册 API」和「上下文工具」是两个层面的东西,别混。

事件订阅里最容易被忽略的一点ExtensionHandler<E, R> 的第二个泛型 R 决定你能不能拦截。tool_callinputcontextR,handler 可以返回 {block: true} 中断流程;agent_start 这类没有 R,只能「听」不能「拦」。

registerTool 深潜:工具是怎么被调起来的

注册:只存不干

registerTool 的实现(loader.ts:264)和 M0 学的所有注册方法一样------把工具定义塞进 extension.tools Map。真正干活在 agent 包。

执行:按名字 find

模型返回 tool_call 后,runLoop 把它交给 executeToolCalls,进入 prepareToolCallagent-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 工具结果,不执行任何东西。

完整链路:

sequenceDiagram autonumber participant M as Model participant L as runLoop (agent-loop.ts:155) participant P as prepareToolCall (600) participant X as execute() participant T as emitToolResult (runner.ts:877) M->>L: tool_call(&#34;add&#34;, {a:123, b:456}) L->>P: executeToolCalls → prepareToolCall P->>P: tools.find(&#34;add&#34;) ← 按名字查找 P->>X: executePreparedToolCall 执行 X-->>P: {content: &#34;123 + 456 = 579&#34;} P->>T: finalize → emitToolResult T-->>M: toolResult 消息 → 追加上下文 → 再请求

实验:让 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 的三个核心认知:

  1. 两个包的职责分界 :coding-agent 是壳(会话/资源/扩展),agent 是核(模型循环)。跨包桥在 agent-session.ts:1066
  2. ExtensionAPI 是 9 组能力的注册面:工具/命令/事件/渲染/动作/会话元数据/模型/Provider/事件总线,触发者各不相同。
  3. 工具的完整生命周期:注册只存注册表 → 模型发起 tool_call → 按 name find → 执行 → toolResult 回传 → 下一轮循环。

下一步计划:基于本文的 API 地图,开始分析官方 examples/extensions/ 里的社区插件(permission-gate、git-checkpoint、todo),归纳真实插件怎么组合这些 API、提炼可复用的设计模式。

如果你也在研究 Agent,欢迎在评论区交流。

相关推荐
zfoo-framework1 小时前
安装pi agent 支持duojie和deepseek
java·服务器·前端
l1m0_1 小时前
告别反复修改Prompt:AI生成React应用并落地开发的实战复盘
前端·react.js·ui·ai·设计
xexpertS2 小时前
前端工程转型实践:从 Ember 迁移到 React,提升构建速度与研发效能
前端·react.js·前端框架
大家的林语冰2 小时前
👍 JS 还在进化,ES2026 正式推出,最新七大特性补全!
前端·javascript·json
铁皮饭盒2 小时前
DeepSeek V4 Pro 0813发布了, 也可以部署到 Codex 了
前端·javascript·后端
ClouGence2 小时前
实测 DeepSeek V4 Pro 正式版:从数据分析、做网站到复杂模拟,能做到什么程度?
agent·ai编程·deepseek
cyadyx2 小时前
OpenTelemetry将 Agent 集成数据上报到 Langfuse
agent·opentelemetry·langfuse
Sterting3 小时前
反馈组件:对话框、消息与通知
前端·javascript·vue.js
breeze jiang3 小时前
ESLint flat config 配置实战:五大字段、规则严重级别与 --fix 能力边界详解
开发语言·前端·javascript