AgentHarness 课程

第五篇:Hook 与插件扩展

1.8万字·45分钟·
HookRegistry生命周期、i18n Hook实战

概述

Hermes Agent 的核心设计遵循"开闭原则"——对扩展开放,对修改关闭。Hook(钩子)系统和 Plugin(插件)系统是两大扩展机制。Hook 是轻量级的事件处理器,在 Agent 生命周期的关键节点被触发,适合横切关注点(如国际化、审计、通知)。Plugin 则是更重的功能模块,用于扩展 Agent 的底层能力(如记忆存储、上下文引擎、MCP Server)。

本篇将从源码级别深入分析 Hook 系统的实现机制,通过 i18n 国际化 Hook 这一真实案例展示完整的开发流程,并给出 Hook 与 Plugin 的选择指南。


1. Hook 系统详解

1.1 HookRegistry:发现、加载与触发

Hook 系统的核心是 HookRegistry 类(源码位于 gateway/hooks.py,第 34 行):

class HookRegistry:
    """Discovers, loads, and fires event hooks.

    Usage:
        registry = HookRegistry()
        registry.discover_and_load()
        await registry.emit("agent:start", {"platform": "telegram", ...})
    """

    def __init__(self):
        # event_type -> [handler_fn, ...]
        self._handlers: Dict[str, List[Callable]] = {}
        self._loaded_hooks: List[dict] = []  # metadata for listing

HookRegistry 维护两个核心数据结构:

  • _handlers:事件类型到处理函数列表的映射,例如 {"agent:start": [fn1, fn2], "session:end": [fn3]}
  • _loaded_hooks:所有已加载 Hook 的元数据列表,用于调试和自省

1.2 discover_and_load():发现与加载

discover_and_load() 方法(第 69 行)是 Hook 系统的入口点,在 Gateway 启动时被调用:

def discover_and_load(self) -> None:
    """Scan the hooks directory for hook directories and load their handlers.

    Also registers built-in hooks that are always active.

    Each hook directory must contain:
      - HOOK.yaml with at least 'name' and 'events' keys
      - handler.py with a top-level 'handle' function (sync or async)
    """
    self._register_builtin_hooks()  # 1. 先注册内置 Hook

    if not HOOKS_DIR.exists():      # HOOKS_DIR = ~/.hermes/hooks/
        return

    for hook_dir in sorted(HOOKS_DIR.iterdir()):  # 2. 扫描 hooks 目录
        if not hook_dir.is_dir():
            continue

        manifest_path = hook_dir / "HOOK.yaml"    # 3. 读取清单文件
        handler_path = hook_dir / "handler.py"    # 4. 定位处理模块

        if not manifest_path.exists() or not handler_path.exists():
            continue

        try:
            manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
            hook_name = manifest.get("name", hook_dir.name)
            events = manifest.get("events", [])
            if not events:
                continue

            # 5. 动态加载 handler 模块
            spec = importlib.util.spec_from_file_location(
                f"hermes_hook_{hook_name}", handler_path
            )
            module = importlib.util.module_from_spec(spec)
            spec.loader.exec_module(module)

            handle_fn = getattr(module, "handle", None)
            if handle_fn is None:
                continue

            # 6. 为每个声明的事件注册处理函数
            for event in events:
                self._handlers.setdefault(event, []).append(handle_fn)

            self._loaded_hooks.append({
                "name": hook_name,
                "description": manifest.get("description", ""),
                "events": events,
                "path": str(hook_dir),
            })
        except Exception as e:
            print(f"[hooks] Error loading hook {hook_dir.name}: {e}")

加载流程的关键步骤:

  1. 内置 Hook 注册:首先注册 boot-md 等内置 Hook
  2. 目录扫描:遍历 ~/.hermes/hooks/ 下的所有子目录
  3. 清单解析:读取 HOOK.yaml 获取 Hook 的名称和关注的事件列表
  4. 动态导入:使用 importlib.util.spec_from_file_location() 动态加载 handler.py
  5. 函数提取:从模块中查找名为 handle 的顶层函数
  6. 事件注册:将 handle 函数注册到所有声明的事件类型上

1.3 emit():事件触发

emit() 方法(第 138 行)负责在指定事件发生时调用所有已注册的处理函数:

async def emit(self, event_type: str, context: Optional[Dict[str, Any]] = None) -> None:
    """Fire all handlers registered for an event.

    Supports wildcard matching: handlers registered for "command:*" will
    fire for any "command:..." event.
    """
    if context is None:
        context = {}

    # Collect handlers: exact match + wildcard match
    handlers = list(self._handlers.get(event_type, []))

    # Check for wildcard patterns
    if ":" in event_type:
        base = event_type.split(":")[0]
        wildcard_key = f"{base}:*"
        handlers.extend(self._handlers.get(wildcard_key, []))

    for fn in handlers:
        try:
            result = fn(event_type, context)
            # Support both sync and async handlers
            if asyncio.iscoroutine(result):
                await result
        except Exception as e:
            print(f"[hooks] Error in handler for '{event_type}': {e}")

关键设计:

  • 通配符匹配command:* 匹配所有 command: 前缀的事件(如 command:resetcommand:new
  • 同步/异步兼容:通过 asyncio.iscoroutine() 检测,同步函数直接调用结果,异步函数 await
  • 错误隔离:单个 Hook 的异常不会影响其他 Hook 或主流程(try/except 包裹)
  • 非阻塞保证:Hook 失败只打印日志,永远不会阻断消息处理管线

1.4 生命周期事件

Hook 系统定义了以下生命周期事件:

事件触发时机典型用途
gateway:startupGateway 进程启动执行启动检查清单、发送启动通知
session:start新会话创建(首次消息)初始化会话状态、加载用户偏好
session:end会话结束(/new/reset清理会话资源、保存会话摘要
session:reset会话重置完成通知用户新会话已就绪
agent:startAgent 开始处理消息记录审计日志、设置计时器
agent:stepAgent 工具调用循环的每一步步骤级监控、资源限制
agent:endAgent 完成消息处理计时统计、结果通知
command:*任何斜杠命令执行命令审计、权限检查

1.5 Hook 目录结构

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

~/.hermes/hooks/
└── my-hook/              # Hook 目录(名称任意)
    ├── HOOK.yaml         # 元数据清单
    └── handler.py        # 处理函数

HOOK.yaml 格式

name: my-hook                    # Hook 名称(必填)
description: 一个示例 Hook        # 描述(可选)
events:                          # 关注的事件列表(必填)
  - agent:start
  - agent:end

handler.py 格式

async def handle(event_type: str, context: dict) -> None:
    """Hook handler — must be a top-level function named 'handle'.

    Args:
        event_type: 事件类型标识符(如 "agent:start")
        context: 事件上下文数据(dict)
    """
    print(f"Hook triggered: {event_type}")
    # 你的处理逻辑

处理函数的签名必须为 handle(event_type: str, context: dict),可以是同步或异步函数。


2. 实战:i18n 国际化 Hook

以下通过一个真实的 i18n 国际化 Hook 案例展示完整的 Hook 开发流程。该 Hook 的目标是:当 Hermes 通过任何平台适配器发送消息时,自动将消息文本翻译为目标语言。

2.1 设计思路

i18n Hook 采用 monkey-patch 策略:在 gateway:startup 事件中,替换 BasePlatformAdapter.send() 方法,在原始发送逻辑之前插入翻译步骤。

BasePlatformAdapter.send() 的调用链:

原始: agent → adapter.send() → 平台 API
Hook:  agent → adapter.send() → translate() → adapter._original_send() → 平台 API

2.2 HOOK.yaml 清单

name: i18n
description: Automatic message translation for multi-language support
events:
  - gateway:startup

2.3 handler.py 完整代码分析

# ~/.hermes/hooks/i18n/handler.py

import logging
import pkgutil
import importlib

logger = logging.getLogger("hooks.i18n")

# --- 翻译字典(从 YAML 加载) ---
_translation_dict = {}

def _load_translations():
    """加载 YAML 翻译字典"""
    global _translation_dict
    try:
        from pathlib import Path
        import yaml
        dict_path = Path(__file__).parent / "translations.yaml"
        if dict_path.exists():
            with open(dict_path, "r", encoding="utf-8") as f:
                _translation_dict = yaml.safe_load(f) or {}
    except Exception as e:
        logger.warning("i18n: Failed to load translations: %s", e)


def translate(text: str, target_lang: str = "en") -> str:
    """翻译文本,支持精确匹配和子串匹配。

    策略:
    1. 精确匹配:text 在翻译字典中直接命中
    2. 子串匹配:遍历字典中的 key,替换 text 中的匹配项
    """
    if not text or not _translation_dict:
        return text

    lang_dict = _translation_dict.get(target_lang, {})
    if not lang_dict:
        return text

    # 1. 精确匹配
    if text in lang_dict:
        return lang_dict[text]

    # 2. 子串匹配 — 按长度降序排列,优先匹配更长的子串
    translated = text
    for source, target in sorted(lang_dict.items(), key=lambda x: -len(x[0])):
        if source in translated:
            translated = translated.replace(source, target)

    return translated


async def handle(event_type: str, context: dict) -> None:
    """gateway:startup handler — monkey-patch BasePlatformAdapter.send()"""

    # 1. 加载翻译字典
    _load_translations()
    logger.info("i18n: Loaded %d translation entries", len(_translation_dict))

    # 2. 使用 pkgutil.iter_modules 遍历 gateway.platforms 的所有子模块
    #    自动发现所有 BasePlatformAdapter 的子类
    import gateway.platforms as platforms_pkg
    from gateway.platforms.base import BasePlatformAdapter

    patched_classes = set()

    for importer, module_name, is_pkg in pkgutil.iter_modules(
        platforms_pkg.__path__, platforms_pkg.__name__ + "."
    ):
        try:
            module = importlib.import_module(module_name)
        except ImportError:
            continue

        # 3. 遍历模块中的所有类,查找 BasePlatformAdapter 的子类
        for attr_name in dir(module):
            attr = getattr(module, attr_name)
            if (
                isinstance(attr, type)
                and issubclass(attr, BasePlatformAdapter)
                and attr is not BasePlatformAdapter
                and attr not in patched_classes
            ):
                # 4. Monkey-patch send() 方法
                _patch_send(attr)
                patched_classes.add(attr)

    logger.info("i18n: Patched %d adapter classes", len(patched_classes))


def _patch_send(adapter_class):
    """保存原始 send() 并替换为翻译版本"""
    original_send = adapter_class.send

    async def translated_send(self, chat_id, content, reply_to=None, metadata=None):
        # 翻译消息内容
        target_lang = getattr(self, "_target_lang", "en")
        translated_content = translate(content, target_lang)
        # 调用原始 send
        return await original_send(self, chat_id, translated_content, reply_to, metadata)

    adapter_class.send = translated_send
    logger.debug("i18n: Patched %s.send()", adapter_class.__name__)

2.4 翻译字典(translations.yaml)

en:
  "你好": "Hello"
  "任务已完成": "Task completed"
  "正在处理": "Processing"
  "部署成功": "Deployment successful"
  "发生错误": "An error occurred"

ja:
  "你好": "こんにちは"
  "任务已完成": "タスク完了"

2.5 关键技术点解析

pkgutil.iter_modules 遍历子类:使用 pkgutil.iter_modules() 动态遍历 gateway.platforms 包下的所有模块,然后通过 issubclass() 检测 BasePlatformAdapter 的子类。这意味着即使未来添加了新的平台适配器,i18n Hook 也能自动生效,无需修改。

translate() 的精确+子串匹配:翻译策略分两层。首先尝试精确匹配(整个文本作为一个 key),如果未命中则进行子串替换。子串替换按 key 长度降序排列,避免短 key 吞噬长 key 的情况(例如先匹配 "好" 会破坏 "你好" 的翻译)。

monkey-patch send():将 BasePlatformAdapter.send() 替换为包装版本,在调用原始方法前插入翻译逻辑。这种方式对平台适配器完全透明——适配器代码无需任何改动。


3. 内置 Hook

3.1 boot-md:启动时执行 BOOT.md

boot-md 是 Hermes 唯一的内置 Hook(源码位于 gateway/builtin_hooks/boot_md.py),它在 Gateway 启动时检查 ~/.hermes/BOOT.md 文件是否存在,如果存在则执行其中的指令。

async def handle(event_type: str, context: dict) -> None:
    """Gateway startup handler — run BOOT.md if it exists."""
    if not BOOT_FILE.exists():
        return

    content = BOOT_FILE.read_text(encoding="utf-8").strip()
    if not content:
        return

    logger.info("Running BOOT.md (%d chars)", len(content))

    # Run in a background thread so we don't block gateway startup.
    thread = threading.Thread(
        target=_run_boot_agent,
        args=(content,),
        name="boot-md",
        daemon=True,
    )
    thread.start()

核心设计:

  • 非阻塞:使用独立线程执行,不阻塞 Gateway 启动
  • 静默模式:Agent 回复包含 [SILENT] 则不发送通知
  • 限制迭代max_iterations=20 防止无限循环
def _run_boot_agent(content: str) -> None:
    prompt = _build_boot_prompt(content)
    agent = AIAgent(
        quiet_mode=True,
        skip_context_files=True,
        skip_memory=True,
        max_iterations=20,
    )
    result = agent.run_conversation(prompt)
    response = result.get("final_response", "")
    if response and "[SILENT]" not in response:
        logger.info("boot-md completed: %s", response[:200])

BOOT.md 示例

# Startup Checklist

1. Check if any cron jobs failed overnight
2. Send a status update to Discord #general
3. If there are errors in /opt/app/deploy.log, summarize them

内置 Hook 的注册发生在 _register_builtin_hooks() 方法中(第 54 行):

def _register_builtin_hooks(self) -> None:
    try:
        from gateway.builtin_hooks.boot_md import handle as boot_md_handle
        self._handlers.setdefault("gateway:startup", []).append(boot_md_handle)
        self._loaded_hooks.append({
            "name": "boot-md",
            "description": "Run ~/.hermes/BOOT.md on gateway startup",
            "events": ["gateway:startup"],
            "path": "(builtin)",
        })
    except Exception as e:
        print(f"[hooks] Could not load built-in boot-md hook: {e}")

4. 插件系统

4.1 插件与 Hook 的区别

Plugin 是 Hermes 的另一种扩展机制,与 Hook 有本质区别:

维度HookPlugin
加载方式~/.hermes/hooks/ 目录发现config.yaml 配置注册
触发机制事件驱动(生命周期事件)接口实现(Provider 模式)
执行时机在主流程的关键节点作为独立模块被调用
典型用途消息拦截、审计、通知记忆存储、上下文引擎、工具服务
代码侵入性monkey-patch(可选)接口实现(显式)
错误处理静默失败,不阻断主流程需要自行处理,可能影响功能

4.2 Memory Provider 插件

Memory Provider 插件扩展 Agent 的记忆存储能力。不同的 Provider 实现不同的存储后端:

# config.yaml
memory:
  provider: sqlite          # 或 redis、chromadb 等
  sqlite:
    path: ~/.hermes/memory.db

Memory Provider 需要实现特定的接口(如 store()retrieve()search()),由 Agent 的记忆系统在运行时调用。

4.3 Context Engine 插件

Context Engine 插件控制 Agent 如何构建和管理上下文窗口。不同的 Engine 可以实现不同的策略:

  • 滑动窗口:保留最近 N 轮对话
  • 摘要压缩:对历史对话进行摘要,保留关键信息
  • 向量检索:基于语义相似度检索相关历史

4.4 MCP Server 插件

MCP(Model Context Protocol)Server 插件为 Agent 提供外部工具和数据源。MCP 是一个标准化协议,允许 Agent 连接到各种外部服务:

# config.yaml
mcp_servers:
  - name: filesystem
    command: npx
    args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"]
  - name: database
    command: python
    args: ["-m", "mcp_server_db"]

MCP Server 作为独立进程运行,通过 JSON-RPC 协议与 Agent 通信。


5. Hook vs Plugin 选择指南

5.1 何时使用 Hook

以下场景适合使用 Hook:

  1. 横切关注点:需要在多个平台适配器上统一生效的行为(如国际化、审计日志、消息过滤)
  2. 生命周期拦截:在 Agent 处理流程的特定节点插入逻辑(如 session 开始时初始化、结束时清理)
  3. 轻量级扩展:不需要引入新的存储后端或外部服务,只是修改现有行为
  4. 快速原型:可以通过 monkey-patch 快速验证想法,无需修改核心代码

典型 Hook 场景:

  • 消息内容过滤/转换
  • 用户权限检查
  • 消息转发/通知
  • 性能监控和日志
  • 自定义命令拦截

5.2 何时使用 Plugin

以下场景适合使用 Plugin:

  1. 新能力接入:需要为 Agent 添加全新的能力(如新的记忆存储后端、外部工具服务)
  2. 数据持久化:需要独立的数据存储和管理(如向量数据库、图数据库)
  3. 外部服务集成:需要与外部服务建立长连接或复杂交互(如数据库、搜索引擎)
  4. 配置驱动:需要用户在 config.yaml 中配置参数

典型 Plugin 场景:

  • 替换默认的 SQLite 记忆存储为 Redis/ChromaDB
  • 添加新的 MCP 工具服务
  • 自定义上下文压缩策略
  • 接入自定义的 LLM Provider

5.3 决策流程图

需要修改现有行为还是添加新能力?
├── 修改现有行为 → 需要修改所有平台适配器?
│   ├── 是 → Hook(monkey-patch BasePlatformAdapter)
│   └── 否 → 修改特定适配器代码或 Hook 特定事件
├── 添加新能力 → 需要外部服务或持久化?
│   ├── 是 → Plugin
│   └── 否 → Hook 或直接修改核心代码
└── 只在生命周期节点执行 → Hook

5.4 最佳实践

Hook 最佳实践

  1. 保持轻量:Hook 处理函数应快速返回,耗时操作使用后台线程或 asyncio.create_task()
  2. 错误隔离:虽然 HookRegistry 会捕获异常,但处理函数内部也应做好错误处理
  3. 幂等性:Hook 可能被多次触发(如 gateway 重启),确保逻辑是幂等的
  4. 避免循环:Hook 中调用 Agent 的方法可能再次触发事件,注意避免无限循环
  5. 日志记录:使用 logging 模块记录关键操作,便于调试

Plugin 最佳实践

  1. 接口一致性:严格实现 Provider 接口的所有方法
  2. 优雅降级:当外部服务不可用时,提供合理的回退行为
  3. 配置验证:在启动时验证所有必要的配置参数
  4. 资源管理:正确管理连接池、文件句柄等资源
  5. 测试覆盖:为 Plugin 编写单元测试和集成测试

调试指南

本节列出 Hook 与插件系统中最常见的问题及排查方法。

Hook 未加载

症状:Hook 目录存在但 Handler 未被触发,emit() 日志中看不到对应事件处理记录。

排查步骤

# 检查 Hook 目录结构是否完整
ls -la ~/.hermes/hooks/<hook-name>/
# 应包含 HOOK.yaml 和 handler.py 两个文件

# 验证 HOOK.yaml 格式(必须有 name 和 events 字段)
cat ~/.hermes/hooks/<hook-name>/HOOK.yaml

# 检查 handler.py 中是否有顶层 handle 函数
grep -n "^def handle\|^async def handle" ~/.hermes/hooks/<hook-name>/handler.py

常见原因:缺少 HOOK.yamlhandler.pyHOOK.yamlevents 字段为空或拼写错误(如写成 event)、handler.py 中没有名为 handle 的顶层函数(必须是模块级函数,不是类方法)。检查 discover_and_load() 的日志输出,搜索 [hooks] Error loading hook 字样。

Hook 执行报错

症状:Hook 被加载了但运行时出错,主流程未受影响但 Hook 功能失效。

排查步骤

# 查看 Hook 执行错误日志
journalctl --user -u hermes-gateway | grep "\[hooks\] Error in handler"

# 查看更详细的错误堆栈
grep -A 5 "Error in handler" ~/.hermes/logs/gateway.log | tail -30

常见原因handler.py 中的代码抛出未捕获的异常(如 ImportErrorKeyErrorAttributeError)。HookRegistry 的 emit() 会捕获所有异常并打印日志,但不会中断主流程。检查错误信息中提到的具体异常类型和行号,修复后重启 Gateway 即可生效。

Monkey-patch 失效

症状:i18n Hook 等 monkey-patch 类 Hook 不生效,消息未被翻译或处理。

排查步骤

# 检查 patch 日志(i18n Hook 会输出 patched 数量)
journalctl --user -u hermes-gateway | grep -i "patched\|patch"

# 确认平台适配器是否在 patch 之后才加载
grep -E "adapter|platform" ~/.hermes/logs/gateway.log | head -20

常见原因:如果某个平台适配器子类自己覆盖了 send() 方法,那么对 BasePlatformAdapter.send() 的 monkey-patch 不会影响该子类。解决方案是在 _patch_send() 中对每个发现的子类单独 patch,而不是只 patch 基类。检查 i18n Hook 的 handle() 函数中 patched_classes 集合的大小——如果为 0,说明未发现任何子类。

调试技巧

在 Hook 开发过程中,可以利用以下方法快速定位问题:

# 方法 1:在 handler.py 中加 print() 输出
# print() 的输出会出现在 journalctl 日志中
# 示例:在 handle() 函数开头添加
#   print(f"[debug-hook] event={event_type}, context keys={list(context.keys())}")

# 查看输出
journalctl --user -u hermes-gateway -f | grep debug-hook

# 方法 2:使用 logging 模块(推荐用于生产)
# import logging; logger = logging.getLogger("hooks.my-hook")
# logger.info("...")

# 方法 3:检查 Hook 是否在加载列表中
# 在 Gateway 启动日志中搜索
journalctl --user -u hermes-gateway | grep -i "hook"

修改 handler.py 后需要重启 Gateway 才能生效(Hook 在启动时一次性加载)。开发阶段建议使用 hermes gateway run 前台运行,便于实时观察日志输出。


思考题

  1. 错误传播:Hook 系统的设计原则是"Hook 失败不阻断主流程"。但考虑一个审计 Hook,它需要在每次消息处理后记录审计日志。如果审计 Hook 失败了,消息仍然被发送,但审计记录丢失了。这个行为是否符合预期?你会如何设计一个更健壮的审计系统?

  2. 通配符匹配:当前 emit() 实现了 command:* 匹配所有 command: 前缀的事件。如果未来需要支持更复杂的模式(如 agent:* 匹配 agent:startagent:step,但不匹配 agent),你会如何修改匹配逻辑?

  3. i18n Hook 的 monkey-patch:i18n Hook 通过替换 BasePlatformAdapter.send() 实现翻译。这种方式有什么潜在风险?考虑以下场景:如果另一个 Hook 也 monkey-patch 了 send(),会发生什么?你能想到更安全的实现方式吗?

  4. boot-md Hook:boot-md 在独立线程中运行 Agent。为什么要用线程而不是 asyncio.create_task()?如果在主事件循环中运行,会有什么问题?

  5. Hook 与 Plugin 的边界:假设你需要实现一个功能——在每次 Agent 回复后,自动将对话保存到 Elasticsearch 进行全文检索。这应该实现为 Hook 还是 Plugin?请分析两种方案的优劣。