"stop" 这类字符串。| 你需要先具备 | 说明 |
|---|---|
| 读完第 1—3 章 | 本章不新增任何工具或权限规则,只在已有流水线上开孔 |
记得 prepare → permission → invoke | 四个挂载点全部围绕这条顺序摆放 |
记得 deny > ask > allow > passthrough | 本章的 Hook 建议要挂进这个合并逻辑 |
记得 validateToolPairing() | 本章一半的设计约束都来自「每个调用必须配对」 |
本章导读(你在这)
↓
① 先看一眼真实的执行顺序 ← 一次运行的完整日志,四个事件各打一行
↓
② 验收结果 + 为什么需要扩展点 ← 循环为什么不该继续长大
↓
③ 四个事件 + HookContext ← 位置固定、数据按事件裁剪
↓
④ HookResult ← 本章设计的核心:为什么不用字符串
↓
⑤ HookRegistry ← 顺序、合并规则、短路
↓
⑥ 四个事件逐个看 ← Submit / Pre / Post / Stop 各自能做什么
↓
⑦ 两个硬约束 ← Hook allow 只是建议;停止也要保持配对
↓
⑧ 故障映射 + P03/P04 差异
↓
⑨ 运行、验证与小结 ← 动手 + 排错 + 自测[Hook] 日志。npm run ch04 -- --prompt '读取 README.md 并概括运行方式',stderr 会打出四行([Permission] 是第 3 章审计,[Hook] 是本章新增):[Hook] UserPromptSubmit ← ① prompt 已提交,模型还没收到请求
[Hook] PreToolUse: read_file ← ② 工具已查到、参数已过 Zod,权限还没判
[Permission] read_file: allow (default) - No permission rule blocked the request
[Hook] PostToolUse: read_file -> ok ← ③ handler 跑完了,结果还没写进历史
[Hook] Stop ← ④ 模型给出了无工具调用的回答,准备结束
用户提交 prompt
├─ ① UserPromptSubmit ─ 能加 system 上下文,不能改/删 prompt
模型返回 tool_calls → ToolRegistry.prepare
├─ ② PreToolUse ────── 能改参数、提权限建议、直接阻断
PermissionPolicy / approval → handler(真正的副作用)
├─ ③ PostToolUse ───── 能改结果、能要求本轮结束
写入配对的 tool result
模型返回无工具调用的回答
└─ ④ Stop ──────────── 能强制再问一次模型(最多一次)
| 事件 | 为什么在这里 |
|---|---|
UserPromptSubmit | 模型还没看到任何东西,是唯一能影响本轮输入的时机 |
PreToolUse | 参数已经可信(过了 Zod),但副作用还没发生——唯一能无损阻断的时机 |
PostToolUse | 结果已经产生,但还没进历史——唯一能改结果的时机 |
Stop | 循环准备结束,是唯一能判断「任务真的做完了吗」的时机 |
| 需求 | 不用 Hook 会怎样 | 挂在哪 |
|---|---|---|
| 每次运行都提醒模型核对验收标准 | 改 system prompt,但那会破坏静态前缀 / KV Cache | UserPromptSubmit 返回 additionalContext |
| 禁止写某个构建脚本生成的文件 | 在 write_file handler 里加 if,工具实现被业务规则污染 | PreToolUse 返回 blockingError |
| 工具输出太长就截断 | 在每个工具的 handler 里各写一遍截断逻辑 | PostToolUse 返回 updatedOutput |
同一个循环膨胀到两百多行:日志、写文件确认、结果通知、自动备份全塞进来,变成谁也不敢改的 if-else 堆。问题不在需求太多,而在扩展行为和核心编排没有分开。Hook 就是在确定的位置发布结构化上下文,让已注册回调按顺序响应——循环只负责确定性编排。
HOOK_EVENTS 冻结元组同时定义运行时集合和 TS 类型。每个事件的 HookContext 只有自己需要的字段:Submit 只拿 user message;Pre 只拿已校验的 prepared;Post 拿 prepared + result;Stop 拿 history + stopHookActive。给 Pre 塞一条 message 会立即得到 HookContractError,而不是让回调猜。
export const HOOK_EVENTS = Object.freeze([
"UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop",
] as const);
export type HookEvent = (typeof HOOK_EVENTS)[number];
一个 "stop" 至少有四种可能的意思(拒绝本次调用 / 执行但别再问 / 整个 run 失败 / 别让 Stop 续跑),副作用完全不同。"stpo" 拼错了运行时只会当成「未知返回值」忽略——正在阻断危险写入的 Hook 静默失效。结构化 HookResult 每个字段只有一种语义,且只在特定事件合法,用 validateFor() 检查非法字段。
interface HookResultOptions {
readonly permissionBehavior?: PermissionBehavior; // Pre 建议
readonly updatedInput?: PreparedToolCall; // Pre 改写参数
readonly updatedOutput?: ToolResult; // Post 改写结果
readonly additionalContext?: readonly ChatMessage[];// 四类,只能 system
readonly blockingError?: ToolResult; // Pre 阻断
readonly preventContinuation?: boolean; // Post 本轮结束
readonly forceContinue?: ChatMessage; // Stop 续跑一次
}
tool 消息 → validateToolPairing 抛 orphan tool result;允许带 toolCalls 的 assistant → 模型以为请求过这些工具但等不到结果;允许 user → 指令来源被伪造。system 消息不参与工具配对,也不会被误认为用户意图。每个事件独立回调列表;run() 对每个回调用 await,同步/异步都保持注册顺序。返回普通 object 不算合法 HookResult(运行时 instanceof 检查)。合并规则按字段分:
updatedInput / updatedOutput 后覆盖前 ← 流水线加工,后者看到前者产出
additionalContext 顺序拼接
preventContinuation 布尔 OR ← 只要有一个要求停就停(更保守的赢)
permissionBehavior deny>ask>allow>passthrough (更保守的赢)
Pre 遇 blockingError 立即短路后续 Pre 回调
Stop 第一次遇 forceContinue 后停止后续 Stop 回调
三个 Post Hook 依次注册,Hook 2 看到的是 Hook 1 的产出——想做「先脱敏、再截断」还是反过来,靠注册顺序。
User Prompt 刚提交、模型还没收到请求时触发。可以记录输入或增加 system context(如「本轮需要特别核对验收结果」)。run() 先运行 Hook,再按固定顺序写入历史。P04 不允许替换、删除或阻断原始 user message。
const submitted = userMessage(prompt);
const promptHook = await hooks.runUserPrompt(submitted);
history.push(submitted, ...promptHook.additionalContext);
工具已存在、参数已过 Zod,但权限尚未判断、handler 尚未运行。可补上下文、改写合法输入、提权限建议或返回结构化阻断错误。命中 blockingError 后权限和 handler 都不跑,Error [hook_blocked] 仍回填给原调用。
// 阻断写入受管理文件
if (argumentsValue.path === "generated.txt") {
return new HookResult({ blockingError: toolError(
"hook_blocked", "generated.txt is managed elsewhere") });
}
Pre Hook 返回 permissionBehavior: "allow",Loop 不会直接执行,而是转成结构化建议(source 是 pre-tool-hook)传给 PermissionPolicy,第 3 章的硬边界、Shell 审批、固定规则仍一起合并。只要任一系统参与方 deny,Hook 的 allow 就不会触发审批或 handler。
Pre Hook → hard permission policy → approval → handler
// Hook 是扩展机制,不是第二套权限系统
在 handler 完成后、tool message 写入历史前运行。`context.result` 反映前一个 Post Hook 的产出;返回 updatedOutput 覆盖结果。handler 成功或返回结构化错误时运行 Post;Pre 阻断、permission deny、Pre Hook 异常时不运行 Post。
// 长输出截断
if (result === undefined || result.isError || result.content.length <= 100_000) {
return new HookResult();
}
return new HookResult({ updatedOutput: toolSuccess(
`\${result.content.slice(0, 100_000)}\n[truncated]`) });
Post Hook 要求停止时,Loop 不能直接 return——同一 assistant 消息可能还有其它 tool call,直接 return 会让它们成为孤儿,OpenAI 会拒绝、validateToolPairing 抛 missing tool results。
call-1 → 正常执行并触发停止
call-2 → Error [hook_stopped_continuation]: Skipped after PostToolUse ...
call-3 → Error [hook_stopped_continuation]: ...
先写入全部三个 tool result,再以 call-1 的结果正常结束
模型返回没有工具调用的回答时运行 Stop Hook。第一次 forceContinue 追加明确的 user 消息并再次请求模型,stopHookActive=true;第二次模型停止时回调仍执行并看到 stopHookActive,即使再返回 forceContinue 也会被移除。一次 run() 最多增加一个模型 turn,不会死循环。
turn N 模型停止 → Stop Hook(active:false) → forceContinue → 续跑
turn N+1 模型停止 → Stop Hook(active:true) → forceContinue 被移除 → 结束
| 故障 | 工具结果 | handler 已执行 |
|---|---|---|
| prepare() 自身抛异常 | tool_preparation_error | 否 |
| Pre 返回非法输入更新 | hook_contract_error | 否 |
| Pre 抛出其他异常 | hook_execution_error | 否 |
| 权限评估或审计异常 | permission_evaluation_error | 否 |
| Post 抛出异常 | hook_execution_error | 是 |
prompt 已提交,模型还没收到请求;可加 system 上下文,不能改写 prompt。
参数已过 Zod,权限未判;可改参数、提建议、阻断。
Hook 建议并入 deny>ask>allow>passthrough。
真正副作用发生。
可改结果、可要求本轮结束;preventContinuation 保持配对。
additionalContext 延后追加。
最多强制续跑一次;第二次停止后结束。
Set-Location code + npm ci。
npm run test:ch04 预期 12 个文件 94 个测试,不需要 Key。
跑只读任务,stderr 出现四行 [Hook]。
写文件任务里 [Permission] 与 [Hook] 交替。
agent-tutorial -- run --chapter 4。
typecheck / test / lint / format / build。
npm run test:ch04
Test Files 12 passed (12)
Tests 94 passed (94)
# 从第 3 章的 61 涨到 94,多出的 33 个几乎全在 hooks.test.ts / ch04-hooks.test.ts
npm run ch04 -- --prompt '读取 README.md 并概括运行方式'
# stderr → [Hook] UserPromptSubmit
# [Hook] PreToolUse: read_file
# [Permission] read_file: allow (default) - ...
# [Hook] PostToolUse: read_file -> ok
# [Hook] Stop
npm run ch04 -- --prompt '把一句话写入 hook-demo.txt'
# 写文件时仍是第 3 章的逐次审批,只是多了四行 [Hook] 生命周期日志
| 现象 | 原因 / 处理 |
|---|---|
看不到任何 [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 |
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 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 | 不做。用户输入不该被扩展代码改写 |