青雲的博客
拆开 Codex 第六部:Agent runtime 怎样被承载与验证 第 35 章

Hooks 为什么不是一条统一回调链

Codex 的十类 Hook 共用 discovery 和 command dispatcher,却分别拥有 scope、matcher、stdin schema、parser、阻断动作和错误语义。本章用一张矩阵和真实 runtime call site 还原这条分叉的生命周期。

源码版本
rust-v0.144.6
验证日期
Commit
5d1fbf26c43abc65a203928b2e31561cb039e06d

第 34 章里,Realtime 的不同入口最后落成不同 Op;Hooks 也是同样的阅读题。协议里确实有一个 HookEventName 枚举,但十个名字只说明“可识别的事件集合”,并没有承诺共享 matcher、payload、parser 或 effect。

更准确的模型是:

discovery / trust setup
  -> registered handler set

originating owner / call site
  -> hook_runtime bridge
  -> event-specific request + HookEventName
  -> event-specific selector over registered handlers
  -> event-specific stdin schema
  -> synchronous command dispatcher
  -> event-specific parser and fold
  -> originating owner consumes the outcome
  -> local state mutation or continuation feedback

十个事件先放进一张矩阵

下面的矩阵把“在哪里触发、用什么匹配、命令读到什么、结果能改变什么”放在同一行。它比记住十个名称更能防止跨事件类推。

事件scopematcher 输入命令 payloadparser / 主要 effectblock、context 与错误边界
PreToolUseTurncanonical tool name + aliasestool input、tool_use_id、turnpre parser;可 block,未 block 时取最后完成的 updated_inputexit 2 必须有 stderr reason;JSON invalid 是 failed
PermissionRequestTurncanonical tool name + aliasespermission mode、tool name/inputallow/deny fold;deny wins不改 input;exit 2 的非空 stderr 是 deny,空 stderr 是 failed
PostToolUseTurncanonical tool name + aliasestool input + tool responsepost parser;收 additional context、feedback、should_block工具已成功执行,block 不能撤销副作用;exit 2 传 feedback
PreCompactTurncompaction triggertrigger、turn metadatastateless parser;continue:false 停在 compact 前非零退出是 failed;stop reason 由 parser 保留
PostCompactTurncompaction triggertrigger、turn metadatastateless parser;continue:false 停在 compact 后不能把 post stop 写成回滚已安装的 compaction
SessionStartThreadstartup/resume/clear/compact sourcesource、thread metadatacontext injection;continue:false 可 stopplain stdout 也成为 context;JSON-looking invalid stdout 是 failed
UserPromptSubmitTurnignoredprompt、turn metadatacontext injection;可 stopmatcher 永远不参与选择;只在 UserInput pending path 调用
SubagentStartThreadagent_typeagent id/type、thread metadatacontext injection onlycontinue:false 被忽略;不能据此停止 child
SubagentStopTurnagent_typeparent/agent transcript path、last message、turnblock decision;给 child completion continuationblock 必须有 reason;与 root Stop 共用部分 schema 但 call site 不同
StopTurnignoredlast assistant message、stop_hook_activeblock decision;形成 continuation promptblock 需要非空 reason;空 reason 不是有效 stop

这张表的“scope”是 HookRunSummary 上的 HookScope 标签,不是命令进程的生命周期或调用次数。Thread-scoped start hook 的 completed event 仍可关联当前 turn id,SubagentStart 的 command input 也带 child turn id;不能从某个 payload 字段反推 scope。

本节源码依据(2 处)

一张图:事件在生命周期里的触发点

矩阵按事件类型排列,便于对照字段;下图换成运行时的时间顺序,标出每个事件在哪一步触发、scope 是 Turn 还是 Thread、以及它能不能 block。

flowchart TB
  accTitle: 十个 hook 事件的触发点、scope 与阻断能力
  accDescr: SessionStart 是 Thread scope,在 startup、resume、clear 或 compact 注入 context;一个 turn 内按顺序触发 UserPromptSubmit、PreToolUse、PermissionRequest、工具执行、PostToolUse、Stop,PreToolUse 与 PermissionRequest 在执行前可 block,PostToolUse 的 block 不能撤销已执行副作用,Stop 用 continuation prompt 可反复阻断结束;PreCompact 与 PostCompact 围绕 compaction,SubagentStart 是 Thread scope 仅注入 context,SubagentStop 可 block。
  START["SessionStart · Thread\ncontext 注入"] --> UPS["UserPromptSubmit · Turn\ncontext 注入 · 可 stop"]
  UPS --> PRE["PreToolUse · Turn\n执行前 · 可 block"]
  PRE --> PERM["PermissionRequest · Turn\ndeny wins · 不改 input"]
  PERM --> EXEC["工具执行"]
  EXEC --> POST["PostToolUse · Turn\nblock 不撤销副作用"]
  POST --> STOP["Stop · Turn\ncontinuation 可反复阻断结束"]
  PRE -. compaction .-> COMPACT["PreCompact / PostCompact · Turn\ncontinue:false 停在 compact 前/后"]
  UPS -. 子 agent .-> SUB["SubagentStart · Thread(仅注入)\nSubagentStop · Turn(可 block)"]

payload schema 也按事件拆开

Hook command stdin 不是一个万能 JSON。PreToolUse、PermissionRequest、PostToolUse、Pre/PostCompact、UserPromptSubmit、Stop 和 SubagentStop 各有自己的 input struct;SessionStart 与 SubagentStart 还分别携带 source 或 agent identity。output 端虽然共享 universal 字段,却把 permission decision、updated input、additional context、block decision 和 reason 放在不同的 event-specific wire struct 里。

本节源码依据(2 处)

deny_unknown_fields 和 invalid JSON-looking stdout 的处理也属于 contract。一个 command 打印普通文本,在 SessionStart/SubagentStart 可以成为 context;同样的普通文本在 tool hook 里通常只是空输出;如果内容看起来像 JSON 却无法解析,parser 会把它标为 failed。不能拿一个 hook 的“宽容 stdout”经验套到另一个 hook。

真实测试:三条 parser 边界通过

在固定 commit 导出的 disposable archive / detached worktree 中,用项目 recipe 执行:

: "${ARCHIVE_CODEX_RS:?先执行第六部导读的 archive 准备脚本}"
cd "$ARCHIVE_CODEX_RS"
just test --locked -p codex-hooks -E 'test(permission_decision_deny_blocks_processing) | test(continue_false_stops_before_compaction) | test(block_decision_with_reason_sets_continuation_prompt)'

输出为:

Starting 3 tests across 2 binaries
PASS permission_decision_deny_blocks_processing
PASS continue_false_stops_before_compaction
PASS block_decision_with_reason_sets_continuation_prompt
Summary: 3 tests run: 3 passed, 117 skipped

三项分别钉住三个容易误读的边界:PreToolUse 的 deny 会形成 blocked status 与 feedback;PreCompact 的 continue:false 会形成压缩前的 stopped outcome;Stop 的 block 只有带 reason 才会生成 continuation prompt。它们是 hooks crate 的确定性单元测试,不是完整模型回路、工具副作用回滚测试,也不是 app-server wire/UI 测试。

本节源码依据(3 处)

这里还要保留一个负边界:即使这三项通过,也没有证明 discovery 会从某个真实 config 找到 handler,没有证明 Core 在正确的 originating call site 调用它,也没有证明客户端看到了同样的 HookStarted/HookCompleted wire event。要证明这些,需要沿本章的 runtime path 再加 integration 或 E2E 证据。

三个工具相邻事件:执行前、审批中、执行后

PreToolUse 能挡住工具,也能改写尚未执行的 input

PreToolUse 先用 canonical tool name 和 matcher aliases 选 handler,再把 session、turn、tool、tool_input 和 tool_use_id 序列化到 stdin。每个 command 的结果单独解析:JSON 中的 block reason 让 outcome 进入 Blocked;updated_input 只有在没有任何 block 时才会继续折叠。多个 handler 同时返回 rewrite 时,真正最后完成的那份 input 获胜,报告仍按配置顺序发出。

本节源码依据(4 处)

它的时序很硬:PreToolUse block 发生在工具 handler 真正执行之前;因此它可以阻断 apply_patch 或 Bash,并把原因带回模型。一个成功的 updated_input 也只是传给同一次 tool invocation 的新参数,不是修改历史中的原始模型输出。

PermissionRequest 不改工具,它只给审批路径一个 verdict

PermissionRequest 的 hook 注释已经把边界写得很直白:它运行在 guardian 或用户审批 UI 之前,不能 rewrite tool input,也不能用 stop 语义直接结束执行。多个结果按 allow/deny 折叠,任何 deny 都胜出;没有 deny 时才保留最后一个 allow。这里的 deny 是审批决策,不等于 PreToolUse 的工具 block。

本节源码依据(2 处)

parser 还把 exit code 2 作为一种有条件的 deny:stderr 必须有非空 reason;没有 reason 就是 failed。JSON 里保留的 updated_input、updated_permissions 或 interrupt 字段属于未来能力,当前 parser 对这些不允许的组合 fail closed。把“hook 进程返回 0”写成“审批通过”也不准确,空 stdout 只是没有 hook verdict。

本节源码依据(2 处)

PostToolUse 可以拒绝结果,但不能撤销副作用

PostToolUse 的 request 包含 tool_input 和 tool_response,只有 tool 成功产生结果后才进入 registry 的 post path。parser 能带回 additional_context、model feedback 或 should_block;continue:false 会变成 stopped 状态/反馈,should_block 会变成 blocked,但这里的 stopped 不等于撤销工具或直接取消整个 Turn。文件已经写入、进程已经启动、网络请求已经发出时,PostToolUse 没有 rollback owner。

本节源码依据(3 处)

exit code 2 同样要求非空 stderr,但这里的文本是给模型的 feedback;它不是 PermissionRequest 的 deny,也不是 Stop 的 continuation reason。这个小差别决定了下一个 loop 是继续解释工具结果、请求审批,还是继续本轮。

本节源码依据(1 处)

启动、压缩和结束:相似输出,不同 owner

SessionStart 与 SubagentStart 都注入 context,但只有前者能 stop

SessionStart 的 matcher input 是 startup、resume、clear 或 compact;SubagentStart 的 matcher input 是 agent_type。两者都把 plain stdout 或 additional_context 变成模型 context,也都能产生 HookCompletedEvent,但 parser 明确只让 SessionStart 解释 continue:false。SubagentStart 的 contract 是 context injection only,不能借一个通用 universal output 停止 child。

本节源码依据(3 处)

两者都属于 Thread scope,却不等于“线程启动就是一次全局 callback”。hook_runtime 会从 pending session-start source 取值;thread-spawn child 只在对应 source 下改成 SubagentStart,内部 synthetic subagent 则直接不暴露用户配置的 start hooks。

UserPromptSubmit 不支持 matcher

UserPromptSubmit 在 pending UserInput 被检查时执行,stdin 带 prompt、session、turn、permission mode 和工作目录。preview/run 都传入 matcher_input=None,因此配置中的 matcher 不会缩小它的范围;parser 只能注入 additional context 或 stop。本事件覆盖的是一次用户输入进入 Turn 前的检查,不是所有 message 或 InterAgentCommunication。

本节源码依据(2 处)

PreCompact 与 PostCompact 是两个时点

第 26 章已经从 compaction owner 的角度固定了停止时点和 replacement 结果;这里不重讲算法,只比较 matcher、parser 与 dispatcher contract。两个 compact event 都以 trigger 匹配,也都可以把 continue:false 解析成 stop;区别在于 Core compact 路径的调用点。PreCompact 在 compact 工作开始前运行,返回 Stopped 会阻止压缩;PostCompact 只在 compact result 成功后运行,返回 Stopped 会让当前 Turn 以 TurnAborted 结束,却不能撤销已经安装的 compact state。相同的 stateless parser 不意味着相同的时间位置。

本节源码依据(4 处)

Stop 与 SubagentStop 需要一个真正的 continuation reason

root turn 进入 Stop;thread-spawn child 进入 SubagentStop,internal/synthetic subagent 则不 dispatch 用户配置的 stop hook。两者的 output schema 都支持 decision:block 和 reason,但 parser 只把非空 reason 当成有效 continuation prompt。exit code 2 也要从 stderr 取得非空文本;空 stderr 会成为 failed,而不是无理由地阻止 Turn 完成。

本节源码依据(3 处)

hook_runtime 是 bridge,不是 originating call site

协议枚举和 hooks crate unit parser 只能说明“某种输入怎样解析”。真正决定 Hook 什么时候发生、结果是否被消费的是各生命周期 owner:pending session-start path 负责启动与 child start,tool registry 负责 PreToolUse/PostToolUse,orchestrator、MCP、network approval 与 shell escalation 各自发现 PermissionRequest 时点,compact modules 负责 Pre/PostCompact,turn-stop owner 负责 Stop/SubagentStop。它们调用 hook_runtime 的事件函数;hook_runtime 组装 request、执行 preview/command/parser、发出完成事件,再把 event-specific outcome 交还原 owner。它是这些调用点与 hooks crate 之间的桥,不是生命周期的 originating call site。

本节源码依据(2 处)

run_permission_request_hooks 自己不会发现审批。真正触发它的是各审批 owner:通用工具由 orchestrator 先从 runtime 取 permission_request_payload,再调用 hook;MCP 工具在检查 remembered approval 之后调用;network approval 与 Unix escalation 也各自在进入 Guardian 或用户审批前调用。同一个 dispatcher 背后是四条独立入口,不能只凭 hook_runtime.rs 推断它们都被走到。

本节源码依据(4 处)

例如,PreToolUse 的 should_block 会在工具执行前变成 Blocked result;Bash 或 apply_patch 的 input 确实带 command 字段时,反馈还会附上该 command,否则只附 tool 名。PostToolUse 的 should_block 只是在 tool output 已经返回后拒绝该结果;UserPromptSubmit 的 additional_context 则由 record_pending_input 记录到当前 Turn。没有这些 call site,单独阅读 parser 很容易写出“所有 block 都在工具执行前发生”的假结论。

本节源码依据(2 处)

共用 dispatcher,只共用机械步骤

matcher 选择不是所有事件都一样

dispatcher 先按 event_name 过滤,再决定是否调用 matcher。PreToolUse、PermissionRequest、PostToolUse、SessionStart、SubagentStart、SubagentStop、PreCompact 和 PostCompact 会在 matcher_inputs 中寻找任一匹配;UserPromptSubmit 与 Stop 直接返回 true。matcher alias 只用于选择,hook stdin 仍保留 canonical tool name,避免内部兼容名污染审计记录。

本节源码依据(2 处)

命令并行执行,报告顺序另行恢复

execute_handlers 把每个匹配 handler 放入 FuturesUnordered,并记录 completion_order。命令可能按不同速度结束,但返回结果会按 configured_order 排回,事件报告稳定;PreToolUse 的 updated_input 又会显式按 completion_order 选最后完成者。这两个顺序故意不同:日志稳定不等于 effect 按配置顺序覆盖。

本节源码依据(1 处)

discovery 目前只把同步 command 变成可执行 handler

配置的 HookHandlerConfig 接受 Command、Prompt、Agent 三种 variant,Command 还带 async 开关;协议里的 HookHandlerType 与 HookExecutionMode 则负责把类型和执行模式投影到运行摘要。但 discovery 在当前版本只构造同步 command handler:async command 会写 warning 并跳过,空命令也跳过;prompt 和 agent handler 同样只留下 unsupported warning。配置文件里存在一行,不等于 runtime 一定会执行它。

本节源码依据(2 处)

交给第 36 章的证据问题

Hooks 这一章的结论不是“有十种回调”,而是“有十个事件名、一个受限的同步 command dispatcher,以及十套不同的 effect contract”。第 36 章:没有证据的“跑通”不算跑通把同样的纪律用于完整任务:source 说明能力,unit test 说明局部语义,rollout/trace 说明运行时发生过什么,终端截图只说明投影。没有把这些层级对齐,任何一句“跑通了”都还缺 owner 和证据。