顶级 AI Agent 是如何利用 Hook 解耦的?
🎯 本章导读
- 说出四个 Hook 事件各自触发在流水线的哪一步,以及为什么是这四个位置。
- 解释为什么 Hook 的返回值必须是结构化对象,而不是
"stop"这类字符串。 - 说清「Hook 说 allow」和「权限系统说 allow」的区别——前者只是建议。
- 讲出一个 Post Hook 要求停止时,同一轮里其他工具调用会发生什么,以及为什么不能直接 return。
- 解释 Stop Hook 为什么最多只能强制续跑一次。
- 判断一个 Hook 抛异常时,会得到哪个错误码、handler 有没有执行过。
你需要先具备什么 ▸
| 需要 | 说明 |
|---|---|
| 读完第 1—3 章 | 本章不新增任何工具或权限规则,只在已有流水线上开孔 |
记得 prepare → permission → invoke | 四个挂载点全部围绕这条顺序摆放 |
记得 deny > ask > allow > passthrough | 本章的 Hook 建议要挂进这个合并逻辑 |
记得 validateToolPairing() | 本章一半的设计约束都来自「每个调用必须配对」 |
建议的阅读路线 ▸
本章导读(你在这)
↓
① 先看一眼真实的执行顺序 ← 一次运行的完整日志,四个事件各打一行
↓
② 验收结果 + 为什么需要扩展点
↓
③ 四个事件 + HookContext ← 位置固定、数据按事件裁剪
↓
④ HookResult ← 为什么不用字符串
↓
⑤ HookRegistry ← 顺序、合并规则、短路
↓
⑥ 四个事件逐个看 ← Submit / Pre / Post / Stop
↓
⑦ 两个硬约束 ← allow 只是建议;停止也要保持配对
↓
⑧ 故障映射 + P03/P04 差异
↓
⑨ 运行、验证与小结
📇 术语速查
| 术语 | 一句话解释 |
|---|---|
| Hook | 注册在固定位置的回调。由应用注册,不是插件沙箱 |
| 挂载点 | Loop 里发布结构化上下文的确定位置。本章共四个 |
| HookEvent | 四个事件名之一:UserPromptSubmit / PreToolUse / PostToolUse / Stop |
| HookContext | 事件发布给回调的数据。不同事件的可用字段不同 |
| HookResult | 回调的返回值。每个字段只有一种语义,且只在特定事件合法 |
| HookRegistry | 按事件保存回调列表;注册顺序 = 执行顺序 |
permissionBehavior | Pre Hook 提出的权限建议,不是决定 |
updatedInput | Pre Hook 改写后的参数。会被重新 Zod 校验并深度冻结 |
blockingError | Pre Hook 直接阻断,权限和 handler 都不跑 |
updatedOutput | Post Hook 改写后的工具结果 |
additionalContext | 追加的 system 消息。只能是 system,且延后写入 |
preventContinuation | Post Hook 表示本轮不再请求模型 |
forceContinue | Stop Hook 追加一条 user 消息并再问一次模型 |
stopHookActive | 标记「已经强制续跑过一次」,防死循环 |
| 短路 | 出现 blockingError 或 forceContinue 时,后续同类回调不再执行 |
| HookContractError | 回调违反契约(返回值类型不对、用了非法字段、改了工具身份) |
📡 先看一眼真实的执行顺序
[Permission] 是第 3 章审计,[Hook] 是本章新增):[Hook] UserPromptSubmit ← ① prompt 已提交,模型还没收到请求
[Hook] PreToolUse: read_file ← ② 工具已查到、参数已过 Zod,权限还没判
[Permission] read_file: allow (default) - No permission rule blocked
[Hook] PostToolUse: read_file -> ok ← ③ handler 跑完了,结果还没写进历史
[Hook] Stop ← ④ 模型给出无工具调用的回答,准备结束
四行的位置摆到第 3 章那条流水线上,每个事件的「为什么在这里」都有唯一性:
| 事件 | 为什么在这里 |
|---|---|
UserPromptSubmit | 模型还没看到任何东西,是唯一能影响本轮输入的时机 |
PreToolUse | 参数已可信(过了 Zod),副作用还没发生——唯一能无损阻断的时机 |
PostToolUse | 结果已产生但还没进历史——唯一能改结果的时机 |
Stop | 循环准备结束,是唯一能判断「任务真的做完了吗」的时机 |
一个更有用的例子:三个真实需求 ▸
| 需求 | 不用 Hook 会怎样 | 挂在哪 |
|---|---|---|
| 每次运行都提醒模型核对验收标准 | 改 system prompt,但那会破坏静态前缀 / KV Cache | UserPromptSubmit 返回 additionalContext |
| 禁止写某个构建脚本生成的文件 | 在 write_file handler 里加 if,工具实现被业务规则污染 | PreToolUse 返回 blockingError |
| 工具输出太长就截断 | 在每个工具的 handler 里各写一遍截断逻辑 | PostToolUse 返回 updatedOutput |
🧠 核心概念
1 · 四个事件覆盖一次完整交互 ▸
用户提交 prompt
↓
UserPromptSubmit
↓
模型返回 tool_calls
↓
ToolRegistry.prepare
↓
PreToolUse
↓
PermissionPolicy / approval
↓
handler
↓
PostToolUse
↓
模型返回无工具回答
↓
Stop
它们不是随意命名的回调,每个事件都有自己的上下文和允许返回的字段。
2 · HookContext:不同事件只拿自己需要的数据 ▸
| 事件 | 可用字段 | 不能出现的字段 |
|---|---|---|
| UserPromptSubmit | 一条 user message | prepared/result/history/stop |
| PreToolUse | 已校验的 prepared | message/result/history/stop |
| PostToolUse | prepared 和 result | message/history/stop |
| Stop | history、stopHookActive | message/prepared/result |
给 Pre Hook 上下文塞 user message 会立即得到 HookContractError。Pre/Post 只接收已通过查找+解析+Zod 校验的 PreparedToolCall,未知工具和非法参数不进入 Hook。
3 · HookResult:不用字符串暗号表达控制流 ▸
用裸字符串 "stop" 会失控:到底是拒绝工具、结束当前轮、还是要求模型再答一次?用明确字段:
| 字段 | 含义 | 允许事件 |
|---|---|---|
| permissionBehavior | 向权限策略提交建议 | Pre |
| updatedInput | 替换当前调用的已验证输入 | Pre |
| blockingError | 权限和 handler 前阻断 | Pre |
| updatedOutput | 替换工具结果 | Post |
| preventContinuation | 正常结束当前工具轮 | Post |
| forceContinue | 追加 user 消息再请求模型 | Stop |
| additionalContext | 延后写入 system 上下文 | 四类 |
additionalContext 只接受 system message——Hook 无法注入孤儿 tool result 或伪造带 toolCalls 的 assistant message 来破坏配对。
4 · Hook allow 只是建议,不是授权 ▸
Pre Hook 返回 permissionBehavior: "allow",Loop 不会直接执行,而是转成结构化建议传给 PermissionPolicy。第 3 章的工作区硬边界、Shell 默认审批、固定规则仍一起参与合并。
Claude Code 官方同样:Hook 可拒绝调用,但保持沉默并不自动批准;没有返回决策时仍走正常权限流程(passthrough 语义)。
5 · Pre 的 updatedInput 不能换工具 ▸
updatedInput 只能改参数,不能换工具身份。注册表验证:tool_call_id 不变、工具名不变、StoredToolDefinition 必须是同一对象、新 arguments 仍过原 Zod schema。
6 · preventContinuation 必须保持消息配对 ▸
一个 assistant message 可能含多个 tool call。第一个调用的 Post Hook 要求停止时,Loop 不能直接 return,否则其余调用成孤儿:
call-1 → 正常执行并触发停止
call-2 → Error [hook_stopped_continuation]
call-3 → Error [hook_stopped_continuation]
先写入全部三个 tool result,再以 call-1 结果正常结束
停止流程不能以破坏协议为代价。
7 · Stop 最多强制续跑一次 ▸
模型返回无工具调用的 assistant message 时,Loop 准备结束,运行 Stop Hook。第一次 forceContinue 追加 user 消息再请求模型。第二次模型停止时 Hook 仍执行,并看到 stopHookActive===true;即使再次返回 forceContinue,注册表也移除该请求。
8 · Hook 故障如何返回 ▸
| 故障 | 工具结果 | handler 是否已执行 |
|---|---|---|
| Pre 返回非法输入更新 | hook_contract_error | 否 |
| Pre 抛其他异常 | hook_execution_error | 否 |
| 权限评估/审计异常 | permission_evaluation_error | 否 |
| Post 抛异常 | hook_execution_error | 是 |
Post 在 handler 之后,失败时不能声称副作用没发生。返回稳定错误并保留配对。UserPromptSubmit 和 Stop 在工具配对之外,非法结果让当前 run() 显式失败。
▶️ 交互演示:Hook 流水线
🔑 一句话总结
UserPromptSubmit → model → prepare → PreToolUse → permission → handler → PostToolUse → 配对结果 → Stop以后增加日志、通知或结果检查,只需注册新回调。权限优先级、工具身份和 OpenAI 消息配对仍由同一个 Agent Loop 维护。
🚀 运行第 4 章
第 0–1 步 · 环境与离线测试 ▸
Set-Location 'F:\笔记\Agent实操\code'
npm ci
npm run test:ch04
Test Files 12 passed (12)
Tests 94 passed (94)
从第 3 章的 61 个涨到 94 个,多出来的 33 个几乎全在 hooks.test.ts 和 ch04-hooks.test.ts 里。那行 配置错误: Missing required settings: ... 仍是被断言的预期输出。
第 2–3 步 · 看四行日志;让 Hook 和审批同时出现 ▸
npm run ch04 -- --prompt '读取 README.md 并概括运行方式'
# stderr: [Hook] UserPromptSubmit
# [Hook] PreToolUse: read_file
# [Permission] read_file: allow (default) - No ...
# [Hook] PostToolUse: read_file -> ok
# [Hook] Stop
npm run ch04 -- --prompt '把一句话写入 hook-demo.txt'
# 写文件时仍是第 3 章的逐次审批,只是多了四行 [Hook] 日志
对比着跑 ch03 和 ch04 同一条 prompt:两次的 [Permission] 行一模一样,唯一差别是 P04 多出四行 [Hook]——这就是「解耦」的具体含义。
第 4–5 步 · 统一入口与完整离线门禁 ▸
npm run agent-tutorial -- run --chapter 4 --prompt '读取 README.md 并概括运行方式'
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build
测试不读取真实 API Key,也不访问网络。真实 OpenAI 运行只是额外冒烟证据。
常见报错排查表 ▸
| 现象 | 原因 / 处理 |
|---|---|
看不到任何 [Hook] 行 | Hook 日志写 stderr,你可能重定向掉了;别加 2>$null,或确认跑的是 ch04 而不是 ch03 |
[Hook] PostToolUse: xxx -> error | 工具本身返回错误结果。正常观察日志,Post 在工具失败时也会跑 |
| 只有 PreToolUse 没有 PostToolUse | 调用被 Pre 阻断或权限拒绝。正常行为 |
Error [hook_contract_error] | Pre Hook 的 updatedInput 不合法:tool_call_id / 工具名 / definition 引用 / Zod 不过 |
Error [hook_execution_error] | 你写的 Hook 抛异常。Pre 和 Post 共用此码,自行判断在哪端 |
Error [hook_stopped_continuation] | 同轮前面的调用触发了 preventContinuation。这些调用没被执行,正常 |
| 启动报 hooks require chapter 4 or later | 给 P01–P03 传了 HookRegistry。换成 P04 或别传 hooks |
| Hook 返回普通对象报 must return HookResult | 必须 return new HookResult({...}),运行时 instanceof 检查 |
| Stop 的 forceContinue 第二次没生效 | stopHookActive=true,一次 run() 最多续跑一次,正常 |
🧪 验证与实验
npm run test:ch04 执行 12 个测试文件、94 个测试。本章新增两个,其余是前 3 章累计测试。| 测试文件 | 验证内容 |
|---|---|
hooks.test.ts | 单元层:HookContext 字段裁剪、HookResult.validateFor()、注册顺序、合并规则、updatedInput 重校验与冻结、短路行为 |
ch04-hooks.test.ts | 端到端:Hook 与权限的优先级、阻断后 handler 未执行、preventContinuation 的配对、Stop 只续跑一次、P01–P03 行为未变 |
四个建议动手做的小实验 ▸
| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一·注册两个 Post Hook 观察加工链 | 第二个 Hook 的 context.result 是第一个的产出 | Post 是流水线,不是并列观察者 |
| 二·让 Pre Hook 阻断一个写入 跑 npm run ch04 -- --prompt '把 hello 写入 blocked.txt' | Error [hook_blocked],handler 没跑 | blockingError 短路权限和 handler |
| 三·让 Pre Hook 的 allow 挑战硬边界 跑 npm run ch04 -- --prompt '把 hello 写入 ../outside.txt' | 仍是 deny (workspace-boundary) | Hook allow 只是建议;系统 deny 永远优先 |
| 四·在 Pre Hook 里就地修改参数 | 抛 hook_contract_error | 参数不可变,必须显式返回新 updatedInput |
📝 本章小结
- Hook 是在四个确定位置发布结构化上下文,让扩展逻辑从核心编排里搬出去。
- 返回值用结构化 HookResult 而不是字符串,因为控制流语义必须能被校验。
- Hook 能建议、能改写、能阻断,但不能授权——系统 deny 永远优先。
一定要记住的七条 ▸
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 挂载点选在「信息刚好齐备、动作还来得及」的位置 | 先看一眼真实的执行顺序 |
| 2 | additionalContext 只能是 system,且延后到配对完成后才写入 | HookResult |
| 3 | updatedInput 必须重过 Zod 并深度冻结,否则会「批准 A 执行 B」 | PreToolUse |
| 4 | definition 用引用相等锁定,防止 write 伪装成 read | PreToolUse |
| 5 | 注册顺序 = 执行顺序 = 加工顺序;保守字段例外(取更严的) | HookRegistry |
| 6 | 提前结束也要把同轮剩余调用补成配对错误 | preventContinuation |
| 7 | Stop 最多续跑一次;第二次回调仍执行,只是控制权被收回 | Stop |
本章代码边界(明确「还没做什么」) ▸
| 已经有 | 还没有 | 说明 |
|---|---|---|
| 四个生命周期事件 | 官方 14+ 类事件(SessionStart、PreCompact、SubagentStart 等) | 教学子集;按需在后续章节增加 |
| 顺序执行的进程内回调 | 插件隔离、独立超时、并行执行 | 不做。并行会让改写顺序和短路语义含混 |
| blockingError / forceContinue 短路 | 官方那种「跑完全部再合并」 | 刻意简化;短路位置明确 |
| 结构化契约保护 Loop 状态 | 操作系统级沙箱 | 不做。Hook 是受信任的应用代码 |
| 统一的 hook_execution_error | 区分「副作用已发生 / 未发生」 | 第 11 章讨论恢复与幂等时处理 |
| UserPromptSubmit 追加上下文 | 替换、删除或阻断原始 prompt | 不做。用户输入不该被扩展代码改写 |
检查你是否真的读懂了(不看文章回答) ▸
- [Hook] PreToolUse 和 [Permission] 哪个先出现?为什么不能反过来?
- 为什么 additionalContext 只允许 system 消息?允许 tool 消息会怎样?
- 一个恶意 Pre Hook 想把 write_file 伪装成 read_file,哪一条检查拦住了它?
- 三个 Post Hook 依次改写结果,第三个看到的是哪个版本?
- Post Hook 要求停止时,同一轮里其它调用为什么变成 hook_stopped_continuation?
- Stop Hook 第二次还能 forceContinue 吗?回调还会执行吗?
- Post Hook 抛异常,handler 执行过没有?为什么这个信息很重要?