2026-09-24 · 13 min read

P30 30_进化篇_钩子函数

P30 · 30_进化篇_钩子函数(6:59)

课时概要

进化篇 · 钩子函数。钩子是 Hermes 在关键节点插入自定义逻辑的机制,官方文档有专门页面;仓库里按用途分成几类钩子脚本。

本分P 没有逐P 的官方简介(视频简介只有资源链接)。以下「本节要点 / 关键概念 / 代码实操」均改写自 Hermes 官方文档 Hooks 页(含 Gateway Event Hooks / Plugin Hooks / Shell Hooks / Outbound Webhooks 四节)与官方仓库原文并标注来源;「我的收获」里标 本机实测 的是在本机实跑得到的真实状态。

视频:30_进化篇_钩子函数 | 时长 6:59 | P30

本节要点

  • 钩子一共四件套,官方给了对照表:Shell hooks | Plugin hooks | Gateway hooks | Outbound webhooks。前三者是「被事件触发」,第四个是「把事件推出去」。
  • 四者对照: · Shell hooks —— 声明在 config.yaml 的 hooks: 块 | 脚本按约定放 ~/.hermes/agent-hooks/ | 任意语言(Bash / Python / Go 二进制…)| 跑在 CLI + Gateway | 事件 = VALID_HOOKS(含 subagent_stop)| 能拦工具调用(pre_tool_call)| 能注入 LLM 上下文(pre_llm_call)| 同意 = 每个 (event, command) 首次提示 | 有进程隔离(子进程) · Plugin hooks —— 在插件的 register() 里 ctx.register_hook(...) | 仅 Python | CLI + Gateway | 同样能拦能注入 | 同意 = 显式 plugins.enabled | 无进程隔离(进程内) · Gateway hooks —— ~/.hermes/hooks/<名字>/ 目录 + HOOK.yaml + handler.py | 仅 Python | 只在 Gateway | 事件 = gateway 生命周期(gateway:startup、agent:*、command:*)| 不能拦、不能注入 | 同意 = 隐式(目录信任) · Outbound webhooks —— hooks.outbound: 列表,事件发生时向你的 HTTP 端点 POST 已签名的 JSON
  • Shell hooks 用来做什么(官方原文四类):① 拦截或改写工具调用 —— 拒掉危险的 terminal 命令、按目录施加策略、对破坏性 write_file / patch 要求审批、在工具执行前重写参数(清洗路径、注入默认值)② 工具调用后动作 —— 自动格式化 agent 刚写的文件、记 API 调用日志、触发 CI ③ 给下一轮 LLM 注入上下文 —— 把 git status、今天是星期几、检索到的文档 prepend 到用户消息 ④ 观察生命周期 —— 子代理完成、会话开始时写日志。不需要写 Python 插件,一个单文件脚本就够。
  • Gateway 事件钩子怎么建:~/.hermes/hooks/my-hook/ 下两个文件 —— HOOK.yaml(name / description / events 列表,支持 command:* 通配)+ handler.py。handler 规则:函数必须叫 handle、收 event_type(字符串)与 context(dict)、async def 或普通 def 都行、出错只记日志,永不崩掉 agent。
  • 可订阅的 gateway 事件(带 context keys):gateway:startup(platforms)| session:start / session:end / session:reset | session:compress(带 in_place 布尔与 compression_count)| agent:start(含 message,截断 500 字符)/ agent:step(含 iteration、tool_names)/ agent:end(多一个 response)| reaction:added / reaction:removed(目前 Slack,需 reactions:read 范围 + reaction_added 订阅)| command:*(任何斜杠命令,一个订阅监控所有命令)。
  • 加载时机很关键:gateway 启动时加载一次(HookRegistry.discover_and_load(),由 gateway/run_startup.py 调);多 profile 网关下,每个被服务 profile 自己的 hooks/ 在该 profile 内首次触发事件时加载;⚠️ CLI / TUI / Desktop / cron 永不加载 gateway hooks。
  • Shell hook 配置 schema:hooks: <事件名>: - matcher: "<正则>"(可选,仅 pre/post_tool_call 用)/ command: "<shell 命令>"(必填,经 shlex.split 执行、shell=False)/ timeout(默认 60,上限 300)/ fail_closed(默认 false,仅 pre_tool_call);顶层还有 hooks_auto_accept。容错行为:事件名拼错 → 出 Did you mean X? 警告并跳过;缺 command → 跳过并警告;timeout > 300 会被夹到 300 并警告;fail_closed 用在非 pre_tool_call 事件上 → 警告并忽略。
  • JSON 线协议:每次事件触发,Hermes 为每个匹配的钩子起一个子进程,把 JSON 负载管道进 stdin,再从 stdout 读回 JSON。stdin 负载含 hook_event_name / tool_name / tool_input / session_id / cwd / profile(哪个 profile 触发的 —— 所以一个脚本可以服务多路复用网关下的所有 profile;子进程也带该 profile 的 HERMES_HOME)/ extra(事件专属 kwargs,如 user_message、conversation_history、child_role、duration_ms)。非工具事件(pre_llm_call、subagent_stop、会话生命周期)的 tool_name / tool_input 为 null。
  • stdout 能返回的指令:block(Claude Code 风格 {"decision":"block","reason":…} 或 Hermes 规范 {"action":"block","message":…},两种都收)| modify(在执行前改写工具参数)| approve(把这次调用升级到人工审批闸门)| 注入上下文({"context": "..."})| continue(verify 门)| 空输出 = 静默 no-op。⚠️ 官方特别提醒:Claude Code 的 {"decision":"approve"} 意思是「自动放行」,与这里的 approve(升级到人工审批)不等价,不做映射。
  • 容错:JSON 格式错误、非零退出码、超时都只记警告,永不中断 agent 循环。
  • exit code 2 = block(Claude Code / Cursor 兼容):pre_tool_call 钩子即使 stdout 不带 block JSON,只要退出码是 2 就阻断。拦截消息按优先级解析:① stdout 的 block JSON(reason / message)② stderr 的前 400 字符 ③ 通用默认 "Blocked by shell hook."。所以最简单的阻断钩子就是三行 bash。对不能阻断的事件(除 pre_tool_call 之外的全部),退出码 2 只当普通非零退出处理(记警告,仍解析 stdout)。
  • 官方四个实战范例:① 每次写文件后自动格式化 Python(post_tool_call + matcher write_file|patch)② 阻断破坏性的 terminal 命令(pre_tool_call + 正则匹配 rm -rf / 后回 block JSON)③ 每轮注入 git status(对应用户提示词提交钩子那一类需求)④ 记录每个子代理完成(subagent_stop)。
  • ⚠️ 一个容易误会的细节(官方特别标注):post_tool_call 里自动格式化只改磁盘上的文件 —— agent 上下文里对那个文件的视图不会自动重读,要后续 read_file 才会拿到格式化后的版本。
  • Outbound webhooks(出站):入站 webhook 是「世界变了叫醒 Hermes」,出站 webhook 是「Hermes 做了事告诉世界」。配 hooks.outbound: 列表(name 标签 / url / events 列表 / secret_env(放 HMAC 密钥的环境变量名)/ timeout 每尝试秒数 1–60),匹配事件发生时 POST 已签名的 JSON,接收端不需要轮询。典型用法:回合结束时通知 CI 或看板(on_session_end)|跨舰队追踪子代理完成(subagent_stop)|把工具活动喂给外部监控(post_tool_call + matcher)|叫醒另一个 Hermes 实例(URL 指向那个实例的入站 webhook)。
  • hermes hooks CLI:list(列出已配置钩子,含 matcher / timeout / 同意状态)| test <event> [--for-tool X] [--payload-file F](用合成负载打所有匹配钩子并打印解析后的响应)| revoke <command>(移除匹配该命令的所有 allowlist 条目,下次重启生效)| doctor(对每个已配置钩子检查:可执行位、allowlist 状态、mtime 漂移、JSON 输出合法性、粗测执行时间)。

关键概念

  • Shell hook:config.yaml 里声明的外部脚本,事件触发时作为子进程运行,走 JSON 线协议 —— 唯一有进程隔离的一类。
  • Plugin hook:插件在 register() 里注册的 Python 回调,进程内运行(无隔离)。
  • Gateway hook:~/.hermes/hooks/<name>/ 下的 HOOK.yaml + handler.py,只在 gateway 里跑、只能观察不能干预。
  • Outbound webhook:把生命周期事件以 HMAC 签名的 JSON 推给外部端点。
  • JSON 线协议:stdin 收负载、stdout 回指令(block / modify / approve / context / continue / 空)。
  • matcher:正则,只在 pre_tool_call / post_tool_call 上生效,用来筛工具名。
  • handle:handler.py 里必须存在的入口函数名。
  • extra:各事件专属的 kwargs 载体(user_message、duration_ms…)。

代码 / 实操

Gateway 事件钩子(~/.hermes/hooks/my-hook/):

yaml
# HOOK.yaml
name: my-hook
description: Log all agent activity to a file
events:
  - agent:start
  - agent:end
  - agent:step
python
# handler.py —— 函数必须叫 handle
import json
from datetime import datetime
from pathlib import Path
 
LOG_FILE = Path.home() / ".hermes" / "hooks" / "my-hook" / "activity.log"
 
async def handle(event_type: str, context: dict):
    entry = {"timestamp": datetime.now().isoformat(), "event": event_type, **context}
    with open(LOG_FILE, "a") as f:
        f.write(json.dumps(entry) + "\n")

Shell hook(config.yaml):

yaml
hooks:
  pre_tool_call:
    - matcher: "terminal"
      command: "~/.hermes/agent-hooks/block-rm-rf.sh"
      timeout: 5
  post_tool_call:
    - matcher: "write_file|patch"
      command: "~/.hermes/agent-hooks/auto-format.sh"
hooks_auto_accept: false
bash
#!/usr/bin/env bash
# ~/.hermes/agent-hooks/block-rm-rf.sh —— 从 stdin 读 JSON、向 stdout 回 JSON
payload="$(cat -)"
cmd=$(echo "$payload" | jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE 'rm[[:space:]]+-rf?[[:space:]]+/'; then
  printf '{"decision": "block", "reason": "blocked: rm -rf / is not permitted"}\n'
else
  printf '{}\n'
fi
bash
hermes hooks list          # 已配置的钩子 + 同意状态
hermes hooks test pre_tool_call --for-tool terminal
hermes hooks doctor        # 可执行位 / allowlist / mtime 漂移 / JSON 合法性

本机实测(2026-09-25):

$ hermes hooks list
No shell hooks or outbound webhooks configured in ~/.hermes/config.yaml.

$ hermes hooks doctor
No shell hooks configured — nothing to check.

$ ls -la ~/.hermes/hooks/     → 空目录(0 文件,创建于 4/17 01:30)
$ ls ~/.hermes/agent-hooks/   → 不存在
$ ~/.hermes/config.yaml       → 没有 hooks: 段

仓库实现文件的真实体积:agent/shell_hooks.py 31,551 B(最大)| agent/outbound_webhooks.py 15,156 B | agent/auxiliary_hooks.py 10,531 B | agent/api_request_hooks.py 8,208 B | gateway/hooks.py 8,184 B | agent/plugin_stream_hooks.py 6,748 B | agent/verify_hooks.py 2,298 B。

我的收获

用户此前已学过本模块;以下是本机实测状态(2026-09-25):

  • 本机一个钩子都没配(三重实证):hermes hooks list 明确回「No shell hooks or outbound webhooks configured」;hooks doctor 回「nothing to check」;~/.hermes/agent-hooks/ 目录根本不存在、config.yaml 里也没有 hooks: 段。
  • ~/.hermes/hooks/(gateway 事件钩子的位置)存在但是空的 —— 这正好印证官方那句「放文件即 opt-in」:目录在但没内容 = 没有任何 gateway 钩子在跑。
  • 仓库里七类钩子实现文件的体积分布很说明问题:shell_hooks.py 以 31.5 KB 远超其余(第二名 outbound_webhooks.py 15 KB)—— 与官方对它的定位一致(唯一能拦截、唯一有进程隔离、唯一支持任意语言,所以实现最重)。
  • 💡 对这台机器最实用的判断:hooks 是零依赖就能加的护栏层 —— 我平时最缺的两类自动化(「写后自动格式化」「阻断危险命令」)官方都给了现成范例,而且不需要写 Python 插件,一个 bash 脚本 + config 三行就够。
  • ⚠️ 一个必须记住的陷阱:本机 hooks_auto_accept 未设(默认 false),而官方明确说非 TTY 运行(gateway / cron / CI)需要三个逃生舱之一,否则新加的钩子会静默保持未注册、只留一条警告 —— 也就是说「我配了钩子但它在 gateway 里没生效」很可能是这个原因,不是钩子写错了。

(个人感想部分待补写。)

待深入

(待填:没听懂、想回头查的。)