Appearance
第 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.yaml 的 platform_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_TOOLS | Webhook 事件来自不可信第三方(如 GitHub PR 标题),不能给终端/文件权限,防 prompt injection | 复用 _HERMES_CORE_TOOLS 做过滤 | 漏一个高危工具就是 RCE(远程命令执行)漏洞 |
复刻代码说明
本期复刻代码在 dongrealm-hermes-agent (day3 分支) 中迭代,功能说明:新增 toolsets.py 模块实现工具集声明与递归展开,model_tools.py 实现 get_tool_definitions() 三层过滤链;Agent 启动时通过 enabled_toolsets 参数控制暴露哪些工具给模型。
常见陷阱
工具注册了但不在任何 toolset 里 = 模型永远看不到它 — 工具必须同时完成两件事:在
tools/*.py中调用registry.register()(进入注册表),且工具名出现在toolsets.py的某个 toolset 的tools列表里(被暴露给模型)。漏了后者是最常见的"工具注册了但不生效"原因。源码验证:model_tools.py:337的_compute_tool_definitions()先 resolve toolset 得到工具名集合,再拿这个集合去registry.get_definitions(tools_to_include)—— 不在集合里的工具不会被查询。includes循环不会报错,只会静默跳过 — 如果 A includes B,B includes A,resolve_toolset()通过visited集合检测到已访问过就返回空列表。不会死循环,也不会告警。源码验证:toolsets.py:625-627,if name in visited: return []。disabled_toolsets是最终减法,优先级最高 — 即使enabled_toolsets包含了某个大工具集(如hermes-cli),disabled_toolsets仍然能从中减去子集。源码验证:model_tools.py:392,tools_to_include.difference_update(resolved)。这意味着 disabled 永远赢。check_fn返回 False 和工具不在 toolset 里的效果不同 — 前者是运行时动态的(Docker 卸载了就不可用,装回来就恢复),后者是声明时静态的。如果你想让工具"有条件可用",用check_fn;如果想让工具"这个平台永远不该有",别把它加进该平台的 toolset。缓存基于
registry._generation,MCP 动态注册后自动失效 —get_tool_definitions()的缓存 key 包含registry._generation计数器。每次register()被调用(如 MCP 服务器上线注册新工具),generation 自增,下次调用时缓存未命中,重新计算。源码验证:model_tools.py:307,cache_key = (..., registry._generation, ...)。
