Skip to content

第 3 期:工具集 & Schema 生成

核心概念:按平台/场景组合工具子集,动态过滤后输出 OpenAI 格式 schema 给模型

概念讲解

第 2 期中,注册表能存所有工具,也能按名字分发执行。但有个问题:Hermes 有 70+ 工具,你不可能每次都把全部 schema 塞给模型 —— Telegram 机器人不需要 Discord 管理工具,Webhook 触发器不该有终端访问权限,有些工具的 API Key 用户压根没配。

解决方案是工具集(Toolset) —— 一个命名的工具分组:

                    TOOLSETS 字典(声明)
                    ─────────────────
"web"    → ["web_search", "web_extract"]
"terminal" → ["terminal", "process"]
"browser" → ["browser_navigate", "browser_snapshot", ...]
"debugging" → tools=["terminal", "process"], includes=["web", "file"]  ← 组合
"hermes-cli" → _HERMES_CORE_TOOLS(~40 个工具)
"hermes-telegram" → _HERMES_CORE_TOOLS(同上,按平台命名)

                    resolve_toolset()
                    ─────────────────
输入: "debugging"

递归展开 includes → 合并去重

输出: {"terminal", "process", "web_search", "web_extract",
       "read_file", "write_file", "patch", "search_files"}

                    get_tool_definitions()
                    ─────────────────────
输入: enabled_toolsets=["hermes-cli"], disabled_toolsets=["browser"]

1. resolve 所有 enabled → 得到工具名集合
2. resolve 所有 disabled → 从集合中减去
3. 拿着集合去 registry.get_definitions() → 过滤 check_fn
4. 返回 OpenAI 格式 [{"type": "function", "function": {...}}, ...]

这是一个三层过滤链:Toolset 定义(编译时) → resolve + enable/disable(启动时) → check_fn(运行时)。用户在 hermes tools(curses 菜单)里勾选/取消工具集,选择结果存入 config.yamlplatform_toolsets 段,下次启动时生效。

源码关键片段

文件:toolsets.py:31-85 — _HERMES_CORE_TOOLS 共享工具列表

python
# 所有平台共享的核心工具列表 —— 改这一处,所有平台同步更新
_HERMES_CORE_TOOLS = [
    # Web
    "web_search", "web_extract",
    # Terminal + process management
    "terminal", "process",
    # File manipulation
    "read_file", "write_file", "patch", "search_files",
    # Vision + image generation
    "vision_analyze", "image_generate",
    # Skills
    "skills_list", "skill_view", "skill_manage",
    # Browser automation
    "browser_navigate", "browser_snapshot", "browser_click",
    "browser_type", "browser_scroll", "browser_back",
    "browser_press", "browser_get_images",
    "browser_vision", "browser_console", "browser_cdp", "browser_dialog",
    # ... 还有 tts, todo, memory, session_search, clarify,
    # execute_code, delegate_task, cronjob, send_message,
    # homeassistant, kanban, computer_use 等
]

文件:toolsets.py:88-143 — TOOLSETS 字典(节选)

python
# 核心工具集定义 —— 每个 key 是工具集名,value 含 tools 和 includes
TOOLSETS = {
    # 叶子工具集 —— 只包含直接工具
    "web": {
        "description": "Web research and content extraction tools",
        "tools": ["web_search", "web_extract"],
        "includes": []  # 不引用其他工具集
    },
    "terminal": {
        "description": "Terminal/command execution and process management tools",
        "tools": ["terminal", "process"],
        "includes": []
    },
    # ...

    # 组合工具集 —— 通过 includes 引用其他工具集
    "debugging": {
        "description": "Debugging and troubleshooting toolkit",
        "tools": ["terminal", "process"],
        "includes": ["web", "file"]  # 递归展开
    },

    # 平台工具集 —— 每个平台对应一个预设
    "hermes-cli": {
        "description": "Full interactive CLI toolset",
        "tools": _HERMES_CORE_TOOLS,  # 引用共享列表
        "includes": []
    },
    "hermes-telegram": {
        "description": "Telegram bot toolset",
        "tools": _HERMES_CORE_TOOLS,  # 同样的核心工具
        "includes": []
    },
    # hermes-discord 在核心工具之上加了 discord/discord_admin
    "hermes-discord": {
        "description": "Discord bot toolset",
        "tools": _HERMES_CORE_TOOLS + ["discord", "discord_admin"],
        "includes": []
    },
}

文件:toolsets.py:606-657 — resolve_toolset() 递归展开

python
def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]:
    """递归展开一个工具集,返回所有工具名(去重)。

    处理三种情况:
    1. "all" / "*" → 展开所有已知工具集
    2. 普通工具集 → 取 tools + 递归展开 includes
    3. 循环检测 → visited 集合防止死循环
    """
    if visited is None:
        visited = set()

    # 特殊别名:所有工具
    if name in {"all", "*"}:
        all_tools: Set[str] = set()
        for toolset_name in get_toolset_names():
            resolved = resolve_toolset(toolset_name, visited.copy())
            all_tools.update(resolved)
        return sorted(all_tools)

    # 循环 / 菱形依赖检测:已访问过则跳过
    if name in visited:
        return []
    visited.add(name)

    toolset = get_toolset(name)
    if not toolset:
        return []

    # 收集直接 tools
    tools = set(toolset.get("tools", []))

    # 递归展开 includes(共享 visited 防止菱形重复解析)
    for included_name in toolset.get("includes", []):
        included_tools = resolve_toolset(included_name, visited)
        tools.update(included_tools)

    return sorted(tools)

文件:model_tools.py:337-430 — _compute_tool_definitions() 核心逻辑

python
def _compute_tool_definitions(
    enabled_toolsets=None, disabled_toolsets=None, quiet_mode=False,
    skip_tool_search_assembly=False,
):
    """工具过滤的核心 —— 三步:resolve → subtract → check_fn 过滤。"""
    tools_to_include: set = set()

    if enabled_toolsets is not None:
        # 显式指定了启用哪些工具集 → 逐个 resolve
        for toolset_name in enabled_toolsets:
            if validate_toolset(toolset_name):
                resolved = resolve_toolset(toolset_name)
                tools_to_include.update(resolved)
    else:
        # 未指定 → 启用所有已知工具集
        for ts_name in get_all_toolsets():
            tools_to_include.update(resolve_toolset(ts_name))

    # disabled_toolsets 作为减法 —— 即使在 enabled 中出现也会被移除
    if disabled_toolsets:
        for toolset_name in disabled_toolsets:
            if validate_toolset(toolset_name):
                resolved = resolve_toolset(toolset_name)
                tools_to_include.difference_update(resolved)

    # 最后一道过滤:registry.get_definitions() 只返回 check_fn 通过的工具
    # 例如 Docker 未安装 → terminal 工具 check_fn 返回 False → 不暴露
    filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)

    return filtered_tools

文件:agent/agent_init.py:907-911 — Agent 初始化时调用

python
    # Agent 启动时,根据 enabled_toolsets / disabled_toolsets 过滤工具
    agent.tools = _ra().get_tool_definitions(
        enabled_toolsets=enabled_toolsets,
        disabled_toolsets=disabled_toolsets,
        quiet_mode=agent.quiet_mode,
    )

文件:hermes_cli/tools_config.py:99 — 默认关闭的工具集

python
# 这些工具集默认不启用 —— 用户必须在 hermes tools 中手动勾选
_DEFAULT_OFF_TOOLSETS = {
    "moa", "homeassistant", "spotify", "discord",
    "discord_admin", "video", "video_gen", "x_search"
}

设计决策

Hermes 的选择为什么这样做没选的替代方案替代方案的代价(为什么不选)
静态 TOOLSETS 字典 声明所有工具集零依赖(不需要 import 任何工具模块);阅读 toolsets.py 就能看到全局组合关系让每个工具文件自声明所属 toolset需要先 import 所有工具才能知道分组,和 AST 静态扫描的 lazy 策略冲突
_HERMES_CORE_TOOLS 共享列表,所有平台引用同一份改一处全平台同步;避免 Telegram 有但 Slack 漏了的不一致每个平台独立列举工具15+ 平台 × 40 工具 = 维护地狱
includes 递归组合 支持工具集嵌套避免重复列举;debugging 只需声明自己独有的 + include 公共部分扁平化,所有工具集都必须完整列举工具挪组时改 N 个地方
enable/disable 是两步 —— enabled 先做并集,disabled 再做减法用户能说"给我 CLI 的全部工具,但去掉 browser",表达力足够只有 enabled 白名单去不掉大工具集中的单个子集,缺乏灵活性
三层过滤(toolset 声明 → enable/disable → check_fn 运行时)关注点分离:组合逻辑归 toolsets.py,运行时可用性归各工具自己把可用性检查也做在 toolset 层toolset 层无法知道用户有没有装 Docker / 配没配 API Key
get_tool_definitions() 有缓存Gateway 长进程中,每个新消息都要调一次,缓存避免重复遍历 70+ 工具的 check_fn不缓存每次构建工具列表多 ~7ms,高并发下累积可观
Webhook 安全工具集 单独维护 _HERMES_WEBHOOK_SAFE_TOOLSWebhook 事件来自不可信第三方(如 GitHub PR 标题),不能给终端/文件权限,防 prompt injection复用 _HERMES_CORE_TOOLS 做过滤漏一个高危工具就是 RCE(远程命令执行)漏洞

复刻代码说明

本期复刻代码在 dongrealm-hermes-agent (day3 分支) 中迭代,功能说明:新增 toolsets.py 模块实现工具集声明与递归展开,model_tools.py 实现 get_tool_definitions() 三层过滤链;Agent 启动时通过 enabled_toolsets 参数控制暴露哪些工具给模型。

常见陷阱

  1. 工具注册了但不在任何 toolset 里 = 模型永远看不到它 — 工具必须同时完成两件事:在 tools/*.py 中调用 registry.register()(进入注册表),且工具名出现在 toolsets.py 的某个 toolset 的 tools 列表里(被暴露给模型)。漏了后者是最常见的"工具注册了但不生效"原因。源码验证:model_tools.py:337_compute_tool_definitions() 先 resolve toolset 得到工具名集合,再拿这个集合去 registry.get_definitions(tools_to_include) —— 不在集合里的工具不会被查询。

  2. includes 循环不会报错,只会静默跳过 — 如果 A includes B,B includes A,resolve_toolset() 通过 visited 集合检测到已访问过就返回空列表。不会死循环,也不会告警。源码验证:toolsets.py:625-627if name in visited: return []

  3. disabled_toolsets 是最终减法,优先级最高 — 即使 enabled_toolsets 包含了某个大工具集(如 hermes-cli),disabled_toolsets 仍然能从中减去子集。源码验证:model_tools.py:392tools_to_include.difference_update(resolved)。这意味着 disabled 永远赢。

  4. check_fn 返回 False 和工具不在 toolset 里的效果不同 — 前者是运行时动态的(Docker 卸载了就不可用,装回来就恢复),后者是声明时静态的。如果你想让工具"有条件可用",用 check_fn;如果想让工具"这个平台永远不该有",别把它加进该平台的 toolset。

  5. 缓存基于 registry._generation,MCP 动态注册后自动失效get_tool_definitions() 的缓存 key 包含 registry._generation 计数器。每次 register() 被调用(如 MCP 服务器上线注册新工具),generation 自增,下次调用时缓存未命中,重新计算。源码验证:model_tools.py:307cache_key = (..., registry._generation, ...)