QueryEngine._execute_tool_call()
发出 pre_tool_use 事件
模型提出的 tool_name 与原始 tool_input 先交给 Hook。此时两者尚未经过 registry 或 Pydantic 校验。
event: "pre_tool_use" · tool_name: "bash"阻止:错误 ToolResult 回给 LLM
tool.execute() 未到达SOURCE NOTE · HOOK SYSTEM
Hook 是生命周期节点上的异步扩展机制:适合门禁、审计、通知与校验;但它不是能任意改写 prompt、工具入参或工具结果的通用中间件。
学完本课,你能回答:Hook 在何时运行?哪些 Hook 真能阻止主流程?四种 Hook 类型怎样工作?想做审计、外部策略校验或上下文保留时该落在哪里?
Hook 是一组按生命周期事件触发、按优先级串行运行的异步拦截器。它适合做门禁、审计、通知与校验;当前并不是可任意改写 prompt、工具入参或工具结果的通用中间件。
用户消息 → user_prompt_submit → LLM
│
▼
tool_use 请求
│
▼
pre_tool_use Hook ← 审计 / 拦截
│
▼
查工具 → 校验参数 → Permissions → 确认
│
▼
tool.execute()
│
▼
post_tool_use Hook ← 记录结果 / 通知
│
▼
ToolResult 回写 LLM
与 Permissions 的分工很清楚:Permissions 是全局统一的安全裁决闸门;Hook 是在生命周期节点上追加行为的扩展机制。比如「永远禁止读取密钥」优先放 PermissionChecker;「执行 Bash 前交给公司服务额外审查」适合 pre_tool_use Hook。
下面的本地演示把这条边界拆开:选一个场景,看 Hook 是否只是在运行,还是它的结果真的被调用方消费,进而改变主流程。
INTERACTIVE TRACE · LOCAL SIMULATION
选择事件场景,沿着匹配、执行和调用方裁决逐步查看。组件不调用真实 Hook、命令或模型。
EVENTpre_tool_use
TYPEcommand
Bash 想写入受保护路径。匹配 Hook 先于工具查找和参数校验运行;调用方消费聚合结果后,主执行路径停止。
QueryEngine._execute_tool_call()
模型提出的 tool_name 与原始 tool_input 先交给 Hook。此时两者尚未经过 registry 或 Pydantic 校验。
event: "pre_tool_use" · tool_name: "bash"阻止:错误 ToolResult 回给 LLM
tool.execute() 未到达hooks/events.py:HookEvent,全部生命周期事件。hooks/schemas.py:四种 Hook 配置模型与公共字段。hooks/types.py:HookResult 与多结果聚合。hooks/loader.py:加载 settings / plugins 的 Hook,并按 priority 排序。hooks/executor.py:HookExecutor,负责匹配、执行与失败处理。engine/query.py:工具调用前后与 stop 事件的真实调用点。services/compact/__init__.py:压缩前后 Hook,以及压缩附件注入。ui/runtime.py:创建 executor、会话启动与关闭事件。事件名必须是以下 snake_case 值:
session_start # 会话启动
user_prompt_submit # 用户消息已提交
pre_compact # 完整上下文压缩前
post_compact # 压缩完成后
pre_tool_use # 工具执行前
notification # 即将显示权限确认提示时
post_tool_use # 工具成功返回后
stop # agent 正常结束时
subagent_stop # 子 Agent 完成时
session_end # 会话关闭时
blocked 会真的改变主流程?pre_tool_use:调用方消费 blocked。 工具不执行;阻止原因作为错误工具结果回给 LLM。pre_compact:调用方消费 blocked。 取消后续完整摘要压缩。post_compact:不作门禁。 输出会作为 compact hook notes 附进压缩后的上下文。blocked。 它们通常只做观察、通知或副作用;返回的 blocked 不会中断该流程。pre_tool_use 在哪里?工具执行路径的实际顺序是:
pre_tool_use
→ registry.get(tool_name)
→ input_model.model_validate(tool_input)
→ PermissionChecker.evaluate(...)
→ 可选 notification + 用户确认
→ tool.execute(...)
→ post_tool_use
这带来两个结论:
pre_tool_use 能在工具产生副作用前阻止它。tool_input,所以不能假定输入可信或合法。pre_result = await hook_executor.execute("pre_tool_use", payload)
if pre_result.blocked:
return ToolResultBlock(pre_result.reason, is_error=True)
# 只有未被调用方阻止时,才继续下面的路径。
tool = registry.get(tool_name)
parsed = tool.input_model.model_validate(tool_input)
decision = permission_checker.evaluate(...)
result = await tool.execute(parsed, context)
await hook_executor.execute("post_tool_use", result_payload)command:启动本地命令;适合本地规则脚本、审计;默认 block_on_failure=false。http:POST 事件 payload 到服务;适合策略服务、SIEM、远程日志;默认 block_on_failure=false。prompt:额外请求模型作判断;适合自然语言规则校验;默认 block_on_failure=true。agent:以更深入提示词请求模型判断;适合较复杂的策略校验;默认 block_on_failure=true。command Hook 可使用两个运行时提供的信息:
OPENHARNESS_HOOK_EVENT # 当前事件名
OPENHARNESS_HOOK_PAYLOAD # JSON 字符串 payload
# 命令模板里的 $ARGUMENTS 会被替换成完整 JSON payload
python scripts/check_policy.py $ARGUMENTS
prompt / agent 期待模型返回类似:
{"ok": true}
// 或
{"ok": false, "reason": "目标路径不允许写入"}
每个 Hook 都能设置:
{
"matcher": "bash",
"priority": 100,
"timeout_seconds": 10,
"block_on_failure": true
}
priority 越大越先执行;同优先级保留注册顺序。settings Hook 通常先于插件 Hook。matcher 用 fnmatch 匹配。工具事件通常匹配 tool_name,用户消息事件匹配 prompt,其他事件通常匹配事件名。block_on_failure=true 时才变成阻止结果。下面是概念上的 settings 配置:执行 Bash 前先运行本地校验,校验脚本非零退出、超时或无法启动时,阻止 Bash 工具。
{
"hooks": {
"pre_tool_use": [
{
"type": "command",
"command": "python scripts/check_bash_policy.py $ARGUMENTS",
"matcher": "bash",
"timeout_seconds": 10,
"block_on_failure": true,
"priority": 100
}
]
}
}它适合把组织策略放在一个可独立测试的脚本里,而非散落在每个 Bash 调用处。
pre_tool_use
→ 记录:模型想调用哪个工具、输入是什么
→ 若策略失败:阻止,不执行工具
工具执行成功
↓
post_tool_use
→ 记录:工具输出、是否为错误
→ 发 HTTP 到审计 / 监控服务
限制也要说清:post_tool_use 只会在工具走到 execute() 并正常返回后触发。若工具不存在、参数无效、权限拒绝、pre Hook 阻止或 execute() 抛异常,都不会走到这个正常的 post 事件。
此外,大输出会先被引擎 offload;所以 post Hook 对超长结果看到的可能是预览与 artifact 路径,而不一定是完整原文。
post_compact:现成的上下文注入点绝大多数 Hook 输出不会自动影响模型上下文。但 post_compact 是重要例外:它的输出会收集为 Compact hook notes,附入压缩后的会话上下文。
适合注入:
这是最容易混淆的一组概念。四种 Hook 类型与 pre_tool_use 等事件不是同一层级:
pre_tool_use、post_tool_use、post_compact;回答「什么时候触发」——时机。command、http、prompt、agent;回答「触发后怎么执行」——手段。 ┌─ command:本地脚本
事件 pre_tool_use ──┼─ http:外部策略 / 审计服务
├─ prompt:模型快速判断
└─ agent:模型深入判断
┌─ command:本地记录脚本
事件 post_tool_use ─┼─ http:上报工具结果
├─ prompt:结果质量检查
└─ agent:复杂结果分析
配置结构就是两个维度的笛卡尔组合:
事件名
→ 一组 Hook 定义
→ 每个定义各自声明 type 和 matcher
原则上四种类型都能挂到任何事件上;语义是否合理取决于目的。
pre_tool_use + command:工具执行前跑本地策略脚本,可阻止。pre_tool_use + http:执行前询问企业审批 / 策略服务。post_tool_use + http:成功后上报审计,通常不阻止。post_compact + prompt:让模型判断压缩后仍该保留哪些规则。session_end + command:清理临时资源或提交会话统计。一句话:pre_tool_use 决定「在工具执行前」;command 决定「通过本地命令处理」;两者组合才得到「工具执行前运行本地命令检查」的完整行为。
HookRegistry 本身有三条运行时路径:
ui/runtime.py 的 build_runtime() 调 load_hook_registry(settings, plugins) 建表,传给 HookExecutor。HookExecutor.execute() 通过 registry.get(event) 取该事件下按优先级排好的列表。handle_line() 重新加载注册表并 update_registry();改 settings/插件后下一条消息即生效。summary() 的链路则是:
用户输入 /hooks
→ commands/registry.py 的 _hooks_handler()
→ CommandContext.hooks_summary
→ RuntimeBundle.hook_summary()
→ load_hook_registry(...).summary()
即 summary() 只负责把当前生效的 Hook 配置格式化成可读文本给 /hooks 展示,不参与实际执行。内部 API client 路径另有 HookReloader 按配置文件 mtime 决定是否重建注册表。
主读:src/openharness/hooks/executor.py —— 执行、匹配、失败与四种 Hook 类型。
接着看:
src/openharness/hooks/events.py、schemas.py、types.py —— 事件、配置与结果契约;src/openharness/hooks/loader.py —— 注册来源与 priority 排序;src/openharness/engine/query.py —— pre_tool_use / post_tool_use 的真实位置;src/openharness/services/compact/__init__.py —— compact Hook 的门禁和 notes 注入;tests/test_hooks/ 与 tests/test_engine/test_query_engine.py —— 测试对照。