AGAgent 学习路线
第 4 / 20
CHAPTER 04 · GPT 生成学习页

Hook 解耦循环

把日志、备份、通知等扩展行为挂到生命周期事件上,保持核心 Loop 可读。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

Hook 解耦循环

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • Hook 是观察与扩展点,不是越权通道。
  • 事件携带稳定结构,扩展行为与核心编排分离。
  • 权限拒绝不能被 Hook 放宽。
1SessionStart
2BeforeToolCall
3AfterToolCall
4SessionEnd
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

20 个小节31 组代码47 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

01导读:问题背景与本章目标上周帮一个朋友看他写的 Agent。最初的代码很简单:调用模型,拿到工具调用就执行,把结果放回 messages,再调用模型。

上周帮一个朋友看他写的 Agent。最初的代码很简单:调用模型,拿到工具调用就执行,把结果放回 messages,再调用模型。

后来,同一个循环膨胀到了两百多行。PowerShell 日志、写文件确认、结果通知、自动备份都直接塞进了循环。每项需求单独看都合理,放在一起却让核心流程变成了谁也不敢改的 if-else 堆。

问题不在需求太多,而在扩展行为和核心编排没有分开。

图片
图片

第 4 章要做的事情很明确:在现有 Agent Loop 中加入四个结构化 Hook 挂载点,同时不破坏第 1—3 章已经建立的消息配对、工具校验和权限边界。

Claude Code 官方目前定义了 14 类以上 Hook 事件,例如 PermissionRequestSessionStartSubagentStartPreCompactElicitation 等,完整清单参见 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 返回 updatedOutputAgent Loop 一行不改,工具实现一行不改。


03先定义验收结果| 1 | 只支持 UserPromptSubmit、PreToolUse、PostToolUse 和 Stop 四类事件 | 全部 | hooks.test.ts |

P 04 必须产生以下可观察行为:

编号验收标准主要事件对应测试
1只支持 UserPromptSubmitPreToolUsePostToolUseStop 四类事件全部hooks.test.ts
2同一事件的同步、异步回调都严格按注册顺序运行全部hooks.test.ts
3Pre Hook 可以补上下文、改写合法输入、提出权限建议或返回结构化阻断错误Prehooks.test.tsch04-hooks.test.ts
4Post Hook 可以按顺序观察并改写前一个 Hook 的输出Posthooks.test.ts
5系统 deny 的优先级始终高于 Hook allowPrech04-hooks.test.ts
6Pre 阻断、Hook 异常和非法 Hook 更新都要保留原 tool_call_id 配对Prech04-hooks.test.ts
7Post 请求停止后,同一 assistant 消息里尚未执行的调用也必须得到明确错误结果Postch04-hooks.test.ts
8Stop Hook 第一次可以强制续跑,第二次仍执行回调,但不能再次续跑Stophooks.test.tsch04-hooks.test.ts
9P 04 只比 P 03 增加 hooks capability,前 3 章行为保持不变全部ch04-hooks.test.ts

直接测试位于 code/chapters/ch04/tests/hooks.test.tscode/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 messageprepared、result、history、stop 状态
PreToolUse已校验的 preparedmessage、result、history、stop 状态
PostToolUsepreparedresultmessage、history、stop 状态
StophistorystopHookActivemessage、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 会把 updatedOutputblockingError 通过 copyToolResult() 复制成新对象;additionalContextsystemMessage() 重建并冻结,forceContinueuserMessage() 重建。

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。这项运行时检查很重要,因为回调可能来自配置或插件边界,不能只依赖编译期类型。

合并时使用以下规则:

  • 后一个 updatedInputupdatedOutput 覆盖前一个,并成为再下一个 Hook 看到的值;
  • additionalContext 按注册顺序连接;
  • preventContinuation 使用布尔 OR;
  • 权限建议继续使用 deny > ask > allow > passthrough
  • Pre 遇到 blockingError 立即停止后续 Pre 回调;
  • Stop 第一次遇到 forceContinue 后停止后续 Stop 回调。

除字段合并外,run() 还会在每轮回调后做三件固定的事:Pre 返回 updatedInput 时,normalizeUpdatedInput() 会重新解析并冻结新参数;stopHookActive 为 true 时,Stop 返回的 forceContinue 会在合并前被移除;blockingErrorforceContinue 一旦产生,就短路剩余回调。

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 elsewhere

Pre 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 → handler

Hook 是扩展机制,不是第二套权限系统。

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 专属事件。

本章只实现四个当前确实使用的事件:UserPromptSubmitPreToolUsePostToolUseStop,没有提前增加子 Agent、压缩、任务或 MCP 专属事件。

Claude Code 官方目前定义了 14 类以上 Hook 事件,例如 PermissionRequestSessionStartSubagentStartPreCompactElicitation 等,完整清单参见 https://code.claude.com/docs/en/hooks.md。第 4 章的事件集合是教学子集。

Hook 回调在当前进程内按顺序执行。P 04 不提供插件隔离、独立超时或并行 Hook;并行会让改写顺序和短路语义变得含混。

与 Claude Code 官方实现的一项差异:官方将匹配的所有同类 Hook 执行完成后合并结果(合并优先级 deny > defer > ask > allow),而 P 04 在 blockingErrorStopforceContinue 时短路,提前停止后续回调。

这是教学简化:阻断错误意味着后续回调对该工具已无意义,而且短路位置明确。并发场景下如果需要所有回调的聚合结果,可以自行扩展。

Hook 也不是沙箱。它是由应用注册的受信任扩展代码,可以抛异常或执行自己的副作用。结构化契约保护 Agent Loop 的状态和权限顺序,不提供操作系统隔离。


19配套代码地图为了让读者能直接定位源码,本章把 code/chapters/ch04/src/ 的全部文件列成一张覆盖表:

为了让读者能直接定位源码,本章把 code/chapters/ch04/src/ 的全部文件列成一张覆盖表:

文件职责讲解位置
core/hooks.tsHook 事件、上下文、结果与注册表本章核心实现
core/loop.tsAgent Loop 和四个 Hook 挂载点本章核心实现
core/profiles.tsP 01-P 04 固定能力快照上文“P 03 与 P 04 的准确差异”
bootstrap.ts按 profile 组装基础设施、权限和 Hook上文“组合根与 CLI”
cli.ts终端入口、人工审批和默认观察 Hook上文“组合根与 CLI”
config.ts.env 模型配置校验下文“运行第 4 章”
chapters/ch01.tsch04.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.tscore/commands.tscore/model.ts文件、命令、模型的最小接口第 2-3 章继承契约
adapters/powershell.tsadapters/openai-chat.tsadapters/filesystem.tsPowerShell、OpenAI、文件系统实现第 2-3 章继承实现
features/builtin-tools.tsshell 与文件工具集第 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 内部生命周期的拦截点:UserPromptSubmitPreToolUsePostToolUseStop 都发生在同一次 run() 内部,用来追加日志、改写工具输入输出、建议权限或控制是否续跑。两者可以组合:

外部事件(定时器、Webhook、IM)
        ↓
事件触发工具 → 生成新 prompt
        ↓
UserPromptSubmit → PreToolUse → handler → PostToolUse → Stop

21运行第 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本章小结三句话版本、一定要记住的七条、以及本章刻意不做的事。

三句话版本:

  1. Hook 是在四个确定位置发布结构化上下文,让扩展逻辑从核心编排里搬出去。
  2. 返回值用结构化 HookResult 而不是字符串,因为控制流语义必须能被校验。
  3. Hook 能建议、能改写、能阻断,但不能授权——系统 deny 永远优先。

一定要记住的七条:

#结论出现在哪一节
1挂载点选在「信息刚好齐备、动作还来得及」的位置先看一眼真实的执行顺序
2additionalContext 只能是 system,且延后到配对完成后才写入HookResult
3updatedInput 必须重过 Zod 并深度冻结,否则会「批准 A 执行 B」PreToolUse
4definition 用引用相等锁定,防止 write 伪装成 readPreToolUse
5注册顺序 = 执行顺序 = 加工顺序;保守字段例外(取更严的)HookRegistry
6提前结束也要把同轮剩余调用补成配对错误preventContinuation
7Stop 最多续跑一次;第二次回调仍执行,只是控制权被收回Stop

本章代码边界(明确「还没做什么」):官方 14+ 类事件(SessionStart/PreCompact/SubagentStart 等)未实现;插件隔离/独立超时/并行未做;blockingError 短路刻意简化;Hook 是受信任的应用代码,不是沙箱;Post 的 hook_contract_error 校验刻意不对称(Pre 更严)。第 11 章恢复与幂等才区分「副作用已发生/未发生」。

检查你是否真的读懂了:

  1. [Hook] PreToolUse 和 [Permission] 哪个先出现?为什么不能反过来?
  2. 为什么 additionalContext 只允许 system 消息?
  3. 恶意 Pre Hook 想把 write_file 伪装成 read_file,哪条检查拦住它?
  4. 三个 Post Hook 依次改写结果,第三个看到哪个版本?
  5. Post Hook 要求停止,同轮其它调用为什么会变成 hook_stopped_continuation?
  6. Stop Hook 第二次还能 forceContinue 吗?回调还会执行吗?
  7. Post Hook 抛异常,handler 执行过没有?为什么这信息很重要?

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始