数据截至 (上游 commit 4ac938ddecce)
工具层与执行环境 —— 从函数调用到六种终端后端
30 秒导读: 模型输出一句
terminal{"command": "pytest -q"},这句话最后到底在哪台机器上跑? 本章从"工具怎么被登记、怎么被告诉模型"讲到"命令怎么被一段 bash 包装脚本送进 Docker / SSH / Modal 沙箱", 中间还有三条支线:子 agent、零上下文成本的编程式调用、外部 MCP 工具接入。
上一章 自我进化闭环 讲了 Hermes 怎么积累经验;这一章讲它的手脚。 主循环怎么把工具调用喂给模型,见 一轮对话是怎么跑完的。
1. 先建立直觉:一句 tool call 的五道关
一个 agent 框架里,"工具"这个词其实盖住了五件互不相关的事:
| 阶段 | 白话 | 关键问题 |
|---|---|---|
| 注册 | 谁把工具告诉框架 | 有哪些工具? |
| 暴露 | 框架把哪些工具告诉模型 | 这一轮给模型看几个? |
| 分发 | 模型点名后谁去执行 | 名字 → 函数 |
| 落地 | 函数最后动了哪台机器 | 本机?容器?云沙箱? |
| 回收 | 结果怎么变回一段文本 | 多大?什么格式? |
Hermes 把这五件事拆得非常干净:前三件归 tools/registry.py + toolsets.py + model_tools.py,
第四件归 tools/environments/,第五件是一条只有两个函数的统一协议。
一句话直觉: 把 ToolRegistry 当成一张电话簿(名字 → 分机),把 BaseEnvironment 当成总机
(不管你打给哪个分公司,拨号方式都一样)。
1.1 一次 terminal 调用穿过全链路
怎么读这张图:从上往下是时间顺序,左边编号对应下文小节;每一格右侧是真实符号。
模型输出: {"name": "terminal", "arguments": {"command": "pytest -q"}}
│
▼
① 参数矫正 ─────────────────── model_tools.coerce_tool_args
"42"→42 / "true"→true / 裸串→[裸串] (model_tools.py:643)
│
▼
② 统一分发口 ─── ────────────── model_tools.handle_function_call
插件 pre 钩子 · 编辑审批 · 请求中间件 (model_tools.py:900)
│
▼
③ 注册表查表 ───────────────── ToolRegistry.dispatch
name → ToolEntry.handler,异常统一包成 JSON (tools/registry.py:446)
│
▼
④ 后端工厂 ─────────────────── terminal_tool._create_environment
读 TERMINAL_ENV:local/docker/ssh/… (tools/terminal_tool.py:1373)
│
▼
⑤ 统一执行流 ───────────────── BaseEnvironment.execute
包脚本 → 起 bash → 排水 → 回读 cwd (tools/environments/base.py:866)
│
▼
结果 JSON 字符串 ────────────── post 钩子 → 塞回对话上下文
关键在于第 ③ 步之后所有工具就分岔了:terminal 走执行环境,read_file 走同一个执行环境的
shell 封装,delegate_task 起一个子 agent,execute_code 起一个带 RPC 的子进程,MCP 工具走网络。
下面按这条顺序逐层拆。
2. 工具注册:一张自己长出来的电话簿
2.1 每个工具文件在模块底部自报家门
Hermes 没有中央的"工具清单"文件。每个 tools/*.py 在模块末尾调用一次 registry.register(...),
把 schema、handler、所属 toolset、可用性探测函数一次性登记进去:
# tools/terminal_tool.py:2970 —— 真实源码(节选)
registry.register(
name="terminal",
toolset="terminal",
schema=TERMINAL_SCHEMA,
handler=_handle_terminal,
check_fn=check_terminal_requirements,
emoji="💻",
max_result_size_chars=100_000,
)
登记项的载体是 ToolEntry(tools/registry.py:204),用 __slots__ 固定了这几个字段:
| 字段 | 作用 |
|---|---|
name / toolset | 工具名,以及它属于哪个工具组 |
schema | 给模型看的 JSON Schema |
handler | 真正干活的函数 |
check_fn | 运行时可用性探测(Docker 在不在?playwright 装没装?) |
is_async | 异步 handler,分发时自动桥接到事件循环 |
max_result_size_chars | 该工具结果的截断上限(终端/文件类是 100K 字符) |
dynamic_schema_overrides | 零参回调,装配 schema 时把运行时配置合并进描述 |
最后一个字段是 Hermes 的一个小巧思:delegate_task 的描述里要写"你最多能并发几个子 agent",
而这个数字来自用户配置,所以它不能是静态字符串。get_definitions 每次都调一遍这个回调并浅合并
(tools/registry.py:1079-1089),回调抛异常就退回静态 schema。
2.2 靠 AST 扫描决定 import 谁
启动时不能盲目 import 全部 tools/*.py(很多模块 import 就要拉重依赖)。
discover_builtin_tools(tools/registry.py:111)的做法是:先读源码文本、ast.parse,
只看模块体的顶层语句里有没有 registry.register(...) 这种调用表达式,有才真去 import。
# tools/registry.py:29 —— 真实源码
def _is_registry_register_call(node: ast.AST) -> bool:
if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call):
return False
func = node.value.func
return (isinstance(func, ast.Attribute) and func.attr == "register"
and isinstance(func.value, ast.Name) and func.value.id == "registry")
这段代码保证了两件事:辅助模块(在函数体内部调 register 的)不会被误当成工具模块
(_module_registers_tools 的注释明说只看 tree.body,tools/registry.py:87-108);
import 失败不致命——单个模块 import 报错只记 warning,其它工具照常加载(tools/registry.py:158-162)。
2.3 防止插件悄悄顶掉内置工具
register() 默认拒绝跨 toolset 的同名覆盖(tools/registry.py:792-860)。想替换必须显式传
override=True,并且会以 INFO 级别写进 agent.log 留痕。唯一的自动放行是 "MCP 覆盖 MCP"——
因为 MCP 服务器刷新工具列表本来就是合法的重注册。
2.4 结果协议:只有两个函数
所有 handler 必须返回 JSON 字符串。为了不让 json.dumps({"error": ...}, ensure_ascii=False)
在几百处重复,注册表模块直接提供两个 helper:
tool_error(message, **extra)→{"error": "..."}(tools/registry.py:1308)tool_result(data=None, **kwargs)→ 任意 payload(tools/registry.py:1323)
dispatch 兜住所有异常,统一转成同一个 {"error": ...} 形状(tools/registry.py:1128-1169),
并且在转之前先过一遍 model_tools._sanitize_tool_error——把异常串里可能夹带的 </tool_call>、
CDATA、markdown 围栏这类结构性 token 剥掉再给模型看(model_tools.py:777),
上限 2000 字符。这是一层纵深防御,细节归 信任边界。
3. 工具暴露:toolset 的组合与"这一轮给模型看几个"
3.1 toolset 是可递归组合的工具组
toolsets.py 里 TOOLSETS(toolsets.py:107)是一张大字典,每项两个字段:tools(直接工具名)
和 includes(引用别的 toolset)。分三层:
- 原子组 ——
web、file、terminal、browser、skills、delegation(toolsets.py:299)、code_execution(toolsets.py:293)…… - 平台包 ——
hermes-cli(toolsets.py:499)、hermes-telegram、hermes-cron等,内容都是同一份_HERMES_CORE_TOOLS(toolsets.py:31,列表里 49 个工具名)。"编辑一次,所有平台同步"。 - 聚合包 ——
hermes-gateway(toolsets.py:646)自己不带工具,靠includes把 19 个消息平台包并起来。
resolve_toolset(toolsets.py:769)负责递归展开,附带三个细节:"all" / "*" 是全量别名,
每个分支用 visited.copy() 避免跨分支污染;重复访问静默返回 [](菱形依赖不是 bug);
名字形如 hermes-<平台> 而字典里没有时,会去 gateway.platform_registry 查是不是插件平台,
是就自动配一份 _HERMES_CORE_TOOLS + 该插件注册的工具。
3.2 一个真实踩过的坑:禁用平台包会清空工具列表
平台包 = 核心工具 + 平台特有工具。如果用户把 hermes-cli 写进 disabled_toolsets,
朴素的"整包相减"会把 terminal / read_file 这些被所有组共享的核心工具一起减掉,
模型的工具列表直接空掉。bundle_non_core_tools(toolsets.py:728)就是为这个而写的:
# toolsets.py:652-661 —— 真实源码
core = set(_HERMES_CORE_TOOLS)
ts_def = get_toolset(toolset_name)
if not (ts_def and "tools" in ts_def):
return set(resolve_toolset(toolset_name)) - core
to_remove = set(ts_def["tools"]) - core
for inc in ts_def.get("includes", []):
...
只减"这个包相对核心的增量"。函数注释里点名这是 issue #33924,并解释了为什么只做一层
includes:实际只有 hermes-gateway 嵌套别的包,而那些叶子不再嵌套。
3.3 可用性探测:带 TTL 和"抖动宽限"的缓存
check_fn 是真去探外部世界的:check_terminal_requirements(tools/terminal_tool.py:3698)会
subprocess.run([docker, "version"], timeout=5);Modal 后端要 importlib.util.find_spec("modal");
Daytona 要读 DAYTONA_API_KEY。这些探测在长跑进程里每次装配 schema 都跑一遍纯属浪费。
_check_fn_cached(tools/registry.py:364)做了两件事:
- 30 秒 TTL(
_CHECK_FN_TTL_SECONDS,tools/registry.py:269)——外部状态按人的时间尺度变化, 30 秒既省了探测又让hermes tools enable这种改动一两轮内生效。 - 失败宽限 60 秒(
_CHECK_FN_FAILURE_GRACE_SECONDS,tools/registry.py:273)——一次docker version超时会让整个 terminal+file 工具组从模型的列表里消失,子 agent 随即报 "Tool read_file does not exist"。所以:距上次成功不足 60 秒的失败被当成抖动,返回上次的True,并且不缓存这次失败(下次调用重新探测),既吸收了 flake 又不会把"真的挂了"永久钉成可用。
# tools/registry.py:395-407 —— 真实源码(节选)
last_good = _check_fn_last_good.get(cache_key)
if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS:
logger.warning("check_fn %s failed (%s) within %.0fs of last success; "
"treating as transient and keeping tool(s) available", ...)
return True
代价要诚实说:后端真的宕掉后,最长会有约 1 分钟仍然把工具广告给模型。
3.4 定义装配:get_tool_definitions 的两层缓存
get_tool_definitions(model_tools.py:323)是对外入口,真正的计算在
_compute_tool_definitions(model_tools.py:417)。计算流程是三步减法:
enabled_toolsets 逐个 resolve_toolset ──► tools_to_include (集合)
│ │
│ 没给 enabled 就取全部 toolset │ disabled_toolsets 做减法
▼ ▼ (平台包走 bundle_non_core_tools)
registry.get_definitions(names) ──► 只留 check_fn 通过的
│
▼
动态 schema 重写:execute_code / discord / delegate_task
quiet_mode=True(子 agent、网关这类静默路径)时走记忆化缓存,缓存键把每一个会影响结果的输入都
捕获了(model_tools.py:366-377):enabled/disabled 集合、注册表 scope 键与
registry._generation 世代号、config.yaml 的 (mtime_ns, size) 指纹、
HERMES_KANBAN_TASK 是否置位,以及 tool_search 装配、委派子进程/调度 worker
上下文和 profile scope 标记。
世代号在注册表每次 mutation 时自增(tools/registry.py:582、:361、:386),MCP 刷新自动失效。
缓存上限 8 条并按最旧淘汰(_TOOL_DEFS_CACHE_MAX,model_tools.py:312)。
返回时永远给调用方浅拷贝的 list——注释里写明原因:网关进程长跑时,
run_agent 会往 self.tools 追加 memory/LCM 的 schema,若共享同一个 list 就会污染缓存、
在下游积累重名工具,而 DeepSeek / Moonshot Kimi 这类要求工具名唯一的 provider 会直接 HTTP 400(issue #17335)。
有一处 schema 重写值得单独看:execute_code 的描述里要列"沙箱里有哪 些工具可用",
所以它必须按实际通过 check_fn 的工具名重建,否则模型会去调一个并不存在的沙箱函数
(model_tools.py:521-530)。
4. 统一分发:handle_function_call
handle_function_call(model_tools.py:1192)是所有工具调用的唯一入口。按顺序它做这些事:
- 参数类型矫正
coerce_tool_args(model_tools.py:797)。 - Tool Search 桥接:
tool_search/tool_describe是纯目录读;tool_call会解出底层工具名后 递归调用自己,这样所有钩子看到的都是真实工具名,桥对钩子完全透明(model_tools.py:1330-1347)。 桥还有一道纵深防御:底层工具必须在本会话作用域内的可延迟目录里,否则拒绝——防止受限会话 (子 agent、kanban worker)通过桥拿到全进程注册表(model_tools.py:1309-1329)。 工具为什么会被"收起来",见 上下文工程 的延迟披露一节。 - 中间件与钩子:
pre_tool_call插件可返回阻断消息;ACP/Zed 的编辑审批在任何文件改动前跑; 执行包在run_tool_execution_middleware里;结束后_emit_post_tool_call_hook(model_tools.py:1136) 带duration_ms上报。审批与危险命令拦截的实质内容见 信任边界。 - 真正 dispatch 到
registry.dispatch。 - 结果变换
transform_tool_result钩子,第一个返回字符串的插件生效。
4.1 coerce_tool_args:替模型擦屁股
开源权重模型经常把数字写成 "42"、布尔写成 "true"、把要求数组的字段写成裸字符串。
Hermes 不让这种小错变成工具失败,而是拿注册的 JSON Schema 逐字段比对后安全强转
(model_tools.py:797)。三类矫正:
| 模型给的 | schema 要的 | 处理 |
|---|---|---|
"42" / "3.5" / "true" | integer / number / boolean | 解析成原生类型,解析失败保留原值 |
"https://a.com" | array | 包成 ["https://a.com"] |
'["a","b"]' | array | 先当 JSON 解析;解析失败但以 [ 开头会先打 warning 再退化成单元素列表 |
最后那条 warning(model_tools.py:860-866)是个好设计:不静默吞掉"看起来像 JSON 数组却解析不了"
这种真实 bug 信号。
_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}(model_tools.py:743)
是一组必须由主循环处理的工具,走到 handle_function_call 里会直接报错——它们需要 agent 自身的
状态(对话历史、上下文),注册表分发拿不到。
5. 执行环境:一套包装脚本抹平六种后端
这是本章工程含量最高的一节。
5.1 核心设计:spawn-per-call + 会话快照
最朴素的做法是"开一个常驻 bash,往它 stdin 里灌命令"。Hermes 不这么干——base.py 模块文档第一句就
点明模型(tools/environments/base.py:3-6):
统一的 spawn-per-call 模型:每条命令都新起一个
bash -c进程。会话状态(环境变量、函数、别名) 在 init 时抓成一份快照文件,每条命令执行前重新 source 它。CWD 靠 stdout 的带内标记(远端) 或一个临时文件(本地)来传递。
为什么值得这么绕? 常驻 shell 有三个死穴:一条卡住的命令会把整个会话卡死;跨后端(Modal SDK、 Daytona SDK)根本没有"常驻 stdin"这种东西;超时后没法干净地只杀一条命令。 spawn-per-call 用"每次重放快照"换来了每条命令天然隔离 + 六种后端能共用一套代码。
BaseEnvironment (ABC) tools/environments/base.py:290
├── execute() :866 ← 唯一的对外入口,模板方法
│ ├── _before_execute() :852 远端后端在这里触发文件同步
│ ├── _prepare_command() :928 sudo 密码注入
│ ├── _wrap_command() :440 ★ 生成那段包装 bash
│ ├── _run_bash() :329 ← 唯一的抽象方法,六个后端各自实现
│ ├── _wait_for_process() :520 select 排水 + 超时 + 中断
│ └── _update_cwd() :810 回读工作目录
└── cleanup() :345 ← 另一个抽象方法
六个后端只需要回答一个问题:怎么把这段 bash 脚本跑起来。 其余全部继承。
5.2 _wrap_command 生成了什么
_wrap_command(tools/environments/base.py:851)把用户命令包成一段多行脚本,结构固定:
source <快照文件> >/dev/null 2>&1 || true # 恢复上一条命令留下的环境变量
builtin cd -- <cwd> || exit 126 # cd 失败用 126 明确区分
eval '<转义后的用户命令>'
__hermes_ec=$? # 先把退出码存住
{ export -p > <tmp>.$BASHPID && mv -f <tmp> <快照>; } || rm -f <tmp> # 原子更新快照
pwd -P > <cwd 文件> # 本地后端读这个
printf '\n__HERMES_CWD_<sid>__%s__HERMES_CWD_<sid>__\n' "$(pwd -P)" # 远端解这个
exit $__hermes_ec
三个细节值得学:
- 临时文件用
mktemp分配,不赌$$也不赌$BASHPID。 注释(tools/environments/base.py:859-866)说得很清楚: 并发的 terminal 调用是以&启动的子 shell,$$在子 shell 里仍是父 shell 的 PID, 两个并发写者会挑到同一个临时名、互相踩,mv反而把撕裂的文件"原子地"发布出去;$BASHPID虽然是子 shell 自己的 PID,但 macOS 自带的 bash 3.2 没有这个变量,展开为空 后所有写者又挤回同一条路上。mktemp跨 bash 版本可移植地为每个写者分配唯一路径(issue #38249)。 source要重定向到/dev/null。 macOS 的 bash 3.2 source 一个含declare -x的文件时会把声明 打到 stdout,每次工具响应白白多 60 行环境变量(issue #15459,tools/environments/base.py:888-894)。cd用builtin cd --。--挡住以连字符开头的目录名被当成选项。
CWD 回读走两条路:本地后端覆写 _update_cwd 去读那个临时文件;远端后端用基类的
_extract_cwd_from_output(tools/environments/base.py:1340)从 stdout 里 rfind 出成对标记,
取出路径后把标记行连同注入的换行一起从输出里剪掉,模型看不到这层机制。
5.3 _wait_for_process:为什么不能简单 for line in proc.stdout
_wait_for_process(tools/environments/base.py:978)是所有后端共用、不允许覆写的。
它用 select() 短轮询而不是阻塞式 readline,注释里给了具体故障(issue #8340):
用户命令里写了 setsid uvicorn ... & disown,这个被后台化的孙子进程 fork 时继承了 stdout 管道的写端。
bash 自己早就退了,管道却因为孙子还攥着而永不 EOF——for line in proc.stdout 于是挂到天荒地老。
改成 select 轮询后,bash 退出不久就停止排水,孙子之后写的东西落到孤儿管道里(内核回收,无害)。
同一个循环还兼了三件事:每 10 秒触发一次 activity_callback,让网关的空闲超时不会误杀长命令
(touch_activity_if_due,tools/environments/base.py:256);每轮检查 is_interrupted();
用增量解码器缓冲跨 chunk 的多字节 UTF-8 字符。try/finally 保证 KeyboardInterrupt / SystemExit
路径也会 _kill_process——否则本地后端(用 os.setsid 开了独立进程组)会留下 PPID=1 的孤儿。
5.4 六种后端对比
选哪个由 TERMINAL_ENV 决定,工厂函数是 _create_environment(tools/terminal_tool.py:1756)。
| 后端 | 命令怎么跑 | 文件系统持久化 | 冷启动 | 主机文件可见性 | 典型场景 |
|---|---|---|---|---|---|
local(local.py:1708) | 直接 Popen 一个 bash,os.setsid 独立进程组 | 就是主机盘 | 无 | 原生 | 个人机上跑代码 agent |
docker(docker.py:852) | docker exec 进常驻容器 | bind mount <sandbox>/docker/<task>/workspace → /workspace(docker.py:983-996) | 秒级(镜像已拉取) | 靠 bind mount,不需要文件同步 | 本机隔离沙箱,最常用 |
singularity(singularity.py:161) | apptainer exec 进 instance | 可写 overlay 目录,跨会话存活(singularity.py:192-205) | 秒级 | bind mount | HPC / 无 root 的集群 |
ssh(tools/environments/ssh.py:46) | ssh … bash -c,ControlMaster 复用连接(tools/environments/ssh.py:94-98,ControlPersist=300) | 远端机器本身 | 首次握手,之后复用 | 需 FileSyncManager | 已有开发机 / GPU 机 |
modal(modal.py:164 直连 / managed_modal.py:36 托管) | Modal SDK Sandbox.create() + exec() | 快照存 ~/.hermes/modal_snapshots.json,跨会话恢复 | 慢,_snapshot_timeout 特意放宽到 60 秒(modal.py:172) | 需 FileSyncManager | 云端弹性算力 |
daytona(daytona.py:30) | Daytona SDK,阻塞调用包进 _ThreadedProcessHandle | 持久沙箱:cleanup 时 stop、下次 resume(daytona.py:93、:262) | 云端冷启动 | 需 FileSyncManager | 云端开发沙箱 |
补充三条:
- 安全基线不一样。 Docker 默认
--cap-drop ALL+--security-opt no-new-privileges+ PID 上限 (_BASE_SECURITY_ARGS,docker.py:343-379);Singularity 用--containall --no-home(singularity.py:202);local 没有任何隔离,全靠上层审批(见 信任边界)。 - stdin 传递方式不一样。 本地/Docker/SSH 走管道;Modal 直连和 Daytona 把 stdin 编成 heredoc 塞进命令串
(
_stdin_mode = "heredoc",modal.py:171/daytona.py:38);托管 Modal 走 payload 字段 (modal_utils.py:69)。基类的_embed_stdin_heredoc(base.py:511)用随机 delimiter 生成 heredoc。 - Modal 有两种模式。
direct用用户自己的 Modal 凭据;managed走 Nous 的托管网关 (tools/managed_tool_gateway.py)。选择逻辑和三种失败提示都写在_create_environment(tools/terminal_tool.py:1847-1899)。
5.5 文件同步:只有三个后端需要
FileSyncManager(tools/environments/file_sync.py:134)的模块文档一句话讲清了边界:
SSH / Modal / Daytona 需要它,"Docker 和 Singularity 用 bind mount(宿主文件系统直通),不需要"
(file_sync.py:4-6)。
它靠 mtime + size 判定变更(_file_mtime_key),检测删除,事务式推送,默认 5 秒同步间隔
(_SYNC_INTERVAL_SECONDS,file_sync.py:42)。触发点是基类的钩子 _before_execute
(base.py:852),远端后端各自覆写它(ssh.py:395、modal.py:400、daytona.py:213)。
同步的是 ~/.hermes 下的技能、记忆等 agent 自带数据,让远端沙箱里的 agent 也能读到它们
(iter_sync_files,file_sync.py:53)。
5.6 环境实例的复用与折叠
环境不是每次调用新建。_active_environments(tools/terminal_tool.py:1091)按 task_id 缓存实例,
_env_lock 串行化创建,超过 TERMINAL_LIFETIME_SECONDS(默认 300)不活跃就回收
(_cleanup_inactive_envs,tools/terminal_tool.py:1948)。
一个反直觉但正确的决定:_resolve_container_task_id(tools/terminal_tool.py:1354)
把子 agent 的 task_id 折叠回 "default"。注释说明了理由——子 agent 有自己的 task_id 是为了让文件状态
跟踪、活跃子 agent 注册表、TUI 事件互不串台;但它们应该共享父 agent 那个长命容器
(一个 bash、一个 /workspace、一套装好的包)。例外是 RL / benchmark 环境注册了 env override 的
task_id,那些才真正拿到独立沙箱。
推论要记住:子 agent 之间是上下文隔离,不是文件系统隔离。
5.7 后台进程
terminal(background=true) 不落到主机,而是穿过同一个 environment 接口跑
(tools/process_registry.py:12-14 模块文档明说这一点)。process_registry 提供 200KB 滚动输出缓冲、
状态轮询、可中断的阻塞等待、kill,以及基于 JSON checkpoint 的崩溃恢复。
schema 里对 background / notify_on_complete / watch_patterns 的描述写得极长
(tools/terminal_tool.py:3874-3902),本质是在用提示词纠正模型的常见误用:
后台任务几乎总该配 notify_on_complete,watch_patterns 只该用于永不退出的长命进程的一次性信号,
且有硬性限流(每进程每 15 秒最多一次通知,连续 3 个窗口丢弃后自动降级为完成通知)。
6. 子 agent:另一个 agent 也是一个工具
delegate_task(tools/delegate_tool.py:3597)把"再开一个 agent"包装成普通工具。
模块文档给了四条隔离承诺(tools/delegate_tool.py:9-17):
父 agent 上下文
│ 只看得见:一次 delegate 调用 + 一段结果摘要
│
├── 子 agent #1 ── 全新对话(无父历史)
│ └ 自己的 task_id → 自己的终端会话 / 文件缓存
│ └ 受限 toolset(黑名单强制剥离)
│ └ 从 goal + context 拼出的聚焦系统提示
├── 子 agent #2 ── 同上,并行
└── 子 agent #N
6.1 黑名单:孩子永远拿不到的六个工具
# tools/delegate_tool.py:45 —— 真实源码
DELEGATE_BLOCKED_TOOLS = frozenset([
"delegate_task", # 不许递归派生
"clarify", # 不许找用户提问
"memory", # 不许写共享的 MEMORY.md
"send_message", # 不许产生跨平台副作用
"execute_code", # 孩子应当一步步推理,而不是写脚本
"cronjob", # 不许以父 agent 名义排新任务
])
工具集的推导规则是交集,永不放大(_build_child_agent,tools/delegate_tool.py:1578):
调用方指定的 toolsets 先和父 agent 的展开集合求交,再过 _strip_blocked_tools
(tools/delegate_tool.py:1278);没指定就继承父 agent 的。
唯一的加法是 role="orchestrator" 时把 delegation 加回来——而这个角色本身受
MAX_DEPTH = 1(tools/delegate_tool.py:129,默认父→子一层,孙子被拒)和一个全局开关双重约束。
6.2 单任务与并行批量
delegate_task 接受 goal(单任务)或 tasks(批量),二选一。批量走
ThreadPoolExecutor(max_workers=max_children),但不用 as_completed()——注释解释得很好
(tools/delegate_tool.py:3938-3944):as_completed 会阻塞到全部完成,一个卡死的子 agent 就能让父 agent
永远等下去。改成 wait() 短超时轮询,父 agent 被中断时收走已完成的、放弃其余。
顶层 agent 的委派默认后台执行(_model_background_value,tools/delegate_tool.py:4867):
模型不选,除非发起者本身就是 orchestrator 子 agent(深度 > 0,它需要在自己这一轮内拿到结果)。
后台生命周期归 tools/async_delegation.py,完成事件推进共享的
process_registry.completion_queue,由 CLI 和网关既有的轮询逻辑捞出来另起一轮
(tools/async_delegation.py:9-23)。这条设计的关键约束写在注释里:
完成结果只会作为新的一轮出现,绝不插在 tool result 和 assistant 消息之间——
保住严格的角色交替和 prompt cache("绝不修改过去的上下文"是硬不变量)。
还有一个易被忽略的线程问题:子 agent 跑在线程池 worker 里,而 CLI 的交互式审批回调存在
threading.local() 里,worker 不继承它,回落到 input() 会和父进程的 prompt_toolkit 抢 stdin 而死锁。
解法是给线程池装 initializer,把非交互回调注入每个 worker 线程;默认是
_subagent_auto_deny(tools/delegate_tool.py:78),显式配置才换成自动批准。
7. execute_code:零上下文成本的编程式工具调用
7.1 要解决的小问题
"下载 20 个 URL,抽出标题,过滤掉 404 的"——用普通工具调用要 20+ 轮,每轮的中间结果都灌进上下文。
execute_code 让模型写一段 Python,脚本里调 Hermes 工具,只有 stdout 回到模型
(tools/code_execution_tool.py:24-25)。多步链条塌缩成一次推理。
7.2 两种传输
本地后端(UDS / Windows 回落到 loopback TCP)
父进程 子进程
├ 生成 hermes_tools.py(UDS 版 stub) ──► import hermes_tools
├ 开 Unix socket + RPC 监听线程 │
└ 起子进程跑脚本 │
▲ │
└──────── 工具调用走 socket 往返 ─────────────┘
只有 stdout 回模型(上限 50KB)
远端后端(Docker / SSH / Modal / Daytona)
父进程 沙箱内
├ 生成 hermes_tools.py(文件 RPC 版 stub)
├ 把脚本 + stub 送进沙箱 ──────► 脚本运行
├ 轮询线程每 100ms 用 env.execute() │ 写 req_N 文件
│ ls 出 req_* → dispatch → 写 resp ◄────────┤ 轮询 resp_N
└ 收 stdout │
对应符号:generate_hermes_tools_module(tools/code_execution_tool.py:431)按 transport 选
_UDS_TRANSPORT_HEADER(:336)或 _FILE_TRANSPORT_HEADER(:400);
远端路径是 _execute_remote(:877)+ _rpc_poll_loop(:735,轮询间隔 100ms)。
7.3 沙箱里只有 7 个工具
# tools/code_execution_tool.py:61 —— 真实源码
SANDBOX_ALLOWED_TOOLS = frozenset([
"web_search", "web_extract", "read_file", "write_file",
"search_files", "patch", "terminal",
])
实际生成的 stub 是这 7 个和本会话已启用工具的交集(tools/code_execution_tool.py:443)。
stub 定义在 _TOOL_STUBS(:213):每项是 (函数名, 签名, docstring, 参数表达式) 四元组,
函数体统一是一行 return _call(name, args)。另外附一小段公共 helper(_COMMON_HELPERS,:296),
里面塞了 json_parse(strict=False,因为终端输出常含裸制表符)和 shell_quote——
这是在用 API 设计预防模型的常见错误。
三道额外约束:环境变量按"先黑名单后白名单"两轮擦洗(_scrub_child_env,:136,
黑名单是 KEY/TOKEN/SECRET/PASSWORD/... 子串);工具调用次数上限 50(DEFAULT_MAX_TOOL_CALLS,:73);
stdout 50KB / stderr 10KB 截断(:74-75)。执行模式有 project(默认,用项目 venv 和会话 cwd)和
strict(临时目录 + sys.executable)两种(_get_execution_mode,:1606)。
8. 外部工具接入
Hermes 的工具边界是双向开放的:既能吃别人的工具,也能把自己变成别人的工具。
| 方向 | 模块 | 做什么 |
|---|---|---|
| 吃外部工具 | tools/mcp_tool.py | MCP 客户端:stdio / StreamableHTTP / SSE 三种传输,发现工具后注册进本地注册表 |
| 外部认证 | tools/mcp_oauth_manager.py | 全进程唯一的 MCP OAuth 状态管理器 |
| 对外提供 | mcp_serve.py | hermes mcp serve:把 Hermes 的消息会话暴露成 10 个 MCP 工具 |
| 托管后端 | tools/managed_tool_gateway.py | Nous Portal 托管的 vendor 直通(托管 Modal 沙箱等) |
| 按需装依赖 | tools/lazy_deps.py | 首次用到某后端时才 pip install |
8.1 MCP 客户端
_register_server_tools(tools/mcp_tool.py:6751)把一台服务器的工具注册成 toolset
mcp-<服务器名>,支持 include / exclude 过滤(include 优先)。注意这里不去改
toolsets.TOOLSETS 这个静态字典,而是让 get_toolset 在运行时从注册表反查
(toolsets.py:696-724)——静态配置和动态发现两条路不互相污染。
MCP 是这套系统里最复杂的单个文件(4911 行),因为它要处理:服务器反向发起的 LLM 采样请求
(SamplingHandler,:856)、要用户补信息的 elicitation(ElicitationHandler,:1253)、
401 后的 OAuth 恢复(_handle_auth_error_and_retry,:2682)、会话过期重连
(_handle_session_expired_and_retry,:2851)、以及 notifications/tools/list_changed 到来时的
"推倒重建"(靠 registry.deregister,tools/registry.py:884)。
一个安全细节先记一笔:MCP 工具的描述文本会过 _scan_mcp_description(tools/mcp_tool.py:862)
做威胁扫描——第三方服务器的工具描述是不可信输入。展开见 信任边界。
8.2 按需装依赖
tools/lazy_deps.py 的 ensure() 在后端首次 import 时检查依赖,缺了就在 venv 内 pip install。
它的安全模型里有一条结构性保证值得抄(tools/lazy_deps.py:27-45):不可变镜像部署时,
lazy install 的目标目录被追加到 sys.path 末尾,绝不前置、绝不导出 PYTHONPATH——
所以懒装的包只能新增可导入模块,永远不能遮蔽、降级或搞坏核心已有的模块。
坏包能造成的最坏后果就是自己 import 失败、报告自己不可用。
9. 文件类工具:改文件时的容错
read_file / write_file / patch / search_files 四个工具都注册在 file 组
(tools/file_tools.py:2809-2812)。它们不直接碰磁盘,而是通过 ShellFileOperations
(tools/file_operations.py:879)把每个文件操作表达成 shell 命令,再交给当前 environment 执行。
这就是为什么 TERMINAL_ENV=docker 时 read_file 读的是容器里的文件——
"所有文件操作都可以表达成 shell 命令,所以我们包一层终端后端的 execute() 接口,得到统一的文件 API"
(tools/file_operations.py:8-9)。_get_file_ops(tools/file_tools.py:1399)复用
terminal_tool 那套 _active_environments 和创建锁,连子 agent 折叠规则都一致。
9.1 模糊匹配:模型给的"旧内容"几乎从不一字不差
patch 的核心难题:模型记忆里的那段代码和磁盘上的往往差几个空格、几个缩进、几个转义。
fuzzy_find_and_replace(tools/fuzzy_match.py:126)用一条降级链解决,命中即停。
下面这九条按 strategies 列表里的真实顺序排(tools/fuzzy_match.py:156-165):
① 精确匹配 _strategy_exact :343
② 逐行去首尾空白 _strategy_line_trimmed :356
③ 空白归一化 _strategy_whitespace_norm… :376
④ 完全忽略缩进 _strategy_indentation_flex… :397
⑤ 转义归一化 \n→换行 _strategy_escape_normalized :413
⑥ 只修剪首尾行 _strategy_trimmed_boundary :432
⑦ Unicode 归一化 _strategy_unicode_normalized :524
⑧ 首尾行锚 + 中间相似度 _strategy_block_anchor :555
⑨ 50% 行相似度阈值 _strategy_context_aware :611
文档与代码不一致,如实记录: 模块 docstring 自称"9 策略链,灵感来自 OpenCode" (
tools/fuzzy_match.py:9-17),但它下面只列了 8 条——漏掉的正是第 ⑦ 条 Unicode 归一化。 上表按代码里实际注册的九个_strategy_*函数列全。
匹配成功后还有两个后处理:_reindent_replacement(:206)按目标位置的实际缩进重排替换文本,
_maybe_unescape_new_string(:271)处理模型把 \n 写成字面量的情况。
除了 old/new 替换,patch 还支持 V4A 补丁格式(codex / cline 那套 *** Begin Patch),
解析器在 tools/patch_parser.py(parse_v4a_patch,:69),支持 Update / Add / Delete / Move 四种操作。
9.2 并发写冲突:file_state
多个子 agent 在同一台机器上并行改文件,会出现这种情况:A 读了 foo.py,B 改了 foo.py,
A 拿着旧内容写回去——B 的改动没了。tools/file_state.py 的 FileStateRegistry 用三个钩子拦住:
| 钩子 | 谁调 | 干什么 |
|---|---|---|
record_read(task_id, path, partial=) | read_file | 记下"某个 agent 在什么时间、以什么 mtime 读过它" |
check_stale(task_id, path) | write_file / patch 之前 | 发现读之后有别人写过就拒绝 |
note_write(task_id, path) | 写入之后 | 更新全局最后写者 |
外加 lock_path(path) 提供每路径锁,把读→改→写整段包住。
可用 HERMES_DISABLE_FILE_STATE_GUARD=1 整体关掉(tools/file_state.py:27)。
模块文档明确了它和单 agent 路径重叠检查的分工:后者管同一个 agent 的并行批次
(那道闸门见 一轮对话是怎么跑完的 的并发闸门一节),
前者管跨 agent 的这种情况(tools/file_state.py:3-7)。
10. 巧妙之处(可以直接借鉴的)
- 可用性探测的"抖动宽限"。 不是简单 TTL 缓存,而是"距上次成功很近的失败不算数,且不缓存"。
这个模式适用于任何"探测失败会导致能力静默消失"的场景(
tools/registry.py:395-408)。 - 动态 schema 覆写钩子。 工具描述里凡是引用运行时配置的部分(并发上限、可用的沙箱工具),
一律用零参回调在装配时算,而不是启动时定死(
ToolEntry.dynamic_schema_overrides,tools/registry.py:226-233)。 mktemp而不是$$/$BASHPID。 并发子 shell 里做原子文件替换的正确写法,值得单独记住 ——macOS bash 3.2 连$BASHPID都没有(tools/environments/base.py:859-866)。- select 排水而不是阻塞 readline。 后台孙子进程继承管道写端导致永不 EOF,是所有"跑 shell 的 agent"
都会撞上的坑(
tools/environments/base.py:1037-1052)。 - 禁用平台包只减增量。 组合式配置里,"整包相减"几乎总是错的
(
bundle_non_core_tools,toolsets.py:728)。 - 懒装依赖只追加
sys.path末尾。 用路径顺序换来"坏包最多自己不可用"的结构性保证 (tools/lazy_deps.py:27-45)。 - 异步子 agent 复用已有的完成队列。 不新开轮询循环,结果只以"新一轮"的身份进上下文,
保住角色交替和 prompt cache(
tools/async_delegation.py:9-23)。 - 给沙箱 stub 附赠防坑 helper。
json_parse(strict=False)和shell_quote直接内建, 用 API 形状消灭模型的高频错误(tools/code_execution_tool.py:474-489)。
11. 边界与局限(诚实清单)
- 子 agent 不是文件系统隔离。 task_id 默认折叠回
"default",孩子和父亲共用同一个容器与/workspace(tools/terminal_tool.py:1354-1366)。要真隔离得走 RL/benchmark 的 env override 路径。 - 后端刚宕机的约 60 秒内,工具仍会被广告给模型。 这是 3.3 节抖动宽限的自觉代价。
execute_code依赖沙箱内有 Python 3。 模块文档写明远端执行额外要求终端后端里有 python3 (tools/code_execution_tool.py:28);Windows 上 AF_UNIX 不可靠,回落到 loopback TCP。- 默认不允许递归委派。
MAX_DEPTH = 1(tools/delegate_tool.py:129),孙 agent 被拒;orchestrator角色要同时满足全局开关和深度上限才生效。 - 子 agent 里的危险命令默认自动拒绝(
_subagent_auto_deny,tools/delegate_tool.py:78), 不是自动批准。这意味着某些需要审批 的任务在子 agent 里会直接失败而不是等人。 - PTY 模式只有 local 和 SSH 支持(terminal schema 的
pty字段,tools/terminal_tool.py:3888)。 _AGENT_LOOP_TOOLS那四个工具走不通注册表分发(model_tools.py:743), 它们必须由主循环处理,直接 dispatch 会拿到错误。
12. 相关章节
| 想知道 | 去哪章 |
|---|---|
| 工具调用在一轮对话里的位置、并行批次怎么决定 | 01 一轮对话是怎么跑完的 |
| 工具 schema 占多少 token、Tool Search 怎么省上下文 | 02 上下文工程 |
| 技能与记忆怎么变成工具能读的东西 | 03 自我进化闭环 |
| 网关、定时任务怎么调用这一层 | 05 到处都能找到它 |
| 危险命令审批、提示注入、凭据隔离的细节 | 06 信任边界 |
| 全局架构 | 总览 |
13. 代码地图(导航索引)
| 主题 | 文件路径 | 关键符号 |
|---|---|---|
| 工具注册表单例 | tools/registry.py | ToolRegistry、registry、ToolEntry |
| AST 扫描发现工具模块 | tools/registry.py | discover_builtin_tools、_is_registry_register_call、_module_registers_tools |
| 可用性探测缓存 | tools/registry.py | _check_fn_cached、_CHECK_FN_TTL_SECONDS、_CHECK_FN_FAILURE_GRACE_SECONDS、invalidate_check_fn_cache |
| 工具结果协议 | tools/registry.py | tool_error、tool_result |
| 工具组定义与组合 | toolsets.py | TOOLSETS、_HERMES_CORE_TOOLS、resolve_toolset、bundle_non_core_tools、get_toolset |
| 定义装配与缓存 | model_tools.py | get_tool_definitions、_compute_tool_definitions、_TOOL_DEFS_CACHE_MAX |
| 统一分发 | model_tools.py | handle_function_call、_AGENT_LOOP_TOOLS、_emit_post_tool_call_hook |
| 参数矫正与错误清洗 | model_tools.py | coerce_tool_args、_coerce_value、_sanitize_tool_error |
| 执行环境抽象基类 | tools/environments/base.py | BaseEnvironment、execute、init_session、_wrap_command、_wait_for_process、_extract_cwd_from_output、_before_execute |
| 本地后端 | tools/environments/local.py | LocalEnvironment、_msys_to_windows_path |
| Docker 后端 | tools/environments/docker.py | DockerEnvironment、_BASE_SECURITY_ARGS、find_docker |
| Singularity 后端 | tools/environments/singularity.py | SingularityEnvironment、_find_singularity_executable |
| SSH 后端 | tools/environments/ssh.py | SSHEnvironment、_ensure_ssh_available |
| Modal 后端(直连 / 托管) | tools/environments/modal.py、managed_modal.py、modal_utils.py | ModalEnvironment、ManagedModalEnvironment、BaseModalExecutionEnvironment |
| Daytona 后端 | tools/environments/daytona.py | DaytonaEnvironment |
| 远端文件同步 | tools/environments/file_sync.py | FileSyncManager、iter_sync_files、_SYNC_INTERVAL_SECONDS |
| 终端工具与后端工厂 | tools/terminal_tool.py | terminal_tool、_create_environment、check_terminal_requirements、_active_environments、_resolve_container_task_id、_cleanup_inactive_envs |
| 后台进程管理 | tools/process_registry.py | process_registry、spawn、poll、wait、completion_queue |
| 子 agent 委派 | tools/delegate_tool.py | delegate_task、DELEGATE_BLOCKED_TOOLS、_build_child_agent、_run_single_child、_strip_blocked_tools、MAX_DEPTH |
| 后台子 agent 生命周期 | tools/async_delegation.py | dispatch_async_delegation、dispatch_async_delegation_batch、_push_completion_event |
| 编程式工具调用 | tools/code_execution_tool.py | execute_code、SANDBOX_ALLOWED_TOOLS、generate_hermes_tools_module、_TOOL_STUBS、_execute_remote、_rpc_poll_loop、_scrub_child_env |
| MCP 客户端 | tools/mcp_tool.py | MCPServerTask、_register_server_tools、discover_mcp_tools、SamplingHandler、ElicitationHandler |
| MCP OAuth | tools/mcp_oauth_manager.py | get_manager |
| Hermes 作为 MCP 服务端 | mcp_serve.py | 模块级 stdio server(conversations_list 等 10 个工具) |
| 托管工具网关 | tools/managed_tool_gateway.py | resolve_managed_tool_gateway、ManagedToolGatewayConfig |
| 懒加载依赖 | tools/lazy_deps.py | ensure、FeatureUnavailable |
| 文件工具 | tools/file_tools.py | _get_file_ops、_handle_read_file、_handle_write_file、_handle_patch、_handle_search_files |
| 文件操作的 shell 封装 | tools/file_operations.py | ShellFileOperations、patch_replace、patch_v4a、normalize_read_pagination |
| 跨 agent 文件状态 | tools/file_state.py | FileStateRegistry、record_read、check_stale、note_write、lock_path |
| V4A 补丁解析 | tools/patch_parser.py | parse_v4a_patch、apply_v4a_operations |
| 模糊匹配降级链 | tools/fuzzy_match.py | fuzzy_find_and_replace、_strategy_exact … _strategy_context_aware、_reindent_replacement |
引用说明: 本文所有 path:line 相对克隆根 aiRef/repos/hermes-agent/,as-of sourceCommit: 9098f6777b93b7881216a9b7d8fb402899f62400。行号会随上游漂移,符号名通常不会——定位时优先 grep 符号。