ByteNoteByteNote

字节笔记本

2026年6月21日

hermes教程-事件钩子

API中转
¥120

事件钩子

Hermes 拥有三个钩子系统,可在关键生命周期点运行自定义代码:

系统注册方式运行环境用例
网关钩子HOOK.yaml + handler.py 位于 ~/.hermes/hooks/仅网关日志记录、告警、Webhook
插件钩子插件中通过 ctx.register_hook() 注册CLI + 网关工具拦截、指标收集、防护措施
Shell 钩子~/.hermes/config.yaml 中的 hooks: 块,指向 Shell 脚本CLI + 网关即插即用脚本,用于阻止、自动格式化、上下文注入

所有三个系统均为非阻塞——任何钩子中的错误都会被捕获并记录,绝不会导致代理崩溃。

网关事件钩子

网关钩子在网关运行期间(Telegram、Discord、Slack、WhatsApp、Teams)自动触发,不会阻塞主代理管道。

创建钩子

每个钩子是 ~/.hermes/hooks/ 下的一个目录,包含两个文件:

text
~/.hermes/hooks/
└── my-hook/
    ├── HOOK.yaml      # 声明要监听的事件
    └── handler.py     # Python 处理函数

HOOK.yaml

yaml
name: my-hook
description: 将所有代理活动记录到文件
events:
  - agent:start
  - agent:end
  - agent:step

events 列表决定哪些事件会触发你的处理函数。你可以订阅任意事件组合,包括通配符如 command:*

handler.py

python
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):
    """为每个订阅的事件调用。必须命名为 'handle'。"""
    entry = {
        "timestamp": datetime.now().isoformat(),
        "event": event_type,
        **context,
    }
    with open(LOG_FILE, "a") as f:
        f.write(json.dumps(entry) + "\n")

处理函数规则:

  • 必须命名为 handle
  • 接收 event_type(字符串)和 context(字典)
  • 可以是 async def 或普通 def——两者均可
  • 错误会被捕获并记录,绝不会导致代理崩溃

可用事件

事件触发时机上下文键
gateway:startup网关进程启动platforms(活跃平台名称列表)
session:start新消息会话创建platform, user_id, session_id, session_key
session:end会话结束(重置前)platform, user_id, session_key
session:reset用户运行 /new/resetplatform, user_id, session_key
agent:start代理开始处理消息platform, user_id, session_id, message
agent:step工具调用循环的每次迭代platform, user_id, session_id, iteration, tool_names
agent:end代理处理完成platform, user_id, session_id, message, response
command:*任何斜杠命令被执行platform, user_id, command, args

通配符匹配

command:* 注册的处理函数会触发任何 command: 事件(command:modelcommand:reset 等)。通过单个订阅监控所有斜杠命令。

示例

长时间任务 Telegram 告警

当代理执行超过 10 步时,给自己发送一条消息:

yaml
## ~/.hermes/hooks/long-task-alert/HOOK.yaml
name: long-task-alert
description: 当代理执行过多步骤时告警
events:
  - agent:step
python
## ~/.hermes/hooks/long-task-alert/handler.py
import os
import httpx

THRESHOLD = 10
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
CHAT_ID = os.getenv("TELEGRAM_HOME_CHANNEL")

async def handle(event_type: str, context: dict):
    iteration = context.get("iteration", 0)
    if iteration == THRESHOLD and BOT_TOKEN and CHAT_ID:
        tools = ", ".join(context.get("tool_names", []))
        text = f"⚠️ 代理已运行 {iteration} 步。最近使用的工具:{tools}"
        async with httpx.AsyncClient() as client:
            await client.post(
                f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage",
                json={"chat_id": CHAT_ID, "text": text},
            )

命令使用记录器

跟踪哪些斜杠命令被使用:

yaml
## ~/.hermes/hooks/command-logger/HOOK.yaml
name: command-logger
description: 记录斜杠命令使用情况
events:
  - command:*
python
## ~/.hermes/hooks/command-logger/handler.py
import json
from datetime import datetime
from pathlib import Path

LOG = Path.home() / ".hermes" / "logs" / "command_usage.jsonl"

def handle(event_type: str, context: dict):
    LOG.parent.mkdir(parents=True, exist_ok=True)
    entry = {
        "ts": datetime.now().isoformat(),
        "command": context.get("command"),
        "args": context.get("args"),
        "platform": context.get("platform"),
        "user": context.get("user_id"),
    }
    with open(LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")

会话启动 Webhook

在新会话时 POST 到外部服务:

yaml
## ~/.hermes/hooks/session-webhook/HOOK.yaml
name: session-webhook
description: 新会话时通知外部服务
events:
  - session:start
  - session:reset
python
## ~/.hermes/hooks/session-webhook/handler.py
import httpx

WEBHOOK_URL = "https://your-service.example.com/hermes-events"

async def handle(event_type: str, context: dict):
    async with httpx.AsyncClient() as client:
        await client.post(WEBHOOK_URL, json={
            "event": event_type,
            **context,
        }, timeout=5)

教程:BOOT.md —— 每次网关启动时运行启动检查清单

社区中流行的模式:在 ~/.hermes/BOOT.md 放置一个 Markdown 检查清单,让代理在每次网关启动时运行一次。适用于“每次启动时,检查隔夜 cron 失败情况,如果有失败则在 Discord 上 ping 我”,或“总结过去 24 小时的 deploy.log 并发布到 Slack #ops”。

本教程展示如何将其构建为用户自定义钩子。Hermes 没有内置 BOOT.md 钩子——你可以完全自定义所需行为。

我们要构建什么

  1. 一个文件 ~/.hermes/BOOT.md,包含自然语言的启动指令。
  2. 一个网关钩子,在 gateway:startup 时触发,使用网关已解析的模型/凭据生成一次性代理,并执行 BOOT.md 中的指令。
  3. 一个 [SILENT] 约定,使代理在无报告内容时可以选择不发送消息。

步骤 1:编写检查清单

创建 ~/.hermes/BOOT.md。像给人类助手下达指令一样编写:

markdown
## 启动检查清单

1. 运行 `hermes cron list` 并检查是否有计划任务在夜间失败。
2. 如果有失败,使用 `send_message` 工具将摘要发送到 Discord #ops。
3. 检查 `/opt/app/deploy.log` 在过去 24 小时内是否有 ERROR 行。如果有,总结并包含在同一条 Discord 消息中。
4. 如果一切正常,仅回复 `[SILENT]`,这样不会发送消息。

代理将此视为提示的一部分,因此任何可以用自然语言描述的操作都有效——工具调用、Shell 命令、发送消息、总结文件。

步骤 2:创建钩子

text
~/.hermes/hooks/boot-md/
├── HOOK.yaml
└── handler.py

~/.hermes/hooks/boot-md/HOOK.yaml

yaml
name: boot-md
description: 网关启动时运行 ~/.hermes/BOOT.md
events:
  - gateway:startup

~/.hermes/hooks/boot-md/handler.py

python
"""每次网关启动时运行 ~/.hermes/BOOT.md。"""

import logging
import threading
from pathlib import Path

logger = logging.getLogger("hooks.boot-md")

BOOT_FILE = Path.home() / ".hermes" / "BOOT.md"

def _build_prompt(content: str) -> str:
    return (
        "你正在执行启动引导检查清单。请严格按照以下指令操作。\n\n"
        "---\n"
        f"{content}\n"
        "---\n\n"
        "执行每条指令。使用 send_message 工具将任何消息发送到 Discord 或 Slack 等平台。\n"
        "如果无需关注且无报告内容,请仅回复:[SILENT]"
    )

def _run_boot_agent(content: str) -> None:
    """生成一次性代理并执行检查清单。

    使用网关已解析的模型和运行时凭据,因此适用于自定义端点、聚合器和基于 OAuth 的提供商。
    """
    try:
        from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
        from run_agent import AIAgent

        agent = AIAgent(
            model=_resolve_gateway_model(),
            **_resolve_runtime_agent_kwargs(),
            platform="gateway",
            quiet_mode=True,
            skip_context_files=True,
            skip_memory=True,
            max_iterations=20,
        )
        result = agent.run_conversation(_build_prompt(content))
        response = (result.get("final_response", "") or "").strip()
        if response.upper() not in {"[SILENT]", "SILENT", "NO_REPLY", "NO REPLY"}:
            logger.info("boot-md 完成:%s", response[:200])
        else:
            logger.info("boot-md 完成(无报告内容)")
    except Exception as e:
        logger.error("boot-md 代理失败:%s", e)

async def handle(event_type: str, context: dict) -> None:
    if not BOOT_FILE.exists():
        return
    content = BOOT_FILE.read_text(encoding="utf-8").strip()
    if not content:
        return

    logger.info("正在运行 BOOT.md(%d 字符)", len(content))
## 后台线程,以免网关启动被完整的代理轮次阻塞。
    thread = threading.Thread(
        target=_run_boot_agent,
        args=(content,),
        name="boot-md",
        daemon=True,
    )
    thread.start()

两个关键行:

  • _resolve_gateway_model() 读取网关当前配置的模型。
  • _resolve_runtime_agent_kwargs() 以与正常网关轮次相同的方式解析提供商凭据——包括 API 密钥、基础 URL、OAuth 令牌和凭据池。

没有这些,裸 AIAgent() 会回退到内置默认值,并对任何非默认端点返回 401。

步骤 3:测试

重启网关:

bash
hermes gateway restart

查看日志:

bash
hermes logs --follow --level INFO | grep boot-md

你应该会看到 正在运行 BOOT.md(N 字符),随后是 boot-md 完成:...(代理所做操作的摘要)或 boot-md 完成(无报告内容)(当代理回复了精确的静默令牌如 [SILENT] 时)。

删除 ~/.hermes/BOOT.md 以禁用检查清单——钩子仍会加载,但文件不存在时会静默跳过。

扩展模式

  • 计划感知检查清单: 在 BOOT.md 的指令中利用 datetime.now().weekday()(例如“如果是周一,还要检查每周部署日志”)。指令是自由格式文本,因此代理能推理的任何内容都适用。
  • 多个检查清单: 将钩子指向不同文件(STARTUP.mdMORNING.md 等),并为每个文件注册单独的钩子目录。
  • 非代理变体: 如果不需要完整的代理循环,可以完全跳过 AIAgent,让处理函数直接通过 httpx 发布固定通知。更便宜、更快,且无提供商依赖。

为什么这不是内置功能

早期版本的 Hermes 将此作为内置钩子,并在每次网关启动时静默生成一个使用裸默认值的代理。这给使用自定义端点的用户带来了意外,并且使该功能对不知道它正在运行的用户不可见。将其保留为文档化的模式——由你在你的钩子目录中构建——意味着你确切知道它做了什么,并通过编写文件来选择加入。

工作原理

  1. 网关启动时,HookRegistry.discover_and_load() 扫描 ~/.hermes/hooks/
  2. 每个包含 HOOK.yaml + handler.py 的子目录被动态加载
  3. 处理函数为其声明的事件注册
  4. 在每个生命周期点,hooks.emit() 触发所有匹配的处理函数
  5. 任何处理函数中的错误都会被捕获并记录——损坏的钩子绝不会导致代理崩溃

信息

网关钩子仅在网关(Telegram、Discord、Slack、WhatsApp、Teams)中触发。CLI 不会加载网关钩子。对于在所有地方都工作的钩子,请使用插件钩子

插件钩子

插件可以注册在 CLI 和网关会话中均触发的钩子。这些钩子通过插件 register() 函数中的 ctx.register_hook() 以编程方式注册。

有关插件打包和注册的详细信息,请参阅插件指南

python
def register(ctx):
    ctx.register_hook("pre_tool_call", my_tool_observer)
    ctx.register_hook("post_tool_call", my_tool_logger)
    ctx.register_hook("pre_llm_call", my_memory_callback)
    ctx.register_hook("post_llm_call", my_sync_callback)
    ctx.register_hook("on_session_start", my_init_callback)
    ctx.register_hook("on_session_end", my_cleanup_callback)

所有钩子的通用规则:

  • 回调函数接收关键字参数。始终接受 **kwargs 以保证向前兼容——未来版本可能添加新参数而不会破坏你的插件。
  • 如果回调函数崩溃,它会被记录并跳过。其他钩子和代理继续正常运行。行为异常的插件永远不会破坏代理。
  • 两个钩子的返回值会影响行为:pre_tool_call 可以阻止工具,pre_llm_call 可以注入上下文到 LLM 调用中。所有其他钩子都是即发即忘的观察者。
  • 观察者回调会自动接收 telemetry_schema_version。当存在时,turn_idapi_request_idtask_idsession_idapi_call_count 是独立的关联字段。将 api_request_id 视为不透明标识符;不要解析其字符串格式。

快速参考

钩子触发时机返回值
pre_tool_call任何工具执行之前{"action": "block", "message": str} 用于否决调用
post_tool_call任何工具返回之后忽略
pre_llm_call每轮一次,在工具调用循环之前{"context": str} 用于将上下文前置到用户消息
post_llm_call每轮一次,在工具调用循环之后忽略
on_session_start新会话创建(仅第一轮)忽略
on_session_end会话结束忽略
on_session_finalizeCLI/网关拆除活动会话(刷新、保存、统计)忽略
on_session_reset网关换入新的会话密钥(例如 /new/reset忽略
subagent_stopdelegate_task 子代理已退出忽略
pre_gateway_dispatch网关收到用户消息,在认证+分发之前{"action": "skip" | "rewrite" | "allow", ...} 用于影响流程
pre_approval_request危险命令需要用户批准,在发送提示/通知之前忽略
post_approval_response用户响应了批准提示(或超时)忽略
transform_tool_result任何工具返回之后,在结果返回给模型之前str 用于替换结果,None 保持不变
transform_terminal_outputterminal 工具内部,在截断/ANSI 剥离/脱敏之前str 用于替换原始输出,None 保持不变
transform_llm_output工具调用循环完成后,在最终响应交付之前str 用于替换响应文本,None/空字符串保持不变

pre_tool_call

每次工具执行之前立即触发——包括内置工具和插件工具。

回调签名:

python
def my_callback(tool_name: str, args: dict, task_id: str, **kwargs):
参数类型描述
tool_namestr即将执行的工具名称(例如 "terminal""web_search""read_file"
argsdict模型传递给工具的参数
task_idstr会话/任务标识符。如果未设置则为空字符串。

触发位置:model_tools.pyhandle_function_call() 内部,在工具处理函数运行之前。每次工具调用触发一次——如果模型并行调用 3 个工具,则触发 3 次。

返回值——否决调用:

python
return {"action": "block", "message": "阻止工具调用的原因"}

代理会短路该工具,将 message 作为错误返回给模型。第一个匹配的阻止指令获胜(Python 插件先注册,然后是 Shell 钩子)。任何其他返回值都被忽略,因此现有的仅观察者回调可以继续工作而不受影响。

用例: 日志记录、审计跟踪、工具调用计数、阻止危险操作、速率限制、按用户策略执行。

示例——工具调用审计日志:

python
import json, logging
from datetime import datetime

logger = logging.getLogger(__name__)

def audit_tool_call(tool_name, args, task_id, **kwargs):
    logger.info("TOOL_CALL session=%s tool=%s args=%s",
                task_id, tool_name, json.dumps(args)[:200])

def register(ctx):
    ctx.register_hook("pre_tool_call", audit_tool_call)

示例——危险工具警告:

python
DANGEROUS = {"terminal", "write_file", "patch"}

def warn_dangerous(tool_name, **kwargs):
    if tool_name in DANGEROUS:
        print(f"⚠ 正在执行潜在危险工具:{tool_name}")

def register(ctx):
    ctx.register_hook("pre_tool_call", warn_dangerous)

post_tool_call

每次工具执行返回之后立即触发。

回调签名:

python
def my_callback(tool_name: str, args: dict, result: str, task_id: str,
                duration_ms: int, **kwargs):
参数类型描述
tool_namestr刚刚执行的工具名称
argsdict模型传递给工具的参数
resultstr工具的返回值(始终是 JSON 字符串)
task_idstr会话/任务标识符。如果未设置则为空字符串。
duration_msint工具分发所花费的时间,以毫秒为单位(使用 time.monotonic()registry.dispatch() 周围测量)。

触发位置:model_tools.pyhandle_function_call() 内部,在工具处理函数返回之后。每次工具调用触发一次。如果工具引发了未处理的异常,则不会触发(错误会被捕获并作为错误 JSON 字符串返回,而 post_tool_call 会以该错误字符串作为 result 触发)。

返回值: 忽略。

用例: 记录工具结果、指标收集、跟踪工具成功/失败率、延迟仪表板、按工具预算告警、在特定工具完成时发送通知。

示例——跟踪工具使用指标:

python
from collections import Counter, defaultdict
import json

_tool_counts = Counter()
_error_counts = Counter()
_latency_ms = defaultdict(list)

def track_metrics(tool_name, result, duration_ms=0, **kwargs):
    _tool_counts[tool_name] += 1
    _latency_ms[tool_name].append(duration_ms)
    try:
        parsed = json.loads(result)
        if "error" in parsed:
            _error_counts[tool_name] += 1
    except (json.JSONDecodeError, TypeError):
        pass

def register(ctx):
    ctx.register_hook("post_tool_call", track_metrics)

pre_llm_call

每轮触发一次,在工具调用循环开始之前。这是唯一使用返回值的钩子——它可以向当前轮次的用户消息注入上下文。

回调签名:

python
def my_callback(session_id: str, user_message: str, conversation_history: list,
                is_first_turn: bool, model: str, platform: str, **kwargs):
参数类型描述
session_idstr当前会话的唯一标识符
user_messagestr本轮用户的原始消息(在技能注入之前)
conversation_historylist完整消息列表的副本(OpenAI 格式:[{"role": "user", "content": "..."}]
is_first_turnbool如果是新会话的第一轮则为 True,后续轮次为 False
modelstr模型标识符(例如 "anthropic/claude-sonnet-4.6"
platformstr会话运行的位置:"cli""telegram""discord"

触发位置:run_agent.pyrun_conversation() 内部,在上下文压缩之后但在主 while 循环之前。每次 run_conversation() 调用触发一次(即每个用户轮次一次),而不是工具循环内的每次 API 调用。

返回值: 如果回调返回一个包含 "context" 键的字典,或一个非空字符串,则文本会被追加到当前轮次的用户消息中。返回 None 则不注入。

python
## 注入上下文
return {"context": "回忆到的记忆:\n- 用户喜欢 Python\n- 正在处理 hermes-agent"}
## 纯字符串(等效)
return "回忆到的记忆:\n- 用户喜欢 Python"
## 不注入
return None

上下文注入位置: 始终是用户消息,而不是系统提示。这保留了提示缓存——系统提示在各轮次之间保持不变,因此缓存的令牌可以被重用。系统提示是 Hermes 的领域(模型指导、工具强制、个性、技能)。插件在用户输入旁边贡献上下文。

所有注入的上下文都是临时的——仅在 API 调用时添加。对话历史中的原始用户消息永远不会被修改,也不会持久化到会话数据库。

多个插件返回上下文时,它们的输出会按插件发现顺序(按目录名称字母顺序)以双换行符连接。

用例: 记忆回忆、RAG 上下文注入、防护措施、每轮分析。

示例——记忆回忆:

python
import httpx

MEMORY_API = "https://your-memory-api.example.com"

def recall(session_id, user_message, is_first_turn, **kwargs):
    try:
        resp = httpx.post(f"{MEMORY_API}/recall", json={
            "session_id": session_id,
            "query": user_message,
        }, timeout=3)
        memories = resp.json().get("results", [])
        if not memories:
            return None
        text = "回忆到的上下文:\n" + "\n".join(f"- {m['text']}" for m in memories)
        return {"context": text}
    except Exception:
        return None

def register(ctx):
    ctx.register_hook("pre_llm_call", recall)

示例——防护措施:

python
POLICY = "未经用户明确确认,不得执行删除文件的命令。"

def guardrails(**kwargs):
    return {"context": POLICY}

def register(ctx):
    ctx.register_hook("pre_llm_call", guardrails)

post_llm_call

每轮触发一次,在工具调用循环完成且代理产生最终响应之后。仅在成功的轮次触发——如果轮次被中断则不会触发。

回调签名:

python
def my_callback(session_id: str, user_message: str, assistant_response: str,
                conversation_history: list, model: str, platform: str, **kwargs):
参数类型描述
session_idstr当前会话的唯一标识符
user_messagestr本轮用户的原始消息
assistant_responsestr本轮代理的最终文本响应
conversation_historylist轮次完成后完整消息列表的副本
modelstr模型标识符
platformstr会话运行的位置

触发位置:run_agent.pyrun_conversation() 内部,在工具循环退出并产生最终响应之后。由 if final_response and not interrupted 保护——因此当用户中途中断或代理达到迭代限制而未产生响应时,它不会触发。

返回值: 忽略。

用例: 将对话数据同步到外部记忆系统、计算响应质量指标、记录轮次摘要、触发后续操作。

示例——同步到外部记忆:

python
import httpx

MEMORY_API = "https://your-memory-api.example.com"

def sync_memory(session_id, user_message, assistant_response, **kwargs):
    try:
        httpx.post(f"{MEMORY_API}/store", json={
            "session_id": session_id,
            "user": user_message,
            "assistant": assistant_response,
        }, timeout=5)
    except Exception:
        pass  # 尽力而为

def register(ctx):
    ctx.register_hook("post_llm_call", sync_memory)

示例——跟踪响应长度:

python
import logging
logger = logging.getLogger(__name__)

def log_response_length(session_id, assistant_response, model, **kwargs):
    logger.info("RESPONSE session=%s model=%s chars=%d",
                session_id, model, len(assistant_response or ""))

def register(ctx):
    ctx.register_hook("post_llm_call", log_response_length)

on_session_start

仅一次,当创建全新会话时触发。在会话继续(用户在现有会话中发送第二条消息)时不会触发。

回调签名:

python
def my_callback(session_id: str, model: str, platform: str, **kwargs):
参数类型描述
session_idstr新会话的唯一标识符
modelstr模型标识符
platformstr会话运行的位置

触发位置:run_agent.pyrun_conversation() 内部,在新会话的第一轮期间——具体在系统提示构建之后但在工具循环开始之前。检查条件是 if not conversation_history(没有先前消息 = 新会话)。

返回值: 忽略。

用例: 初始化会话范围的状态、预热缓存、向外部服务注册会话、记录会话开始。

示例——初始化会话缓存:

python
_session_caches = {}

def init_session(session_id, model, platform, **kwargs):
    _session_caches[session_id] = {
        "model": model,
        "platform": platform,
        "tool_calls": 0,
        "started": __import__("datetime").datetime.now().isoformat(),
    }

def register(ctx):
    ctx.register_hook("on_session_start", init_session)

on_session_end

在每次 run_conversation() 调用的最末尾触发,无论结果如何。如果代理在用户退出时处于轮次中间,也会从 CLI 的退出处理程序触发。

回调签名:

python
def my_callback(session_id: str, completed: bool, interrupted: bool,
                model: str, platform: str, **kwargs):
参数类型描述
session_idstr会话的唯一标识符
completedbool如果代理产生了最终响应则为 True,否则为 False
interruptedbool如果轮次被中断(用户发送新消息、/stop 或退出)则为 True
modelstr模型标识符
platformstr会话运行的位置

触发位置: 在两个地方:

  1. run_agent.py —— 每次 run_conversation() 调用结束时,在所有清理之后。始终触发,即使轮次出错。
  2. cli.py —— 在 CLI 的 atexit 处理程序中,但仅当代理在退出发生时处于轮次中间(_agent_running=True)。这捕获了处理过程中的 Ctrl+C 和 /exit。在这种情况下,completed=Falseinterrupted=True

返回值: 忽略。

用例: 刷新缓冲区、关闭连接、持久化会话状态、记录会话持续时间、清理在 on_session_start 中初始化的资源。

示例——刷新和清理:

python
_session_caches = {}

def cleanup_session(session_id, completed, interrupted, **kwargs):
    cache = _session_caches.pop(session_id, None)
    if cache:
## 将累积的数据刷新到磁盘或外部服务
        status = "completed" if completed else ("interrupted" if interrupted else "failed")
        print(f"会话 {session_id} 结束:{status},{cache['tool_calls']} 次工具调用")

def register(ctx):
    ctx.register_hook("on_session_end", cleanup_session)

示例——会话持续时间跟踪:

python
import time, logging
logger = logging.getLogger(__name__)

_start_times = {}

def on_start(session_id, **kwargs):
    _start_times[session_id] = time.time()

def on_end(session_id, completed, interrupted, **kwargs):
    start = _start_times.pop(session_id, None)
    if start:
        duration = time.time() - start
        logger.info("SESSION_DURATION session=%s seconds=%.1f completed=%s interrupted=%s",
                     session_id, duration, completed, interrupted)

def register(ctx):
    ctx.register_hook("on_session_start", on_start)
    ctx.register_hook("on_session_end", on_end)

on_session_finalize

当 CLI 或网关拆除活动会话时触发——例如,用户运行 /new、网关 GC 了空闲会话,或 CLI 在活动代理时退出。这是在会话身份消失之前刷新与传出会话关联状态的最后机会。

回调签名:

python
def my_callback(session_id: str | None, platform: str, **kwargs):
参数类型描述
session_idstrNone传出会话的 ID。如果没有活动会话则可能为 None
platformstr"cli" 或消息平台名称("telegram""discord" 等)。

触发位置:cli.py/new / CLI 退出时)和 gateway/run.py(会话重置或 GC 时)。在网关端始终与 on_session_reset 配对。

返回值: 忽略。

用例: 在会话 ID 被丢弃前持久化最终会话指标、关闭每会话资源、发出最终遥测事件、排空排队写入。


on_session_reset

当网关为活动聊天换入新的会话密钥时触发——用户调用了 /new/reset/clear,或适配器在空闲窗口后选择了新会话。这让插件能够对对话状态已被清除这一事实做出反应,而无需等待下一个 on_session_start

回调签名:

python
def my_callback(session_id: str, platform: str, **kwargs):
参数类型描述
session_idstr新会话的 ID(已轮换为最新值)。
platformstr消息平台名称。

触发位置:gateway/run.py 中,在新会话密钥分配之后但在处理下一个入站消息之前。在网关上,顺序是:on_session_finalize(old_id) → 交换 → on_session_reset(new_id) → 在第一个入站轮次时 on_session_start(new_id)

返回值: 忽略。

用例: 重置以 session_id 为键的每会话缓存、发出“会话已轮换”分析、准备新的状态桶。


请参阅**构建插件指南**,获取包含工具模式、处理函数和高级钩子模式的完整演练。


subagent_stop

delegate_task 完成后每个子代理触发一次。无论你是委托单个任务还是三个任务批次,此钩子都会为每个子代理触发一次,并在父线程上序列化。

回调签名:

python
def my_callback(parent_session_id: str, child_role: str | None,
                child_summary: str | None, child_status: str,
                duration_ms: int, **kwargs):
参数类型描述
parent_session_idstr委托父代理的会话 ID
child_rolestr | None在子代理上设置的角色标签(如果未启用该功能则为 None
child_summarystr | None子代理返回给父代理的最终响应
child_statusstr"completed""failed""interrupted""error"
duration_msint运行子代理的挂钟时间,以毫秒为单位

触发位置:tools/delegate_tool.py 中,在 ThreadPoolExecutor.as_completed() 耗尽所有子 future 之后。触发被编排到父线程,因此钩子作者无需考虑并发回调执行。

返回值: 忽略。

用例: 记录编排活动、累积子代理持续时间用于计费、编写委托后审计记录。

示例——记录编排活动:

python
import logging
logger = logging.getLogger(__name__)

def log_subagent(parent_session_id, child_role, child_status, duration_ms, **kwargs):
    logger.info(
        "SUBAGENT parent=%s role=%s status=%s duration_ms=%d",
        parent_session_id, child_role, child_status, duration_ms,
    )

def register(ctx):
    ctx.register_hook("subagent_stop", log_subagent)

信息

在大量委托的情况下(例如,编排角色 × 5 个叶子节点 × 嵌套深度),subagent_stop 每轮会触发多次。保持回调快速;将耗时工作推送到后台队列。


pre_gateway_dispatch

在网关中每个入站 MessageEvent 触发一次,在内部事件守卫之后但在认证/配对和代理分发之前。这是网关级消息流策略(仅监听窗口、人工交接、按聊天路由等)的拦截点,这些策略无法干净地适配到任何单个平台适配器。

回调签名:

python
def my_callback(event, gateway, session_store, **kwargs):
参数类型描述
eventMessageEvent规范化的入站消息(具有 .text.source.message_id.internal 等)。
gatewayGatewayRunner活动的网关运行器,因此插件可以调用 gateway.adapters[platform].send(...) 进行侧信道回复(所有者通知等)。
session_storeSessionStore用于通过 session_store.append_to_transcript(...) 进行静默转录摄取。

触发位置:gateway/run.pyGatewayRunner._handle_message() 内部,在 is_internal 计算之后立即触发。内部事件完全跳过钩子(它们是系统生成的——后台进程完成等——不得被面向用户的策略把关)。

返回值: None 或一个字典。第一个被识别的动作字典获胜;其余插件结果被忽略。插件回调中的异常会被捕获并记录;网关在出错时始终回退到正常分发。

返回值效果
{"action": "skip", "reason": "..."}丢弃消息——无代理回复、无配对流程、无认证。假定插件已处理(例如,静默摄取到转录中)。
{"action": "rewrite", "text": "new text"}替换 event.text,然后继续使用修改后的事件进行正常分发。适用于将缓冲的环境消息合并为单个提示。
{"action": "allow"} / None正常分发——运行完整的认证/配对/代理循环链。

用例: 仅监听群聊(仅在被标记时回复;将环境消息缓冲到上下文中);人工交接(静默摄取客户消息,同时所有者手动处理聊天);按配置文件速率限制;策略驱动路由。

示例——静默拒绝未授权的私信而不触发配对代码:

python
def deny_unauthorized_dms(event, **kwargs):
    src = event.source
    if src.chat_type == "dm" and not _is_approved_user(src.user_id):
        return {"action": "skip", "reason": "unauthorized-dm"}
    return None

def register(ctx):
    ctx.register_hook("pre_gateway_dispatch", deny_unauthorized_dms)

示例——在提及时将环境消息缓冲区重写为单个提示:

python
_buffers = {}

def buffer_or_rewrite(event, **kwargs):
    key = (event.source.platform, event.source.chat_id)
    buf = _buffers.setdefault(key, [])
    if _bot_mentioned(event.text):
        combined = "\n".join(buf + [event.text])
        buf.clear()
        return {"action": "rewrite", "text": combined}
    buf.append(event.text)
    return {"action": "skip", "reason": "ambient-buffered"}

def register(ctx):
    ctx.register_hook("pre_gateway_dispatch", buffer_or_rewrite)

pre_approval_request

批准请求显示给用户之前立即触发——涵盖所有界面:交互式 CLI、Ink TUI、网关平台(Telegram、Discord、Slack、WhatsApp、Matrix 等)以及 ACP 客户端(VS Code、Zed、JetBrains)。

这是连接自定义通知器的正确位置——例如,一个 macOS 菜单栏应用弹出允许/拒绝通知,或一个记录每个批准请求及其上下文的审计日志。

回调签名:

python
def my_callback(
    command: str,
    description: str,
    pattern_key: str,
    pattern_keys: list[str],
    session_key: str,
    surface: str,
    **kwargs,
):
参数类型描述
commandstr等待批准的 Shell 命令
descriptionstr命令被标记的人类可读原因(当多个模式匹配时合并)
pattern_keystr触发批准的主要模式键(例如 "rm_rf""sudo"
pattern_keyslist[str]所有匹配的模式键
session_keystr会话标识符,用于按聊天范围通知
surfacestr"cli" 用于交互式 CLI/TUI 提示,"gateway" 用于异步平台批准

返回值: 忽略。此处的钩子仅为观察者;它们不能否决或预先回答批准。使用 pre_tool_call 在工具到达批准系统之前阻止它。

用例: 桌面通知、推送告警、审计日志记录、Slack Webhook、升级路由、指标收集。

示例——macOS 桌面通知:

python
import subprocess

def notify_approval(command, description, session_key, **kwargs):
    title = "Hermes 需要批准"
    body = f"{description}: {command[:80]}"
    subprocess.Popen([
        "osascript", "-e",
        f'display notification "{body}" with title "{title}"',
    ])

def register(ctx):
    ctx.register_hook("pre_approval_request", notify_approval)

post_approval_response

用户响应批准提示(或提示超时)之后触发。

回调签名:

python
def my_callback(
    command: str,
    description: str,
    pattern_key: str,
    pattern_keys: list[str],
    session_key: str,
    surface: str,
    choice: str,
    **kwargs,
):

pre_approval_request 相同的 kwargs,外加:

参数类型描述
choicestr以下之一:"once""session""always""deny""timeout"

返回值: 忽略。

用例: 关闭匹配的桌面通知、在审计日志中记录最终决定、更新指标、推进速率限制器。

python
def log_decision(command, choice, session_key, **kwargs):
    logger.info("approval %s: %s for session %s", choice, command[:60], session_key)

def register(ctx):
    ctx.register_hook("post_approval_response", log_decision)

transform_tool_result

工具返回之后结果追加到对话之前触发。允许插件重写任何工具的结果字符串——不仅仅是终端输出——在模型看到它之前。

回调签名:

python
def my_callback(
    tool_name: str,
    arguments: dict,
    result: str,
    task_id: str | None,
    **kwargs,
) -> str | None:
参数类型描述
tool_namestr产生结果的工具(read_fileweb_extractdelegate_task 等)。
argumentsdict模型调用工具时使用的参数。
resultstr工具的原始结果字符串,经过截断和 ANSI 剥离后。
task_idstr | None在 RL/基准测试环境中运行时的任务/会话 ID。

返回值: str 用于替换结果(返回的字符串是模型看到的),None 保持不变。

用例:web_extract 输出中脱敏组织特定的 PII、将长 JSON 工具响应包装在摘要标题中、将检索增强提示注入 read_file 结果、将 delegate_task 子代理报告重写为项目特定模式。

python
import re
SECRET = re.compile(r"sk-[A-Za-z0-9]{32,}")

def redact_secrets(tool_name, result, **kwargs):
    if SECRET.search(result):
        return SECRET.sub("[REDACTED]", result)
    return None

def register(ctx):
    ctx.register_hook("transform_tool_result", redact_secrets)

适用于每个工具。对于仅终端的重写,请参见下面的 transform_terminal_output——它更窄,并且在管道中更早运行(在截断、脱敏之前)。


transform_terminal_output

terminal 工具的前台输出管道内部触发,在默认的 50 KB 截断、ANSI 剥离和秘密脱敏之前。允许插件在任何下游处理接触之前重写 Shell 命令的原始 stdout/stderr。

回调签名:

python
def my_callback(
    command: str,
    output: str,
    exit_code: int,
    cwd: str,
    task_id: str | None,
    **kwargs,
) -> str | None:
参数类型描述
commandstr产生输出的 Shell 命令。
outputstr原始合并的 stdout/stderr(可能非常大——截断在钩子之后发生)。
exit_codeint进程退出代码。
cwdstr命令运行的工作目录。

返回值: str 用于替换输出,None 保持不变。

用例: 为产生大量输出的命令(du -ahfindtree)注入摘要、用项目特定标记标记输出以便下游钩子知道如何处理、剥离在运行之间波动并破坏提示缓存的时序噪声。

python
def summarize_find(command, output, **kwargs):
    if command.startswith("find ") and len(output) > 50_000:
        lines = output.count("\n")
        head = "\n".join(output.splitlines()[:40])
        return f"{head}\n\n[summary: {lines} paths total, showing first 40]"
    return None

def register(ctx):
    ctx.register_hook("transform_terminal_output", summarize_find)

transform_tool_result(覆盖所有其他工具)配合良好。


transform_llm_output

每轮触发一次,在工具调用循环完成且模型产生最终响应之后,在该响应交付给用户(CLI、网关或程序化调用者)之前。允许插件使用经典编程方法重写助手的最终文本——无需在 SOUL 风格文本或技能驱动的转换上消耗额外的推理令牌。

回调签名:

python
def my_callback(
    response_text: str,
    session_id: str,
    model: str,
    platform: str,
    **kwargs,
) -> str | None:
参数类型描述
response_textstr本轮助手的最终响应文本。
session_idstr此对话的会话 ID(对于一次性运行可能为空)。
modelstr产生响应的模型名称(例如 anthropic/claude-sonnet-4.6)。
platformstr交付平台(clitelegramdiscord 等;未设置时为空)。

返回值: 非空 str 用于替换响应文本,None 或空字符串保持不变。当多个插件注册时,第一个非空字符串获胜——与 transform_tool_result 类似。

用例: 应用个性/词汇转换(海盗语、海绵宝宝)、从最终文本中脱敏用户特定标识符、追加项目特定签名页脚、强制执行内部风格指南而不在 SOUL 指令上消耗令牌。

python
import os, re

def spongebob(response_text, **kwargs):
    if os.environ.get("SPONGEBOB_MODE") != "on":
        return None  # 原样传递
    return re.sub(r"!", "!! Tartar sauce!", response_text)

def register(ctx):
    ctx.register_hook("transform_llm_output", spongebob)

该钩子受非空、非中断响应的保护——它不会在停止按钮中断或空轮次时触发。异常会作为警告记录,不会破坏代理执行。


Shell 钩子

cli-config.yaml 中声明 Shell 脚本钩子,Hermes 会在相应的插件钩子事件触发时将它们作为子进程运行——在 CLI 和网关会话中均可。无需编写 Python 插件。

当你想要一个即插即用的单文件脚本(Bash、Python,任何带有 shebang 的脚本)时,使用 Shell 钩子来:

  • 阻止工具调用——拒绝危险的 terminal 命令、强制执行按目录策略、要求批准破坏性的 write_file / patch 操作。
  • 在工具调用后运行——自动格式化代理刚刚编写的 Python 或 TypeScript 文件、记录 API 调用、触发 CI 工作流。
  • 向下一轮 LLM 注入上下文——将 git status 输出、当前工作日或检索到的文档前置到用户消息中(参见 pre_llm_call)。
  • 观察生命周期事件——在子代理完成(subagent_stop)或会话开始(on_session_start)时写入日志行。

Shell 钩子通过在 CLI 启动(hermes_cli/main.py)和网关启动(gateway/run.py)时调用 agent.shell_hooks.register_from_config(cfg) 来注册。它们与 Python 插件钩子自然组合——两者都通过同一个分发器。

快速对比

维度Shell 钩子插件钩子网关钩子
声明位置~/.hermes/config.yaml 中的 hooks:插件 plugin.yaml 中的 register()HOOK.yaml + handler.py 目录
存放位置~/.hermes/agent-hooks/(约定)~/.hermes/plugins/<name>/~/.hermes/hooks/<name>/
语言任意(Bash、Python、Go 二进制等)仅 Python仅 Python
运行环境CLI + 网关CLI + 网关仅网关
事件VALID_HOOKS(包括 subagent_stopVALID_HOOKS网关生命周期(gateway:startupagent:*command:*
能否阻止工具调用是(pre_tool_call是(pre_tool_call
能否注入 LLM 上下文是(pre_llm_call是(pre_llm_call
同意每个 (event, command) 对首次使用提示隐式(Python 插件信任)隐式(目录信任)
进程间隔离是(子进程)否(进程内)否(进程内)

配置模式

yaml
hooks:
  <event_name>:                  # 必须在 VALID_HOOKS 中
    - matcher: "<regex>"         # 可选;仅用于 pre/post_tool_call
      command: "<shell command>" # 必需;通过 shlex.split 运行,shell=False
      timeout: <seconds>         # 可选;默认 60,上限 300

hooks_auto_accept: false         # 参见下面的“同意模型”

事件名称必须是插件钩子事件之一;拼写错误会产生“你是说 X 吗?”警告并被跳过。单个条目中的未知键被忽略;缺少 command 会跳过并发出警告。timeout > 300 会被钳制并发出警告。

JSON 线协议

每次事件触发时,Hermes 会为每个匹配的钩子(如果匹配器允许)生成一个子进程,将 JSON 负载通过 stdin 管道传入,并从 stdout 读取 JSON 作为响应。

stdin——脚本接收的负载:

json
{
  "hook_event_name": "pre_tool_call",
  "tool_name":       "terminal",
  "tool_input":      {"command": "rm -rf /"},
  "session_id":      "sess_abc123",
  "cwd":             "/home/user/project",
  "extra":           {"task_id": "...", "tool_call_id": "..."}
}

对于非工具事件(pre_llm_callsubagent_stop、会话生命周期),tool_nametool_inputnullextra 字典携带所有事件特定的 kwargs(user_messageconversation_historychild_roleduration_ms 等)。不可序列化的值会被字符串化而不是省略。

stdout——可选响应:

jsonc
// 阻止 pre_tool_call(两种形式都接受;内部规范化):
{"decision": "block", "reason":  "禁止:rm -rf"}   // Claude-Code 风格
{"action":   "block", "message": "禁止:rm -rf"}   // Hermes 规范

// 为 pre_llm_call 注入上下文:
{"context": "今天是星期五,2026-04-17"}

// 静默无操作——任何空/不匹配的输出都可以:

格式错误的 JSON、非零退出代码和超时会记录警告,但绝不会中止代理循环。

工作示例

1. 每次写入后自动格式化 Python 文件

yaml
## ~/.hermes/config.yaml
hooks:
  post_tool_call:
    - matcher: "write_file|patch"
      command: "~/.hermes/agent-hooks/auto-format.sh"
bash
#!/usr/bin/env bash
## ~/.hermes/agent-hooks/auto-format.sh
payload="$(cat -)"
path=$(echo "$payload" | jq -r '.tool_input.path // empty')
[[ "$path" == *.py ]] && command -v black >/dev/null && black "$path" 2>/dev/null
printf '{}\n'

代理对文件的上下文视图不会自动重新读取——重新格式化仅影响磁盘上的文件。后续的 read_file 调用会获取格式化后的版本。

2. 阻止破坏性的 terminal 命令

yaml
hooks:
  pre_tool_call:
    - matcher: "terminal"
      command: "~/.hermes/agent-hooks/block-rm-rf.sh"
      timeout: 5
bash
#!/usr/bin/env bash
## ~/.hermes/agent-hooks/block-rm-rf.sh
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": "已阻止:不允许 rm -rf /"}\n'
else
  printf '{}\n'
fi

3. 向每一轮注入 git status(相当于 Claude-Code 的 UserPromptSubmit

yaml
hooks:
  pre_llm_call:
    - command: "~/.hermes/agent-hooks/inject-cwd-context.sh"
bash
#!/usr/bin/env bash
## ~/.hermes/agent-hooks/inject-cwd-context.sh
cat - >/dev/null   # 丢弃 stdin 负载
if status=$(git status --porcelain 2>/dev/null) && [[ -n "$status" ]]; then
  jq --null-input --arg s "$status" \
     '{context: ("工作目录中的未提交更改:\n" + $s)}'
else
  printf '{}\n'
fi

Claude Code 的 UserPromptSubmit 事件故意不作为单独的 Hermes 事件存在——pre_llm_call 在相同位置触发并且已经支持上下文注入。在此处使用它。

4. 记录每个子代理完成

yaml
hooks:
  subagent_stop:
    - command: "~/.hermes/agent-hooks/log-orchestration.sh"
bash
#!/usr/bin/env bash
## ~/.hermes/agent-hooks/log-orchestration.sh
log=~/.hermes/logs/orchestration.log
jq -c '{ts: now, parent: .session_id, extra: .extra}' < /dev/stdin >> "$log"
printf '{}\n'

同意模型

每个唯一的 (event, command) 对会在 Hermes 首次看到时提示用户批准,然后将决定持久化到 ~/.hermes/shell-hooks-allowlist.json。后续运行(CLI 或网关)会跳过提示。

三个逃生舱口可以绕过交互式提示——任何一个都足够:

  1. CLI 上的 --accept-hooks 标志(例如 hermes --accept-hooks chat
  2. HERMES_ACCEPT_HOOKS=1 环境变量
  3. cli-config.yaml 中的 hooks_auto_accept: true

非 TTY 运行(网关、cron、CI)需要这三个之一——否则任何新添加的钩子会静默保持未注册状态并记录警告。

脚本编辑被静默信任。 允许列表键基于确切的命令字符串,而不是脚本的哈希值,因此编辑磁盘上的脚本不会使同意失效。hermes hooks doctor 会标记 mtime 漂移,以便你发现编辑并决定是否重新批准。

hermes hooks CLI

命令功能
hermes hooks list转储配置的钩子,包含匹配器、超时和同意状态
hermes hooks test <event> [--for-tool X] [--payload-file F]针对合成负载触发每个匹配的钩子并打印解析后的响应
hermes hooks revoke <command>删除所有与 <command> 匹配的允许列表条目(下次重启生效)
hermes hooks doctor对于每个配置的钩子:检查可执行位、允许列表状态、mtime 漂移、JSON 输出有效性以及大致执行时间

安全性

Shell 钩子使用你的完整用户凭据运行——与 cron 条目或 Shell 别名相同的信任边界。将 config.yaml 中的 hooks: 块视为特权配置:

  • 仅引用你编写或完全审查过的脚本。
  • 将脚本保存在 ~/.hermes/agent-hooks/ 中,以便路径易于审计。
  • 在拉取共享配置后重新运行 hermes hooks doctor,以便在它们注册之前发现新添加的钩子。
  • 如果你的 config.yaml 在团队中进行版本控制,请像审查 CI 配置一样审查更改 hooks: 部分的 PR。

排序和优先级

Python 插件钩子和 Shell 钩子都通过相同的 invoke_hook() 分发器。Python 插件首先注册(discover_and_load()),Shell 钩子其次(register_from_config()),因此在平局情况下,Python 的 pre_tool_call 阻止决定优先。第一个有效的阻止获胜——聚合器在任何回调产生带有非空消息的 {"action": "block", "message": str} 时立即返回。



分享: