第 2 章 · 执行环境与“无 shell 会话”
本章讲清楚:环境层(
environments/local.py、environments/docker.py)如何执行一条命令;为什么“每条命令开新进程、没有持久 shell”是 mini 的一个关键赌注;以及 agent 怎么靠一个哨兵字符串自己宣告完成。
2.1 环境的契约小得惊人
一个环境只需实现三个方法(Environment Protocol,src/minisweagent/__init__.py:61-70):execute(action)、get_template_vars()、serialize()。真正干活的就 execute——输入一个 {"command": ...},输出一个 {"output", "returncode", "exception_info"}。就这么简单。
正因为契约这么小,项目里塞得下七种环境(environments/__init__.py 的 _ENVIRONMENT_MAPPING):local、docker、singularity、swerex_docker、swerex_modal、bubblewrap、contree。
2.2 思路:动作之间完全独立(无 shell 会话)
这是 mini 最反直觉、也最值得学的一点。它不维护一个一直开着的 shell。每条命令都是一次全新的子进程。
看本地环境的执行(environments/local.py:24-43):它调用模块级的 _run,而 _run 用 subprocess.Popen(command, shell=True, ...) 开一个新进程跑这条命令,拿到 stdout 和返回码就结束。下一条命令又是全新的一个。
这带来一组连锁好处与代价:
| 后果 | 好处 / 代价 |
|---|---|
| 命令彼此独立 | 好处: 把 subprocess.run 换成 docker exec 就直接跑在容器里;并行 N 个实例毫无共享状态 |
| 没有持久状态 | 代价: cd /foo、export X=1 不会保留到下一条命令 |
| 进程组隔离 | 好处: 超时能整组 kill,不留孤儿进程 |
项目作者认为这是“a big deal”(README 反复强调),因为有状态的 shell 会话在长时间运行里极易出各种诡异故障(卡住、半个命令、状态污染),而独立子进程稳定、可复现、可扩展。
怎么补偿“状态不持久”? 不在代码里补,在 prompt 里告诉模型(config/default.yaml:38-40):“目录/环境变量改动不持久,每个动作在新子shell里执行;但你可以用 MY_ENV_VAR=val cd /path && ... 这样把它们串进一条命令”。又一次:能丢给模型解决的,就不写进脚手架。
2.3 超时:连孩子一起杀
_run(environments/local.py:72-92)有个容易被忽略的细节——用 start_new_session=True 把命令放进新进程组,超时后 os.killpg(process.pid, SIGKILL) 把整个进程组杀掉:
# 示意,非源码:local.py:72-92 的核心
process = subprocess.Popen(command, shell=True, start_new_session=(os.name == "posix"), ...)
try:
stdout, _ = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL) # 杀整组,别留孤儿
stdout, _ = process.communicate()
raise subprocess.TimeoutExpired(command, timeout, output=stdout)
为什么重要: 模型常会跑出 fork 子进程的命令(比如启动一个服务器、跑测试套件)。只杀父进程会留下一堆吃 CPU 的孤儿。杀整个进程组才干净。默认超时 30 秒(LocalEnvironmentConfig.timeout,local.py:13-16)。
2.4 精华:哨兵字符串 + Submitted 异常 = 自我了结
agent 怎么知道“ 任务做完了”?mini 不另设一个“finish 工具”,而是约定一个魔法字符串。prompt 里教模型:做完后运行 echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT(config/default.yaml:32-33)。
环境在每次执行后检查输出第一行(local.py:45-56 的 _check_finished):
# 真实逻辑,local.py:45-56
def _check_finished(self, output: dict):
lines = output.get("output", "").lstrip().splitlines(keepends=True)
if lines and lines[0].strip() == "COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT" and output["returncode"] == 0:
submission = "".join(lines[1:]) # 哨兵之后的内容 = 提交物
raise Submitted({"role": "exit", "content": submission,
"extra": {"exit_status": "Submitted", "submission": submission}})
妙在哪:
- 第一行精确匹配 + 返回码为 0,避免模型只是在讨论这个字符串时误触发。
- 哨兵之后的所有行就是“提交物”(submission)——在 SWE-bench 里通常是
git diff的补丁。所以模型可以echo COMPLETE...; git diff一把把补丁交出来。 - 它抛
Submitted(继承自InterruptAgentFlow),异常自带的exit消息让 第 1 章的主循环干净退出。完成逻辑因此完全长 在环境层,Agent 不必管。
Docker 环境里有一模一样的 _check_finished(environments/docker.py:140-151)——这是刻意复制而非抽象共享,保持每个环境文件自包含、好读。
2.5 Docker 环境:换个执行器而已
DockerEnvironment(environments/docker.py)和本地版长得几乎一样,差别只在 execute:它把命令拼成 docker exec -w <cwd> <container> bash -lc <command>(docker.py:101-138)。容器在 __init__ 时用 docker run -d ... sleep <container_timeout> 起一个常驻容器(docker.py:74-99),析构时后台 docker stop(docker.py:153-161)。
几个生产级细节:
forward_env/env:可把宿主机环境变量转发进容器,或直接设值(docker.py:107-112)。interpreter可配(默认["bash", "-lc"],docker.py:38-42):想用非登录 shell 或换成 python 解释命令都行。run_args默认["--rm"]:容器退出即删,不留垃圾。
这一章证明了 第 1 章开头那句:换沙箱真的就是换一个 execute 实现,Agent 与 Model 一行不动。SWE-bench 批量跑分正是用 docker 环境(见 第 4 章)。
2.6 边界与坑
shell=True意味着环境会原样执行模型给的任意 shell——这正是设计意图(给模型完整 shell),但也意味着安全边界完全靠外层沙箱(docker/singularity/bubblewrap),本地环境直接在你机器上跑命令,务必清楚风险。交互式confirm模式(见 第 4 章)就是给本地跑加的人工闸门。cd不持久是最常绊到模型的点;靠 prompt 提醒缓解,但偶尔模型仍会忘。- 输出编码用
errors="replace"(local.py:80-83),二进制垃圾不会让进程崩,而是替换成占位符。
2.7 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 环境契约(Protocol) | src/minisweagent/__init__.py | Environment |
| 本地执行一条命令 | src/minisweagent/environments/local.py | LocalEnvironment.execute |
| 新进程组 + 超时杀整组 | src/minisweagent/environments/local.py | _run |
| 哨兵检测 → 抛 Submitted | src/minisweagent/environments/local.py | LocalEnvironment._check_finished |
| 本地环境配置(timeout/env) | src/minisweagent/environments/local.py | LocalEnvironmentConfig |
| Docker 起常驻容器 | src/minisweagent/environments/docker.py | DockerEnvironment._start_container |
| Docker exec 执行 | src/minisweagent/environments/docker.py | DockerEnvironment.execute |
| 容器后台清理 | src/minisweagent/environments/docker.py | DockerEnvironment.cleanup |
| 环境工厂表 | src/minisweagent/environments/__init__.py | get_environment _ENVIRONMENT_MAPPING |