核心命题:把"在哪里执行"从"执行什么"中分离出来,是依赖倒置原则的教科书案例——但在 AI Agent 语境下,这个分离有着比一般应用更复杂的语义挑战。


从一个具体的噩梦开始

假设 terminal_tool.py 没有做后端抽象,直接把本地 subprocess.Popen 的调用逻辑写死在函数体里。某天,一个使用 Hermes 的研究员想把执行环境切换到 Docker 容器。他打开 terminal_tool.py,看到的是:

def execute_command(cmd: str, cwd: str) -> str:
    proc = subprocess.Popen(
        ["bash", "-c", cmd],
        cwd=cwd,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        preexec_fn=os.setsid,
    )
    # ... 读取 stdout, 处理中断, 追踪 cwd ...

要改成 Docker,他需要:

  1. 把 subprocess.Popen 调用换成 docker exec 命令
  2. 处理容器启动时机(第一次执行前需要 docker run)
  3. 重写 CWD 追踪逻辑(Docker 容器里的 pwd 结果需要重新解析)
  4. 重写中断处理(proc.kill() 变成 docker stop <container_id>)
  5. 重写会话快照逻辑(环境变量快照要在容器内执行,而不是在宿主机)

在这五步之后,代码里充满了 if backend == "docker": ... else: ... 的条件分支。当他再加一个 Modal 云后端时,每个条件分支再增加一个分支。测试也爆炸了:本来可以直接 mock subprocess.Popen 的单元测试现在需要知道它在测试哪个 backend,或者根本无法在没有 Docker 的 CI 环境里运行。

这正是 tools/environments/ 子目录要解决的问题。


抽象层的解剖

Hermes 的终端后端架构分三层:

tools/terminal_tool.py          # 工具层:处理审批、sudo、env 生命周期
        │
        ▼
tools/environments/base.py      # 抽象层:BaseEnvironment ABC + ProcessHandle Protocol
        │
        ├── local.py            # 本地进程后端(subprocess.Popen)
        ├── docker.py           # Docker 容器后端
        ├── ssh.py              # SSH 远程后端
        ├── modal.py            # Modal 云计算后端
        ├── daytona.py          # Daytona 开发环境后端
        ├── singularity.py      # Singularity/Apptainer HPC 后端
        └── vercel_sandbox.py   # Vercel Sandbox 后端

terminal_tool.py 只和 BaseEnvironment 接口交互,永远不需要知道底层是什么。当 config.yaml 里的 terminal.backend 从 local 改成 docker,工具层代码一行不动。

ProcessHandle:为什么选 Protocol 而不是 ABC

base.py 里定义的 ProcessHandle 是一个 Protocol(结构子类型),而不是 ABC(名义子类型):