Coding Agent Harness 拆解 · Hooks:在关键时机拦截、改写与通知(十二)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent codebot,以及它底下的执行内核 agentcore,看清一个"能每天用的 coding agent"到底由哪些子系统拼成。本篇钻进 internal/hooks:看清 codebot 在一次 prompt 的生命周期里开了哪些"插槽",用户怎样用一段 shell、一句 prompt 或一个 HTTP 端点,在这些插槽上拦截、改写、否决或旁路通知。
Hook 是什么,为什么要有
内核只暴露一条事件流,产品层的所有策略都从事件长出来。但"策略"里有一类特殊需求:用户想在不改一行代码的前提下,往关键时机塞自己的逻辑:提交 prompt 前跑一遍合规检查、执行 bash 前拦下 rm -rf、每次 agent 说完话弹个桌面提醒、停下来之前先跑一遍测试。
这类"可配置的旁路逻辑"就是 hook。它和审批门(见《权限与审批门》)是两种互补的工具门控:审批门问"这次副作用要不要人来点头",hook 问"这个时机上,用户挂没挂自己的程序"。codebot 把 hook 收敛成一个 Runner(internal/hooks/hooks.go),配置来自 settings.json 的 hooks 字段,运行期以 agentcore.ToolMiddleware 的形式挂进工具管线,再由 Session 在几个生命周期节点上手动触发。
一次 prompt 里,hook 的位置
flowchart TB
U["用户输入"] --> UPS{{"UserPromptSubmit\n同步 · 可阻塞 · 可加 context"}}
UPS -->|放行| Turn["组装消息 → 起循环"]
Turn --> Model["请求模型"]
Model --> Call["模型请求工具调用"]
Call --> PRE{{"PreToolUse\n同步 · 可阻塞 · 可改参数"}}
PRE -->|放行| Exec["真正执行工具"]
Exec --> POST["PostToolUse\n异步 · 不可阻塞"]
POST --> Model
Model -->|自然停止| END["EventAgentEnd"]
END --> PSV{{"PostStopValidation\n软阻塞 → 失败输出回喂 reminder"}}
END --> Notify["Notification\n异步通知"]
注意两类节点的形状差异:菱形(UserPromptSubmit / PreToolUse)是同步闸门,主流程会等它,它能否决;圆角框(PostToolUse / Notification)是异步旁路,fire-and-forget,只观察不阻拦。PostStopValidation 介于两者之间,是一种"软阻塞",后面单独说。
全部事件:时机 × 执行方式 × 能否阻塞
internal/hooks/hooks.go 里用一组 EventType 常量枚举了所有时机:
| 事件 | 触发点(真实代码) | 执行 | 能否阻塞 | 阻塞 / 放行后的影响 |
| --- | --- | --- | --- | --- |
| PreToolUse | Middleware() 内、next() 之前 | 同步 | 是 | 阻塞→工具不执行、middleware 返错;放行→可用 UpdatedInput 改参数、AdditionalContext 加料 |
| PostToolUse | Middleware() 内、next() 之后 | 异步 goroutine | 否 | 纯观察;用 detached context,父级取消后仍能跑完 |
| UserPromptSubmit | Session.Prompt() 开头 | 同步 | 是 | 阻塞→Prompt 返错、这轮输入被拒;放行→AdditionalContext 包成 system-reminder 注入本轮 |
| PostStopValidation | afterAgentEnd()(自然停止且有未验证改动时) | 同步(在后台 goroutine 内) | 软阻塞 | 失败输出被当作 system-reminder 回喂,让模型继续修 |
| Notification | 每轮 agent 结束后 | 异步 | 否 | 通知外部(如桌面提醒) |
| TaskCreated | task_create 工具内 | 同步 | 是 | 阻塞→删除刚建的任务、回滚 |
| TaskCompleted | task_update 转 completed 时 | 同步 | 是 | 阻塞→完成转换被拒 |
| SessionStart | wireSessionRuntime 末尾 | 异步 | 否 | 会话启动通知 / 预热 |
| SessionEnd | Runtime.Close() | 异步 | 否 | 会话收尾 |
| SubagentStop | 队友 agent loop 退出时 | 异步 | 否 | 观察;队友已退出,无法挽留 |
这张表的要点:只有"主流程会等它返回"的同步 hook 才能阻塞(PreToolUse / UserPromptSubmit / TaskCreated / TaskCompleted);异步 hook 一律只能观察和通知。PostStopValidation 是特例,它同步跑,但这里的"阻塞"不掐断谁,只是把失败信息反手喂回模型。
三种执行器:command / prompt / http
一个 hook 具体"怎么跑",由 internal/hooks/executor.go 里的 executor 接口三种实现决定,buildExecutor 按配置里的 type 分派:
| type | 执行器 | 输入 | 输出 / 阻塞判定 |
| --- | --- | --- | --- |
| command | commandExec | payload(JSON)走 stdin,env 注入 HOOK_EVENT / HOOK_TOOL_NAME / HOOK_SESSION_ID | 真实退出码;exit 2 即阻塞;stdout 可打印一段 hookOutput JSON |
| prompt | promptExec | 模板里的 $ARGUMENTS 替换成 payload,单轮 model.Generate | 解析模型回复里的 {ok, reason};ok=false → 阻塞(解析失败则宽松放行) |
| http | httpExec | payload 作为 POST body(带 SSRF 防护:校验 scheme、DNS 预检、拦私网 IP、防 DNS 重绑定) | 响应 body 当作 stdout;状态码 ≥400 记为错误 |
三者的输出最后都归一成同一个"意图信封",hook 通过往 stdout 打印一段 JSON 来影响这次运行:
// hookOutput is the JSON a hook may print to stdout to influence the run.
type hookOutput struct {
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
AdditionalContext string `json:"additional_context,omitempty"`
UpdatedInput json.RawMessage `json:"updated_input,omitempty"`
}
interpret 把执行结果归一为是否阻塞,规则很直白:打印了 {"block":true},或(command hook)退出码为 2,就算阻塞;其它任何非零退出或传输错误都只是"非阻塞错误",记日志但不拦主流程。prompt 执行器则是把模型的 ok=false 翻译成 Block:true 再走同一条归一路径。
Matcher:先配工具名,再配参数
不是每个 hook 都对所有工具生效。internal/hooks/matcher.go 给每个 hook 编译一个 matcher,parseMatcher 支持三种写法:
""(空)→ anyMatcher,匹配所有工具;
"/pattern/" 或 "/pattern/i" → regexMatcher(i 加 (?i) 忽略大小写);
- 其它字面量 →
exactMatcher,用 strings.EqualFold 做大小写不敏感的全名比对。
关键设计是两级匹配。entry 上除了 matcher(比对工具名),还有一个可选的 argFilter(来自配置里的 if 字段,比对工具参数的 JSON 字符串)。Runner.matching 里,两道关卡都命中才算数:
match := func(e entry) bool {
if !e.matcher.Match(toolName) {
return false
}
if e.argFilter != nil && !e.argFilter.Match(argsStr) {
return false
}
return true
}
argFilter 复用同一套 parseMatcher。因为 exactMatcher 是整串相等比对,对着一大坨 args JSON 做全等几乎没意义,所以要按参数内容拦,实践上得写正则,例如用 if: "/rm -rf/" 在 bash 的参数里命中危险片段。"只对 bash 且命令含 rm -rf 的调用触发"就是靠 matcher + if 这两级拼出来的。
配置侧对应 config.HookEntry 的字段(internal/config/settings.go),一段拦截危险 bash 的 command hook 长这样:
{
"hooks": {
"PreToolUse": [
{
"type": "command",
"command": "exit 2",
"matcher": "bash",
"if": "/rm -rf/",
"blocking": true,
"timeout": 30
}
]
}
}
matcher 选工具、if 选参数、blocking 决定它有没有否决权、timeout(秒,默认 60)给它一个执行上限,command 里 exit 2 就是最朴素的"拦下"。
阻塞语义:硬否决 vs 软回喂
同样是"阻塞",落点完全不同:
-
硬否决(PreToolUse / UserPromptSubmit / TaskCreated / TaskCompleted)走 evaluate:它按顺序跑所有命中的 hook,遇到第一个阻塞的 blocking hook 就短路,返回 error。对 PreToolUse,这个 error 从 middleware 冒出去,工具压根不执行;对 UserPromptSubmit,error 让 Session.Prompt 直接返回,这轮输入被拒;对 TaskCreated,调用方(task_create 工具)收到 error 后会把刚建的任务删掉回滚。放行的 hook 则把各自的 Decision(AdditionalContext 拼接、UpdatedInput 后者覆盖前者)合并返回。
-
软回喂(PostStopValidation)走 RunPostStopValidation:它在 agent 自然停止、且距上次成功校验有未验证改动时触发(internal/agent/runtime_policy.go 的 runPostStopValidation)。它同步跑校验 hook(典型是"跑测试 / lint"),任何一个失败就把它的输出当成 reminder 反手喂回模型,让模型自己接着修,不掐断会话:
s.continueWithRuntimeReminder(
"post_stop_validation",
ReminderPostStopValidation,
fmt.Sprintf(
"<system-reminder>\nThe PostStopValidation hook failed. Fix the problem based on the following output:\n%s\n</system-reminder>",
failOutput,
),
)
这就是"软阻塞":它不否决任何单次动作,只是不让 agent 轻易收工,把"停不下来直到验证通过"变成一条可配置的产品策略。
它怎么挂进循环:一个 ToolMiddleware
围绕工具的两个时机(PreToolUse / PostToolUse),Runner 并不在循环里自己找钩子。它把自己包装成一个标准的 agentcore.ToolMiddleware,塞进内核的工具执行管线(internal/hooks/middleware.go):
// Middleware returns a ToolMiddleware that runs PreToolUse and PostToolUse hooks
// around each tool execution.
func (r *Runner) Middleware() agentcore.ToolMiddleware {
return func(ctx context.Context, call agentcore.ToolCall, next agentcore.ToolExecuteFunc) (json.RawMessage, error) {
dec, err := r.RunPreToolUse(ctx, call.Name, call.Args)
if err != nil {
return nil, err // a blocking PreToolUse hook vetoes the tool
}
args := call.Args
if len(dec.UpdatedInput) > 0 {
args = dec.UpdatedInput // rewrite the tool input
}
output, execErr := next(ctx, args)
r.RunPostToolUse(ctx, call.Name, args, output, execErr != nil)
return output, execErr
}
}
一段 middleware 就把"前拦 + 改参 + 真执行 + 后观察"串成了一条线:next() 前是 PreToolUse(同步、能返错否决、能改 args),next() 后是 PostToolUse(异步、只观察)。装配侧在 internal/bootstrap/assemble_session.go 的 buildHookSupport 里 New(...) 出 Runner 再取 Middleware(),然后在 assemble_runtime.go 里把它 append 进 middleware 列表、经 agentcore.WithMiddlewares(...) 交给 NewAgent。对内核而言,hook 只是普通 middleware,内核完全不知道"hook"这个概念,这也是"内核提供机制、harness 决定策略"的又一处体现。
至于不围绕工具的那些时机,则由 Session 在对应节点手动调 Runner 的方法:
| 触发点文件 | 调用 | 时机 |
| --- | --- | --- |
| agent/session_runtime.go | RunUserPromptSubmit | 每次 Prompt 开头 |
| agent/session_state.go | RunNotification | EventAgentEnd,agent 说完话 |
| agent/runtime_policy.go | RunPostStopValidation | afterAgentEnd,有未验证改动时 |
| bootstrap/assemble_runtime.go | RunSessionStart | 会话运行时装配完成 |
| bootstrap/boot.go | RunSessionEnd | Runtime.Close() |
| tools/task.go | RunTaskCreated / RunTaskCompleted | 任务创建 / 完成转换 |
| agent/teammate_spawn.go | RunSubagentStop | 队友 agent loop 退出 |
::: tip Hook 也要过审批门(门中门)
每个 hook 在真正执行前,runOne 会先过一道 approval.ApproveHook(若配了审批引擎):审批被拒等同于一次阻塞。也就是说,用户挂的 hook 本身也是受审计的副作用,而不是一条能绕过所有门禁的后门。这和《权限与审批门》里工具走的是同一套机制。
:::
三问回顾
- hook 解决了什么、和审批门有何不同? 让用户不改代码就能在关键时机注入逻辑;审批门管"这次副作用要不要人点头",hook 管"这个时机上挂没挂用户程序",两者都收口在同一个副作用边界上。
- 为什么只有一部分 hook 能阻塞? 因为只有同步、主流程会等它返回的 hook(
PreToolUse / UserPromptSubmit / 两个任务事件)才有资格否决;异步的 fire-and-forget 天然只能观察。PostStopValidation 用"回喂 reminder"实现了不掐断会话的软阻塞。
- 它怎么做到内核无感? 围绕工具的两个时机被封成一个
agentcore.ToolMiddleware 挂进管线,内核当它是普通中间件;其余时机由 Session 在生命周期节点手动触发。策略全在 harness,机制全在内核。
延伸阅读