Appearance
扩展机制补遗:Hooks 的接口边界
小a在概念篇学过 Hooks,但真要自己给 Agent 挂钩子时,发现框架文档里有一堆名字:convertToLlm、transformContext、beforeToolCall、afterToolCall、shouldStopAfterTurn、prepareNextTurn……他盯着这些名字,分不清哪些能拦、哪些只是看,哪些是请求前的、哪些是请求后的。
“别照着名字猜。”老z说,“名字会骗你。beforeToolCall 听起来像‘工具调用前’,但它在‘参数已校验之后’——你拦的是已合法的调用,不是原始输入。本附录列出 pi 这个快照里真实可核对的低层 loop hooks;它们不是通用 Hook 标准,换一个 Agent 框架,接口和时序可能完全不同。”
“先立一条读表规则,”老z说,“每个 hook 都按三样东西来记:何时触发、能否改变什么、失败时发生什么。 前两样决定你能拿它干什么,第三样决定你写它时要不要胆战心惊。把这三列对清楚,名字就不会再骗你——beforeToolCall 的名字说的是‘什么时候’,但真正要搞清的是后两列。”
小a:“为什么本附录只讲 pi 的低层 loop hooks,不讲概念篇里那个更宽的 Hook 图景?”
“因为概念篇讲的是意图,这里要给你坐标。”老z说,“概念篇里的拦截器、订阅者、转换器,是帮助你建立心智模型的分类;但真要动手挂钩子,你必须知道 pi 这个快照里每个接口的具体位置和时序。**分类告诉你‘有什么可能’,坐标告诉你‘这个框架里哪里能落’。**两样缺一,写出来的 hook 要么挂错地方,要么挂了个不存在的接口。”
“还有一个常见误区要提前排掉,”老z说,“别把‘hook 存在’当成‘权限存在’。 表格里 convertToLlm 那栏写着‘不负责权限’——这不是一句免责声明,是接口的真实边界:它只管消息形状的转换,权限校验发生在别处。读表时要连‘结果/边界’那栏一起读——一个 hook 能做什么,和它不能做什么,同样重要。
先分清三类 Hook
老z让小a把这一堆名字按“能做什么”分三类:
- 拦截器(interceptor)——能允许、拒绝或改写主路径。
beforeToolCall(可 block)、afterToolCall(可覆盖结果)属于这类。- 订阅者(subscriber)——只观察事件,不改变主路径。UI 渲染、日志记录通常用订阅者。
- 转换器(transformer)——改写输入但不决定是否继续。
convertToLlm、transformContext属于这类。
“这个分类不是装饰,”老z说,“它决定了你的 hook 出错时会发生什么。**拦截器出错可能让整个 turn 失败;订阅者出错最多丢一条日志;转换器出错则会让请求带着错误上下文发出去。**调试时你要先知道它属于哪类,才能预判影响范围。”
小a:“三类都有,那写的时候到底按哪类写?”
“按你要不要影响主路径来选。”老z说,“如果你只是想记录‘某个工具被调了’,那是订阅者——它出错不该把 turn 弄崩,所以要包 try/catch、要容错。如果你要拦截危险调用,那是拦截器——它的失败语义必须明确定义(默认放行还是默认拒绝?),不能含糊。如果你要改写上下文,那是转换器——它的失败会让请求带着坏数据出去,所以必须做防御性检查。写 hook 之前先问自己一句:‘我挂的这个 hook 挂了,整条 loop 应该怎么办?’答得上来,才动手写。”
小a:“那判断‘该按哪类写’,有没有具体的触发标准?”
“有,一句话:看你是不是要改变主路径的输出。”老z说,“只读不改,是订阅者;改输入,是转换器;改决定(放行/拒绝/继续/停止),是拦截器。三个问题依次问——我要改输出吗?我要改输入吗?我只是在看吗?——答案会把你自动分到某一类。分到哪类,决定你写多少防御代码:订阅者写最少,拦截器写最多。”
小a:“三类 hook 的调试难度是不是也差很多?”
“差很多,而且是递进关系。”老z说,“订阅者出错,最多丢一条日志,你扫一眼就看到了;转换器出错,请求带着坏数据发出去了,你要等到模型答错才能倒推回来;拦截器出错,轻则 turn 失败,重则整个会话停摆。**调试点离触发点越远,越难查——这三类正好是‘一眼看到’、‘等下一轮看到’、‘等到用户抱怨才看到’的递进。**所以调试成本最高的那类,写的时候就要最小心:宁可多写防御,也别省事。”
小a:“这个‘问一句’能不能再给个具体例子?”
“拿拦截器举例。”老z说,“你挂一个 beforeToolCall 拦 rm 工具,你得先回答:如果这个 hook 自己抛异常,工具还执行吗?执行——那你拦了个寂寞;不执行——那异常算不算你想要的‘拦截成功’?两个答案都成立,但必须显式选一个并写下来。含糊的 hook 是最危险的 hook——它的行为完全取决于 loop 当时的心情。”小a记下了这句话。
可核对的 loop hooks
下表列出 pi 快照里真实存在的低层 loop hooks,位置与触发时机来自源码,不是文档承诺。
| 接口 | 位置与触发 | 结果/边界 |
|---|---|---|
convertToLlm | AgentConfig;每次 provider 请求前 | 将 AgentMessage[] 转为 LLM Message[];不负责权限 |
transformContext | AgentConfig;convertToLlm 前 | 调整上下文;错误会影响请求路径 |
beforeToolCall | 参数已校验、tool_execution_start 后、执行前 | 可返回 { block, reason };中止 signal 需要 hook 自己响应 |
afterToolCall | 工具结束、最终 tool result event 前 | 可覆盖 content、details、isError、usage,或 terminate |
shouldStopAfterTurn | 正常 assistant 与工具执行完成、turn_end 后 | 返回 true 时在下一次模型调用前结束;不取消已运行工具 |
prepareNextTurn | turn_end 后、shouldStopAfterTurn 前 | 为下一轮准备上下文/配置;不修改正在进行的 provider 请求;即使随后停止,本次准备已发生 |
小a盯着表格问:“时序我画不清楚——prepareNextTurn 在 shouldStopAfterTurn 之前?那准备完了又停止,准备工作不是白做了?”
“对,这就是时序约束。”老z说,“prepareNextTurn 的副作用(改了上下文配置)在停止决定之前就发生了。即使 shouldStopAfterTurn 返回 true 要结束,刚才的准备已经写入状态。所以这个 hook 不能假设‘如果随后要停,我就不该执行’——它的执行是无条件的。”
“表格里还有一条边界值得展开,”老z说,“shouldStopAfterTurn 只决定‘下一轮要不要开始’,不取消已经跑起来的工具。 这是很多人会踩的坑:以为停了就等于中止了。实际语义是‘到此为止,把已发的请求跑完,别再发新的’——它是停止点,不是中断开关。 想真正中止正在跑的工具,得走 signal,而不是这个 hook。”
小a:“那 convertToLlm 和 transformContext 都改上下文,区别在哪?”
“transformContext 改的是送给转换的原始材料,convertToLlm 改的是转换后的最终形状。”老z说,“前者在‘转换前’,你可以裁剪、增补、排序上下文;后者在‘转换那一刻’,你把 AgentMessage[] 变成 provider 认识的 Message[]。一个改素材,一个改成品——想在最后一步统一调整消息格式,挂 convertToLlm;想先决定‘哪些材料进上下文’,挂 transformContext。两者的错误后果也不一样:前者出错影响的是转换的输入,后者出错直接影响发给 provider 的请求。”
“还有一个顺序细节,”老z补充,“transformContext 在 convertToLlm 之前,所以 transformContext 的改动会经过 convertToLlm。 如果你的两个 hook 都动了同一条消息,后者的改动会盖在改动之后的材料上。写 hook 前把这条链画出来,比事后对着事件日志猜顺序省事得多。”
“那 prepareNextTurn 呢?它跟 transformContext 都‘为下一轮做准备’,会不会功能重叠?”
“会有重叠,但作用时间不同。”老z说,“transformContext 是每一轮请求前都跑的,它面对的是‘本轮的材料’;prepareNextTurn 是turn 与 turn 之间跑的,它准备的是‘下一轮的配置’。前者的修改影响本轮请求,后者的修改影响下一轮。**一个管‘这次发什么’,一个管‘下次按什么状态发’。**作用时间一错,你就不知道哪次改动生效了。”小a把“作用时间”四个字圈了出来。
两个最容易误用的接口
beforeToolCall:拦了一次 ≠ 安全控制
源码事实: packages/agent/src/types.ts 的 BeforeToolCallResult:
ts
export interface BeforeToolCallResult {
block?: boolean;
reason?: string;
}小a:“beforeToolCall 阻止一次工具,是不是就完成安全控制?”
老z:“**不是。**它只在这条 loop 路径生效。三个边界要分清:
- hook 没装时——默认放行。你不能假设‘我没写 beforeToolCall,工具就不会执行’。
- hook 失败时——异常会按 loop 的错误处理走,可能不是‘安全失败’。
- hook 之外——工具实现仍要自己验证输入、限定权限。
beforeToolCall是循环层的闸门,不是工具层的权限。”
“工具的输入校验(workspacePath、参数 schema)和 beforeToolCall 是两道独立的闸,”老z强调,“去掉任何一道,另一道还在;但不能因为有一道就拆掉另一道。”
小a:“那这条闸的失败语义,我该怎么定义才算明确?”
“三选一,写清楚。”老z说,“默认放行、默认拒绝、失败即崩溃——三种都是明确的语义,可以接受;最不能接受的是‘没想好’,因为没想好的默认行为往往就是放行。选哪种取决于场景:审计类拦截可以放行并记录,高风险类拦截应该拒绝,而‘必须拦住否则出大事’的拦截应该失败即崩溃——宁可不干活,不能放行。定义失败语义,就是回答‘我宁可错杀,还是宁可放过’。”
小a:“那 reason 字段呢?拦住之后,这个字符串去哪了?”
“回给模型当上下文。”老z说,“模型下一轮会看到‘这个调用被拒,原因是……’。所以 reason 不是给人看的备注,是给模型的行为指导——写‘参数越界’和写‘文件路径不能包含 ..’,模型下一轮的行动完全不同。**reason 写得越具体,模型的自我修正越有效;写得太笼统,它下一轮还会用同样的姿势再撞一次。**这条经验也反向适用于工具错误信息:失败原因的颗粒度,直接决定模型能不能改对。”
“最后补一个 hook 之外的动作,”老z说,“beforeToolCall 只能拦,不能代替你处理。 拦下来之后——记录、通知、改参数重试、还是直接停循环——这些后续动作要由宿主代码写。hook 是闸门,开关之后走哪条路,是宿主的事。把‘拦截’和‘拦截后的处理’分清楚,才不会出现‘拦了但没人管’的尴尬状态。”
afterToolCall:覆盖不是深合并
源码事实: 同文件的 AfterToolCallResult 是部分覆盖结果,包含 content、details、isError、usage 和 terminate。提供字段替换原值;它不是对 content 的深合并。
小a:“那我只想改 content 里的一个字段呢?”
“你要返回完整的 content,不能指望框架帮你合并。”老z说,“这是部分覆盖(partial override)的语义:你给的字段整体替换,你没给的字段保持原值。如果你只给 content 的一个片段,模型看到的就是那个片段,不是‘原 content 加上你的修改’。这个语义和深合并(deep merge)完全不同,混淆了会产生静默的数据丢失。”
老z又补了一条失败语义的提醒:“afterToolCall 覆盖了 isError,就等于你接管了‘这次调用算不算失败’的判定。 如果你把一个本该是 error 的结果覆盖成 success(比如想‘修一下让它继续跑’),模型就会把错误结果当成正常数据往下用——这种‘看似好心’的改写,往往是后续一连串错乱答案的源头。覆盖之前先问:我改的是显示,还是改了语义?改语义的覆盖要慎之又慎。”
“还有 terminate 字段,”老z说,“它是 afterToolCall 里唯一能‘提前结束循环’的开关。terminate 和 shouldStopAfterTurn 的区别要分清:前者在工具结果这一轮就喊停,后者要等到 turn 结束才生效。想‘这个工具跑完就停’,用前者;想‘这一轮全部跑完再停’,用后者。把两者搞混,会出现‘想停却多跑了一轮’或者‘提前停断了别的工具’的意外。”
小a:“usage 覆盖那栏呢?这也能改?”
“能,但改之前先想清楚为什么。”老z说,“usage 是报告给上层的 token 统计。正常情况下你不需要动它;只有在‘工具结果里包含的用量需要合并进这次调用’时才需要。覆盖 usage 的风险是审计失真——上层按 usage 算成本、做监控,你改它等于在成本账上动手脚。除非有明确的合并需要,否则别碰这一栏。”小a在 usage 旁边画了个圈,写了“慎改”。
文档里的目标 ≠ 运行时代码
老z最后提醒了一个陷阱。
“packages/agent/docs/agent-harness.md 里有一段‘designed, not implemented’的通用 harness hook 计划。”老z说,“不要把文档中的目标设计误写成当前运行时代码。文档写的是‘我们打算支持这些 hook’,源码写的是‘现在真的有这些 hook’。本附录只列已在 AgentConfig/loop 契约中可核对的接口——也就是源码里真实存在的。”
小a:“那 README 里的描述能信吗?”
“源码事实优先。”老z说,“比如 packages/agent/README.md 说 shouldStopAfterTurn 不会 abort provider stream、不会取消运行工具、不会改写 assistant 的 stop reason——这条要回到 loop 代码核对。如果 README 和源码冲突,以源码为准,把 README 标记为待更新。不要调和冲突,要择一并说明理由。”
“再给一个判断口诀,”老z说,“‘设计了’和‘实现了’之间,隔着一次 grep。 在源码里搜不到符号名的接口,无论文档把它描述得多完整,都当成规划,别当成可用的钩子。反过来,源码里存在的接口,文档没写——那是文档欠账,不影响你使用。源码是事实层,文档是意向层,永远拿事实层压意向层。”
小a:“那版本升级之后,这些 hook 的接口会不会变?”
“会,而且变了不会专门告诉你。”老z说,“pi 在演进,接口、时序、失败语义都可能调整。所以本附录里所有位置都基于当前快照——**升级后要重新核对,别拿旧坐标当新地图。**核对的办法就是那三问:接口还在吗?触发时序变了吗?失败语义改了吗?三问都答完,才敢继续用。接口稳定性不是框架的义务,是你的核对清单。”
Hook 的价值与硬约束
小a:“那 Hook 到底能保证什么?”
“Hook 的价值是让可观察、可审计的扩展点进入控制流——你可以在工具执行前记录、在请求发出前转换、在 turn 结束后判断是否停止。”老z说,“但硬性安全约束仍应由工具、权限和执行环境承担,不能由 hook 独自承担。”
小a:“既然 hook 不能兜底,那它和‘配置’有什么区别?不都是往 Agent 里插东西吗?”
“区别在是否进入控制流。”老z说,“配置是静止的——你读它、它不动作;hook 是活的——它在特定时刻被调用,能返回结果,结果会改变主路径。配置改变的是‘Agent 是什么样’,hook 改变的是‘Agent 在某个瞬间做什么’。所以 hook 的接口和时序要精确核对,配置只需要核对取值——这也解释了为什么本附录花大篇幅讲时序:配置写错了,改一行就好;hook 挂错了时机,行为整个走样。”
小a:“那 hook 的‘可观察’价值,具体怎么兑现?”
“把不透明的 loop 变成有记号的 loop。”老z说,“没有 hook,工具执行就是一个黑箱——你只知道它发生了,不知道它怎么发生;挂了 hook,你可以在每个关键点留记号:请求发出前、工具执行前、执行后、turn 结束时。记号多了,loop 的行为就可以被回放、被审计、被分析——这正是调试和事故复盘要的基础。‘可观察’不是锦上添花,是让 Agent 工程化成为可能的先决条件。”
小a:“审计这个用途,有没有推荐的落地方式?”
“有,订阅者是最适合审计的那一类。”老z说,“在工具执行前挂一个只读监听,把参数摘要、调用 ID、结果写进审计日志——**它不改主路径,所以挂了也不会影响功能;它只做记录,所以正是审计要的位置。**审计 hook 和拦截 hook 不要混写:拦截失败可能让整个 turn 失败,你不想因为‘审计组件坏了’而让合法工具也执行不了。审计用订阅者,拦截用拦截器,各归其位。”
“还有一条纪律,”老z说,“审计日志要包含调用 ID 和参数摘要,但不要包含完整秘密 payload。 日志会进文件、进备份、进事故复盘——把密钥写进日志,等于把审计对象搬进了事故现场。审计要回答‘谁在什么时候调了什么’,不需要回答‘参数全文是什么’。”小a把这条写在了 hook 笔记的第一页。
小a合上笔记,问了一句收尾的话:“那我到底该把什么放心地交给 hook?”
“交给它观察和编排,别交给它兜底。”老z说,“观察——记录、审计、统计,订阅者管得住;编排——在正确的时间点插入策略、在 turn 之间准备上下文,转换器和拦截器做得到。兜底——无论 hook 有没有装、装得对不对,主路径都必须安全——这是工具层和权限层的事。**hook 是‘锦上添花’的那块锦,不是‘雪中送炭’的那盆炭。**分清这两者,你才不会在事故复盘时发现‘那个安全 hook 一直没装上,而我们以为它装着’。”
小a想了想,补了一句:“也就是说,要按‘没装 hook 也安全’的标准去设计工具,再按‘装了 hook 更可控’的标准去挂 hook。”
“对,这就是顺序。”老z说,“先保证主路径安全,再谈扩展;先保证默认行为正确,再谈拦截。**hook 是主路径上的节点,主路径本身才是地基。**地基稳了,hook 才有意义。”
Hook 的两条边界
- Hook 是软扩展点,不是硬安全层——它能拦截、能改写,但它的存在不是强制的(没装就放行),它的失败不保证安全。
- 安全要纵深——工具层验证输入,权限层限定范围,执行环境(OS 沙箱/容器)隔离副作用,Hook 层做审计和策略。四层各有职责,不能互相替代。
回到全书目标:Hook 让 Agent 的控制流可扩展、可观察;但安全、权限和隔离是独立的工程问题,不能寄望于一个 hook 接口解决。
小a把这张表折起来放进兜里。第二天,他给 Agent 挂的第一个 hook 是审计用的订阅者——他记得老z那句“先分清三类”,也记得那句“审计用订阅者,拦截用拦截器”。名字还是那些名字,但这次他分得清了。
Hook 的两条边界
- Hook 是软扩展点,不是硬安全层——它能拦截、能改写,但它的存在不是强制的(没装就放行),它的失败不保证安全。
- 安全要纵深——工具层验证输入,权限层限定范围,执行环境(OS 沙箱/容器)隔离副作用,Hook 层做审计和策略。四层各有职责,不能互相替代。
回到全书目标:Hook 让 Agent 的控制流可扩展、可观察;但安全、权限和隔离是独立的工程问题,不能寄望于一个 hook 接口解决。