Hook 解耦循环
把日志、备份、通知等扩展行为挂到生命周期事件上,保持核心 Loop 可读。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Hook 解耦循环
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- Hook 是观察与扩展点,不是越权通道。
- 事件携带稳定结构,扩展行为与核心编排分离。
- 权限拒绝不能被 Hook 放宽。
顺着原文把边界看清
01导读:问题背景与本章目标上周帮一个朋友看他写的 Agent。最初的代码很简单:调用模型,拿到工具调用就执行,把结果放回 messages,再调用模型。⌄
上周帮一个朋友看他写的 Agent。最初的代码很简单:调用模型,拿到工具调用就执行,把结果放回 messages,再调用模型。
后来,同一个循环膨胀到了两百多行。PowerShell 日志、写文件确认、结果通知、自动备份都直接塞进了循环。每项需求单独看都合理,放在一起却让核心流程变成了谁也不敢改的 if-else 堆。
问题不在需求太多,而在扩展行为和核心编排没有分开。
第 4 章要做的事情很明确:在现有 Agent Loop 中加入四个结构化 Hook 挂载点,同时不破坏第 1—3 章已经建立的消息配对、工具校验和权限边界。
Claude Code 官方目前定义了 14 类以上 Hook 事件,例如 PermissionRequest、SessionStart、SubagentStart、PreCompact、Elicitation 等,完整清单参见 https://code.claude.com/docs/en/hooks.md。
第 4 章按教学需要先选择覆盖一次完整工具交互的四类核心事件。
02先看一眼真实的执行顺序跑一个只读任务,stderr 上看到四行 [Hook] 日志——它们各占流水线一个确定位置。⌄
先不看代码。P04 的真实 CLI 注册了四个只打日志的 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 ← ④ 模型给出了无工具调用的回答,准备结束把四行的位置摆到第 3 章那条流水线上:
用户提交 prompt ─→ ① UserPromptSubmit(能加 system 上下文)
模型返回 tool_calls → ToolRegistry.prepare
─→ ② PreToolUse(能改参数、提权限建议、直接阻断)
PermissionPolicy / approval → handler(真正的副作用)
─→ ③ PostToolUse(能改结果、能要求本轮结束)
写入配对的 tool result
模型返回无工具调用的回答
─→ ④ Stop(能强制再问一次模型,最多一次)| 事件 | 为什么在这里 |
|---|---|
UserPromptSubmit | 模型还没看到任何东西,是唯一能影响本轮输入的时机 |
PreToolUse | 参数已可信(过了 Zod),副作用还没发生——唯一能无损阻断的时机 |
PostToolUse | 结果已产生但还没进历史——唯一能改结果的时机 |
Stop | 循环准备结束,是唯一能判断「任务真的做完了吗」的时机 |
注意每一行的「唯一」。挂载点不是随便挑的位置,是那个信息刚好齐备、动作又还来得及的位置。早一步信息不全,晚一步无法挽回。
只打日志看不出价值。三个真实需求各挂各的位置:提醒核对验收标准 → UserPromptSubmit 返回 additionalContext;禁止写某个生成文件 → PreToolUse 返回 blockingError;输出太长截断 → PostToolUse 返回 updatedOutput。Agent Loop 一行不改,工具实现一行不改。
03先定义验收结果| 1 | 只支持 UserPromptSubmit、PreToolUse、PostToolUse 和 Stop 四类事件 | 全部 | hooks.test.ts |⌄
P 04 必须产生以下可观察行为:
| 编号 | 验收标准 | 主要事件 | 对应测试 |
|---|---|---|---|
| 1 | 只支持 UserPromptSubmit、PreToolUse、PostToolUse 和 Stop 四类事件 | 全部 | hooks.test.ts |
| 2 | 同一事件的同步、异步回调都严格按注册顺序运行 | 全部 | hooks.test.ts |
| 3 | Pre Hook 可以补上下文、改写合法输入、提出权限建议或返回结构化阻断错误 | Pre | hooks.test.ts、ch04-hooks.test.ts |
| 4 | Post Hook 可以按顺序观察并改写前一个 Hook 的输出 | Post | hooks.test.ts |
| 5 | 系统 deny 的优先级始终高于 Hook allow | Pre | ch04-hooks.test.ts |
| 6 | Pre 阻断、Hook 异常和非法 Hook 更新都要保留原 tool_call_id 配对 | Pre | ch04-hooks.test.ts |
| 7 | Post 请求停止后,同一 assistant 消息里尚未执行的调用也必须得到明确错误结果 | Post | ch04-hooks.test.ts |
| 8 | Stop Hook 第一次可以强制续跑,第二次仍执行回调,但不能再次续跑 | Stop | hooks.test.ts、ch04-hooks.test.ts |
| 9 | P 04 只比 P 03 增加 hooks capability,前 3 章行为保持不变 | 全部 | ch04-hooks.test.ts |
直接测试位于 code/chapters/ch04/tests/hooks.test.ts 和 code/chapters/ch04/tests/ch04-hooks.test.ts。
04Agent Loop 为什么需要扩展点第 3 章的循环已经包含模型调用、工具准备、权限和执行。把细节压缩后,结构大致是:⌄
第 3 章的循环已经包含模型调用、工具准备、权限和执行。把细节压缩后,结构大致是:
for (let turn = 1; turn <= maxTurns; turn += 1) {
const reply = await model.complete(request);
history.push(reply.message);
if (reply.message.toolCalls.length === 0) {
return reply.message.content;
}
for (const call of reply.message.toolCalls) {
const prepared = tools.prepare(call);
let result: ToolResult;
if (prepared.error !== undefined) {
result = prepared.error;
} else {
const decision = await permissionPolicy.decide(
new PermissionRequest({ prepared, context }),
);
result = decision.isAllowed
? await tools.invoke(prepared, context)
: decision.toToolResult();
}
history.push(toolMessage(result.content, call.id));
}
}这个循环应该继续只负责确定性编排。日志、通知、额外上下文和完成检查不是新的核心流程,它们需要的是固定挂载点。
Hook 的作用就是:在确定的位置发布结构化上下文,让已注册回调按顺序响应。
05四个事件覆盖一次完整交互code/chapters/ch04/src/core/hooks.ts 用一个冻结元组同时定义运行时事件集合和 TypeScript 联合类型:⌄
code/chapters/ch04/src/core/hooks.ts 用一个冻结元组同时定义运行时事件集合和 TypeScript 联合类型:
export const HOOK_EVENTS = Object.freeze([
"UserPromptSubmit",
"PreToolUse",
"PostToolUse",
"Stop",
] as const);
export type HookEvent = (typeof HOOK_EVENTS)[number];四个事件的位置固定:
用户提交 prompt
↓
UserPromptSubmit
↓
模型返回 tool_calls
↓
ToolRegistry.prepare
↓
PreToolUse
↓
PermissionPolicy / approval
↓
handler
↓
PostToolUse
↓
模型返回无工具回答
↓
Stop它们不是四个随意命名的回调。每个事件都有自己的上下文和允许返回的字段。
06HookContext:不同事件只拿到自己需要的数据export interface HookContextOptions {⌄
HookContext 是冻结对象,核心字段如下:
export interface HookContextOptions {
readonly event: HookEvent;
readonly message?: ChatMessage;
readonly prepared?: PreparedToolCall;
readonly result?: ToolResult;
readonly history?: readonly ChatMessage[];
readonly stopHookActive?: boolean;
}字段和事件一一对应:
| 事件 | 可用字段 | 不能出现的字段 |
|---|---|---|
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 只接收已经通过工具查找、JSON 解析和 Zod 校验的 PreparedToolCall。未知工具和非法参数不会进入 Hook。
07HookResult:不用字符串暗号表达控制流一个 Hook 可能只观察,也可能改写或阻断。用裸字符串返回值会很快失控:"stop" 到底是拒绝工具、结束当前轮,还是要求模型再回答一次?⌄
一个 Hook 可能只观察,也可能改写或阻断。用裸字符串返回值会很快失控:"stop" 到底是拒绝工具、结束当前轮,还是要求模型再回答一次?
本章使用明确的 HookResult:
export interface HookResultOptions {
readonly permissionBehavior?: PermissionBehavior;
readonly updatedInput?: PreparedToolCall;
readonly updatedOutput?: ToolResult;
readonly additionalContext?: readonly ChatMessage[];
readonly blockingError?: ToolResult;
readonly preventContinuation?: boolean;
readonly forceContinue?: ChatMessage;
}每个字段的语义只有一个:
| 字段 | 含义 | 允许事件 |
|---|---|---|
permissionBehavior | 向权限策略提交建议 | Pre |
updatedInput | 替换当前调用的已验证输入 | Pre |
blockingError | 在权限和 handler 前阻断 | Pre |
updatedOutput | 替换工具结果 | Post |
preventContinuation | 正常结束当前工具轮 | Post |
forceContinue | 追加一条 user 消息并再请求模型 | Stop |
additionalContext | 延后写入 system 上下文 | 四类事件 |
错误事件使用不属于自己的字段时,validateFor() 会列出非法字段并拒绝执行。
additionalContext 只接受 system message。这样 Hook 无法注入孤儿 tool result,也无法伪造带 toolCalls 的 assistant message 来破坏 OpenAI 消息配对。
通过校验后,HookResult 会把 updatedOutput 和 blockingError 通过 copyToolResult() 复制成新对象;additionalContext 用 systemMessage() 重建并冻结,forceContinue 用 userMessage() 重建。
Pre 的 updatedInput 不直接进入注册表。normalizeUpdatedInput() 先用原 StoredToolDefinition.inputSchema 重新 safeParse,再通过 freezePreparedToolCall() 用 structuredClone 创建参数副本并递归冻结。
因此,即使回调保留了原对象引用,并在之后修改这个引用,也不会污染注册表结果或 Agent 历史;审批与 handler 只能看到同一个不可变副本。
08HookRegistry:注册顺序就是执行顺序const hooks = new HookRegistry();⌄
注册表为每个事件保存独立回调列表:
const hooks = new HookRegistry();
hooks.register("UserPromptSubmit", (context) => {
console.error(`[Hook] ${context.event}`);
return new HookResult();
});回调可以同步返回,也可以返回 Promise<HookResult>。run() 对每个回调使用 await,因此两种形式都保持注册顺序:
for (const callback of callbacks) {
const outcome: unknown = await callback(current);
if (!(outcome instanceof HookResult)) {
throw new HookContractError(
`${context.event} hook callback must return HookResult`,
);
}
outcome.validateFor(context.event);
// 合并结果,并把改写后的输入或输出传给下一个 Hook。
}返回普通 object 不算合法 HookResult。这项运行时检查很重要,因为回调可能来自配置或插件边界,不能只依赖编译期类型。
合并时使用以下规则:
- 后一个
updatedInput或updatedOutput覆盖前一个,并成为再下一个 Hook 看到的值; additionalContext按注册顺序连接;preventContinuation使用布尔 OR;- 权限建议继续使用
deny > ask > allow > passthrough; - Pre 遇到
blockingError立即停止后续 Pre 回调; - Stop 第一次遇到
forceContinue后停止后续 Stop 回调。
除字段合并外,run() 还会在每轮回调后做三件固定的事:Pre 返回 updatedInput 时,normalizeUpdatedInput() 会重新解析并冻结新参数;stopHookActive 为 true 时,Stop 返回的 forceContinue 会在合并前被移除;blockingError 或 forceContinue 一旦产生,就短路剩余回调。
mergeResults() 使用 strongerPermission() 按 deny > ask > allow > passthrough 合并权限建议,因此后一个 allow 不会覆盖前一个 deny。
09UserPromptSubmit:补充本轮上下文User Prompt 刚提交、模型还没收到请求时触发这个事件:⌄
User Prompt 刚提交、模型还没收到请求时触发这个事件:
hooks.register("UserPromptSubmit", (context) => {
if (context.message?.role !== "user") {
throw new Error("UserPromptSubmit context is incomplete");
}
console.error(`[Hook] prompt: ${context.message.content}`);
return new HookResult({
additionalContext: [systemMessage("本轮需要特别核对验收结果。")],
});
});AgentRunner.run() 先运行 Hook,再按固定顺序写入历史:
const submitted = userMessage(prompt);
const promptHook = await hooks.runUserPrompt(submitted);
history.push(submitted, ...promptHook.additionalContext);Hook 可以记录输入或增加 system context,但 P 04 不允许它替换、删除或阻断原始 user message。
10PreToolUse:副作用之前的扩展点Pre Hook 运行时,工具已经存在,参数也已通过 Zod,但权限尚未判断,handler 尚未运行。⌄
Pre Hook 运行时,工具已经存在,参数也已通过 Zod,但权限尚未判断,handler 尚未运行。
下面的 Hook 阻止写入一个受管理文件:
hooks.register("PreToolUse", (context) => {
const argumentsValue = context.prepared?.arguments;
if (
typeof argumentsValue === "object" &&
argumentsValue !== null &&
Reflect.get(argumentsValue, "path") === "generated.txt"
) {
return new HookResult({
blockingError: toolError(
"hook_blocked",
"generated.txt is managed elsewhere",
),
});
}
return new HookResult();
});命中 blockingError 后,后续 Pre Hook、权限、handler 和 Post Hook 都不会运行。Loop 仍会把这个错误回填给原调用:
Error [hook_blocked]: generated.txt is managed elsewherePre Hook 还可以返回 updatedInput。但它只能改参数,不能换工具身份。注册表会验证:
tool_call_id不变;- 工具名不变;
StoredToolDefinition必须是同一个对象;- 新 arguments 仍通过原 Zod schema;
- prepared call 不能带 error,也不能缺 definition 或 arguments。
锁定 definition 对象同时锁定了 effect、handler 和 schema。否则恶意或有 bug 的 Hook 可以把一个 write 工具伪装成 read,再绕过权限。
注册表还会冻结准备完成的 definition、arguments 和 prepared 外壳。Hook 不能就地修改原参数;需要改写时必须显式返回新的 updatedInput。注册表随后用原 Zod schema 重新解析,复制参数并深度冻结规范化副本,审批和 handler 只读取这个副本。
这一步消除了异步审批中的竞态:Hook 即使保留了原对象引用,并在用户审批期间把“值 A”改成“值 B”,权限请求和 handler 仍看到同一个已经冻结的 A,不会出现“批准 A、执行 B”。
11Hook allow 只是建议,不是授权return new HookResult({ permissionBehavior: "allow" });⌄
Pre Hook 可以返回:
return new HookResult({ permissionBehavior: "allow" });Loop 不会因此直接执行工具,而是把它转换为结构化建议:
const recommendations = [
new PermissionDecision(
hook.permissionBehavior,
`PreToolUse hook requested ${hook.permissionBehavior}`,
"pre-tool-hook",
),
];
const decision = await permissionPolicy.decide(
new PermissionRequest({
prepared: effective,
context,
recommendations,
}),
);第 3 章的工作区硬边界、Shell 默认审批和固定规则仍一起参与合并。只要任一系统参与方给出 deny,Hook 的 allow 就不会触发审批或 handler。
这条顺序不能交换:
Pre Hook → hard permission policy → approval → handlerHook 是扩展机制,不是第二套权限系统。
Claude Code 官方同样强调这点(https://code.claude.com/docs/en/hooks-guide.md):Hook 可以拒绝工具调用,但保持沉默并不会自动批准调用;没有返回决策时,调用仍走正常权限流程。
P 04 的 passthrough 正是这个语义:Hook 不产生任何建议,工具是否执行完全由 PermissionPolicy 决定。
12PostToolUse:串联观察和结果改写Post Hook 在 handler 完成后、tool message 写入历史前运行:⌄
Post Hook 在 handler 完成后、tool message 写入历史前运行:
hooks.register("PostToolUse", (context) => {
const result = context.result;
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 把 original 改成 rewritten once,第二个 Hook 的 context.result 看到的就是 rewritten once。注册顺序因此也是改写顺序。
handler 或权限拒绝时不会错误地运行 Post Hook:
- handler 成功或返回结构化工具错误:运行 Post;
- Pre 阻断:不运行 Post;
- permission deny:不运行 Post;
- Pre Hook 异常:不运行 Post。
13preventContinuation 也必须保持消息配对Post Hook 可以用 preventContinuation: true 表示当前结果已经足够,本轮不再请求模型:⌄
Post Hook 可以用 preventContinuation: true 表示当前结果已经足够,本轮不再请求模型:
return new HookResult({ preventContinuation: true });困难在于,一个 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 的结果正常结束配对错误文本固定为:
Error [hook_stopped_continuation]: Skipped after PostToolUse requested a stop停止流程不能以破坏协议为代价。
14Stop:最多强制续跑一次当模型返回一条没有工具调用的 assistant message 时,Loop 准备结束,此时运行 Stop Hook:⌄
当模型返回一条没有工具调用的 assistant message 时,Loop 准备结束,此时运行 Stop Hook:
hooks.register("Stop", (context) => {
const toolCount = context.history.filter(
(message) => message.role === "tool",
).length;
console.error(`[Hook] 本次会话共执行 ${toolCount} 次工具调用`);
if (toolCount === 0) {
return new HookResult({
forceContinue: userMessage("请先用工具核对结果。"),
});
}
return new HookResult();
});第一次 forceContinue 会把明确的 user message 追加到历史,然后再次请求模型:
const stopHook = await hooks.runStop(history, stopHookActive);
if (stopHook.forceContinue !== undefined) {
history.push(...stopHook.additionalContext, stopHook.forceContinue);
stopHookActive = true;
continue;
}第二次模型停止时,Hook 仍然执行,并看到 stopHookActive === true。即使回调再次返回 forceContinue,注册表也会移除这项请求。Loop 使用第二次回答结束。
因此一次 run() 最多增加一个模型 turn,不会形成 Stop Hook 死循环;生命周期日志仍能完整记录两次 Stop。
15Hook 故障如何返回扩展代码同样可能出错。工具阶段不能因为 Hook 抛异常而遗留未配对调用。⌄
扩展代码同样可能出错。工具阶段不能因为 Hook 抛异常而遗留未配对调用。
本章固定映射如下:
| 故障 | 工具结果 | handler 是否已执行 |
|---|---|---|
| Pre 返回非法输入更新 | hook_contract_error | 否 |
| Pre 抛出其他异常 | hook_execution_error | 否 |
| 权限评估或审计异常 | permission_evaluation_error | 否 |
| Post 抛出异常 | hook_execution_error | 是 |
Post 发生在 handler 之后,所以它失败时不能声称副作用没有发生。返回稳定错误并保留配对,让模型和调用方能看到这次生命周期失败。
UserPromptSubmit 和 Stop 发生在工具配对之外;它们的非法结果会让当前 run() 显式失败,不会静默忽略扩展错误。
16P 03 与 P 04 的准确差异export const P04: ChapterProfile = Object.freeze({⌄
P 04 是在 P 03 固定能力上增加 hooks:
export const P04: ChapterProfile = Object.freeze({
chapter: 4,
capabilities: new CapabilitySet([
"loop",
"powershell",
"tool_registry",
"files",
"policy",
"hooks",
]),
});组合根只允许 P 04 或后续 profile 注入 HookRegistry:
if (dependencies.hooks !== undefined && !profile.capabilities.has("hooks")) {
throw new Error("hooks require chapter 4 or later");
}真实 P 04 CLI 注册四个只记录生命周期的默认 Hook:
[Hook] UserPromptSubmit
[Hook] PreToolUse: read_file
[Hook] PostToolUse: read_file -> ok
[Hook] Stop它们不修改参数、不覆盖输出,也不授予权限。若模型选择 shell 或文件写入,仍执行第 3 章的显式审批。
17组合根与 CLI:Hook 在真实入口中如何接入code/chapters/ch04/src/bootstrap.ts 是组合根,负责按 ChapterProfile 组装模型、工具、权限策略和 Hook。P 04 使用同一个 buildAgent,只是多了 hooks capability:⌄
code/chapters/ch04/src/bootstrap.ts 是组合根,负责按 ChapterProfile 组装模型、工具、权限策略和 Hook。P 04 使用同一个 buildAgent,只是多了 hooks capability:
export function buildAgent(
profile: ChapterProfile,
dependencies: BuildDependencies,
): AgentRunner {
// 禁止伪造同章节号但能力不同的 profile,保持教学快照固定。
if (profileForChapter(profile.chapter) !== profile) {
throw new Error("profile must be a fixed chapter profile");
}
if (dependencies.hooks !== undefined && !profile.capabilities.has("hooks")) {
throw new Error("hooks require chapter 4 or later");
}
// ...
return new AgentRunner({
model: dependencies.model,
tools,
systemPrompt: SYSTEM_PROMPT,
workspace: dependencies.workspace,
...(permissionPolicy === undefined ? {} : { permissionPolicy }),
...(dependencies.hooks === undefined ? {} : { hooks: dependencies.hooks }),
});
}这段代码把“章节能力”变成运行时约束:P 01-P 03 即使有人错误地传入 HookRegistry,也会在创建 Agent 前直接失败。
真实 CLI 的 liveHooks() 位于 code/chapters/ch04/src/cli.ts,它注册四个只输出 stderr 的观察型回调:
function liveHooks(): HookRegistry {
const hooks = new HookRegistry();
hooks.register("UserPromptSubmit", () => {
stderr.write("[Hook] UserPromptSubmit\n");
return new HookResult();
});
hooks.register("PreToolUse", (context: HookContext) => {
stderr.write(`[Hook] PreToolUse: ${hookToolName(context)}\n`);
return new HookResult();
});
hooks.register("PostToolUse", (context: HookContext) => {
if (context.result === undefined) {
throw new Error("PostToolUse context is incomplete");
}
const outcome = context.result.isError ? "error" : "ok";
stderr.write(`[Hook] PostToolUse: ${hookToolName(context)} -> ${outcome}\n`);
return new HookResult();
});
hooks.register("Stop", () => {
stderr.write("[Hook] Stop\n");
return new HookResult();
});
return hooks;
}每个回调都返回空 HookResult,因此默认运行只增加生命周期日志,不改变参数、结果或权限建议。真实 CLI 统一用 stderr.write 输出 Hook 日志,stdout 只写最终答案;教学片段中的 console.error 是为了让示例更短,不代表实际入口混用输出流。
18当前边界与刻意不做的事本章只实现四个当前确实使用的事件:UserPromptSubmit、PreToolUse、PostToolUse、Stop,没有提前增加子 Agent、压缩、任务或 MCP 专属事件。⌄
本章只实现四个当前确实使用的事件:UserPromptSubmit、PreToolUse、PostToolUse、Stop,没有提前增加子 Agent、压缩、任务或 MCP 专属事件。
Claude Code 官方目前定义了 14 类以上 Hook 事件,例如 PermissionRequest、SessionStart、SubagentStart、PreCompact、Elicitation 等,完整清单参见 https://code.claude.com/docs/en/hooks.md。第 4 章的事件集合是教学子集。
Hook 回调在当前进程内按顺序执行。P 04 不提供插件隔离、独立超时或并行 Hook;并行会让改写顺序和短路语义变得含混。
与 Claude Code 官方实现的一项差异:官方将匹配的所有同类 Hook 执行完成后合并结果(合并优先级 deny > defer > ask > allow),而 P 04 在 blockingError 或 Stop 的 forceContinue 时短路,提前停止后续回调。
这是教学简化:阻断错误意味着后续回调对该工具已无意义,而且短路位置明确。并发场景下如果需要所有回调的聚合结果,可以自行扩展。
Hook 也不是沙箱。它是由应用注册的受信任扩展代码,可以抛异常或执行自己的副作用。结构化契约保护 Agent Loop 的状态和权限顺序,不提供操作系统隔离。
19配套代码地图为了让读者能直接定位源码,本章把 code/chapters/ch04/src/ 的全部文件列成一张覆盖表:⌄
为了让读者能直接定位源码,本章把 code/chapters/ch04/src/ 的全部文件列成一张覆盖表:
| 文件 | 职责 | 讲解位置 |
|---|---|---|
core/hooks.ts | Hook 事件、上下文、结果与注册表 | 本章核心实现 |
core/loop.ts | Agent Loop 和四个 Hook 挂载点 | 本章核心实现 |
core/profiles.ts | P 01-P 04 固定能力快照 | 上文“P 03 与 P 04 的准确差异” |
bootstrap.ts | 按 profile 组装基础设施、权限和 Hook | 上文“组合根与 CLI” |
cli.ts | 终端入口、人工审批和默认观察 Hook | 上文“组合根与 CLI” |
config.ts | .env 模型配置校验 | 下文“运行第 4 章” |
chapters/ch01.ts 至 ch04.ts | 固定章节入口,禁止切换能力 | 上文“P 03 与 P 04 的准确差异” |
core/messages.ts | 消息模型、tool_call_id 配对契约 | 前文 HookResult 与 preventContinuation |
core/tools.ts | 工具注册、prepare、invoke、冻结参数 | 上文“PreToolUse” |
core/permissions.ts | 权限规则、审批、审计与合并 | 上文“Hook allow 只是建议” |
core/filesystem.ts、core/commands.ts、core/model.ts | 文件、命令、模型的最小接口 | 第 2-3 章继承契约 |
adapters/powershell.ts、adapters/openai-chat.ts、adapters/filesystem.ts | PowerShell、OpenAI、文件系统实现 | 第 2-3 章继承实现 |
features/builtin-tools.ts | shell 与文件工具集 | 第 2 章继承实现 |
这张图也说明了 Hook 的边界:本章没有为每个 adapter 增加回调,而是把扩展点集中在 Loop 的四个固定位置。外部实现差异仍由各自 adapter 收敛。
20从 ai-agent-book 学到什么ai-agent-book 第 4 章讲述的是另一条互补路线:工具分类、MCP 协议和事件驱动异步 Agent。它把“事件触发工具”定义为 Agent 注册、外部触发的一类工具,例如定时器、后台命令监控、外部消息频道;这些工具负责在用户没有主动提问时唤醒 Agent。⌄
ai-agent-book 第 4 章讲述的是另一条互补路线:工具分类、MCP 协议和事件驱动异步 Agent。它把“事件触发工具”定义为 Agent 注册、外部触发的一类工具,例如定时器、后台命令监控、外部消息频道;这些工具负责在用户没有主动提问时唤醒 Agent。
P 04 的 Hook 不是这套机制。Hook 是 Agent 内部生命周期的拦截点:UserPromptSubmit、PreToolUse、PostToolUse 和 Stop 都发生在同一次 run() 内部,用来追加日志、改写工具输入输出、建议权限或控制是否续跑。两者可以组合:
外部事件(定时器、Webhook、IM)
↓
事件触发工具 → 生成新 prompt
↓
UserPromptSubmit → PreToolUse → handler → PostToolUse → Stop21运行第 4 章npm run ch04 -- --prompt '读取 README.md 并概括运行方式'⌄
固定入口:
npm run ch04 -- --prompt '读取 README.md 并概括运行方式'统一入口:
npm run agent-tutorial -- run --chapter 4 --prompt '读取 README.md 并概括运行方式'运行第 4 章专项测试:
npm run test:ch04运行完整离线门禁:
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build测试通过 ScriptedModelClient、结构化权限替身和内存 Hook 验证行为,不读取真实 API Key,也不访问网络。
第 4 章最终保留的是一条可验证的固定流水线:
UserPromptSubmit
↓
model
↓
prepare
↓
PreToolUse
↓
permission / approval
↓
handler
↓
PostToolUse
↓
paired tool results
↓
Stop以后增加日志、通知或结果检查时,只需要注册新的回调。权限优先级、工具身份和 OpenAI 消息配对仍由同一个 Agent Loop 维护。
22验证与实验12 个测试文件、94 个测试。本章新增两个,其余 10 个是前 3 章累计测试。⌄
npm run test:ch04 会执行 12 个测试文件、94 个测试。本章新增的两个是:
| 测试文件 | 验证内容 |
|---|---|
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 阻断一个写入 | Error [hook_blocked],handler 没跑 | blockingError 短路权限和 handler |
| 三·让 Pre Hook 的 allow 挑战硬边界 | 仍是 deny (workspace-boundary) | Hook allow 只是建议;系统 deny 永远优先 |
| 四·在 Pre Hook 里就地修改参数 | 抛 hook_contract_error | 参数不可变,必须显式返回新 updatedInput |
23本章小结三句话版本、一定要记住的七条、以及本章刻意不做的事。⌄
三句话版本:
- 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 短路刻意简化;Hook 是受信任的应用代码,不是沙箱;Post 的 hook_contract_error 校验刻意不对称(Pre 更严)。第 11 章恢复与幂等才区分「副作用已发生/未发生」。
检查你是否真的读懂了:
- [Hook] PreToolUse 和 [Permission] 哪个先出现?为什么不能反过来?
- 为什么 additionalContext 只允许 system 消息?
- 恶意 Pre Hook 想把 write_file 伪装成 read_file,哪条检查拦住它?
- 三个 Post Hook 依次改写结果,第三个看到哪个版本?
- Post Hook 要求停止,同轮其它调用为什么会变成 hook_stopped_continuation?
- Stop Hook 第二次还能 forceContinue 吗?回调还会执行吗?
- Post Hook 抛异常,handler 执行过没有?为什么这信息很重要?
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始