162 个工具文件,加上一个 39 KB 的投放策略文件。这一章讲工具怎么被组织、怎么被投放、以及调用参数怎么被矫正。
tools/ 目录里是 157 个工具「怎么做」;toolsets.py 里是它们「在什么场景下该被拿出来用」。
这两件事被彻底拆开了 —— 这是 Hermes 最值得直接搬走的一个设计。
# 命令行界面和所有消息平台共用的工具清单。
# 改这一处就同时更新了所有平台。
_HERMES_CORE_TOOLS = [
# 网络
"web_search", "web_extract",
# 终端与进程管理
"terminal", "process",
# 注意:桌面图形界面相关的能力(read_terminal、open_preview 等)
# 刻意不放在这里,理由和下面的 project 工具一样:
# 它们只在有图形渲染器能响应的地方才有意义。它们住在 desktop_ui
# 工具集里,只由图形网关为"来源是桌面应用"的会话启用 ——
# 绝不基于进程环境变量判断,因为那看不见"桌面客户端连的是远程后端"这种情况。
# 文件操作
"read_file", "write_file", "patch", "search_files",
# 视觉与图像生成
"vision_analyze", "image_generate",
# 技能
"skills_list", "skill_view", "skill_manage",
# 浏览器自动化
"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",
"browser_exec", # browser.backend 为 "browser-use" 时替代其他浏览器工具
# 文字转语音
"text_to_speech",
# 规划与记忆
"todo", "memory",
# 会话历史搜索
"session_search",
# 澄清提问
"clarify",
# 代码执行与委派
"execute_code", "delegate_task",
# 定时任务
"cronjob",
# 智能家居(通过 check_fn 检查 HASS_TOKEN 决定是否启用)
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
# 看板多智能体协作 —— 只有当智能体是作为看板工人被派生
# (设置了 HERMES_KANBAN_TASK 环境变量),或当前身份显式启用了
# kanban 工具集时,才会进入 schema。通过 check_fn 控制。
"kanban_show", "kanban_list", "kanban_complete", "kanban_block",
"kanban_request_review", "kanban_request_changes", "kanban_heartbeat",
"kanban_comment", "kanban_create", "kanban_link", "kanban_unblock",
"kanban_attach", "kanban_attach_url", "kanban_attachments",
# 计算机操作(macOS,通过 check_fn 检查 cua-driver 是否安装)
"computer_use",
]
hermes-agent/toolsets.py
注释里那句话值得逐字读:
「…enabled solely by the GUI gateway for a session whose SOURCE is the desktop app — never keyed on a process env var, which is blind to a desktop client talking to a remote/cloud backend.」
译:只由图形网关为「来源是桌面应用」的会话启用 —— 绝不基于进程环境变量判断,因为那看不见「桌面客户端连接的是一个远程/云端后端」这种情况。
展开这个坑:桌面应用相关的工具(比如「打开预览窗口」)需要有一个图形界面来响应。最直觉的判断方式是看环境变量「我是不是跑在桌面环境里」。
但 Hermes 的部署形态里,桌面客户端可能连着一台云端服务器上的智能体。那台服务器上没有图形界面,环境变量说「不是桌面环境」—— 但用户确实是从桌面应用发来的消息,确实需要这些工具。
正确的判断依据是「这条消息从哪个入口进来的」,而这个信息只有网关知道。
# Webhook 事件可能源自不可信的第三方内容(例如公开代码仓库的
# 合并请求标题或评论)。默认的 webhook 工具集刻意保持收窄,
# 以避免提示词注入触发本地的文件读写或系统命令执行。
_HERMES_WEBHOOK_SAFE_TOOLS = [
"web_search", # 联网搜索(只读)
"web_extract", # 提取网页内容(只读)
"vision_analyze", # 分析图片(只读)
"clarify", # 向用户提问(无副作用)
]
对比一下核心清单里被排除的:terminal(执行命令)、write_file(写文件)、patch(改文件)、execute_code(跑代码)、delegate_task(派生子智能体)—— 全部是有副作用的。
留下的四个的共同点是:即使模型被完全操控,它能造成的最大伤害也只是「搜了些无关的东西」。
这就是「按信任边界配置工具面」的范式。安全性不是靠在提示词里写「请不要执行危险命令」实现的 —— 模型无法调用一个它不知道存在的工具。
TOOLSETS = {
"web": {
"description": "Web research and content extraction tools",
"tools": ["web_search", "web_extract"],
"includes": [] # ★ 可以包含其他工具集
},
"search": {
"description": "Web search only (no content extraction/scraping)",
"tools": ["web_search"],
"includes": []
},
"vision": { "tools": ["vision_analyze"], ... },
"video": { "description": "…(选择性加入,不在默认工具集里)",
"tools": ["video_analyze"], ... },
"image_gen": { "tools": ["image_generate"], ... },
...
}
每个工具集有三个字段:描述(给人看的)、工具清单、包含的其他工具集。
def get_toolset(name: str, *, include_registry: bool = True) -> Optional[Dict]
def bundle_non_core_tools(toolset_name: str) -> Set[str]
def resolve_toolset(name: str, visited: Set[str] = None, *,
include_registry: bool = True) -> List[str] # ★ 带环检测
def resolve_multiple_toolsets(toolset_names: List[str]) -> List[str]
def _get_plugin_toolset_names() -> Set[str] # 插件提供的工具集
def _get_registry_toolset_aliases() -> Dict[str, str] # 别名
def get_all_toolsets() -> Dict[str, Dict[str, Any]]
def get_toolset_names() -> List[str]
def validate_toolset(name: str) -> bool
def create_custom_toolset(...)
def get_toolset_info(name: str) -> Dict[str, Any]
resolve_toolset 的 visited 参数是环检测:工具集 A 包含 B,B 又包含 A,会造成无限递归。用一个「已访问集合」防住。
bundle_non_core_tools(打包非核心工具)的存在说明:系统需要区分「核心工具」和「附加工具」 —— 大概是为了在计算 token 成本或做投放决策时区别对待。
从函数名可以看出工具集有三个来源:
TOOLSETS 字典里写死的_get_plugin_toolset_names()include_registry 参数控制,还支持别名 _get_registry_toolset_aliases()Hermes 没有 Tool 类抽象,所有工具调用都进同一个函数:
def handle_function_call(...) # model_tools.py 第 1240 行
配套的注册表机制:
TOOL_TO_TOOLSET_MAP: Dict[str, str] = registry.get_tool_to_toolset_map()
TOOLSET_REQUIREMENTS: Dict[str, dict] = registry.get_toolset_requirements()
def get_all_tool_names() -> List[str]
def get_toolset_for_tool(tool_name: str) -> Optional[str]
def get_available_toolsets() -> Dict[str, dict]
def check_toolset_requirements() -> Dict[str, bool] # ★ 依赖检查
def check_tool_availability(quiet: bool = False) -> Tuple[List[str], List[dict]]
TOOLSET_REQUIREMENTS(工具集依赖要求)是一个重要机制:有些工具需要外部条件才能工作。
| 工具 | 依赖条件 |
|---|---|
| 智能家居工具 | 环境变量 HASS_TOKEN(Home Assistant 的访问令牌) |
| 计算机操作 | 安装了 cua-driver 驱动,而且只在 macOS 上 |
| 看板工具 | 环境变量 HERMES_KANBAN_TASK,或身份配置显式启用 |
| 浏览器工具 | 安装了对应的浏览器自动化后端 |
核心清单里注释提到这些是「通过 check_fn 控制」的 —— 也就是每个工具可以提供一个检查函数,运行时判断自己是否可用。不可用的工具不会进入模型的工具清单,从而不占用 token、也不会被调用后失败。
这是 Hermes 特有的、而且非常实用的一层:
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any] # 845 行
def _schema_accepts_kind(schema: Any, kind: str) -> bool # 953
def _normalize_json_strings_for_schema(value: Any, schema: Any) -> Any # 974
def _coerce_value(value: str, expected_type, schema: dict | None = None) # 1051
def _schema_allows_null(schema: dict | None) -> bool # 1080
def _coerce_json(value: str, expected_python_type: type) # 1104
def _coerce_number(value: str, integer_only: bool = False) # 1135
def _coerce_boolean(value: str) # 1153
def _canonicalize_tool_call_arguments(arg_str: str) -> str # 1293
hermes-agent/model_tools.py
因为 Hermes 是不绑定模型供应商的。
强模型输出的工具参数类型基本可靠 —— 说要数字就给数字。但 Hermes 要支持 Qwen、DeepSeek、以及跑在用户本机的各种小模型。这些模型经常:
| 模型输出的 | 工具期望的 |
|---|---|
"true"(字符串) | true(布尔值) |
"5"(字符串) | 5(数字) |
"{\"a\": 1}"(JSON 字符串) | {"a": 1}(对象) |
"null"(字符串) | None |
| 带 Markdown 代码围栏的 JSON | 纯 JSON |
如果不矫正、直接按格式报错,弱模型上的工具调用成功率会崩塌 —— 而这些弱模型正是「本地部署、不花钱」这个卖点的基础。
这是「模型无关」的隐性成本。它不体现在架构图上,而体现为几百行防御性代码。
注意函数签名:_coerce_value(value, expected_type, schema) —— 它接收目标 schema。所以矫正是有依据的:
_schema_accepts_kind(schema, kind) —— 先问「这个 schema 接受这种类型吗」_schema_allows_null(schema) —— 「允许空值吗」,决定要不要把 "null" 转成 None_normalize_json_strings_for_schema(value, schema) —— 按 schema 递归处理嵌套结构里的 JSON 字符串如果盲目转换(比如「所有看起来像数字的字符串都转成数字」),会造成新的 bug —— 比如一个本该是字符串的版本号 "1.20" 被转成数字 1.2。按 schema 判断就不会。
_TOOL_ERROR_ROLE_TAG_RE = re.compile(...) # 剥离伪造的角色标签
_TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*',
re.MULTILINE)
_TOOL_ERROR_FENCE_CLOSE_RE = re.compile(r'\s*```\s*$', re.MULTILINE)
_TOOL_ERROR_CDATA_RE = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)
def _sanitize_tool_error(error_msg: str) -> str: ...
四个正则各自处理一类载体:
| 正则 | 剥离什么 |
|---|---|
_TOOL_ERROR_ROLE_TAG_RE | 伪造的角色标签(</system>、<user> 之类) |
_TOOL_ERROR_FENCE_OPEN/CLOSE_RE | Markdown 代码围栏 —— 攻击者可以用它来「关闭」当前的代码块,让后面的文字被当成正文 |
_TOOL_ERROR_CDATA_RE | XML 的 CDATA 段 —— 另一种可以藏内容的结构 |
这类攻击面的共同特征是:它们走的是异常路径,所以正常的功能测试完全覆盖不到。你的测试会验证「工具成功时行为正确」,但很少验证「工具失败时的报错信息里有什么」。
值得在自己的项目里专门排查一遍:列出所有会把外部数据回灌进模型上下文的路径。工具执行结果、错误消息、日志内容、异常堆栈 —— 每一条都是潜在的注入入口。
def suppress_post_tool_call_hook() # 上下文管理器:临时禁用钩子
def _tool_result_observer_fields(...)
def _emit_post_tool_call_hook(...)
suppress_post_tool_call_hook()(抑制工具调用后钩子)是一个上下文管理器。它的用途是:某些内部的工具调用不该触发用户的钩子。
比如:智能体内部为了做压缩而调用某个工具,这不是用户任务的一部分,不该触发用户配置的「每次工具调用后记录一下」钩子 —— 否则日志会被内部操作淹没。
def _is_delegated_child_context() -> bool # 我是不是一个被委派的子智能体
def _is_dispatcher_owned_worker() -> bool # 我是不是调度器拥有的工人
def _get_tool_loop() # 获取工具执行的事件循环
def _get_worker_loop() # 获取工人的事件循环
def _run_async(coro) # 在正确的事件循环里跑协程
这一组函数处理的是 Python 异步编程的一个实际问题:子智能体可能跑在不同的事件循环里(甚至不同的线程里)。工具执行时必须找到正确的循环去调度协程,否则会抛「事件循环已关闭」或者死锁。
_is_delegated_child_context() 还有一个业务用途:子智能体需要知道自己是子智能体,从而应用不同的行为(比如自动审批策略,见第 10 章)。
_TOOL_DEFS_CACHE_MAX = 8
def _clear_tool_defs_cache() -> None
def get_tool_definitions(...) # 第 323 行,带缓存
def _compute_tool_definitions(...) # 第 417 行,实际计算
def _resolve_active_context_length() -> int
工具定义(也就是发给模型的那份工具清单和参数说明)被缓存了,最多 8 份。
为什么需要多份?因为不同场景的工具集不同:主智能体一份、子智能体一份、看板工人一份、webhook 一份……而每份的计算涉及遍历所有工具、解析 schema、检查依赖条件,不便宜。
_resolve_active_context_length() 出现在这里说明:工具定义的生成可能和上下文窗口大小有关 —— 大概是在窗口较小时裁剪掉一些工具或简化描述。