核心命题:把"在哪里执行"从"执行什么"中分离出来,是依赖倒置原则的教科书案例——但在 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,他需要:
subprocess.Popen 调用换成 docker exec 命令docker run)pwd 结果需要重新解析)proc.kill() 变成 docker stop <container_id>)在这五步之后,代码里充满了 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,工具层代码一行不动。
base.py 里定义的 ProcessHandle 是一个 Protocol(结构子类型),而不是 ABC(名义子类型):