Pi Agent · Book
冬瓜 热衷于拆解 AI 工程的博主
P06

第6章:事件监听 —— 实现你的所有个性化需求

8914字 · 含 196 行代码 · 约 45 分钟

写在前面:本章是全书难度最大的一章——事件系统是 pi-agent 的核心机制,概念较多。我已经尽量简化、只留重点。它的价值在于把 pi-agent 的运行机制讲透。建议沉下心顺着读一遍。

一、你想在 Agent 里加入自己的逻辑,怎么做

跟着第 5 章走下来,你已经能让 Agent 调你的工具了,查数据、调 API 都跑通了。

但真做业务时,你会遇到不少「光靠工具解决不了」的需求。比如:

  1. 拦危险操作:LLM 调你的查询工具时,偶尔会传离谱的参数(比如想把整张表一次性取出来)。你想在它真执行之前就拦下来。

  2. 给工具调用打日志:5 个业务工具,每个都得记「谁调的、传了什么、花了多久、成没成」。要是在每个工具里复制一遍日志代码,就是 5 份重复劳动。

  3. 让 Agent 记住用户偏好:用户第一句说「以后数字都用万元为单位」,第二轮 Agent 又得被提醒——因为内存会话重启就忘记。能不能让它自动带上?

  4. 监控 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,你基本做两类事:

  1. 读它的数据:纯观察(打日志、记统计、推前端),不影响任何东西——这种「只看不改」的活,session.subscribe 也能做,后面会专门讲。
  2. 想影响 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_calltool_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收到用户输入后敏感词过滤、快捷指令、改写输入
发给 LLMcontext发请求给大模型前注入用户偏好、塞实时数据
before_provider_requestHTTP 请求体组装完、即将发出改请求体(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.onsession.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_calltool_resultcontextbefore_agent_startinput)走「钩子通道」:Agent 到了这些点,会逐个调用注册的 on handler,等你跑完、读你的返回值,再决定下一步。这就是为什么它们能拦截、能改数据。
  • 通知类事件agent_endmessage_updatetool_execution_start 等)走「广播通道」:Agent 干完一步就对外广播,subscribe 监听器收到,但广播不读返回值、也不等谁

一句话on决策参与者(Agent 等你、读你的返回值、你能改流程);subscribe事后旁观者(Agent 不等你、丢弃返回值、你改不了任何东西)。

这就引出实战决策口诀,写代码前先问自己一句:

我这段代码是「改变 Agent 的行为」,还是「只是查询/观察信息」?

  • 改变行为(拦截、改参数、改消息、存状态)→ 用 pi.on(Agent 会等你、读你的返回值)。代价是拖主流程,所以 handler 里别干重活。
  • 只是查询观察(打日志、推前端、记统计)→ 用 subscribe 更轻(Agent 不等你、不读返回值,不拖主流程)。

on 能不能干 subscribe 的观察活? 能。对那些两边都能订阅的事件(如 message_updatetool_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 连听都听不到(最大坑)

通道不同还不够。更要紧的是:subscribeon 有不少事件是重合的,但有一批事件只在 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_shutdownmodel_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 的硬规定:onsubscribe两个不同对象上的方法,互不通用——pi 上只有 onsession 上只有 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)
用户输入inputtext(用户输入的文本)、source(来源:交互输入 / RPC / 扩展)、images?(附图)、streamingBehavior?(流式投递方式:steer / followUp)① 改写输入 → return {action:"transform", text}
② 直接拦掉(Agent 不再处理)→ return {action:"handled"}
③ 放行不干预 → return {action:"continue"}
开跑前before_agent_startprompt(用户原始提问)、images?(附图)、systemPrompt(组装好的系统提示词)、systemPromptOptions(组装它用的结构化选项)① 改本轮系统提示词 → return {systemPrompt}(多扩展链式覆盖)
② 注入一条开场消息 → return {message}
发 LLM 前contextmessages(即将发给 LLM 的消息列表)① 替换整个消息列表 → return {messages:[...]}
② 往列表里增删改 → 直接改 event.messages(不用 return,框架给你的是深拷贝副本)
发 LLM 前before_provider_requestpayload(发给 LLM 的 HTTP 请求体,unknown 类型)① 替换整个请求体 → return 新 payload
② 改请求体某字段 → 直接改 event.payload(不用 return,同引用)
收到响应after_provider_responsestatus(HTTP 状态码,如 200 / 429)、headers(响应头)❌ 只读 · 用途:监控限流、看状态码告警
消息流式message_updatemessage(正在流式输出的消息)、assistantMessageEvent(这次的增量,如一段文字 / 一个工具调用片段)❌ 只读 · 用途:做流式输出
消息结束message_endmessage(刚结束的那条消息)① 替换最终消息 → return {message}(需保持原 role)
工具执行前tool_calltoolCallId(本次调用的唯一 ID)、toolName(工具名)、input(LLM 传给工具的参数对象)① 拦截(不让执行)→ return {block:true, reason}
② 改参数 → 直接改 event.input(不用 return,改了直接生效,框架不再做 schema 校验)
工具执行中tool_execution_start / update / endtoolCallId(唯一 ID)、toolName(工具名)、args(参数);end 另有 result(执行结果)、isError(是否失败)❌ 只读 · 用途:打审计日志、算耗时、展示进度
工具执行后tool_resulttoolCallIdtoolName(工具名)、input(调用参数)、content(返回给 LLM 的内容)、isError(是否失败)、usage(token 用量)① 改返回给 LLM 的结果 → return {content?, isError?, usage?}(v0.81+ 可覆盖 token 统计)
上下文压缩session_before_compactpreparation(压缩准备数据)、branchEntries(要压缩的历史条目)、customInstructions?(自定义压缩指令)、reason(触发原因:手动 / 超阈值 / 溢出恢复)、willRetry(压缩后是否重试本轮)① 取消本次压缩 → return {cancel:true}
② 自定义压缩结果(用自己的摘要替代框架默认压缩)→ return {compaction:{summary, firstKeptEntryId, ...}}
会话生命周期session_startreason(启动原因:startup / reload / new / resume / fork)、previousSessionFile?(上一个会话文件)❌ 只读 · 用途:初始化、恢复状态
会话生命周期session_shutdown v0.83reason(卸载原因:quit / reload / new / resume / fork)、targetSessionFile?(要切去的目标会话文件)❌ 只读 · 用途:扩展卸载时清理资源
Agent 收尾agent_settled v0.83无字段❌ 只读 · 用途:每 prompt 一次的可靠结束信号
模型切换model_select / thinking_level_selectmodel(当前模型)/ level(思考强度)❌ 只读 · 用途:联动 UI、记日志

这张表怎么用? 先想「我这段代码发生在哪个环节」→ 在表里找到对应事件 → 看「event 里有什么」决定怎么读数据 → 看「你能做什么」决定是 return、改 event、还是只观察。后面 3 个实战,就是这张表从上往下逐行的演示,你边看边对。

4.6 那些只读事件,用 on 订阅还有什么意义?

回到那个困惑:既然只有约 15 个事件能动手,剩下约 18 个纯只读事件,用 on 订阅它们有什么用? 三个理由:

  1. 其中约 7 个,subscribe 根本听不到session_startsession_compactsession_shutdownsession_treeafter_provider_responsemodel_selectthinking_level_select)——它们没接进广播通道,你想听只能用 on
  2. 扩展内部天然用 on。你写扩展时手里只有 pi,没有 session(见 4.4 对比表)。如果你的扩展既要拦截(决策点)又要打日志(只读),两段逻辑写在一个闭包里共享状态最自然(如 5.2 节审计例子的 startTimes Map),那就都用 on
  3. on 的 handler 多一个 ctx(扩展上下文),能调 ctx.sessionManager.getEntries() 读会话历史等,subscribe 给不了。

反过来说:对那 11 个「重叠 + 只读」的事件(message_updatetool_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:586prepareToolCallArguments 一次性处理,之后不再验证)。这给了你完全的参数控制权,但也意味着你得自己保证改出来的参数合法。


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 会被 Agent await(见 4.2 同步屏障)。console.log 是瞬时的没问题,可一旦换成「发 ELK」「打 Prometheus」这类网络 I/O,handler 就要等网络往返——Agent 主循环会被这段等拖慢。正确做法:handler 里只把日志数据推进一个内存队列后立刻返回,真正发 ELK / 打指标交给独立的后台 worker 异步处理(fire-and-forget)。或者,如果你不需要 ctx、纯做观测,干脆改用 session.subscribetool_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] };
  });
}

为什么强调「系统注入,非用户输入」? 因为这条消息 roleuser,LLM 可能当成用户的真话。加个标记,它就知道这是系统给的规则,不会跟用户实际说的搞混。

这跟第 4 章的 systemPromptOverride 有什么区别? 一句话:静态人设用 systemPromptOverride,动态用户偏好用 context

维度systemPromptOverridecontext 事件
触发时机会话启动算一次每轮 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_callreturn { block: true, reason }06a-limit-guard.ts
工具审计日志tool_execution_start/end配对算耗时(文中内联)
注入用户偏好contextreturn { 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)=>voidPromise`extensions/types.ts:1495
extensionFactories 注册resource-loader.ts:167DefaultResourceLoaderOptions 字段(agentDir 必填
全部事件名(33 个)extensions/types.ts:1190-1231on() 的重载列表
subscribe 收不到的事件(约 21 个)agent-session.ts:139-183AgentSessionEvent 只转发生命周期类事件
能动手的事件(约 15 个)extensions/types.ts:1065-1135(各 *EventResult 类型)return 或改 event 能干预;其余约 18 个只读
on 派发逐个 await(同步屏障)extensions/runner.ts:796-826await 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-464agent-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-724agent-session.ts:581-585重试/压缩都处理完才发
session_start/session_shutdown reasonextensions/types.ts:562-568,616-622startup / reload / new / resume / fork 等