跳到主要内容

数据截至 (上游 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.pyTOOLSETS(toolsets.py:107)是一张大字典,每项两个字段:tools(直接工具名) 和 includes(引用别的 toolset)。分三层:

  • 原子组 —— webfileterminalbrowserskillsdelegation(toolsets.py:299)、code_execution(toolsets.py:293)……
  • 平台包 —— hermes-cli(toolsets.py:499)、hermes-telegramhermes-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)做了两件事:

  1. 30 秒 TTL(_CHECK_FN_TTL_SECONDS,tools/registry.py:269)——外部状态按人的时间尺度变化, 30 秒既省了探测又让 hermes tools enable 这种改动一两轮内生效。
  2. 失败宽限 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)是所有工具调用的唯一入口。按顺序它做这些事:

  1. 参数类型矫正 coerce_tool_args(model_tools.py:797)。
  2. Tool Search 桥接:tool_search / tool_describe 是纯目录读;tool_call 会解出底层工具名后 递归调用自己,这样所有钩子看到的都是真实工具名,桥对钩子完全透明(model_tools.py:1330-1347)。 桥还有一道纵深防御:底层工具必须在本会话作用域内的可延迟目录里,否则拒绝——防止受限会话 (子 agent、kanban worker)通过桥拿到全进程注册表(model_tools.py:1309-1329)。 工具为什么会被"收起来",见 上下文工程 的延迟披露一节。
  3. 中间件与钩子:pre_tool_call 插件可返回阻断消息;ACP/Zed 的编辑审批在任何文件改动前跑; 执行包在 run_tool_execution_middleware 里;结束后 _emit_post_tool_call_hook(model_tools.py:1136) 带 duration_ms 上报。审批与危险命令拦截的实质内容见 信任边界
  4. 真正 dispatchregistry.dispatch
  5. 结果变换 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)。
  • cdbuiltin 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 mountHPC / 无 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:395modal.py:400daytona.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.pyMCP 客户端:stdio / StreamableHTTP / SSE 三种传输,发现工具后注册进本地注册表
外部认证tools/mcp_oauth_manager.py全进程唯一的 MCP OAuth 状态管理器
对外提供mcp_serve.pyhermes mcp serve:把 Hermes 的消息会话暴露成 10 个 MCP 工具
托管后端tools/managed_tool_gateway.pyNous 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.pyensure() 在后端首次 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=dockerread_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.pyFileStateRegistry 用三个钩子拦住:

钩子谁调干什么
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. 巧妙之处(可以直接借鉴的)

  1. 可用性探测的"抖动宽限"。 不是简单 TTL 缓存,而是"距上次成功很近的失败不算数,且不缓存"。 这个模式适用于任何"探测失败会导致能力静默消失"的场景(tools/registry.py:395-408)。
  2. 动态 schema 覆写钩子。 工具描述里凡是引用运行时配置的部分(并发上限、可用的沙箱工具), 一律用零参回调在装配时算,而不是启动时定死(ToolEntry.dynamic_schema_overrides,tools/registry.py:226-233)。
  3. mktemp 而不是 $$/$BASHPID 并发子 shell 里做原子文件替换的正确写法,值得单独记住 ——macOS bash 3.2 连 $BASHPID 都没有(tools/environments/base.py:859-866)。
  4. select 排水而不是阻塞 readline。 后台孙子进程继承管道写端导致永不 EOF,是所有"跑 shell 的 agent" 都会撞上的坑(tools/environments/base.py:1037-1052)。
  5. 禁用平台包只减增量。 组合式配置里,"整包相减"几乎总是错的 (bundle_non_core_tools,toolsets.py:728)。
  6. 懒装依赖只追加 sys.path 末尾。 用路径顺序换来"坏包最多自己不可用"的结构性保证 (tools/lazy_deps.py:27-45)。
  7. 异步子 agent 复用已有的完成队列。 不新开轮询循环,结果只以"新一轮"的身份进上下文, 保住角色交替和 prompt cache(tools/async_delegation.py:9-23)。
  8. 给沙箱 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.pyToolRegistryregistryToolEntry
AST 扫描发现工具模块tools/registry.pydiscover_builtin_tools_is_registry_register_call_module_registers_tools
可用性探测缓存tools/registry.py_check_fn_cached_CHECK_FN_TTL_SECONDS_CHECK_FN_FAILURE_GRACE_SECONDSinvalidate_check_fn_cache
工具结果协议tools/registry.pytool_errortool_result
工具组定义与组合toolsets.pyTOOLSETS_HERMES_CORE_TOOLSresolve_toolsetbundle_non_core_toolsget_toolset
定义装配与缓存model_tools.pyget_tool_definitions_compute_tool_definitions_TOOL_DEFS_CACHE_MAX
统一分发model_tools.pyhandle_function_call_AGENT_LOOP_TOOLS_emit_post_tool_call_hook
参数矫正与错误清洗model_tools.pycoerce_tool_args_coerce_value_sanitize_tool_error
执行环境抽象基类tools/environments/base.pyBaseEnvironmentexecuteinit_session_wrap_command_wait_for_process_extract_cwd_from_output_before_execute
本地后端tools/environments/local.pyLocalEnvironment_msys_to_windows_path
Docker 后端tools/environments/docker.pyDockerEnvironment_BASE_SECURITY_ARGSfind_docker
Singularity 后端tools/environments/singularity.pySingularityEnvironment_find_singularity_executable
SSH 后端tools/environments/ssh.pySSHEnvironment_ensure_ssh_available
Modal 后端(直连 / 托管)tools/environments/modal.pymanaged_modal.pymodal_utils.pyModalEnvironmentManagedModalEnvironmentBaseModalExecutionEnvironment
Daytona 后端tools/environments/daytona.pyDaytonaEnvironment
远端文件同步tools/environments/file_sync.pyFileSyncManageriter_sync_files_SYNC_INTERVAL_SECONDS
终端工具与后端工厂tools/terminal_tool.pyterminal_tool_create_environmentcheck_terminal_requirements_active_environments_resolve_container_task_id_cleanup_inactive_envs
后台进程管理tools/process_registry.pyprocess_registryspawnpollwaitcompletion_queue
子 agent 委派tools/delegate_tool.pydelegate_taskDELEGATE_BLOCKED_TOOLS_build_child_agent_run_single_child_strip_blocked_toolsMAX_DEPTH
后台子 agent 生命周期tools/async_delegation.pydispatch_async_delegationdispatch_async_delegation_batch_push_completion_event
编程式工具调用tools/code_execution_tool.pyexecute_codeSANDBOX_ALLOWED_TOOLSgenerate_hermes_tools_module_TOOL_STUBS_execute_remote_rpc_poll_loop_scrub_child_env
MCP 客户端tools/mcp_tool.pyMCPServerTask_register_server_toolsdiscover_mcp_toolsSamplingHandlerElicitationHandler
MCP OAuthtools/mcp_oauth_manager.pyget_manager
Hermes 作为 MCP 服务端mcp_serve.py模块级 stdio server(conversations_list 等 10 个工具)
托管工具网关tools/managed_tool_gateway.pyresolve_managed_tool_gatewayManagedToolGatewayConfig
懒加载依赖tools/lazy_deps.pyensureFeatureUnavailable
文件工具tools/file_tools.py_get_file_ops_handle_read_file_handle_write_file_handle_patch_handle_search_files
文件操作的 shell 封装tools/file_operations.pyShellFileOperationspatch_replacepatch_v4anormalize_read_pagination
跨 agent 文件状态tools/file_state.pyFileStateRegistryrecord_readcheck_stalenote_writelock_path
V4A 补丁解析tools/patch_parser.pyparse_v4a_patchapply_v4a_operations
模糊匹配降级链tools/fuzzy_match.pyfuzzy_find_and_replace_strategy_exact_strategy_context_aware_reindent_replacement

引用说明: 本文所有 path:line 相对克隆根 aiRef/repos/hermes-agent/,as-of sourceCommit: 9098f6777b93b7881216a9b7d8fb402899f62400。行号会随上游漂移,符号名通常不会——定位时优先 grep 符号。