Hermes Agent 源码专题【左扬精讲】— Model Tools 编排层
Hermes Agent 源码专题【左扬精讲】— Model Tools 编排层
这是 Hermes Agent 源码专题【左扬精讲】系列的 第 6 篇 / 共 40 篇。本篇是 Layer 2 的工具编排层。
本篇不是工具实现的讲解,而是工具系统的 "编排层" 分析:当你调用 hermes 时,工具是如何被发现、过滤、分发给模型的?当你让模型执行一个函数时,调用链是怎么走的?
Layer 框架的视角是:先看分层(Layer),再看路由(工具从注册到调用的完整链路),最后看边界(安全拦截点的设计意图)。
Layer 2 ─ 工具编排层(model_tools.py)
════════════════════════════════════════════════════════════
Layer 3 ─ 工具实现层(自注册)
tools/registry.py ← 全局注册表
tools/*.py ← 工具实现(import 时自调用 registry.register())
Layer 2 ─ 工具编排层(model_tools.py)
model_tools.py ← get_tool_definitions() 发给模型的 schema
← handle_function_call() 分发函数调用
toolsets.py ← _HERMES_CORE_TOOLS / TOOLSETS 字典
Layer 2 ─ 工具编排层(model_tools.py)
model_tools.py ← coerce_tool_args() 参数类型修正
← run_async() 异步桥接
← _sanitize_tool_error() 错误脱敏
Layer 2 ─ 工具编排层(model_tools.py)
run_agent.py ← AIAgent 类 ─ 调用 model_tools
Layer 2 工具编排 model_tools 工具集 Tool Search
本篇学习重点
必须掌握
- 理解 model_tools.py 的两个核心函数:get_tool_definitions() 和 handle_function_call()
- 理解工具集的过滤逻辑:enabled / disabled toolsets 如何组合
- 理解参数类型修正(coerce_tool_args)为什么必要
需要了解
- 工具搜索渐进式加载(Tool Search)的触发条件
- 异步工具调用的 run_async 桥接机制
- 错误信息脱敏防止提示注入
一、Layer 2.1 ─ get_tool_definitions:从注册表到模型 schema
Layer 视角 ─ 这一层解决什么?
model_tools.py 是工具系统的编排层,位于注册表(tools/registry.py)和智能体核心(run_agent.py)之间。它负责两件事:
- 暴露:把注册表中的工具按工具集过滤后,以 OpenAI 兼容的 schema 格式发给模型
- 分发:接收模型的函数调用请求,路由到实际工具函数并返回结果
第 5 篇讲了工具如何注册,本篇讲注册后的工具如何被发现和调用。
1.1 定位:核心函数在哪
看 model_tools.py 的两个核心函数:
源码视角 ─ get_tool_definitions 和 handle_function_call 的位置
看 model_tools.py:
# 第 272 行:发给模型的工具 schema
def get_tool_definitions(
enabled_toolsets: Optional[List[str]] = None,
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
) -> List[Dict[str, Any]]:
...
# 第 876 行:分发函数调用
def handle_function_call(
function_name: str,
function_args: Dict[str, Any],
task_id: Optional[str] = None,
...
) -> str:
...
两个函数分别是工具系统的"入口"和"出口":get_tool_definitions() 把工具暴露给模型,handle_function_call() 把模型的调用路由到实际工具。
1.2 工具 schema 的生成链路
行为:
get_tool_definitions() 的调用链:
get_tool_definitions() 调用链
════════════════════════════════════════════════════════
调用方(AIAgent / Gateway)
│
▼
get_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)
│
├─── 缓存命中? ──→ 直接返回(quiet_mode=True 时,约 7ms 节省)
│
▼
_compute_tool_definitions()
│
├─── 1. enabled_toolsets 非空? ──→ resolve_toolset() 获取工具名列表
│ 空? ──→ 从所有工具集聚合
│
├─── 2. disabled_toolsets ──→ 集合差运算排除
│
├─── 3. registry.get_definitions() ──→ 按工具名取 schema + check_fn 过滤
│
├─── 4. sanitize_tool_schemas() ──→ schema 规范化
│
└─── 5. Tool Search 渐进加载(可选)──→ 大量 MCP/插件工具时触发
│
└─── assemble_tool_defs() ──→ 超出阈值则替换为 bridge 工具
1.3 缓存机制:为什么需要缓存
源码视角 ─ get_tool_definitions 的缓存键
看 model_tools.py 第 304-319 行:
if quiet_mode:
cache_key = (
frozenset(enabled_toolsets) if enabled_toolsets is not None else None,
frozenset(disabled_toolsets) if disabled_toolsets else None,
registry._generation,
cfg_fp, # 配置文件修改时间
bool(os.environ.get("HERMES_KANBAN_TASK")),
bool(skip_tool_search_assembly),
)
cached = _tool_defs_cache.get(cache_key)
if cached is not None:
return list(cached)
缓存键包含:工具集配置 + registry 代际 + 配置文件修改时间 + Kanban 任务标记。Gateway 运行时每次 turn 都用 quiet_mode,缓存命中可节省约 7ms 的 registry 遍历 + schema 过滤 + check_fn 探测。
What-if ─ 不用缓存会怎样?
① 每次 turn 都要遍历 registry、取 schema、调用 check_fn,在高频交互场景下累积显著延迟。
② 缓存通过 registry._generation 自动失效 —— 当有工具注册/注销时,registry._generation 递增,所有缓存条目失效,保证新工具立即可见。
本节小结
- get_tool_definitions() 是工具系统的"入口"—— 把注册表中的工具按工具集过滤后发给模型
- 缓存键包含工具集配置 + registry 代际 + 配置文件修改时间,配置变更自动失效
- Tool Search 在工具数超过阈值时触发,把大量 MCP/插件工具替换为 bridge 工具
二、Layer 2.2 ─ handle_function_call:函数调用的分发链路
Layer 视角 ─ 这一层解决什么?
当模型返回 tool_calls 时,Hermes 需要:
- 把模型返回的 JSON 参数转换为 Python 类型
- 依次通过安全拦截点(插件 hook、ACP 审批)
- 调用实际工具函数并返回 JSON 结果
- 对错误信息脱敏,防止提示注入
handle_function_call() 是这个链路的唯一入口。
2.1 分发链路概览
行为:
handle_function_call() 的分发链路:
handle_function_call() 调用链
════════════════════════════════════════════════════════
模型返回 tool_calls
│
▼
coerce_tool_args() ──→ 修正 "42"→42 等类型问题
│
├─── Tool Search bridge? ──→ dispatch_tool_search / tool_describe
│ tool_call 展开为真实工具后递归
│
▼
apply_tool_request_middleware() ──→ 请求中间件
│
├─── pre_tool_call hook ──→ 插件可阻止(block)
│
├─── ACP edit approval ──→ 文件修改前审批
│
└─── reset_consecutive_read_counter() ──→ 非读工具则重置
│
▼
registry.dispatch() ──→ 调用实际工具函数
│
├─── 同步工具 ──→ 直接调用
│
└─── 异步工具 ──→ run_async() 桥接
│
▼
_sanitize_tool_error() ──→ 错误信息脱敏
│
▼
_emit_post_tool_call_hook() ──→ 插件可观测性
│
▼
JSON 字符串返回给模型
2.2 三层安全拦截
源码视角 ─ handle_function_call 的拦截点
看 model_tools.py 第 1017-1078 行:
try:
if function_name in _AGENT_LOOP_TOOLS:
return json.dumps({"error": f"{function_name} must be handled by the agent loop"})
# 第 1 层:插件 pre_tool_call hook(可阻止)
if not skip_pre_tool_call_hook:
block_message = get_pre_tool_call_block_message(function_name, function_args, ...)
if block_message is not None:
return json.dumps({"error": block_message}, ensure_ascii=False)
# 第 2 层:ACP/Zed 文件修改审批
edit_block_message = maybe_require_edit_approval(function_name, function_args)
if edit_block_message is not None:
return edit_block_message
分发前有三层拦截:Agent 循环专用工具(todo、memory 等)、插件 block hook、ACP 审批。这确保了安全边界在工具执行前被检查。
2.3 Tool Search bridge 展开
源码视角 ─ tool_call 如何展开为真实工具
看 model_tools.py 第 955-995 行:
if function_name == _ts_mod.TOOL_CALL_NAME:
underlying_name, underlying_args, err = _ts_mod.resolve_underlying_call(function_args or {})
if err or not underlying_name:
return json.dumps({"error": err or "tool_call could not be resolved"})
# 安全检查:工具必须在会话的 deferrable 集合中
_scoped_deferrable = _ts_mod.scoped_deferrable_names(current_defs)
if underlying_name not in _scoped_deferrable:
return json.dumps({
"error": f"'{underlying_name}' is not available in this session."
})
# 递归调用真实工具,所有 hook 都会触发
return handle_function_call(
function_name=underlying_name,
function_args=underlying_args,
...
)
tool_call bridge 会展开为真实工具后递归调用,这样所有 hook(pre/post、guardrails)都针对真实工具名触发,bridge 本身对插件透明。
LessonBridge 对插件透明 ─ Tool Search 的三个 bridge 工具(tool_search、tool_describe、tool_call)在插件看来是透明的。当模型通过 bridge 调用真实工具时,pre_tool_call hook 收到的是真实工具名,而不是 tool_call。这是设计上的对称性:bridge 是模型的"代理",对安全边界不可见。
本节小结
- handle_function_call() 是工具系统的"出口"—— 把模型的调用路由到实际工具
- 三层安全拦截:Agent 循环专用工具、插件 block hook、ACP 审批
- Tool Search bridge 展开为真实工具后递归调用,bridge 对插件透明
三、Layer 2.3 ─ 辅助机制:参数修正、异步桥接、错误脱敏
Layer 视角 ─ 这一层解决什么?
除了核心的分发逻辑,model_tools.py 还有三个重要的辅助机制:
- coerce_tool_args:修正常见的 LLM 参数类型错误
- run_async:在 sync/async 上下文之间桥接
- _sanitize_tool_error:防止工具错误信息中的提示注入
3.1 coerce_tool_args:参数类型修正
源码视角 ─ coerce_tool_args 的实现
看 model_tools.py 第 619-700 行:
def coerce_tool_args(tool_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
schema = registry.get_schema(tool_name)
if not schema:
return args
properties = (schema.get("parameters") or {}).get("properties")
if not properties:
return args
for key, value in list(args.items()):
prop_schema = properties.get(key)
if not prop_schema:
continue
# Wrap bare non-list values when schema declares array.
# Also coerce strings to expected types (integer, number, boolean).
if isinstance(value, str):
coerced = _coerce_value(value, prop_schema.get("type"), prop_schema)
if coerced is not value:
args[key] = coerced
continue
# Handle array type: wrap non-list scalars in a list.
expected = prop_schema.get("type")
if expected == "array" and value is not None and not isinstance(value, (list, tuple)):
args[key] = [value]
return args
核心逻辑:遍历每个参数,如果值是字符串且 schema 期望其他类型,尝试修正。支持 integer、number、boolean、array、object 类型。
为什么需要这个修正?LLM 返回的参数经常类型不正确:
- 数字返回为字符串:
"42"而不是42 - 布尔值返回为字符串:
"true"而不是true - 数组返回为字符串:
"[1, 2, 3]"而不是[1, 2, 3]
工具函数的类型注解是严格的 Python 类型,必须修正后才能正确执行。
3.2 run_async:异步桥接
源码视角 ─ run_async 的完整实现
看 model_tools.py 第 84-173 行:
def _run_async(coro):
"""Run an async coroutine from a sync context."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# Inside an async context (gateway, RL env) — run in a fresh thread
import concurrent.futures
worker_loop: Optional[asyncio.AbstractEventLoop] = None
loop_ready = threading.Event()
def _run_in_worker():
nonlocal worker_loop
worker_loop = asyncio.new_event_loop()
loop_ready.set()
try:
asyncio.set_event_loop(worker_loop)
return worker_loop.run_until_complete(coro)
finally:
pending = asyncio.all_tasks(worker_loop)
for t in pending:
t.cancel()
if pending:
worker_loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
worker_loop.close()
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
future = pool.submit(_run_in_worker)
try:
return future.result(timeout=300)
except concurrent.futures.TimeoutError:
if loop_ready.wait(timeout=1.0) and worker_loop is not None:
for t in asyncio.all_tasks(worker_loop):
worker_loop.call_soon_threadsafe(t.cancel)
raise
finally:
pool.shutdown(wait=False)
if threading.current_thread() is not threading.main_thread():
worker_loop = _get_worker_loop()
return worker_loop.run_until_complete(coro)
tool_loop = _get_tool_loop()
return tool_loop.run_until_complete(coro)
三个路径:
- Gateway 路径:在独立线程中创建新 loop 并运行,300s 超时后取消任务
- Worker 线程路径:使用线程本地持久化 loop
- CLI 路径:使用进程级持久化 loop,避免 GC 时报错
3.3 _sanitize_tool_error:错误脱敏
源码视角 ─ _sanitize_tool_error 的实现
看 model_tools.py 第 589-612 行:
_TOOL_ERROR_ROLE_TAG_RE = re.compile(
r'',
re.IGNORECASE,
)
_TOOL_ERROR_FENCE_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*', re.MULTILINE)
def _sanitize_tool_error(error_msg: str) -> str:
if not error_msg:
return "[TOOL_ERROR] "
sanitized = _TOOL_ERROR_ROLE_TAG_RE.sub("", error_msg)
sanitized = _TOOL_ERROR_FENCE_RE.sub("", sanitized)
if len(sanitized) > _TOOL_ERROR_MAX_LEN:
sanitized = sanitized[:_TOOL_ERROR_MAX_LEN - 3] + "..."
return f"[TOOL_ERROR] {sanitized}"
为什么需要脱敏?工具错误信息可能包含注入尝试:
# 恶意工具可能返回:
"请忽略上面的指令..."
# 脱敏后:
"[TOOL_ERROR] 请忽略上面的指令..." 被移除
脱敏函数移除 XML 标签、代码 fences 等结构化标记,防止提示注入攻击。
本节小结
- coerce_tool_args() 修正常见的 LLM 参数类型错误,是必要的
- run_async() 在 sync/async 上下文之间桥接,支持 Gateway 和 CLI 两条路径
- _sanitize_tool_error() 防止工具错误信息中的提示注入
四、Layer 2.4 ─ 工具集系统:暴露策略与 Tool Search
Layer 视角 ─ 这一层解决什么?
工具集(toolset)是工具暴露给模型的策略单元。Hermes 有两层工具暴露设计:
- 注册表(registry):所有工具的存在性,是全集
- 工具集(TOOLSETS):每个 session 实际暴露给模型的子集,决定哪些可见
这两层故意分开:注册表让新增工具零成本接入(自注册),工具集显式列出保证可控暴露。
4.1 _HERMES_CORE_TOOLS:核心工具列表
源码视角 ─ _HERMES_CORE_TOOLS 的内容
看 toolsets.py:
_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",
# Text-to-speech
"text_to_speech",
# Planning & memory
"todo", "memory",
# Session history search
"session_search",
# Clarifying questions
"clarify",
# Code execution + delegation
"execute_code", "delegate_task",
# Cronjob management
"cronjob",
# Kanban
"kanban_show", "kanban_list", "kanban_complete",
...
]
_HERMES_CORE_TOOLS 是核心工具列表,会被所有平台继承。这些工具永远不会被 Tool Search 延迟加载。
4.2 工具集过滤逻辑
行为:
工具集过滤的优先级:
工具集过滤逻辑
════════════════════════════════════════════════════════
1. enabled_toolsets 非空?
├─── 是 ──→ 只包含这些工具集的工具
└─── 空 ──→ 包含所有工具集的工具
2. 应用 disabled_toolsets(集合差运算)
└─── 排除被禁用的工具集的工具
3. 特殊处理:HERMES_KANBAN_TASK 环境变量
└─── 自动追加 kanban 工具集(dispatcher-spawned worker 必须有 kanban 工具)
注意:disabled_toolsets 在 enabled_toolsets 之后应用,所以禁用列表可以覆盖启用列表。例如:如果 enabled 是 ["web", "file"],disabled 是 ["file"],最终只有 web 工具。
4.3 Tool Search 渐进加载
当 MCP 服务器和插件工具数量很多时,全部发给模型会消耗大量上下文窗口。Tool Search 在工具总数超过阈值(默认 10% 上下文长度)时触发:
- 把大量 MCP/插件工具替换为三个 bridge 工具:tool_search、tool_describe、tool_call
- 模型需要某个工具时,通过 bridge 按需加载
- 核心工具(_HERMES_CORE_TOOLS)永远不会被延迟
What-if ─ 不用 Tool Search 会怎样?
① 当 MCP 服务器和插件数量很多时,工具 schema 的总 token 数可能超过模型的上下文窗口,导致无法正常调用。
② 实时案例:Gateway 懒加载 MCP 工具时,如果 MCP 服务器响应慢,会阻塞 Gateway 启动 120 秒。修复方案是把 MCP 工具 discovery 移出模块级,改为按需加载。
本节小结
- 工具集是工具暴露给模型的策略单元,工具必须被 TOOLSETS 显式列出才暴露
- _HERMES_CORE_TOOLS 是核心工具列表,会被所有平台继承,永远不会被 Tool Search 延迟
- Tool Search 渐进加载防止大量 MCP 工具撑爆上下文窗口
五、FAQ 20 问
FAQ 分组说明
本节围绕 model_tools.py 的编排层设计,每条都是"读整个系列前最该知道的事"。
FAQ 覆盖的核心问题:
- 工具集和工具的关系
- 缓存失效机制
- 安全拦截的设计意图
- Tool Search 的原理
- 参数修正的必要性
Q1. 工具集(toolset)和工具(tool)是什么关系?
工具集是工具的分组,决定哪些工具对模型可见。例如 web 工具集包含 web_search 和 web_extract。一个工具可以属于多个工具集。工具集可以通过 includes 字段引用其他工具集,实现组合。
Q2. 为什么有些工具属于 _HERMES_CORE_TOOLS?
_HERMES_CORE_TOOLS 是核心工具列表,会被所有平台继承。这些工具包括 terminal、read_file、patch 等基础能力,永远不会被 Tool Search 延迟加载。
Q3. 模型调用了一个不存在的工具名会怎样?
handle_function_call() 会返回错误 JSON。registry 会尝试获取 handler,如果工具名不存在,返回 {"error": "tool not found"}。这个错误会经过 _sanitize_tool_error() 脱敏后返回给模型。
Q4. quiet_mode 参数是干什么的?
quiet_mode=True 时不打印工具集启用的提示信息,并且启用结果缓存。Gateway 运行时每次 turn 都用 quiet_mode,CLI 调试时用 quiet_mode=False 看到详细的工具加载信息。
Q5. disabled_toolsets 和 enabled_toolsets 的优先级?
先按 enabled_toolsets 过滤,再应用 disabled_toolsets 排除。这确保了禁用列表可以覆盖启用列表。例如 enabled=["web", "file"],disabled=["file"],最终只有 web 工具。
Q6. Tool Search 是什么原理?
当工具 schema 超过阈值时,用 bridge 工具替代实际工具。三个 bridge 工具(tool_search、tool_describe、tool_call)替代大量 MCP/插件工具。模型通过 bridge 按需发现和调用工具。
Q7. check_fn 是什么时候被调用的?
在 registry.get_definitions() 中调用。当 check_fn 返回 False 时,该工具不会被包含在返回的 schema 列表中,从而对模型不可见。这实现了凭据门控(如 API key 缺失时隐藏相关工具)。
Q8. 为什么需要 _emit_post_tool_call_hook?
插件可以通过 post_tool_call hook 记录工具调用日志、发送遥测数据。这个 hook 在工具执行完成后、错误脱敏之前发射,确保插件看到真实的执行结果。
Q9. canary 工具集是什么?
在 toolsets.py 中定义,包含可能不稳定的新功能工具。用于向用户暴露测试中的功能,同时与稳定工具集分开管理。
Q10. execute_code 工具为什么特殊处理?
execute_code 需要知道当前会话可用的工具列表。它使用 enabled_tools 参数传入,这个参数来自 _last_resolved_tool_names(最近一次 get_tool_definitions() 的结果)。
Q11. 缓存是如何失效的?
通过 registry._generation 自动失效。当有工具注册/注销时,registry._generation 递增,所有缓存条目失效,保证新工具立即可见。配置文件修改时间也包含在缓存键中。
Q12. pre_tool_call hook 可以做什么?
插件可以通过 pre_tool_call hook 阻止工具执行。hook 返回 block_message 时,handle_function_call() 直接返回错误,工具不会被执行。这用于实现访问控制、审批流程等。
Q13. 为什么需要两个异步桥接路径?
Gateway 有 running loop,CLI 没有,需要不同的处理策略。Gateway 内部已经有 asyncio 在运行,不能再创建新 loop;CLI 场景没有 running loop,可以用持久化的 loop 避免客户端在 GC 时报错。
Q14. Tool Search 的阈值是多少?
默认是模型上下文窗口的 10%。可以通过配置修改。当工具 schema 总 token 数超过这个阈值时,触发渐进加载。
Q15. _AGENT_LOOP_TOOLS 包括哪些工具?
todo、memory、session_search、delegate_task。这些工具需要 agent 级别的状态(TodoStore、MemoryStore 等),必须由 run_agent.py 处理,不能通过 model_tools.py 分发。
Q16. 为什么错误信息要脱敏?
防止工具错误信息中的提示注入攻击。恶意工具可能返回包含 XML 标签或代码 fences 的错误信息,脱敏后移除这些结构化标记。
Q17. MCP 工具 discovery 为什么移出模块级?
Gateway 懒加载 MCP 工具时,如果 MCP 服务器响应慢,会阻塞启动 120 秒。修复方案是把 MCP discovery 移出模块级,改为按需加载。
Q18. 如何让一个工具在某些平台不可见?
在 check_fn 中实现平台检测逻辑,返回 False 时工具对模型不可见。例如 read_terminal 工具的 check_fn 检测 HERMES_DESKTOP 环境变量,只在桌面应用中可用。
Q19. kanban 工具为什么只在特定条件下暴露?
kanban 工具通过 check_fn 实现条件暴露。只有当 HERMES_KANBAN_TASK 环境变量设置(dispatcher-spawned worker)或显式启用 kanban 工具集时,kanban 工具才对模型可见。
Q20. _last_resolved_tool_names 是什么?
最近一次 get_tool_definitions() 返回的工具名列表。用于 execute_code 工具生成沙箱代码时的工具提示,以及 Tool Search bridge 的安全检查。
FAQ 全篇总纲
model_tools.py 是工具系统的编排层:
- 入口:get_tool_definitions() 把工具暴露给模型(按工具集过滤 + 缓存)
- 出口:handle_function_call() 把调用分发给实际工具(三层安全拦截)
- 辅助:参数修正、异步桥接、错误脱敏
- 策略:工具集控制暴露,Tool Search 防止上下文溢出
六、下一篇
学习路径
本篇介绍了 Model Tools 编排层的完整链路。下一篇将继续深入 Layer 2 的其他组件。
Model Tools 工具编排 工具集 Tool Search FAQ

浙公网安备 33010602011771号