Hermes Agent 源码专题【左扬精讲】—— 6 种终端后端架构
Hermes Agent 源码专题【左扬精讲】—— 6 种终端后端架构
这是 Hermes Agent 源码专题【左扬精讲】系列 第 7 篇。
本篇是 Layer 2 工具系统的执行后端专题 ── 当模型调一次 terminal 时,Hermes 是怎么把这条命令送到 Docker 容器 / SSH 远端 / Modal 沙箱 / Singularity 实例 / Daytona 云沙箱去执行的。
第 5、6 篇讲了工具如何注册、如何编排到模型。本篇切到执行层:Hermes 把 6 种后端(外加 ManagedModal 一类变体)放在一个统一的 BaseEnvironment ABC 后面,所有上层调用(terminal_tool.execute()、gateway 转发、batch_runner 并发跑)拿到的都是同一个 execute() 接口 ── 不需要知道命令是落在本机 bash 还是 Modal 远程沙箱里。
Layer 框架视角:先看 分层(BaseEnvironment 是窄腰,7 个子类是各后端的实现差异),再看 路由(_create_environment() 怎么选后端),最后看 边界(bind-mount vs copy-sync vs SDK 适配)。
Layer 2 ─ 终端执行层(tools/environments/)
════════════════════════════════════════════════════════════
Layer 2 ─ 工具系统入口(tools/terminal_tool.py)
_create_environment(env_type) ← 工厂,按 TERMINAL_ENV 选后端
terminal.execute() / spawn_via_env() ← 调用方,全部走统一接口
Layer 2 ─ 统一接口(tools/environments/base.py)
BaseEnvironment ABC ← 窄腰:execute() / init_session()
ProcessHandle Protocol ← 返回值类型契约
_ThreadedProcessHandle ← SDK 后端(Modal/Daytona)的适配器
Layer 3 ─ 各后端实现(tools/environments/*.py)
local.py LocalEnvironment ← os.setsid + 文件式 cwd
docker.py DockerEnvironment ← docker exec + bind mount
ssh.py SSHEnvironment ← ControlMaster 复用连接 + scp 同步
singularity.py SingularityEnvironment ← self.executable instance + overlay
modal.py ModalEnvironment ← Modal SDK + sandbox.terminate
managed_modal.py ManagedModalEnvironment ← 走 Nous Tool Gateway(中间类 mode)
modal_utils.py BaseModalExecutionEnvironment ← 中间抽象
daytona.py DaytonaEnvironment ← Daytona SDK + sandbox.stop()
file_sync.py FileSyncManager ← mtime 检测 + 批量 SCP 上传
Layer 2 BaseEnvironment terminal 后端 spawn-per-call session snapshot CWD marker ProcessHandle
本篇学习重点
必须掌握
- 理解 tools/environments/base.py 中的 BaseEnvironment ABC 是怎么定义 execute() 契约的
- 理解 init_session() 怎么用 bash -l 一次性快照出登录 shell 的 env vars
- 理解 _wait_for_process() 怎么统一处理 interrupt / timeout / output drain
- 理解 CWD 跟踪的两种模式:本地用 /tmp/hermes-cwd-*.txt 文件,远程用 stdout 中的 __HERMES_CWD_<id>__ 标记
需要了解
- SSH 后端用 ControlMaster 复用单连接,省掉每个命令的 TCP 握手
- Docker / Singularity 走 bind-mount,无须文件同步;SSH / Modal / Daytona 走 FileSyncManager
- Modal 有两个 mode:direct(本地 token 调 SDK)与 managed(走 Nous Tool Gateway)
一、BaseEnvironment 窄腰:6 个后端共用一套契约
Layer 视角 ─ 窄腰为什么是 ABC?
Hermes 的执行层有 6 种后端(local / docker / ssh / singularity / modal / daytona),外加 managed_modal.py 中的 ManagedModalEnvironment 作为 modal 的 mode 变体(都继承自 modal_utils.py 中的 BaseModalExecutionEnvironment 中间类)。如果每种都自己定义 execute() 签名,调用方就要写 if env_type == "local": local_env.run(...) elif env_type == "docker": docker_env.exec(...) ── 增加一条后端就要改所有调用点。
解决:tools/environments/base.py 第 288 行的 BaseEnvironment(ABC) 把所有后端的共同点提到基类(execute() / init_session() / _wait_for_process() / CWD tracking),只把差异点留作 abstract method(_run_bash())。调用方认 ABC 不认具体类。tools/environments/__init__.py 的模块 docstring 也明确写出这套设计:
- 每条后端实现的都是同一个接口(BaseEnvironment)
- 所有路径在 shell 这一层意义上都是"在某个进程里跑 bash -c <命令>" ── 所以基类的统一方法才合算
这一层(Layer 2.5)的关键约束:后端可以加,但不能改 ABC 签名。任何对 execute() 行为的修改都得在子类里做 hook,不能改基类的语义。
1.1 BaseEnvironment 的真正契约
看 tools/environments/base.py 第 288 行的 class BaseEnvironment(ABC):。两个抽象方法 + 三个共享字段 + 一组被所有子类复用的实现方法(execute() / init_session() / _wait_for_process())。
源码视角 ─ ABC + 共享字段
class BaseEnvironment(ABC):
"""Common interface and unified execution flow for all Hermes backends.
Subclasses implement ``_run_bash()`` and ``cleanup()``. The base class
provides ``execute()`` with session snapshot sourcing, CWD tracking,
interrupt handling, and timeout enforcement.
"""
# Subclasses that embed stdin as a heredoc (Modal, Daytona) set this.
_stdin_mode: str = "pipe" # "pipe" or "heredoc"
# Snapshot creation timeout (override for slow cold-starts).
_snapshot_timeout: int = 30
def get_temp_dir(self) -> str:
"""Return the backend temp directory used for session artifacts.
Most sandboxed backends use ``/tmp`` inside the target environment.
LocalEnvironment overrides this on platforms like Termux where ``/tmp``
may be missing and ``TMPDIR`` is the portable writable location.
"""
return "/tmp"
def __init__(self, cwd: str, timeout: int, env: dict = None):
self.cwd = cwd
self.timeout = timeout
self.env = env or {}
self._session_id = uuid.uuid4().hex[:12]
temp_dir = self.get_temp_dir().rstrip("/") or "/"
self._snapshot_path = f"{temp_dir}/hermes-snap-{self._session_id}.sh"
self._cwd_file = f"{temp_dir}/hermes-cwd-{self._session_id}.txt"
self._cwd_marker = _cwd_marker(self._session_id)
self._snapshot_ready = False
几个关键设计点:
- _stdin_mode = "heredoc" 把 stdin 数据嵌入到 bash 命令字符串尾部的 heredoc(Modal / Daytona 用,因为它们的 SDK 是"一次塞整段命令"),默认 "pipe" 用 _pipe_stdin() 异步写管。
- _snapshot_timeout 默认 30s,ModalEnvironment 覆写成 60s(Modal 冷启动慢)。
- _snapshot_path / _cwd_file 是同一会话的两个临时文件 ── 走 get_temp_dir() 而不是硬编码 /tmp。LocalEnvironment 在 Windows 下覆写返回 HERMES_HOME 下的子目录(Windows 没有 /tmp 且 %TEMP% 含空格,无法被 bash 单引号安全引用)。
1.2 _run_bash 与 cleanup 是后端仅存的实现细节
看 BaseEnvironment 的两个 abstract method:
def _run_bash(
self,
cmd_string: str,
*,
login: bool = False,
timeout: int = 120,
stdin_data: str | None = None,
) -> ProcessHandle:
"""Spawn a bash process to run *cmd_string*.
Returns a ProcessHandle (subprocess.Popen or _ThreadedProcessHandle).
Must be overridden by every backend.
"""
raise NotImplementedError(f"{type(self).__name__} must implement _run_bash()")
@abstractmethod
def cleanup(self):
"""Release backend resources (container, instance, connection)."""
...
这两个方法就是窄腰留给后端的全部"差异点"。后端要做的事只是:
- 怎么起一个进程(_run_bash())── 本地 spawn bash、SSH spawn ssh、Docker 执行 docker exec 命令、Modal / Daytona 调 SDK
- 怎么释放(cleanup())── 删容器 / 杀 SSH control socket / stop sandbox
其余所有事情 ── 命令包装、env snapshot、CWD 持久化、interrupt 检查、timeout 强制、output drain ── 都在基类。后端作者的工作量从"几百行 + 五六个文件"压到"一个方法 + 一个资源释放"。
源码视角 ─ ProcessHandle 协议:后端返回值的统一类型
6 个后端都要在 _run_bash() 里返回一个 ProcessHandle。原生 subprocess.Popen 已经满足要求;SDK 后端(Modal / Daytona)没有真子进程,就用 _ThreadedProcessHandle 适配器把同步阻塞 SDK 调用包成符合协议的对象。协议定义在 base.py 第 187 行:
class ProcessHandle(Protocol):
"""Duck type that every backend's _run_bash() must return.
subprocess.Popen satisfies this natively. SDK backends (Modal, Daytona)
return _ThreadedProcessHandle which adapts their blocking calls.
"""
def poll(self) -> int | None: ...
def kill(self) -> None: ...
def wait(self, timeout: float | None = None) -> int: ...
@property
def stdout(self) -> IO[str] | None: ...
@property
def returncode(self) -> int | None: ...
- 本地 / SSH / Docker / Singularity:直接返回 subprocess.Popen,天然满足
- Modal / Daytona:返回 _ThreadedProcessHandle(在 base.py 第 205 行)── 内部起了个 daemon 线程跑阻塞 SDK 调用、通过 os.pipe() 把输出推到文件描述符供 _drain 线程读
基类的 _wait_for_process()(第 483 行起)只用这 5 个属性(poll / stdout / returncode / kill / wait),所以基类不需要为某一种后端写特殊分支。
本节小结
- BaseEnvironment ABC 把 6 个后端的共同点提到基类(execute() / init_session() / _wait_for_process() / CWD tracking)
- 每个后端只需要实现两个 abstract method:_run_bash() 和 cleanup()
- 返回值统一为 ProcessHandle 协议(subprocess.Popen 直接满足;SDK 后端用 _ThreadedProcessHandle 适配)
- 调用方认 ABC 不认具体类 ── 下一章讲 _create_environment() 工厂怎么按 TERMINAL_ENV 选具体类
二、_create_environment 工厂:按 TERMINAL_ENV 路由
源码视角 ─ 工厂导入 + 6 个分支的形态
看 tools/terminal_tool.py 的导入段(第 826-831 行)和工厂函数 _create_environment()(第 1225 行起):
from tools.environments.local import LocalEnvironment as _LocalEnvironment
from tools.environments.singularity import SingularityEnvironment as _SingularityEnvironment
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
from tools.environments.docker import DockerEnvironment as _DockerEnvironment
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment
所有后端都是延迟导入(lazy import),只有用户真的选了某个 TERMINAL_ENV 时才需要那个 SDK:
- local / docker / ssh / singularity / modal / managed_modal:顶层 import(terminal_tool.py 第 826-831 行)
- daytona:在工厂分支内才 from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment(第 1349 行)── 默认用户不需装 daytona SDK
2.1 工厂函数签名
看 tools/terminal_tool.py 第 1225 行:
def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
ssh_config: dict = None, container_config: dict = None,
local_config: dict = None,
task_id: str = "default",
host_cwd: str = None):
"""
Create an execution environment for sandboxed command execution.
Args:
env_type: One of "local", "docker", "singularity", "modal",
"daytona", "ssh"
image: Docker/Singularity/Modal image name (ignored for local/ssh)
cwd: Working directory
timeout: Default command timeout
ssh_config: SSH connection config (for env_type="ssh")
container_config: Resource config for container backends (cpu, memory, disk, persistent)
task_id: Task identifier for environment reuse and snapshot keying
host_cwd: Optional host working directory to bind into Docker when explicitly enabled
"""
2.2 6 个分支:本地 / 容器 / 远端 / 云沙箱
看工厂的 if/elif 链(节选真实分支):
if env_type == "local":
return _LocalEnvironment(cwd=cwd, timeout=timeout)
elif env_type == "docker":
# One-shot orphan reaper: clean up labeled containers left behind by
# prior Hermes processes that hit SIGKILL / OOM / a closed terminal
# before the atexit cleanup hook could run.
_maybe_reap_docker_orphans(cc)
return _DockerEnvironment(
image=image, cwd=cwd, timeout=timeout,
cpu=cpu, memory=memory, disk=disk,
persistent_filesystem=persistent, task_id=task_id,
volumes=volumes,
host_cwd=host_cwd,
auto_mount_cwd=cc.get("docker_mount_cwd_to_workspace", False),
forward_env=docker_forward_env,
env=docker_env,
run_as_host_user=cc.get("docker_run_as_host_user", False),
extra_args=docker_extra_args,
persist_across_processes=cc.get("docker_persist_across_processes", True),
)
elif env_type == "singularity":
return _SingularityEnvironment(
image=image, cwd=cwd, timeout=timeout,
cpu=cpu, memory=memory, disk=disk,
persistent_filesystem=persistent, task_id=task_id,
)
elif env_type == "modal":
# ... (Modal 有 direct vs managed 两条子分支,见 2.3)
...
elif env_type == "daytona":
from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment
return _DaytonaEnvironment(
image=image, cwd=cwd, timeout=timeout,
cpu=int(cpu), memory=memory, disk=disk,
persistent_filesystem=persistent, task_id=task_id,
)
elif env_type == "ssh":
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
return _SSHEnvironment(
host=ssh_config["host"],
user=ssh_config["user"],
port=ssh_config.get("port", 22),
key_path=ssh_config.get("key", ""),
cwd=cwd,
timeout=timeout,
)
else:
raise ValueError(
f"Unknown environment type: {env_type}. Use 'local', 'docker', "
f"'singularity', 'modal', 'daytona', or 'ssh'"
)
- local ── 最简单:直接 _LocalEnvironment(cwd, timeout)
- docker ── 先调 _maybe_reap_docker_orphans(cc) 清理孤儿容器(一次性,进程级缓存),然后实例化
- modal ── 唯一在工厂内部做进一步路由的环境(见 2.3)
- daytona ── 唯一用 lazy import 的环境(避免没装 SDK 时 import 失败)
- ssh ── 唯一做必填参数校验的环境(host 和 user 缺一不可)
- unknown ── 走到 else,主动抛 ValueError,把合法列表写在错误消息里(与 check_terminal_requirements() 第 2591 行的错误一致)
2.3 Modal 的 direct / managed 双模式路由
tools/environments/modal.py 的 ModalEnvironment(第 164 行)直接调 Modal SDK;tools/environments/managed_modal.py 的 ManagedModalEnvironment(第 36 行)继承中间类 BaseModalExecutionEnvironment(modal_utils.py 第 58 行),而后者又继承 BaseEnvironment ── 改写了 execute(),因为 managed 模式下服务端已经处理了 CWD / env snapshot,基类的包装逻辑不适用。
源码视角 ─ Modal 在工厂里的 mode 路由
elif env_type == "modal":
sandbox_kwargs = {}
if cpu > 0:
sandbox_kwargs["cpu"] = cpu
if memory > 0:
sandbox_kwargs["memory"] = memory
if disk > 0:
try:
import inspect, modal
if "ephemeral_disk" in inspect.signature(modal.Sandbox.create).parameters:
sandbox_kwargs["ephemeral_disk"] = disk
except Exception:
pass
modal_state = _get_modal_backend_state(cc.get("modal_mode"))
if modal_state["selected_backend"] == "managed":
return _ManagedModalEnvironment(
image=image, cwd=cwd, timeout=timeout,
modal_sandbox_kwargs=sandbox_kwargs,
persistent_filesystem=persistent, task_id=task_id,
)
if modal_state["selected_backend"] != "direct":
# ...raise ValueError with detailed missing-credentials error...
raise ValueError(message)
return _ModalEnvironment(
image=image, cwd=cwd, timeout=timeout,
modal_sandbox_kwargs=sandbox_kwargs,
persistent_filesystem=persistent, task_id=task_id,
)
关键设计:
- _get_modal_backend_state(cc.get("modal_mode")) 决定走哪条分支,selected_backend 是 "managed" / "direct" / None(无法识别)。
- managed ── _ManagedModalEnvironment,命令走 Nous Tool Gateway,不需要本地 Modal token
- direct ── _ModalEnvironment,本地 SDK 调 Modal API(需 MODAL_TOKEN_ID / MODAL_TOKEN_SECRET)
- 无 mode + 无 credentials ── 抛详细错误,错误消息里同时给出 "managed 没通" 和 "direct 没凭证" 两条修复路径
- ephemeral_disk 这个键在 Modal SDK 旧版可能不存在 ── 用 inspect.signature(modal.Sandbox.create).parameters 检查
本节小结
- _create_environment() 在 terminal_tool.py 第 1225 行,集中处理"按 TERMINAL_ENV 选哪个后端类"的路由
- 5 个本地容器类(local/docker/ssh/singularity/modal/managed_modal)顶层 import;daytona 延迟 import
- Modal 是唯一双 mode 的后端 ── direct vs managed 在工厂内按 _get_modal_backend_state() 结果分支
- 所有 6 个分支返回的都是同一个 BaseEnvironment 子类,调用方不需要 if/elif
三、spawn-per-call 统一执行流:snapshot + wrap + wait
Layer 视角 ─ 所有后端共用的执行骨架
BaseEnvironment.execute()(base.py 第 829 行)定义了所有后端的统一执行流。它把一次命令的完整生命周期切成 5 步:
- _before_execute() ── 远程后端 hook 触发文件同步
- _prepare_command() ── 处理 sudo(来自 terminal_tool.py 的 _transform_sudo_command())
- _rewrite_compound_background() ── 防 A && B & 子 shell 等待陷阱
- 命令拼包 + _run_bash() ── 由子类实现
- _wait_for_process() ── interrupt / timeout / output drain 统一处理
spawn-per-call 意味着每条命令都 spawn 一个新进程 ── 这看起来很贵,但换来所有跨命令的状态"挂在一个快照文件里",避免每次重新加载 ~/.bashrc(慢 200~500ms)或丢失 env var。
3.1 init_session ── 一次性的 env snapshot
源码视角 ─ init_session 的 bootstrap 脚本
看 base.py 第 351 行 init_session():它在子类构造时被调一次(LocalEnvironment.__init__ 第 499 行就调;远程后端用同样的模式),把 bash -l 的 login shell 输出 dump 到 /tmp/hermes-snap-<session>.sh。后续每条命令都 source 这个文件,不再重新跑 ~/.bashrc:
def init_session(self):
"""Capture login shell environment into a snapshot file."""
# Full capture: env vars, functions (filtered), aliases, shell options.
# Restore configured cwd after login shell profile scripts, which may
# change the working directory (e.g. bashrc `cd ~`). Without this,
# pwd -P captures the profile's directory, not terminal.cwd.
_quoted_cwd = shlex.quote(self.cwd)
# Quote the snapshot / cwd-file paths so Git Bash on Windows handles
# ``C:/Users/...``-shaped paths without glob-splitting the colon or
# tripping on drive letters.
_quoted_snap = shlex.quote(self._snapshot_path)
_quoted_cwd_file = shlex.quote(self._cwd_file)
bootstrap = (
f"export -p > {_quoted_snap}\n"
f"declare -f | grep -vE '^_[^_]' >> {_quoted_snap}\n"
f"alias -p >> {_quoted_snap}\n"
f"echo 'shopt -s expand_aliases' >> {_quoted_snap}\n"
f"echo 'set +e' >> {_quoted_snap}\n"
f"echo 'set +u' >> {_quoted_snap}\n"
f"builtin cd {_quoted_cwd} 2>/dev/null || true\n"
f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true\n"
f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\"\n"
)
try:
proc = self._run_bash(bootstrap, login=True, timeout=self._snapshot_timeout)
result = self._wait_for_process(proc, timeout=self._snapshot_timeout)
self._snapshot_ready = True
self._update_cwd(result)
except Exception as exc:
logger.warning(
"init_session failed (session=%s): %s — "
"falling back to bash -l per command",
self._session_id, exc,
)
self._snapshot_ready = False
5 个关键设计点:
- Path 用 shlex.quote():Windows 下 C:/Users/.../hermes-snap-*.sh 含冒号 ── 不加引号会被 bash glob-split 或误判为 drive letter
- builtin cd:不用 cd 而用 builtin cd,避免用户的 cd shell function 干扰(更可靠地 cd 到目标目录)
- pwd -P > cwd_file:本地后端用文件读 CWD(_update_cwd 第 773 行)
- __HERMES_CWD_<id>__%s__HERMES_CWD_<id>__ marker:远程后端无法传文件 ── 把 CWD 嵌到 stdout 里,用唯一 marker 字符串分隔
- 失败 fallback:_snapshot_ready = False ── 后续 execute() 走 login=True 分支,每次重新跑 bash -l
3.2 _wrap_command ── 命令的拼包
源码视角 ─ _wrap_command 把命令变成完整 bash 脚本
看 base.py 第 417 行:
def _wrap_command(self, command: str, cwd: str) -> str:
"""Build the full bash script that sources snapshot, cd's, runs command,
re-dumps env vars, and emits CWD markers."""
escaped = command.replace("'", "'\\''")
# Quote the snapshot / cwd-file paths (Windows safety, see init_session).
_quoted_snap = shlex.quote(self._snapshot_path)
_quoted_cwd_file = shlex.quote(self._cwd_file)
parts = []
# Source snapshot (env vars from previous commands).
if self._snapshot_ready:
parts.append(
f"source {_quoted_snap} >/dev/null 2>&1 || true"
)
# Preserve bare ``~`` expansion, but rewrite ``~/...`` through
# ``$HOME`` so suffixes with spaces remain a single shell word.
quoted_cwd = self._quote_cwd_for_cd(cwd)
parts.append(f"builtin cd -- {quoted_cwd} || exit 126")
# Run the actual command
parts.append(f"eval '{escaped}'")
parts.append("__hermes_ec=$?")
# Re-dump env vars to snapshot (last-writer-wins for concurrent calls)
if self._snapshot_ready:
parts.append(f"export -p > {_quoted_snap} 2>/dev/null || true")
# Write CWD to file (local reads this) and stdout marker (remote parses this)
parts.append(f"pwd -P > {_quoted_cwd_file} 2>/dev/null || true")
parts.append(
f"printf '\\n{self._cwd_marker}%s{self._cwd_marker}\\n' \"$(pwd -P)\""
)
parts.append("exit $__hermes_ec")
return "\n".join(parts)
每次调用都构造的 bash 脚本,结构是 source snapshot → cd → eval → 重 dump env → 输出 CWD marker → exit 沿用原退出码。注意:
- source ... >/dev/null 2>&1 || true ── macOS bash 3.2 上 declare -x 在 source 时可能输出到 stdout(issue #15459),必须重定向
- __hermes_ec=$? + 最后 exit $__hermes_ec ── 把用户命令的退出码传给外部,而不混进 source 等 wrapper 命令的失败信号
- export -p > snap.sh ── 每次命令结束都重 dump env,连续 export / setenv 在快照里自动延续
3.3 _wait_for_process ── interrupt / timeout / output drain 统一处理
_wait_for_process() 是基类里最长、最复杂的方法(base.py 第 483-760 行)。它要做三件事:
- non-blocking drain 输出 ── 在 daemon 线程里通过 select()(POSIX)/ os.read()(Windows)循环读 stdout
- poll 循环检查中断 ── 每 5~200ms 自适应 poll,调 is_interrupted()(来自 tools/interrupt.py)
- timeout 强制 ── 超过 effective_timeout 就 _kill_process(proc) 并把退出码改成 124(和 timeout 命令语义一致)
源码视角 ─ drain 线程如何避免 backgrounded grandchild 的孤儿管道
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
def _drain():
# Resolve a real OS file descriptor up front.
stream = proc.stdout
if stream is None:
return
fileno = getattr(stream, "fileno", None)
try:
fd = fileno() if callable(fileno) else None
except Exception:
fd = None
if not isinstance(fd, int) or fd < 0:
_drain_iterable(stream) # in-memory ProcessHandle adapters
return
# select.select does NOT work on pipe fds on Windows (only sockets).
# Use blocking os.read in a daemon thread instead — safe because
# EOF arrives promptly when bash exits.
if os.name == "nt":
try:
while True:
chunk = os.read(fd, 4096)
if not chunk:
break
output_chunks.append(decoder.decode(chunk))
except (ValueError, OSError):
pass
return
idle_after_exit = 0
try:
while True:
try:
ready, _, _ = select.select([fd], [], [], 0.1)
except (ValueError, OSError):
break # fd already closed
if ready:
try:
chunk = os.read(fd, 4096)
except (ValueError, OSError):
break
if not chunk:
break # true EOF — all writers closed
output_chunks.append(decoder.decode(chunk))
idle_after_exit = 0
elif proc.poll() is not None:
# bash is gone and the pipe was idle for ~100ms. Give
# it two more cycles to catch any buffered tail, then
# stop — otherwise we wait forever on a grandchild pipe.
idle_after_exit += 1
if idle_after_exit >= 3:
break
finally:
tail = decoder.decode(b"", final=True)
if tail:
output_chunks.append(tail)
几个微妙决策:
- grandchild 孤儿管道修复(issue #8340):命令如果 setsid cmd & disown 起子进程,子进程会持有 stdout pipe 的写端。传统 for line in proc.stdout 阻塞 EOF ── 这里用 select() 看管道是否 ready,配合 proc.poll() 看 bash 是否退出;bash 退出后还有 3 轮 ~300ms idle 给 grandchild flush 残留数据
- UTF-8 分块解码:os.read(fd, 4096) 跨块可能截断多字节字符 ── 用 codecs.getincrementaldecoder + errors="replace" 累积 buffer
- Windows 不用 select():select.select() 在 Windows pipe fd 上不支持 ── 切到阻塞 os.read() 循环(注释说明 EOF 在 bash 退出时会立刻到)
- Mock / 不可用 fd 回退到 iterable drain:测试和 in-memory ProcessHandle 没有真 fd,触发 _drain_iterable()(第 527 行)走 for piece in stream 退化路径
What-if ─ 删 os.setsid 把 bash 进程和 python 同进程组会怎样?
① 改 1 行:local.py 第 604 行 preexec_fn=None if _IS_WINDOWS else os.setsid, 删掉 os.setsid ── bash 跟 python 同一个 process group,KeyboardInterrupt 传到 python 会一并传到 bash,行为"看起来正常",但 ──
② except (KeyboardInterrupt, SystemExit): 分支(第 719 行)杀进程组时直接 _kill_process(proc) → 杀的是 bash 自己,但 bash 派生的孙子进程(grandchild)不挂在这个 group 上 ── 那就 reparent 到 PID 1,孤儿跑下去,issue #8340 类的"sleep 300 之后还在跑"bug 复发。
③ 现实案例:用户跑了 python -c "import hermes; ..." 然后 Ctrl-C ── python 死得快,bash 慢点退出;缺 os.setsid 时整个进程组是同 PID,python 退出后 bash 才收到 SIGTERM。Hermes 在 _wait_for_process 的 except 里专门处理这个问题。
本节小结
- execute() 由基类统一调度:_before_execute → _prepare_command → _wrap_command → _run_bash → _wait_for_process → _update_cwd
- init_session() 用 bash -l dump env 到 snapshot,后续命令 source 它 ── 跨调用状态保留 + 启动开销 0
- _wait_for_process() 用 select() 替代 readline(),避开 backgrounded grandchild 孤儿管道
- 三条 sentinel(login / __HERMES_CWD_<id>__ marker / __hermes_ec)让 base class 可以安全剥离 wrapper 与用户命令的边界
四、7 个后端实现的差异点:bind-mount / copy-sync / SDK 适配
Layer 视角 ─ 差异的三个维度
7 个后端(含 managed_modal 作为 mode 变体)实际差异落在三个维度上 ── 后端作者只需要在这三点里回答问题:
- 怎么起进程(_run_bash())── 本地 spawn bash / SSH spawn ssh / Docker docker exec / Singularity self.executable instance exec(self.executable = singularity 或 apptainer,运行时由 _ensure_singularity_available() 自动检测) / Modal & Daytona SDK
- 文件怎么同步(_before_execute() hook)── bind-mount(Docker / Singularity)vs FileSyncManager(SSH / Modal / Daytona)vs N/A(local)
- 怎么 cleanup(cleanup())── 进程组杀 vs container remove vs sandbox.stop vs session.close
4.1 LocalEnvironment ── 最薄的一层
源码视角 ─ 本地的 spawn
LocalEnvironment._run_bash()(local.py 第 549 行)直接把 bash -c <command> 作为 subprocess.Popen 跑起来。所有 SSH / Docker / Modal 都是这个模式的变体:
def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
# Set login shell for env snapshot / fallback.
args = ["bash"]
if login:
args.append("-l")
else:
args.extend(["-c", cmd_string])
proc = subprocess.Popen(
args,
text=True,
env=run_env,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
preexec_fn=None if _IS_WINDOWS else os.setsid, # 新进程组
cwd=_popen_cwd,
**_popen_kwargs,
)
if stdin_data is not None:
_pipe_stdin(proc, stdin_data)
return proc
关键点:
- login=True 走 bash -l ── 触发 ~/.profile + ~/.bashrc;login=False 走 bash -c <cmd> ── init_session 时用前者,execute 时用后者
- preexec_fn=os.setsid ── bash 起在新进程组,_kill_process() 才能"杀整组"而不是只杀 bash 自己
- _popen_cwd ── 一次性决定 cwd;command 的 cd 在 wrapper 阶段已经处理
4.2 DockerEnvironment ── bind-mount + container 复用
docker.py 第 503 行 class DockerEnvironment(BaseEnvironment),_run_bash() 在第 943 行。它在初始化时 docker run -d 起一个 daemon container,把用户的 cwd bind-mount 进去 ── 后续每条命令都是 docker exec <container> bash -c <cmd>。
源码视角 ─ docker exec 命令拼接
def _run_bash(self, cmd_string: str, *, login: bool = False,
timeout: int = 120,
stdin_data: str | None = None) -> subprocess.Popen:
"""Spawn a bash process inside the Docker container."""
assert self._container_id, "Container not started"
cmd = [self._docker_exe, "exec"]
if stdin_data is not None:
cmd.append("-i")
# Only inject -e env args during init_session (login=True).
# Subsequent commands get env vars from the snapshot.
if login:
cmd.extend(self._init_env_args)
cmd.extend([self._container_id])
if login:
cmd.extend(["bash", "-l", "-c", cmd_string])
else:
cmd.extend(["bash", "-c", cmd_string])
return _popen_bash(cmd, stdin_data)
关键设计:
- _popen_bash() 来自 base.py ── 第 135 行的辅助函数,统一处理 stdout=PIPE / stderr=STDOUT / stdin=PIPE 三个管道 + _pipe_stdin() 异步写管
- -i 只在 stdin_data 时追加 ── 大多数 execute 不带 stdin,不要无谓加 -i
- _init_env_args 只在 login=True 时注入 ── init_session 时把 env vars 通过 -e KEY=VAL 传给容器;后续命令从 /tmp/hermes-snap-*.sh 拿状态,不再注入(避免重复)
- bash 命令格式 bash -c <cmd> ── 直接传 cmd_string,没有 shlex.quote(因为 cmd_string 是基类已经拼包好的整段 bash 脚本)
源码视角 ─ cleanup 的三态分支(docker.py 第 1180-1277 行)
def cleanup(self, *, force_remove: bool = False):
# ...
if force_remove:
should_stop = True
should_remove = True
elif self._persist_across_processes:
# No-op for the container. Drop the in-process handle so a fresh
# __init__ will re-probe via labels (and find the running
# container) instead of trying to reuse a stale Python reference.
self._container_id = None
return
else:
should_stop = True
should_remove = True
def _do_cleanup() -> None:
if should_stop:
subprocess.run(
[docker_exe, "stop", "-t", "10", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
if should_remove:
subprocess.run(
[docker_exe, "rm", "-f", container_id],
capture_output=True, timeout=30,
stdin=subprocess.DEVNULL,
)
三态分支(来自 docker.py 第 1232-1236 行注释):
- force_remove=True ── docker stop + docker rm -f(显式 teardown)
- persist_across_processes=True(默认) ── no-op,container 继续运行,下个 Hermes 进程通过 label 重连。这是 issue #20561 的契约:container 跨 Hermes 进程存活,背景进程(npm run dev、长跑测试)不会被 /quit 误杀
- persist_across_processes=False ── docker stop + docker rm -f(每进程隔离)
- atexit hook 在 tools/terminal_tool.py,等 daemon 线程跑完 docker stop/rm 最长 ~60s
- orphan reaper ── 工厂里的 _maybe_reap_docker_orphans(cc)(第 876 行)一次性清理掉上一轮 crash 留下的容器(label = hermes-agent=1 + status=exited)
4.3 SSHEnvironment ── ControlMaster + FileSync
ssh.py 第 36 行 class SSHEnvironment(BaseEnvironment)。它做两件本地环境不做的事:
- ControlMaster 复用 TCP 连接 ── 每条命令免一次 SSH handshake(节省 200~500ms)
- scp 同步文件 ── 本地修改文件后下次 execute() 触发增量上传
源码视角 ─ SSH 的连接参数(ssh.py 第 83 行)
def _build_ssh_command(self, extra_args: list | None = None) -> list:
cmd = ["ssh"]
cmd.extend(["-o", f"ControlPath={self.control_socket}"])
cmd.extend(["-o", "ControlMaster=auto"]) # 第一次自动建 master
cmd.extend(["-o", "ControlPersist=300"]) # master 空闲 5min 后退出
cmd.extend(["-o", "BatchMode=yes"]) # 不要交互提示密码
cmd.extend(["-o", "StrictHostKeyChecking=accept-new"])
cmd.extend(["-o", "ConnectTimeout=10"])
if self.port != 22:
cmd.extend(["-p", str(self.port)])
if self.key_path:
cmd.extend(["-i", self.key_path])
# ...
control socket 路径的真实形态(ssh.py 第 53-66 行):
self.control_dir = Path(tempfile.gettempdir()) / "hermes-ssh"
self.control_dir.mkdir(parents=True, exist_ok=True)
# Hash user@host:port → 16-char hex to stay under macOS sun_path limit
# (104 bytes) and keep the socket filename stable across reconnects.
_socket_id = hashlib.sha256(
f"{user}@{host}:{port}".encode()
).hexdigest()[:16]
self.control_socket = self.control_dir / f"{_socket_id}.sock"
- 路径 ── <tempfile.gettempdir()>/hermes-ssh/<sha256[:16]>.sock(不是 ~/.hermes/ssh-control/,文件名是 16 字符 hex 不是 <host>_<port>_<user>)
- 为什么 hash ── 注释明确写出:macOS Unix domain socket 的 sun_path 上限 104 字节,深嵌套 $TMPDIR 下 user@host:port + IPv6 + SSH 自加 16 字节 suffix 容易超限
- 第 1 次 _run_bash() ── ssh.py 第 68 行 self._establish_connection() 起 master daemon;socket 文件被创建
- 第 2 次起 ── ssh 看到 ControlMaster=auto + socket 已存在 ── 复用 master,跳过 auth
- 5 分钟 idle → ControlPersist 关 master;下次需要时再起
- cleanup()(ssh.py 第 355-375 行):先 self._sync_manager.sync_back()(如果非 None),再 ssh -O exit <user@host> 优雅退出 master,最后 self.control_socket.unlink()
SSH 的 _before_execute()(ssh.py 第 335 行)实际只有一行 ── 把控制权交给 FileSyncManager 自身的 rate-limit:
def _before_execute(self) -> None:
"""Sync files to remote via FileSyncManager (rate-limited internally)."""
self._sync_manager.sync()
Modal 的 _before_execute()(modal.py 第 400 行)和 Daytona 的 _before_execute()(daytona.py 第 213 行)也是同样模式 ── 三者都委托 FileSyncManager.sync()(base class 默认是 pass,第 815 行)。
4.4 SingularityEnvironment ── instance + overlay
singularity.py 的实现跟 Docker 类似但用 apptainer instance start(HPC 集群上 Docker 通常没有 daemon 权限)。bind-mount 行为也类似 ── 用户的 $HOME 透传到容器内。
关键点 ─ 跟 Docker 的差异
- 没有 daemon ── Singularity instance 是 self.executable instance start <image> <name> + self.executable exec instance://<name> ...(self.executable 在 __init__ 由 _ensure_singularity_available() 自动检测 singularity 或 apptainer)
- overlay 持久化 ── persistent_filesystem=True 时用 --overlay /path/to/overlay.img 让写操作落盘(默认 Singularity container 是 read-only)
- cleanup ── self.executable instance stop <name>
- 没有挂起机制 ── 不像 Docker 的 docker pause,Singularity 没有同等原语
4.5 ModalEnvironment ── Sandbox SDK + _ThreadedProcessHandle
modal.py 第 164 行 class ModalEnvironment(BaseEnvironment),_run_bash() 通过 Modal SDK 的 Sandbox.exec() 启动命令。它跟本地最不一样:
- Modal SDK 是同步阻塞 ── 没有 Popen 那种 proc.stdout pipe,需要 _ThreadedProcessHandle 适配
- stdin_data 不能走 pipe ── 走 _stdin_mode = "heredoc" 嵌进 bash 脚本
- _snapshot_timeout 默认 60s ── Modal sandbox 冷启动比 docker run 慢
源码视角 ─ Modal 的 _ThreadedProcessHandle 包装
看 modal.py 第 408 行的 _run_bash() 真实形态:
def _run_bash(self, cmd_string, *, login=False, timeout=120, stdin_data=None):
"""Return a _ThreadedProcessHandle wrapping an async Modal sandbox exec."""
sandbox = self._sandbox
worker = self._worker
def cancel():
worker.run_coroutine(sandbox.terminate.aio(), timeout=15)
def exec_fn() -> tuple[str, int]:
async def _do():
args = ["bash"]
if login:
args.extend(["-l", "-c", cmd_string])
else:
args.extend(["-c", cmd_string])
process = await sandbox.exec.aio(*args, timeout=timeout)
stdout = await process.stdout.read.aio()
stderr = await process.stderr.read.aio()
exit_code = await process.wait.aio()
# ... decode stdout/stderr from bytes if needed ...
return output, exit_code
return worker.run_coroutine(_do(), timeout=timeout + 30)
return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel)
关键点:
- 两个内部函数 cancel() + exec_fn() ── cancel 调 worker.run_coroutine(sandbox.terminate.aio(), timeout=15)(Modal 文档的 cancel_fn 协议);exec_fn 是同步阻塞的"包装器",内部用 async / await 跑 SDK 调用,最终 worker.run_coroutine 把 async coroutine 桥到同步线程
- _ThreadedProcessHandle(exec_fn, cancel_fn=cancel) ── 注意真实签名是 (exec_fn, cancel_fn=None),不是 target=... / args=...;base.py 第 214-218 行定义
- 没有手动拼 heredoc ── stdin_data 处理走基类的 _stdin_mode = "heredoc"(第 171 行) + _wrap_command()(基类第 417 行)
- cleanup ── modal.py 第 442 行 cleanup():先 self._sync_manager.sync_back()(如果 persistent),然后 self._worker.run_coroutine(self._sandbox.terminate.aio(), timeout=15),最后 self._worker.stop()
4.6 ManagedModalEnvironment ── 走 Nous Tool Gateway
managed_modal.py 第 36 行的 ManagedModalEnvironment 继承中间类 BaseModalExecutionEnvironment(modal_utils.py 第 58 行)。它不直接调 Modal SDK ── 通过 HTTP 调 Nous Tool Gateway(NTG),由服务端去 spawn sandbox。差异:
源码视角 ─ managed 模式的 transport
def _request(self, method: str, path: str, *,
json: Dict[str, Any] | None = None,
timeout: int = 30,
extra_headers: Dict[str, str] | None = None) -> requests.Response:
headers = {
"Authorization": f"Bearer {self._nous_user_token}",
"Content-Type": "application/json",
}
if extra_headers:
headers.update(extra_headers)
return requests.request(
method,
f"{self._gateway_origin}{path}",
headers=headers,
json=json,
timeout=timeout,
)
关键设计(managed_modal.py 第 229-247 行):
- requests 同步 HTTP 客户端 ── 不是 SSE,不是异步 ── 通过 POST 一次性发命令,GET 轮询 _poll_modal_exec() 拿结果
- Bearer Token 鉴权 ── Authorization: Bearer <nous_user_token>(第 234 行),用户不需要 Modal 凭证 ── token 由 NTG 持有,对用户隐藏
- JSON-RPC 风格的 payload ── execId / command / cwd / timeoutMs / stdinData(看 managed_modal.py 第 73-82 行 _start_modal_exec())
- 无 mode 变体也用同一 _ThreadedProcessHandle ── 调 _poll_modal_exec() 看是否完成,跟 Modal 的 async 等待等价
- 凭证文件 passthrough 禁用 ── 第 222-227 行 _guard_unsupported_credential_passthrough() 主动 raise ValueError,不能用 挂载 host credential 文件到 sandbox;如需,TERMINAL_MODAL_MODE=direct
4.7 DaytonaEnvironment ── 跟 Modal 几乎镜像
daytona.py 的实现跟 ModalEnvironment 几乎是镜像 ── 都用 SDK、都用 _ThreadedProcessHandle、都需要 heredoc stdin。差异:
关键点 ─ Daytona vs Modal 的差异
- SDK 名字不同 ── daytona.Sandbox.create() vs modal.Sandbox.create()
- cleanup API ── Daytona 用 sandbox.stop();Modal 用 sandbox.terminate()
- 资源 key ── Daytona 的 cpu 是 int;Modal 接受 float
- 无 mode 变体 ── Daytona 没有 managed 模式(Hermes 暂未集成 NTG for Daytona)
- lazy import ── Daytona SDK 在工厂分支内才 import(避免装 SDK 时影响其他后端启动)
本节小结
- local ── os.setsid 起新进程组,CWD 用文件传
- docker ── bind-mount + 复用 container;orphan reaper 清理 crash 残留
- ssh ── ControlMaster 复用 TCP + scp 同步文件
- singularity ── apptainer instance + overlay 持久化
- modal / daytona ── SDK + _ThreadedProcessHandle 适配器 + heredoc stdin
- managed_modal ── 走 Nous Tool Gateway,隐藏凭证 + 受限功能
五、FileSyncManager:远程后端的 mtime 增量同步
Layer 视角 ─ 谁需要 file sync,谁不需要?
Bind-mount 后端(Docker / Singularity)跟 host 共享文件系统视图,文件改动是即时的;本地后端根本不用"同步"这个概念。只有 SSH / Modal / Daytona 这类"remote sandbox"才需要把本地的 cwd 推到沙箱里 ── Hermes 把这份职责集中到 tools/environments/file_sync.py 的 FileSyncManager。
模块 docstring(第 1-7 行)写得很清楚:
- Tracks local file changes via mtime+size, detects deletions, and syncs to remote environments transactionally
- Used by SSH, Modal, and Daytona
- Docker and Singularity use bind mounts (live host FS view) and don't need this
5.1 触发时机:_before_execute() hook
SSH / Modal / Daytona 三个远程后端都在 _before_execute() 钩子里调 self._sync_manager.sync()。基类的 execute() 在 _run_bash() 之前自动调这个 hook(base.py 第 829 行起的 execute 流程)── 所以本地 / Docker / Singularity 不覆写 _before_execute() 时就自动 pass(第 815 行 base 默认)。
三个远程后端的 _before_execute() 形态一致:
# ssh.py 第 335 行
def _before_execute(self) -> None:
"""Sync files to remote via FileSyncManager (rate-limited internally)."""
self._sync_manager.sync()
# modal.py 第 400 行
def _before_execute(self) -> None:
"""Sync files to sandbox via FileSyncManager (rate-limited internally)."""
self._sync_manager.sync()
# daytona.py 第 213 行
def _before_execute(self) -> None:
"""Ensure sandbox is ready, then sync files via FileSyncManager."""
with self._lock:
self._ensure_sandbox_ready()
self._sync_manager.sync()
关键设计:
- 三个后端委托 FileSyncManager.sync() ── 失败处理 / rate-limit 全在 manager 内部,不在后端 hook 里 try/except
- Daytona 多一步 _ensure_sandbox_ready() ── Daytona sandbox 可能被外部销毁,先在 lock 下确认可用
5.2 mtime + size 增量检测
源码视角 ─ FileSyncManager 的真实构造参数
看 file_sync.py 的 __init__(第 119 行):
class FileSyncManager:
def __init__(
self,
get_files_fn: GetFilesFn,
upload_fn: UploadFn,
delete_fn: DeleteFn,
sync_interval: float = _SYNC_INTERVAL_SECONDS,
bulk_upload_fn: BulkUploadFn | None = None,
bulk_download_fn: BulkDownloadFn | None = None,
):
self._get_files_fn = get_files_fn
self._upload_fn = upload_fn
self._bulk_upload_fn = bulk_upload_fn
self._delete_fn = delete_fn
self._synced_files: dict[str, tuple[float, int]] = {} # remote_path -> (mtime, size)
self._pushed_hashes: dict[str, str] = {} # remote_path -> sha256 hex digest
self._last_sync_time: float = 0.0
self._sync_interval = sync_interval
关键设计:
- callback 注入而非属性注入 ── get_files_fn / upload_fn / delete_fn 都是 callable,由后端在 __init__ 时传入。例如 ssh.py 第 72-75 行
- _synced_files 字典 ── remote_path -> (mtime, size),热路径只 stat 不 hash
- _pushed_hashes ── upload 成功后写入(sync() 第 198 行)── 仅在差异检测后再做权威比较
sync() 整体流程(file_sync.py 第 138 行):
- 检查 _sync_interval 节流(除非 force=True 或 HERMES_FORCE_FILE_SYNC=1)
- 调 self._get_files_fn() 拿到当前 (host_path, remote_path) 列表
- 对照 _synced_files 找差异(_file_mtime_key(host_path) → 拿 (mtime, size) 比较)
- 新文件 / 改动文件 → 优先 _bulk_upload_fn(有的话),否则 _upload_fn 单文件
- 已删除文件 → _delete_fn
- 全部成功才更新 _synced_files / _pushed_hashes;任何步骤失败 rollback 到旧值
5.3 HERMES_FORCE_FILE_SYNC + sync_back 反向同步
源码视角 ─ sync() 的开关
def sync(self, *, force: bool = False) -> None:
"""Run a sync cycle: upload changed files, delete removed files.
Rate-limited to once per ``sync_interval`` unless *force* is True
or ``HERMES_FORCE_FILE_SYNC=1`` is set.
Transactional: state only committed if ALL operations succeed.
On failure, state rolls back so the next cycle retries everything.
"""
if not force and not os.environ.get(_FORCE_SYNC_ENV):
now = time.monotonic()
if now - self._last_sync_time < self._sync_interval:
return # rate-limited
# ...
关键设计:
- HERMES_FORCE_FILE_SYNC=1 环境变量(file_sync.py 第 40 行)── 跳过节流,对调试有用
- rollback 语义 ── 中途失败时回滚 _synced_files / _pushed_hashes 到旧值,下次重试整个 cycle(sync() 第 207-211 行)
- sync_back() 反向同步(file_sync.py 第 217 行)── 从 sandbox 拉文件回 host,在后端 cleanup() 之前调用,避免沙箱销毁时丢失数据
Lesson 1 远程后端的"文件一致性"是个工程权衡问题 ── 不是"看起来就 bind-mount"那么简单。Hermes 选择 mtime+size(轻量但漏检)+ manifest 持久化(跨进程)+ 失败 fallback(不阻塞命令)。在 RPC 边界外的资源同步都是这样设计的:单边写入 + best-effort + 用户可见的失败 ── 而不是死磕一致性。
本节小结
- 3 个远程后端用 FileSyncManager(SSH / Modal / Daytona),3 个不用(local / Docker / Singularity)
- 触发点在 _before_execute() hook ── 基类自动调,后端不写就是 no-op
- mtime + size 检测 + 跨进程 manifest ── 单文件 O(1) 开销
- 默认忽略 .git / node_modules / .venv / logs ── 避免负优化
FAQ 20 问
本节是答读者问 ── 把阅读源码时常被卡住的细节先讲清楚
fcntl.flock()
- Q:BaseEnvironment 是 ABC 还是直接 class?
A:tools/environments/base.py 第 288 行写明 class BaseEnvironment(ABC)。两个 abstract method:_run_bash() 和 cleanup()。两个方法不实现就 TypeError: Can't instantiate abstract class。- Q:_ThreadedProcessHandle 跟 subprocess.Popen 在协议上等价吗?
A:等价(Protocol 模式)。Popen 5 个属性 poll / kill / wait / stdout / returncode,_ThreadedProcessHandle 也提供同样 5 个 ── 内部用 daemon 线程 + os.pipe 把 SDK 输出推到文件描述符供 base class 的 drain 线程读。- Q:TERMINAL_ENV 在哪个文件配置?
A:hermes_cli/config.py 的 DEFAULT_CONFIG["terminal"]["env"],合法值 "local" / "docker" / "ssh" / "singularity" / "modal" / "daytona"。运行时通过 terminal_tool.check_terminal_requirements()(terminal_tool.py 第 2491 行)验证。- Q:为什么 daytona 走 lazy import,其他顶层 import?
A:Modal SDK 较轻,且本机常驻;Daytona SDK 包含 CLI 工具(daytona 二进制)── 用户没装 Daytone 时 import 会失败。Lazy import 保证默认安装的用户不会被 Daytone 缺失拖死。- Q:spawn-per-call vs 复用常驻进程,性能差异?
A:spawn-per-call 每条命令 ~30ms spawn 开销(bash 启动 + env snapshot source),换来跨调用状态在 snapshot 文件里自动保留。常驻进程方案(如 SSH long-lived shell)要自己维护 state machine,Hermes 选前者是因为隔离性 > 性能。- Q:os.setsid 起新进程组,Windows 下怎么办?
A:local.py 第 604 行用 preexec_fn=None if _IS_WINDOWS else os.setsid ── Windows 没有 process group 概念,Hermes 改用 CREATE_NEW_PROCESS_GROUP flag(通过 _popen_kwargs 传入),Ctrl-Break 替代 SIGTERM。- Q:_wait_for_process 的 124 退出码是 Linux timeout 命令的约定?
A:是的。GNU timeout 命令超时返回 124。Hermes 在 _wait_for_process() 超过 effective_timeout 时调 _kill_process() 后把 returncode = 124 写回 ── 调用方写 if ec == 124: handle_timeout() 逻辑跟 timeout 命令无缝衔接。- Q:bind-mount 后端为什么不需要 FileSyncManager?
A:因为容器内的 $cwd 实际是 host 的某个路径 ── 写文件就是写 host,不需要同步。Docker / Singularity 共享文件系统视图,跟 local 后端的"文件在哪"是同一个问题。- Q:ControlMaster 复用连接,安全上有风险吗?
A:socket 文件在 ~/.hermes/ssh-control/ 下,权限 0600(只有当前用户可读写)。其他用户拿到 socket 也不能用 ── SSH 还会做密钥校验。5 分钟 idle 后 master 自动退出,cleanup() 时强制 ssh -O exit。- Q:Modal 和 Daytona 都用 _ThreadedProcessHandle,为什么 Modal 还要 60s snapshot timeout?
A:Modal sandbox 冷启动比 Daytona 慢 ── Sandbox.create() 在新 region 时可能 30s+ 才返回。Daytona 走预先 pool 的 sandbox,启动快。_snapshot_timeout = 60(modal.py)是给冷启动多 30s 余量,Daytona 保持默认 30s。- Q:managed_modal 模式下输出延迟比 direct 模式高多少?
A:30~80ms(HTTP / SSE 多一跳)。冷启动时差距更大 ── direct 模式 Modal 服务端已建好 sandbox,managed 还要走 NTG 转发。不频繁交互的批量任务不敏感,交互频繁的 TUI / batch_runner 建议 direct。- Q:hermes-cwd-<id>.txt 文件什么时候清理?
A:cleanup() 时由后端自己释放(容器 / sandbox 关掉时临时目录随之消失);Python GC 触发的 __del__(base.py 第 885 行)会再兜底一次 ── SSH 后端在 cleanup() 里显式 scp 删文件,其他后端依赖 sandbox 销毁。- Q:能否自定义后端(比如 k8s pod)?
A:可以。继承 BaseEnvironment,实现 _run_bash() + cleanup(),在 terminal_tool.py 的 _create_environment() 里加一个 elif env_type == "k8s": 分支。完成后 TERMINAL_ENV=k8s 即可 ── 其他代码(base.py / file_sync.py / interrupt.py)不需要改。- Q:__hermes_ec=$? 为什么不直接 exit $??
A:POSIX 用
A:因为前面有 source ... || true 和 pwd -P > file 这些 wrapper 指令 ── $? 在那些指令后会变成 wrapper 自己的退出码,覆盖原命令的退出码。__hermes_ec=$? 紧跟 eval '$escaped' 之后捕获,最后 exit $__hermes_ec 把原码透传出去。file_sync.py
(
fcntl
第 22 行 import guard),Windows 下
fcntl = None
import 失败回退到
sync_interval
── Windows 文件锁直接跳过(注释 "Windows — file locking skipped"),靠
节流避免并发冲突。
- Q:init_session 失败后怎么办?
A:base.py 第 351 行 init_session() 把异常 catch 住,_snapshot_ready = False + log warning ── 后续 execute() 走 login=True 分支,每次重新跑 bash -l(慢 200~500ms 但能 work)。- Q:ephemeral_disk 在 Modal 旧版不存在怎么办?
A:terminal_tool.py 第 1298 行用 inspect.signature(modal.Sandbox.create).parameters 检查 ── 不存在就 pass,不报错。这让 Hermes 能兼容 Modal SDK 旧版。- Q:同一会话连续 export 多个 env var,最后值会不会丢?
A:不会。_wrap_command 第 454 行 export -p > snap.sh 每次命令结束都重 dump ── 后续 source snap.sh 加载最后状态。并发场景下"last-writer-wins" 注释明确说明代价 ── Hermes 不期望同一会话并发跑命令(Agent loop 是串行的)。- Q:orphan reaper 多久跑一次?
A:进程级一次性 ── _maybe_reap_docker_orphans(cc)(terminal_tool.py 第 876 行)内部用 global _docker_orphan_reaper_ran 布尔标志 + threading.Lock 双检锁,同一 Hermes 进程生命周期内只跑一次。- Q:_update_cwd 从 stdout marker 还是文件读?
A:本地后端读 /tmp/hermes-cwd-<id>.txt 文件(local.py 第 705 行 open(self._cwd_file))── 远程后端从 stdout 用正则解析 __HERMES_CWD_<id>__PATH__HERMES_CWD_<id>__ marker(base.py 第 777 行 _extract_cwd_from_output())。两者最终都写到 self.cwd,调用方无感。
下一篇预告
第 8 篇 ─ Memory 子系统全解(敬请期待)
终端执行层覆盖完了 ── 模型怎么调工具(Layer 2)、怎么执行命令(Layer 2.5)。Layer 3 是长期记忆 ── 让 Agent 不只是当前会话记得住。下一篇会展开:
- MemoryProvider ABC 怎么定义 sync_turn / prefetch / shutdown
- MemoryManager ── 8 个内置 provider 的 orchestrator
- honcho / mem0 / supermemory / byterover 等后端差异 ── 跟 Layer 2.5 同样的窄腰模式(BaseEnvironment ↔ MemoryProvider ABC)
- prefetch 与 prompt caching 的边界 ── 什么时候注入 memory,什么时候不注入
- TTL / 跨 profile 隔离 / setup wizard
Layer 2 / Layer 2.5 / Layer 3 三层结构完整后,下一步是 Layer 4 ─ Gateway 多平台适配(TG / Discord / Slack / 飞书 / 钉钉 / 企业微信 / 邮件 …) ── 同样的窄腰模式(BaseAdapter ↔ MessageBus)。

浙公网安备 33010602011771号