青雲的博客
深入浅出 Pi 第六部:工具与扩展怎样进入运行时 第 36 章

Extension Runner 怎样串起事件与变换

从 ExtensionRunner.bindCore、AgentSession event projection 与专用 emit 方法,区分观察事件、链式变换、短路拦截和失败传播。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

extension factory 调 pi.on("turn_end", handler) 时,只把函数存进某个 Extension.handlers map。真正运行 handler 的是 ExtensionRunner,而事件源大多仍在 AgentSession 与底层 Agent。Runner 做了三件容易混在一起的事:把 runtime action 绑定回当前 session,为每次调用生成带状态读取能力的 context,按事件合同组合多个 handler 的返回值。

如果把它概括成“扩展事件总线”,会丢掉最重要的差异。turn_start 只是观察通知;context 会把前一 handler 输出交给下一 handler;input 可以 transform 或 handled;session_before_compact 可以 cancel;tool_call 可以修改输入并阻断执行。它们共用注册形式,不共用结果语义。

Load-time API 到 session action 的接线

extension 加载时拿到的 API 引用同一个 ExtensionRuntimeAgentSession 创建 Runner 后调用 bindCore(),把 sendMessage、session metadata、active tools、model 和 thinking level 等 action 写回这份 runtime;同时把 context 所需的 model、idle、trust、signal、abort、usage 和 system prompt getter 绑定到当前 session。

provider 是 load-time 特例:factory 阶段的注册先排队,bindCore() 按队列顺序 flush 到 ModelRuntime,再把 register/unregister 换成即时 action。于是 extension 不必为了注册 provider 等 session_start,又不会在 model registry 尚未存在时直接访问空对象。

createContext() 没把 model、cwd、signal 等值复制成快照,而是用 getter 在访问时读取 Runner 当前绑定。session replacement 或 reload 会 invalidate() 旧 Runner 与共享 runtime;旧 context 的 getter 再访问就抛 stale message。扩展若跨 ctx.newSession() 保存旧 ctx,即使对象仍在内存,也不再拥有新 session 的操作权。

Agent 事件先在 AgentSession 里改名和补字段

底层 Agent 发出 turn_startmessage_updatetool_execution_end 等事件。AgentSession._emitExtensionEvent() 把它们转换成 extension types,给 turn 加 index/timestamp,再交给 Runner。这个投影发生在 session 层,因为只有这里知道 session persistence、turn index 和当前 extension runtime。

message_end 是一个值得单独看清的例外。Runner 可以链式返回同 role 的 replacement;AgentSession 将 replacement 原地写回底层 Agent 已保存的 message object。这样后续 turn/agent event、listener 以及稍后的 SessionManager.appendMessage(event.message) 看到同一份修改。不同 role 的 replacement 被 Runner 拒绝并报告 extension error。

flowchart LR
  accTitle: Agent 事件进入扩展运行时
  accDescr: 底层 Agent 发出事件,AgentSession 补充 session 语义并调用专用 Runner 方法;Runner 再按事件合同广播、链式变换或短路。
  AGENT["agent-core events"] --> PROJECT["AgentSession projection"]
  PROJECT --> OBSERVE["generic observe events"]
  PROJECT --> TRANSFORM["message/context/input transforms"]
  PROJECT --> GATE["before-session / tool-call gates"]
  OBSERVE --> HANDLERS["ordered extension handlers"]
  TRANSFORM --> HANDLERS
  GATE --> HANDLERS
  HANDLERS --> STATE["Agent/session/provider state"]

四类 handler,四种合并方法

普通 emit() 遍历 extension 与其 handlers,逐个 await。大部分事件忽略返回值;handler 抛错会转成 ExtensionError 发给 listener,然后继续后面的 handler。session_before_switch/fork/compact/tree 属于同一方法里的 before-event:非空结果会暂存,cancel 为 true 立即返回。多个不取消的结果不是深度 merge,后一个赋值会替换前一个 result。

contextbefore_provider_requestbefore_agent_startmessage_endtool_result 使用专用方法做链式变换。context 先 structured clone messages,每个 handler 都看到上一项输出;provider payload 同理。before_agent_start 收集每个 extension 追加的 custom message,同时把 system prompt replacement 传给下一个 handler。tool result 则逐字段覆盖当前 event。

input 又多一个分支:transform 更新 text/images 后继续,handled 立即返回,后续 Skill/Prompt 展开和模型调用都不发生。AgentSession 在 slash extension command 之后、Skill/Prompt 展开之前发 input event,因此 handler 看到的是原始普通输入,却看不到已被 extension command 提前消费的命令。

tool_call 的错误会直接阻止执行

工具 interception 安装在底层 Agent 的 beforeToolCallafterToolCall 上,而且 callback 每次读取 this._extensionRunner,所以 reload 换 Runner 后不用重装 hook。tool_result handler 的异常被 Runner 捕获并报告,其他 handler 仍可继续;tool_call 的实现则没有局部 try/catch。handler 抛错会回到 AgentSession,Error 原样继续抛,非 Error 被包装成“Extension failed, blocking execution”。

这不是所有 extension error 的通则。它只说明 tool preflight 采用 fail-closed:负责批准或修改参数的 hook 自己失效时,不应该悄悄执行原命令。普通 turn_end telemetry hook 失败则 fail-open,不阻断 Agent 已完成的工作。

可以用这组只读命令核对哪些事件走专用 emitter:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" grep -n 'async emitToolCall\|async emitToolResult\|async emitContext\|async emitInput' \
  v0.83.0 -- packages/coding-agent/src/core/extensions/runner.ts
git -C "$repo" grep -n '_installAgentToolHooks\|_emitExtensionEvent' v0.83.0 -- \
  packages/coding-agent/src/core/agent-session.ts

这能建立调用索引,但评审一个 extension 还要逐事件看 result contract。看到 pi.on() 不能推断它能取消动作,也不能推断异常一定被吞掉。

Runner 已经把多个 extension 排成有序处理链。最后一个问题是同名注册:两个 tool、两个 provider overlay、两个 custom renderer 相遇时,究竟谁留下?第 37 章不发明一条万能规则,而是分别沿三份 registry 读出 precedence。