写在前面:本章是全书难度最大的一章——事件系统是 pi-agent 的核心机制,概念较多。我已经尽量简化、只留重点。它的价值在于把 pi-agent 的运行机制讲透。建议沉下心顺着读一遍。
一、你想在 Agent 里加入自己的逻辑,怎么做
跟着第 5 章走下来,你已经能让 Agent 调你的工具了,查数据、调 API 都跑通了。
但真做业务时,你会遇到不少「光靠工具解决不了」的需求。比如:
-
拦危险操作:LLM 调你的查询工具时,偶尔会传离谱的参数(比如想把整张表一次性取出来)。你想在它真执行之前就拦下来。
-
给工具调用打日志:5 个业务工具,每个都得记「谁调的、传了什么、花了多久、成没成」。要是在每个工具里复制一遍日志代码,就是 5 份重复劳动。
-
让 Agent 记住用户偏好:用户第一句说「以后数字都用万元为单位」,第二轮 Agent 又得被提醒——因为内存会话重启就忘记。能不能让它自动带上?
-
监控 LLM 厂商限流:智谱、OpenAI 偶尔返回 429(请求太频繁),你想收到 429 就自动告警。可
session.prompt()内部那个 HTTP 请求,你根本看不见。
这四个需求有一个共同点。
它们都不是「换工具」或「改提示词」能解决的。它们都是想在 Agent 干活的某个环节上,加一段你自己的代码。 「工具执行前」「工具执行后」「给 LLM 发请求前」「会话刚启动时」——在这些固定环节上,挂自己的代码。
pi-agent 提供的这种「加自己代码」的机制,叫事件监听。
二、事件监听:广播通知,谁注册谁处理
pi-agent 的事件机制,用一句话概括:框架在干活的各个关键环节,会发出一个事件;你提前注册好「这个事件我来处理」,事件发生时你的代码就被调用。
事件发出后不关心谁来处理,只负责通知到位。你提前注册了哪个事件、写了什么处理逻辑,那段逻辑就在事件发生时跑起来。
这段代码可以做任何事——拦截、记录、改数据、记状态……同一个事件,写不同的业务逻辑,就能实现完全不同的功能。开头那四个需求,对应的就是同一套机制下、不同事件、不同代码:
| 你的需求 | 监听哪个事件 | 这段代码做什么 |
|---|---|---|
| 拦危险查询 | 工具执行前 | 检查参数,危险就喊「停」 |
| 给工具打日志 | 工具开始/结束 | 记一笔,算耗时 |
| 注入用户偏好 | 发 LLM 前 | 往消息列表里加一段偏好 |
| 监控限流 | 收到 LLM 响应后 | 看状态码,429 就告警 |
核心是:事件是固定的(框架定的),监听器是你写的(业务定的)。 写什么逻辑,就能实现什么功能。
这套「在固定环节监听事件、运行业务逻辑」的能力,pi-agent 统一叫扩展(Extension)。名字听着复杂,其实它就是「在 Agent 干活的固定环节上,挂一段你自己的代码」,仅此而已。下面用一段全景代码,让你看清整件事从头到尾怎么做,细节后面再展开。
2.1 全景图:从有需求到代码接入的完整过程
这一节把整个流程走一遍,让你脑子里先有个全景图,后面再分门别类地展开。
整个过程就三步:决定监听哪个事件 → 写处理代码 → 把代码接进 Agent。
第 1 步:决定监听哪个事件,写处理代码。
框架启动时会调用你这个函数,给你传一个 pi 对象——pi 就是用来控制 Agent 的接口。你用 pi.on("事件名", 处理函数) 监听事件,处理函数会收到一个 event 对象,里面是这个事件的数据。你读它的数据、做你的事;想影响 Agent,就 return 一个特定对象。
// 这就是一个扩展:框架启动时调它,把遥控器 pi 传进来
function 我的扩展(pi) {
// 用 pi.on 盯住「工具调用前」这个事件
// event 是框架给你的事件数据,里面有工具名、参数等信息
pi.on("tool_call", async (event) => {
// 读 event 里的数据:哪个工具、参数是啥
if (event.toolName === "drop_table") {
// 想拦住?return 这个对象,框架就停掉这次工具调用
return { block: true, reason: "禁止删表" };
}
return undefined; // 啥也不 return,就是「放行」,工具正常执行
});
}
监听代码的核心就两步:第一步读 event,第二步决定做什么。
event 是什么?它是这次事件的数据——告诉你刚发生了什么。比如工具调用的事件,event 里就有「哪个工具、参数是什么」;LLM 响应的事件,event 里就有「HTTP 状态码、响应头」。拿到这个数据,就能决定怎么处理。
拿到 event,你基本做两类事:
- 读它的数据:纯观察(打日志、记统计、推前端),不影响任何东西——这种「只看不改」的活,
session.subscribe也能做,后面会专门讲。 - 想影响 Agent?有两种技术手段——这是本章的核心:
- 直接改
event上的字段:比如工具执行前,你想改它的参数,就直接改event.input.limit = 100;发给 LLM 前,你想往消息列表里加内容,就直接改event.messages。改了就生效,不用 return。 - 按约定格式
return一个对象,给 Agent 下指令:比如工具执行前你想拦掉它,就return { block: true, reason: "..." },Agent 收到这个指令就不执行了。
- 直接改
这两种手段不是你随便挑的,而是每个事件规定好用哪种——有的靠 return 下指令(如 input 改写、tool_call 拦截),有的靠改 event 改数据(如 context 改消息列表、tool_call 改参数),有的两者都行。具体每个事件用哪种、怎么写,后面的速查表会一栏栏列清楚,写代码时再查。
第 2 步:把扩展接进 Agent。 有两种接法,挑一种即可。
import {
createAgentSession,
DefaultResourceLoader, // 资源装载器,扩展挂它身上
getAgentDir, // 拿配置目录(~/.pi/agent)
} from "@earendil-works/pi-coding-agent";
// ── 接法 A:内联 ──
// 直接把函数塞进 extensionFactories 数组。适合一次性的、简单的扩展。
const loaderA = new DefaultResourceLoader({
cwd: process.cwd(),
agentDir: getAgentDir(), // ★ 配置目录(必填,读 auth.json/models.json 用)
extensionFactories: [我的扩展], // ★ 你的扩展函数直接放这儿
});
await loaderA.reload();
// ── 接法 B:独立扩展文件(推荐)──
// 把「我的扩展」写进单独的 .ts 文件,主代码 import 进来。
// 适合要复用、要分享、要多个扩展组合的场景。
// 文件 extensions/my-extension.ts:export function 我的扩展(pi) { ... }
// 主文件里:
// import { 我的扩展 } from "./extensions/my-extension.ts";
// const loaderB = new DefaultResourceLoader({
// extensionFactories: [我的扩展, 审计扩展, 限流监控扩展, ...], // 想挂几个挂几个
// });
接法 A 和 B 干的事一模一样,区别只是「代码放哪」。接法 B(独立文件)的好处是:扩展能复用、能组合。 比如你写了个 审计扩展,三个项目都能 import 来用;一个 Agent 也能同时挂好几个扩展,各管各的。
第 3 步:创建会话时带上 loader。
const modelRuntime = await ModelRuntime.create();
const model = (await modelRuntime.getAvailable())[0];
const { session } = await createAgentSession({
model,
modelRuntime,
resourceLoader: loaderA, // ★ 让会话知道有扩展(接法 A 或 B 的 loader)
sessionManager: SessionManager.inMemory(),
});
await session.prompt("你好");
这三步走完,你的代码就挂进 Agent 的干活流程里了。记住这张全景图:
① 决定盯哪个事件 → ② 写处理函数(读 event 数据、return 干预) → ③ 接进 Agent
├ 接法 A:内联塞进 extensionFactories
└ 接法 B:独立文件,import 后塞进 extensionFactories
→ 会话带上 resourceLoader
小提醒:监听事件还有另一种写法
session.subscribe,它跟pi.on长得像但差别很大,后面会专门讲。这里先记住pi.on这种「写进扩展、能动手」的写法。
好了,全景有了。下面分门别类展开:先看「有哪些事件、能做什么」(菜单),再看「两套监听方式的区别」(坑),最后是实战。
三、有哪些事件,能做什么
pi-agent 在干活的各个环节,一共发出 30 来个事件,但日常用到的就十来个。先看一张全流程图建立直觉,再看表格查细节。
3.1 全流程图:一次 session.prompt() 里的事件流
下面这张图,把一次问答从头到尾画出来。每个事件挂在它发生的环节上——🟦 是决策点(Agent 会停下来等你扩展回话,能动手干预,而且都只在 pi.on 派发);其它是广播点(只读,Agent 不等你的返回值)。
用户调 session.prompt("你好")
│
▼ ── 会话层 ──────────────────────────────────────────
🟦 input 收到用户输入(可改写 / 拦截)
🟦 before_agent_start 开跑前(可改系统提示词)
│
▼ ── 进入 Agent Loop(多轮 ReAct,直到 LLM 不再调工具)──
│
│ ┌── 第 1 轮 Turn ─────────────────────────────────┐
│ │ │
│ ├─ turn_start 每轮开始 │
│ │ │
│ │ ── 准备发给 LLM ── │
│ ├─ 🟦 context 发请求前 │
│ │ (可改消息列表) │
│ ├─ 🟦 before_provider_request 请求体就绪 │
│ │ (可改 payload) │
│ │ ── 📡 HTTP 发出 ── │
│ ├─ after_provider_response 收到响应(只读)│
│ │ (看状态码告警;subscribe 也收不到) │
│ │ │
│ │ ── LLM 流式返回 ── │
│ ├─ message_start 一条消息开始 │
│ ├─ message_update ×N 逐 token 增量 │
│ ├─ 🟦 message_end 一条消息结束 │
│ │ (可改最终消息) │
│ │ │
│ │ ── 如果 LLM 决定调工具 ── │
│ ├─ 🟦 tool_call ★ 执行前【安检门】 │
│ │ ├ 可 block 拦截 │
│ │ ├ 可改 event.input 参数 │
│ │ └ 一旦 block → 下面 tool_execution_* 不触发 │
│ │ │ │
│ │ ▼ tool.execute() 开跑 │
│ ├─ tool_execution_start 执行开始(只读)│
│ ├─ tool_execution_update ×N 执行进度(只读)│
│ ├─ tool_execution_end 执行结束(只读)│
│ │ │
│ ├─ 🟦 tool_result 执行后 │
│ │ (可改返回内容 content / isError) │
│ │ │
│ ├─ turn_end 每轮结束 │
│ └─────────────────────────────────────────────────┘
│
│ ┌── 第 2 轮 Turn(LLM 拿着工具结果继续)───────────┐
│ └── ... 同上 ... ┘
│
▼ ── Agent Loop 结束 ─────────────────────────────────
agent_start / agent_end 一轮问答的始末
agent_settled 彻底跑完的可靠信号(只读)
│
▼ ── 会话层收尾 ──────────────────────────────────────
session_shutdown 扩展运行时卸载(quit / reload)
🟦 = 决策点(能动手,只在
pi.on派发);普通文字 = 广播点(只读,大多subscribe也能收到)。
顺便回答一个常见困惑:tool_call 和 tool_execution_start 有什么区别? 看图就明白——tool_call 在「执行前」的安检门位置,能拦、能改参数;tool_execution_start 是「已经开跑」的广播,拦不了、改不了,而且被 tool_call 拦掉的调用根本不会触发 tool_execution_*。两者一前一后,不重复。
3.2 事件菜单(速查)
光看图记不住全部。下面这张表按「发生在哪个环节」分类,每行标上「能做什么」,扫一遍心里有个数就行,不用背。
| 环节 | 事件 | 对应Agent的什么位置 | 能做什么(常见场景) |
|---|---|---|---|
| 会话 | session_start | 会话启动 / 恢复 | 初始化数据、恢复用户偏好 |
session_shutdown | 扩展运行时被卸载(quit / reload / 切换会话) | 清理资源 | |
| Agent 主循环 | before_agent_start ⭐ | 提交问题后、开跑前 | 改系统提示词、注入开场消息 |
agent_start / agent_end | 一轮问答的开始 / 结束 | 计时、收尾、通知前端 | |
agent_settled ⭐ | 一次 prompt() 彻底跑完(含 retry/compaction/queue 全部处理完) | 可靠结束信号:写库收尾、推 SSE done | |
turn_start / turn_end | 每一小轮的开始 / 结束 | 预加载数据、每轮存档 | |
| 用户输入 | input ⭐ | 收到用户输入后 | 敏感词过滤、快捷指令、改写输入 |
| 发给 LLM | context ⭐ | 发请求给大模型前 | 注入用户偏好、塞实时数据 |
before_provider_request | HTTP 请求体组装完、即将发出 | 改请求体(return 新 payload) | |
after_provider_response | 收到 LLM 的 HTTP 响应后 | 监控限流、错误告警(只读) | |
| 消息输出 | message_start | 一条消息开始 | 消息到来的通知 |
message_update | 消息流式更新(逐字) | 实时渲染、打字机效果 | |
message_end | 一条消息结束 | 改最终消息、记 token 用量 |
⚠️
message_end一轮会触发多次:它不是「整轮问答结束才发一次」,而是「每条 assistant 消息结束都发一次」。一次prompt()里 Agent 跑多轮 ReAct、调多次工具,就会触发好几次message_end(中间那些「要调工具了」的 assistant 消息结束时也会发)。所以别拿message_end当整轮收尾信号——在那里写「推 SSE done」「算总 token」会重复触发好几次。整轮的可靠收尾用agent_settled(每 prompt 只触发一次);记 token 总量要在agent_settled时累加,而不是在message_end里各自记一笔。 | 工具 |tool_call⭐ | 工具执行前 | 拦危险操作、改参数、权限检查 | | |tool_execution_start| 工具真正开跑 | 审计日志、显示「正在执行」 | | |tool_execution_update| 工具执行中的进度 | 展示进度片段 | | |tool_execution_end| 工具执行结束 | 算耗时、记结果 | | |tool_result⭐ | 工具执行后 | 改返回值、敏感数据脱敏 | | 模型切换 |model_select| 切了模型 | 联动 UI、记日志 | | |thinking_level_select| 切了思考强度 | 记录、通知 | | 上下文压缩 |session_before_compact| 压缩上下文前 | 取消压缩、自定义压缩方式(用自己的摘要替代默认压缩)|
⭐标星的 5 个事件(
before_agent_start/context/tool_call/tool_result/input)有个特殊身份——它们是那个坑的主角(只在pi.on派发,session.subscribe收不到),而且是「能动手」的事件里最常用的几个(能动手的事件总共约 15 个,详见 4.5 速查表)。
这张表就是你的「菜单」。下次你有个需求,先想「这事发生在哪个环节」,再去表里找对应的事件。比如「我想拦危险查询」发生在「工具执行前」,对应 tool_call;「我想监控限流」发生在「收到 LLM 响应后」,对应 after_provider_response。
想亲眼看看事件流? 我写了个小练习
06b-see-all-events.ts,把所有主要事件注册一遍、每个打一行日志,跑一遍你就能看到 Agent 回答一个问题内部到底「触发了多少次事件、分别是什么」。这是辅助理解用的,不用它也能看懂本章。运行:npx tsx L06-extensions/06b-see-all-events.ts。
记不住没关系。下面挑 3 个最常用的,每个配一个实战,跑完你就有手感了。但在那之前,有个必须先讲的坑——监听事件有两种写法,搞错了你的代码会「静默失效」。
四、两套监听方式:pi.on 和 session.subscribe
第 5 章你其实已经在监听事件了——用的是 session.subscribe:
session.subscribe((event) => {
if (event.type === "message_update") {
process.stdout.write(event.assistantMessageEvent.delta); // 把回答流式打出来
}
});
而全景图里我用的是 pi.on。这两种写法都能监听事件,长得也像,但区别很大。这是整章最容易踩的坑,下面把它讲清楚。
4.1 一句话区分
pi.on(eventName, handler):写在扩展里。能听到全部事件,而且能动手干预(拦截、改数据)。session.subscribe(handler):写在外部宿主(你的脚本、路由)里。能听到大部分事件,但只能看,不能改。
第 5 章用 subscribe 是为了「把回答流式打出来」——这种「只看不改」的场景,subscribe 完全够用。但本章这些需求(拦危险查询、改请求、记状态)都得「动手」,而且有些事件外部层根本听不到。这些活,必须用 pi.on,写成扩展。
4.2 为什么 on 能改、subscribe 改不了?
你可能会有疑问:「同一个事件,为什么 on 能拦截、能改,subscribe 就只能看?」
一句话说清根本差别:Agent 会记录所有 on 事件的订阅者,到了那个环节就停下来等所有订阅者跑完,才走下一步;但 subscribe 的订阅者它不等。
为什么这么设计? 因为 on 接的是「决策点」——Agent 要读你的返回值才能决定下一步(你让它拦它就拦),不读怎么决定?subscribe 接的是「广播」——Agent 干完一步就对外通知一声,根本不读你的返回值,等了也没意义。所以「等 / 不等」不是一条额外规定,而是从「用不用你的返回值」自然推导出来的。
on:Agent 会停下来等你(同步屏障)。
Agent 在干活的环节,会逐个调用你注册的 on handler,等你跑完、拿到你的返回值,才走下一步。这个「停下来等」的特性,行话叫同步屏障(sync barrier)——就是「等你回话」。因为 Agent 既等你、又读你的返回值,所以你的返回值能直接改变它接下来做什么——你 return { block: true },它就不执行工具了;你 return 新的消息列表,它就用你的新列表。
代价也在这:Agent 在干等,所以你在 on handler 里干重活(查大库、调慢接口),会真的拖慢主流程。记住这条——on handler 里别干重活。
subscribe:Agent 不等你(通知完就走)。
Agent 调你的 subscribe handler 时,是「通知一声就走」的(fire-and-forget)——不管你跑没跑完,Agent 照常往下走,你的返回值它根本不看。所以你改变不了任何东西,只能在旁边观察、记录、转发。你的 handler 即使挂了,也不影响 Agent。
为什么会这样? 因为两者走的「通道」不同:
on接在 Agent 的决策点上:工具执行前、发 LLM 前、工具返回后这些地方,天生设计成「让扩展插手干预」的。Agent 到了这些点,会专门停下来问扩展「你有意见吗」,等扩展回话、读返回值,再决定干不干。subscribe接的是通知广播:Agent 干完一步,就对外广播一条「我刚干了什么」。谁想听谁听,广播不读返回值,也不等谁。
一套是「协商」,一套是「通知」,差别就在这。
落到实现上:事件其实走两条不同的通道——
- 决策类事件(
tool_call、tool_result、context、before_agent_start、input)走「钩子通道」:Agent 到了这些点,会逐个调用注册的onhandler,等你跑完、读你的返回值,再决定下一步。这就是为什么它们能拦截、能改数据。 - 通知类事件(
agent_end、message_update、tool_execution_start等)走「广播通道」:Agent 干完一步就对外广播,subscribe监听器收到,但广播不读返回值、也不等谁。
一句话:on 是决策参与者(Agent 等你、读你的返回值、你能改流程);subscribe 是事后旁观者(Agent 不等你、丢弃返回值、你改不了任何东西)。
这就引出实战决策口诀,写代码前先问自己一句:
我这段代码是「改变 Agent 的行为」,还是「只是查询/观察信息」?
- 要改变行为(拦截、改参数、改消息、存状态)→ 用
pi.on(Agent 会等你、读你的返回值)。代价是拖主流程,所以 handler 里别干重活。- 只是查询观察(打日志、推前端、记统计)→ 用
subscribe更轻(Agent 不等你、不读返回值,不拖主流程)。
那 on 能不能干 subscribe 的观察活? 能。对那些两边都能订阅的事件(如 message_update、tool_execution_*),用 on 写观察逻辑完全可以——但代价是 Agent 每次都得等你跑完(同步屏障),而 subscribe 不等。所以纯观察、你又不在扩展里,优先 subscribe。
两边都能订阅的事件,收到的 event 一样吗? 字段基本一样(如 message_update 两边都是 {type, message, assistantMessageEvent}),但有两点不同:① on 的 handler 多一个参数 (event, ctx),ctx 是扩展上下文,能调 ctx.sessionManager.getEntries() 等扩展能力;subscribe 只有 (event),没有 ctx。② on 拿到的是精确事件类型(TS 上更窄更安全),subscribe 拿到的是大联合类型 AgentSessionEvent。
4.3 有一批事件,subscribe 连听都听不到(最大坑)
通道不同还不够。更要紧的是:subscribe 和 on 有不少事件是重合的,但有一批事件只在 pi.on 派发,subscribe 一个都收不到。其中最关键的是下面这 5 个决策点事件(能动手、能改数据):
before_agent_start (提交问题后、开跑前)
context (即将发给 LLM 的消息列表)
tool_call (工具执行前)
tool_result (工具执行后)
input (收到用户输入后、Agent 处理前)
为什么这 5 个 subscribe 听不到? 因为它们全是前面说的「决策点」——就是 Agent 会停下来等扩展回话、读返回值的地方。这些点只走 pi.on 这条线,根本没接进广播。subscribe 接的是广播通道,广播名单里没有这 5 个事件名。不是「不传给你」,而是「这套事件根本不走广播」。
换句话说:tool_call 这些决策点,天生设计成「只给 pi.on 用」。你想听它们,只能写成扩展走 pi.on,别无他途。
最坑的地方在这:你在 subscribe 里写 if (event.type === "tool_call") { ... },它不报错、不警告,handler 该被调用还被调用,只是这个 type 分支永远命中不了。我见过一个项目把日志逻辑写在 subscribe 里收 tool_call,整轮对话日志全空,排查了好几个小时,控制台一个字没提示。
所以记住这条分层原则:
on(扩展层)做「动手的活」:拦截、改数据(这些要读你的返回值,只能走 on);subscribe(外部层)做「转发的活」:把状态、内容、进度推给前端、推给日志。
⚠️ 落库 / 审计 / 打日志这类「纯观测」活,别无脑塞进 on。on 的 handler 被派发方 await(见 4.2 同步屏障),你在里面 await db.insert() 会拖慢整个主循环。正确写法分两种情况:
- 要存的数据来自
message_*/turn_*/tool_execution_*/agent_settled这些 subscribe 收得到的事件 → 首选 subscribe:listener 里直接await db.insert()即可,派发方不等它,I/O 在后台跑,不阻塞 Agent。 - 要存的数据来自
tool_call/tool_result/context等 subscribe 收不到的 6 个独有事件 → 只能走 on,但 handler 必须 fire-and-forget:把数据推进一个队列后立刻return,真正的落库交给独立 worker 异步处理,别让await链绑住主循环。
顺带澄清:subscribe 收不到的其实不止这 5 个——provider 类(
before_provider_request/after_provider_response)、session_start/session_shutdown、model_select等也只在pi.on派发。但它们都是只读观察类,subscribe 收不到顶多「少个监控」;而这 5 个是决策点,你在 subscribe 里写了对它们的处理会静默命中不了——这才是最坑的。所以本章把它们单列出来重点讲。
两者配着用,不冲突。下一节起的所有实战,全是「动手」的活,一律走 pi.on,写成扩展。
4.4 两层的能力对比
列张表你扫一眼:
| 对比项 | pi.on(扩展层) | session.subscribe(外部层) |
|---|---|---|
| 你的身份 | 决策参与者 | 事后旁观者 |
| 接的是 | 钩子(await + 读返回值) | 广播(不 await,返回值丢弃) |
| Agent 等你吗 | 等(同步屏障,会拖主流程) | 不等(通知完就走,不拖主流程) |
| 定义在哪个对象上 | pi(ExtensionAPI) | session(AgentSession) |
| 怎么拿到这个对象 | 只能在扩展工厂函数里拿到 pi | 在主脚本 / 路由里拿到 session |
| handler 签名 | (event, ctx)——多一个扩展上下文 ctx | (event)——没有 ctx |
| 能听到全部事件? | ✅ 全部 33 个 | ❌ 收不到决策点等约 21 个事件,静默漏听 |
| 能改 Agent 行为? | ✅ 能(返回值有效) | ❌ 不能(返回值作废) |
| 典型用途 | 拦截 / 审计 / 改请求 / 记状态 | 流式输出 / 进度推送 / 结束通知 |
记住一句话:你在扩展里就只能 on,你在脚本里就只能 subscribe。
延伸理解:为什么”写在哪”是结构差异?
这一节稍微进阶,赶时间可以先跳过,记住上面那句话就行。
表里「定义在哪个对象上」那两行,背后是 SDK 的硬规定:on 和 subscribe 是两个不同对象上的方法,互不通用——pi 上只有 on,session 上只有 subscribe。道理就这么简单,但不如代码直白。下面这段把四个组合全列出来(handler 就当是个占位的处理函数):
// ── 场景 A:写扩展(手里拿到的是 pi)──
function myExtension(pi) {
pi.on("tool_call", handler); // ✅ 能调:拦截、改参数
pi.subscribe(handler); // ❌ 报错:pi 上没有 subscribe
}
// ── 场景 B:写宿主脚本(手里拿到的是 session)──
const { session } = await createAgentSession({ /* 配置省略 */ });
session.subscribe(handler); // ✅ 能调:流式输出、打日志
session.on("tool_call", handler); // ❌ 报错:session 上没有 on
四个组合,两个 ✅ 两个 ❌,泾渭分明。所以「用哪个」不是你自由挑的——你手里是哪个对象,就只能调那个对象上的方法。这也是本章反复强调”想干预就得写扩展”的根本原因。
4.5 事件里到底有什么、你能做什么(速查表)
学到这儿你可能会问:那每个事件的 event 里到底有什么?能动手干预吗?这张表收藏好,写代码时对着查。
**看表前再记一个关键区分:能动手的事件,干预方式就两种——
return:按约定格式 return 一个对象,给 Agent 下指令(如return { block: true });- 改
event:直接改 event 上的字段,改完不用 return(如event.input.limit = 100)。
每个事件规定好用哪种(有的只能 return、有的只能改 event、有的两者都行),下表「你能做什么」列明确标注了。
| 环节 | 事件 | event 里有什么 | 你能做什么(影响 Agent) |
|---|---|---|---|
| 用户输入 | input ⭐ | text(用户输入的文本)、source(来源:交互输入 / RPC / 扩展)、images?(附图)、streamingBehavior?(流式投递方式:steer / followUp) | ① 改写输入 → return {action:"transform", text}② 直接拦掉(Agent 不再处理)→ return {action:"handled"}③ 放行不干预 → return {action:"continue"} |
| 开跑前 | before_agent_start ⭐ | prompt(用户原始提问)、images?(附图)、systemPrompt(组装好的系统提示词)、systemPromptOptions(组装它用的结构化选项) | ① 改本轮系统提示词 → return {systemPrompt}(多扩展链式覆盖)② 注入一条开场消息 → return {message} |
| 发 LLM 前 | context ⭐ | messages(即将发给 LLM 的消息列表) | ① 替换整个消息列表 → return {messages:[...]}② 往列表里增删改 → 直接改 event.messages(不用 return,框架给你的是深拷贝副本) |
| 发 LLM 前 | before_provider_request | payload(发给 LLM 的 HTTP 请求体,unknown 类型) | ① 替换整个请求体 → return 新 payload② 改请求体某字段 → 直接改 event.payload(不用 return,同引用) |
| 收到响应 | after_provider_response | status(HTTP 状态码,如 200 / 429)、headers(响应头) | ❌ 只读 · 用途:监控限流、看状态码告警 |
| 消息流式 | message_update | message(正在流式输出的消息)、assistantMessageEvent(这次的增量,如一段文字 / 一个工具调用片段) | ❌ 只读 · 用途:做流式输出 |
| 消息结束 | message_end | message(刚结束的那条消息) | ① 替换最终消息 → return {message}(需保持原 role) |
| 工具执行前 | tool_call ⭐ | toolCallId(本次调用的唯一 ID)、toolName(工具名)、input(LLM 传给工具的参数对象) | ① 拦截(不让执行)→ return {block:true, reason}② 改参数 → 直接改 event.input(不用 return,改了直接生效,框架不再做 schema 校验) |
| 工具执行中 | tool_execution_start / update / end | toolCallId(唯一 ID)、toolName(工具名)、args(参数);end 另有 result(执行结果)、isError(是否失败) | ❌ 只读 · 用途:打审计日志、算耗时、展示进度 |
| 工具执行后 | tool_result ⭐ | toolCallId、toolName(工具名)、input(调用参数)、content(返回给 LLM 的内容)、isError(是否失败)、usage(token 用量) | ① 改返回给 LLM 的结果 → return {content?, isError?, usage?}(v0.81+ 可覆盖 token 统计) |
| 上下文压缩 | session_before_compact | preparation(压缩准备数据)、branchEntries(要压缩的历史条目)、customInstructions?(自定义压缩指令)、reason(触发原因:手动 / 超阈值 / 溢出恢复)、willRetry(压缩后是否重试本轮) | ① 取消本次压缩 → return {cancel:true}② 自定义压缩结果(用自己的摘要替代框架默认压缩)→ return {compaction:{summary, firstKeptEntryId, ...}} |
| 会话生命周期 | session_start | reason(启动原因:startup / reload / new / resume / fork)、previousSessionFile?(上一个会话文件) | ❌ 只读 · 用途:初始化、恢复状态 |
| 会话生命周期 | session_shutdown v0.83 | reason(卸载原因:quit / reload / new / resume / fork)、targetSessionFile?(要切去的目标会话文件) | ❌ 只读 · 用途:扩展卸载时清理资源 |
| Agent 收尾 | agent_settled v0.83 | 无字段 | ❌ 只读 · 用途:每 prompt 一次的可靠结束信号 |
| 模型切换 | model_select / thinking_level_select | model(当前模型)/ level(思考强度) | ❌ 只读 · 用途:联动 UI、记日志 |
这张表怎么用? 先想「我这段代码发生在哪个环节」→ 在表里找到对应事件 → 看「event 里有什么」决定怎么读数据 → 看「你能做什么」决定是 return、改 event、还是只观察。后面 3 个实战,就是这张表从上往下逐行的演示,你边看边对。
4.6 那些只读事件,用 on 订阅还有什么意义?
回到那个困惑:既然只有约 15 个事件能动手,剩下约 18 个纯只读事件,用 on 订阅它们有什么用? 三个理由:
- 其中约 7 个,
subscribe根本听不到(session_start、session_compact、session_shutdown、session_tree、after_provider_response、model_select、thinking_level_select)——它们没接进广播通道,你想听只能用on。 - 扩展内部天然用
on。你写扩展时手里只有pi,没有session(见 4.4 对比表)。如果你的扩展既要拦截(决策点)又要打日志(只读),两段逻辑写在一个闭包里共享状态最自然(如 5.2 节审计例子的startTimesMap),那就都用on。 on的 handler 多一个ctx(扩展上下文),能调ctx.sessionManager.getEntries()读会话历史等,subscribe给不了。
反过来说:对那 11 个「重叠 + 只读」的事件(message_update、tool_execution_* 等),如果你不在扩展里、只是纯观察,subscribe 确实更合适(更轻、不阻塞)。所以别一刀切——扩展内用 on,外部纯观察用 subscribe,就是这个分工。
好,机制和「菜单」都讲完了,接下来全是实战。
五、实战:三个最常用的事件
机制和「菜单」都讲完了,下面三个实战覆盖最常见的需求——拦截危险操作、审计工具调用、注入用户偏好。它们都是「能动手」的活,一律走 pi.on 写成扩展。
5.1 拦危险查询 —— tool_call 的拦截
事件:
tool_call(工具执行前) · 能力:拦截执行
给第 5 章那把 query_data 工具,加一道「执行前」的安全门。
什么算「危险」?最典型的一种:LLM 想一次性把整张表导出来——它给 limit 传了个 9999,想拖垮你的服务、或把敏感数据全取走。你想在工具真执行之前就把它拦下。
核心就一句话:监听 tool_call,发现 limit 过大就返回 { block: true, reason: "..." },框架直接拦住这次工具调用,把 reason 文本交给 LLM。LLM 看到「单次最多 100 行」,就会转而让用户缩小范围。
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
// 主线工具 query_data(第 5 章那把,这里简化贴一下结构,完整版见 shared/lib/tools/query-data.ts)
const queryDataTool = defineTool({
name: "query_data",
description: "查询销售数据。字段:日期、产品、地区、销售额、数量、销售人员。",
parameters: Type.Object({
column: Type.String({ description: "要过滤的列名" }),
operator: Type.Union([Type.Literal("="), Type.Literal("contains") /* …省略其余运算符 */]),
value: Type.String({ description: "过滤值" }),
limit: Type.Optional(Type.Number({ description: "最多返回行数,默认 20" })),
}),
async execute(_id, params) {
// …真正查 CSV 的逻辑见 shared/lib/tools/query-data.ts
return { content: [{ type: "text", text: "查询结果..." }] };
},
});
// 这就是安全门:盯住 tool_call,在工具真执行前检查 limit
// 参数里的 any 是 TS 的「任意类型」——先不纠结精确类型,能跑就行;
// 实际项目里建议换成 SDK 提供的精确类型(如 ExtensionAPI)
function limitGuardExtension(pi: any) {
const MAX_LIMIT = 100; // 单次最多返回 100 行
pi.on("tool_call", async (event: any) => {
// 只盯 query_data 这把工具,别的工具不管
if (event.toolName !== "query_data") return;
// event.input 就是 LLM 准备传给工具的参数对象;?. 是「可选链」,input 为空也不会报错
const limit = event.input?.limit;
if (limit && limit > MAX_LIMIT) {
return {
block: true, // ← 拦!工具根本不会被调用
reason: `单次最多返回 ${MAX_LIMIT} 行,你请求了 ${limit} 行。请加更精确的过滤条件后重试。`,
};
}
return undefined; // 不拦,放行,工具正常执行
});
}
装载和运行的方式跟全景图一样(extensionFactories: [limitGuardExtension]),完整代码看 L06-extensions/06a-limit-guard.ts。跑起来问一句「帮我导出所有销售记录」,你会看到:LLM 调 query_data 时想传 limit: 9999,工具根本没被调用,LLM 收到拦截原因后,转而告诉你「单次最多 100 行,请缩小范围」。
这里用的是主线工具
query_data,跟第 5 章同一个,你不用适应新例子。如果你的业务工具确实是 SQL 类(接收一条 SQL 语句),拦截逻辑换成检查DROP/DELETE等关键字即可,方式完全一样——读event.input.sql,命中危险词就block。
补充:tool_call 还能「改参数」
拦截(block)是最常用的用法,但它还有另一种用法——直接改 event.input,工具拿到的就是改后的参数(注意是原地改,不是返回新对象):
pi.on("tool_call", async (event: any) => {
if (event.toolName === "query_data") {
const input = event.input; // 直接拿到入参对象
if (!input.limit || input.limit > 100) {
input.limit = 100; // ← 强制最多 100 行,工具收到的就是 100(静默修正,不报错给 LLM)
}
}
return undefined;
});
「拦截」(block)和「改参数」(改 event.input)的区别要分清:拦截会把原因告诉 LLM、让它重试;改参数是静默修正,LLM 不知道你动过。前者适合「这事不让干」,后者适合「这事让干,但参数得收紧一点」。
框架改完不再做 Schema 校验——你改成什么,工具就收到什么(源码 agent-loop.ts:586 的 prepareToolCallArguments 一次性处理,之后不再验证)。这给了你完全的参数控制权,但也意味着你得自己保证改出来的参数合法。
5.2 工具调用审计日志 —— tool_execution_*
事件:
tool_execution_start+tool_execution_end· 能力:统计耗时、记录调用
拦截是「不让他干」,审计是「看他做了什么、花了多久」。这两个事件配对用:start 时记一笔开始时间,end 时算出耗时。
关键是 event.toolCallId——每次工具调用的唯一 ID。拿它作索引,把开始时间存起来,结束时取出来算差值。哪怕 Agent 并发调好几个工具,这个 ID 也能保证日志不错乱:
function auditExtension(pi: any) {
// 一个「字典」:toolCallId → 开始时间戳(用 Map 存,按 id 取放)
const startTimes = new Map<string, number>();
// 工具开跑时,记下开始时间
pi.on("tool_execution_start", (event: any) => {
startTimes.set(event.toolCallId, Date.now());
console.log(`📝 [审计] 调用 ${event.toolName},参数:${JSON.stringify(event.args)}`);
});
// 工具跑完时,算出耗时,然后清理掉这条记录
pi.on("tool_execution_end", (event: any) => {
const start = startTimes.get(event.toolCallId) ?? Date.now(); // 取开始时间
startTimes.delete(event.toolCallId); // 用完清理
const cost = Date.now() - start;
console.log(`📝 [审计] ${event.toolName} 完成,耗时 ${cost}ms,${event.isError ? "失败" : "成功"}`);
});
}
生产环境,把 console.log 换成写日志文件、发 ELK、打 Prometheus 指标,就是一套完整的工具调用监控。
⚠️ 但注意:这段审计代码挂在
pi.on("tool_execution_*")上,handler 会被 Agentawait(见 4.2 同步屏障)。console.log是瞬时的没问题,可一旦换成「发 ELK」「打 Prometheus」这类网络 I/O,handler 就要等网络往返——Agent 主循环会被这段等拖慢。正确做法:handler 里只把日志数据推进一个内存队列后立刻返回,真正发 ELK / 打指标交给独立的后台 worker 异步处理(fire-and-forget)。或者,如果你不需要ctx、纯做观测,干脆改用session.subscribe听tool_execution_*——它不 await 你的 listener,可以直接await网络 I/O。
为什么用 tool_execution_* 而不是 tool_call/tool_result? 区别在时机:tool_call 是「执行前」(能拦),tool_execution_start 是「真正开跑时」(拦不了)。审计关心的是「实际跑了什么」,被拦掉的调用不该计入审计——所以用 tool_execution_* 更准。
5.3 注入用户偏好 —— context
事件:
context(每次发 LLM 前) · 能力:改发给 LLM 的消息列表
SessionManager.inMemory() 的会话,进程结束就全部遗忘。让 LLM 记住用户偏好,本质就是「每次发请求前,把偏好加进消息列表交给它」。context 事件正好挂在这个点上。
它给你当前要发给 LLM 的消息列表,你返回一个新列表就行(推荐返回,框架拿返回值替换)。直接原地改 event.messages 也生效——框架会深拷贝一份给你,你手里那份就是最终发给 LLM 的。两种写法都行,返回新列表语义更清晰:
function userMemoryExtension(pi: any) {
// 用户偏好(运行时由别处填充,比如从数据库读)
const userPrefs = { unit: "万元", language: "zh-CN" };
pi.on("context", async (event: any) => {
// event.messages 是即将发给 LLM 的消息列表(框架已深拷贝,改它安全)
// 把偏好写成一段文字,包装成一条「user 消息」
const memoryText = `[系统注入的用户偏好,非用户输入]
- 数据单位统一用 ${userPrefs.unit}
- 回答语言:${userPrefs.language}
回答时请遵循以上偏好。`;
const memoryMessage = { role: "user", content: memoryText, timestamp: Date.now() };
// ★ 必须返回 { messages: [...] }:在原列表头部插入偏好消息,原消息原样保留
return { messages: [memoryMessage, ...event.messages] };
});
}
为什么强调「系统注入,非用户输入」? 因为这条消息 role 是 user,LLM 可能当成用户的真话。加个标记,它就知道这是系统给的规则,不会跟用户实际说的搞混。
这跟第 4 章的 systemPromptOverride 有什么区别? 一句话:静态人设用 systemPromptOverride,动态用户偏好用 context。
| 维度 | systemPromptOverride | context 事件 |
|---|---|---|
| 触发时机 | 会话启动算一次 | 每轮 LLM 调用都触发 |
| 数据来源 | 闭包变量,会话内不变 | 可读最新的数据库 / 缓存 |
| 适合 | 静态人设、固定规则 | 动态变化的用户状态、实时数据 |
context 的陷阱:每轮都触发,别干重活
context 不是「会话开始触发一次」,是「每轮 LLM 调用都触发」。一次 prompt() 里 Agent 调几次工具、跑几轮 ReAct,context 就触发几次。所以你的 handler 别在里面查数据库——那会拖慢每轮响应。需要查库的话,结果缓存到内存里再读。
六、多说一句:这些活为什么不直接写在工具里?
读到这里你可能在想:拦危险查询、打日志,这些写在工具的 execute 里不也行吗?
能做,但有两个问题。
第一,职责不清,还重复。 工具的职责是「执行业务」,安全检查是「跨工具的通用策略」。混在一起,工具代码越来越臃肿。更头疼的是——你以后加 10 个数据库相关工具,难道每个的 execute 里都复制一遍检查逻辑?那 10 份重复代码,改一处漏九处,迟早出事。
第二,时机不对。 工具的 execute 已经被调用了,参数已经传进来了。虽然你能在里头拦住「真执行」,但要是工具一进来就申请了数据库连接、打了开始日志,这些副作用已经发生了。
扩展层做拦截的好处,一句话:通用策略和业务工具解耦。 你只要保证工具 name 命名规范,一个 sqlGuardExtension 就能把所有数据库工具统一拦住——新增工具零成本接入。
这叫纵深防御:工具层做兜底(/^SELECT/i.test 那个最基本校验),扩展层做严格策略(关键字黑名单)。任何一层漏了,另一层接住。两层各司其职,比把所有检查都塞进一个 execute,靠谱得多。
结尾
到这儿,扩展系统的核心,你就掌握了。
回头看看,其实就一件事:在 Agent 干活的固定环节上,挂一段你自己的代码。
你学会了:
- 全过程三步走:决定监听哪个事件 → 写处理函数(读 event 数据、return 干预)→ 接进 Agent(内联或独立扩展文件)。
- 两套监听方式的区别:
pi.on是决策参与者(能动手、返回值有效、听全事件),session.subscribe是事后旁观者(只看、返回值作废、漏听决策点等约 21 个只在pi.on的事件,其中最关键的 5 个是:before_agent_start/context/tool_call/tool_result/input)。动手的活一律走pi.on。 - 一张事件菜单:30 来个事件按环节分类,需求来了对号入座。
- 3 个实战演示:
tool_call(拦截)、tool_execution_*(审计)、context(注入用户偏好)。
一张表收个尾:
| 你的需求 | 监听哪个事件 | 关键能力 | 代码文件 |
|---|---|---|---|
| 看事件全景(练习) | 全部 | 打印事件流 | 06b-see-all-events.ts |
| 拦危险查询(limit 过大) | tool_call | return { block: true, reason } | 06a-limit-guard.ts |
| 工具审计日志 | tool_execution_start/end | 配对算耗时 | (文中内联) |
| 注入用户偏好 | context | return { messages: [...] } | (文中内联) |
这三个组合起来,能覆盖最常见的集成需求。
但到目前为止,你的 Agent 还在本地命令行跑——用户看不到流式输出。真正的应用,得把这些事件推到浏览器,让用户看到「Agent 正在思考…」「正在调用工具…」这种实时反馈。
下一章讲 SSE 流式输出:把 pi-agent 的事件流推到浏览器,这才是 Web 集成的命脉。
下一章见。
附录:知识点—源码对照(v0.83.0)
本章涉及的 API / 机制,在
repo/(v0.83.0 checkout)中的源码位置。行号会随版本漂移,定位时以符号名为主、行号为辅。
| 知识点 | 源码位置 | 一句话说明 |
|---|---|---|
pi.on(eventName, (event, ctx)=>...) | extensions/types.ts:1180,1190-1231 | 第二参数是 ExtensionContext |
| 扩展工厂 `(pi)=>void | Promise` | extensions/types.ts:1495 |
extensionFactories 注册 | resource-loader.ts:167 | DefaultResourceLoaderOptions 字段(agentDir 必填) |
| 全部事件名(33 个) | extensions/types.ts:1190-1231 | on() 的重载列表 |
| subscribe 收不到的事件(约 21 个) | agent-session.ts:139-183 | AgentSessionEvent 只转发生命周期类事件 |
| 能动手的事件(约 15 个) | extensions/types.ts:1065-1135(各 *EventResult 类型) | return 或改 event 能干预;其余约 18 个只读 |
| on 派发逐个 await(同步屏障) | extensions/runner.ts:796-826 | await handler(event, ctx),等返回值 |
| subscribe 派发不等、不读返回值 | agent-session.ts:548-552 | _emit 同步 for 循环,返回 void |
| on 与 subscribe 入口互不通用 | extensions/types.ts:1180+(ExtensionAPI) | ExtensionAPI 只有 on、无 subscribe;session 反之亦然 |
tool_call 返回 {block,reason} | extensions/types.ts:1071-1075 | 拦截/放行工具调用 |
tool_call 原地改 input 有效 | agent-harness.ts:456-464;agent-loop.ts:586-649 | 改后不重新校验,直接用 |
tool_result 返回值 | extensions/types.ts:1085-1090 | {content?, isError?, usage?, details?} |
context 原地改 event.messages 有效 | extensions/runner.ts:979-1010 | 一次性 structuredClone;原地改(push/splice)生效,重赋值需 return |
before_provider_request 原地改 payload 有效 | extensions/runner.ts:1011-1044 | 不克隆,同引用,原地改生效 |
session_before_compact 返回 compaction 跳过默认压缩 | agent-session.ts:1810-1843 | 返回 {cancel} 取消;返回 {compaction} 用扩展的摘要 |
after_provider_response 只读 | extensions/types.ts:692-695 | 字段 status / headers |
before_agent_start 返回 {systemPrompt?} 链式 | extensions/types.ts:1097-1101 | 替换本轮系统提示词 |
agent_settled finally 单次发 | extensions/types.ts:723-724;agent-session.ts:581-585 | 重试/压缩都处理完才发 |
session_start/session_shutdown reason | extensions/types.ts:562-568,616-622 | startup / reload / new / resume / fork 等 |