sandbox-agent
把 LLM agent CLI 关进一次性 Docker 容器里跑。
agent 会写文件、跑命令、装依赖。让它在你的开发机上直接干这些事是个坏主意。 这个库给它一个用完就扔的容器,并把结果安全地取出来。
零运行时依赖,只要求宿主机有 docker。
from sandbox_agent import SandboxAgent, OpenCodeDriver
driver = OpenCodeDriver(provider="zhipuai-coding-plan", api_key=API_KEY)
with SandboxAgent(driver=driver, model="zhipuai-coding-plan/glm-5.3") as agent:
print(agent.ask("用 Python 写个快排, 存到 /tmp/qs.py"))
code, out = agent.container.exec(["python", "/tmp/qs.py"]) # 验收产出
agent.container.download("/tmp/qs.py", "./qs.py") # 取回宿主机
# 退出 with 块 -> 容器销毁
安装
uv add sandbox-agent # 或 pip install sandbox-agent
还需要一个装好了 agent CLI 的镜像,见 docker/ 与 .ci/build_docker.sh。
包和镜像的对应关系
装了哪个版本的包,就唯一确定了该拉哪个镜像。 不靠约定,靠推导:
pip install sandbox-agent
└─ wheel 里带着 sandbox_agent/versions.json
claude_version = 2.1.235
image_date = 20260819
└─ donaldtrump/sandbox-agent:claude-2.1.235-20260819
例子里的数字会过时。装好的包指向哪个镜像,直接问它:
python -c "from sandbox_agent import versions; print(versions.CLAUDE_IMAGE)"
tag 格式就是 {repo}:{cli}-{cli版本}-{image_date},三段都来自那一份
versions.json——构建脚本和 Python 运行时读的是
同一份,两边各存一份就会得到一个名字和内容对不上的镜像,而且不报错。
几条刻意的选择:
- 包版本不进 tag。 快照内容已经唯一确定了 tag,再把「这是哪个快照」编码进去是
多绕一圈。反过来
pyproject.toml也不读versions.json:打包版本是打包的事, 镜像日期是运行时的事,粘在一个文件里只会互相绑架。 image_date是手写的声明,不是构建时观测的。 它管的是「哪个 CLI 都没升, 但基础镜像要重建」那种情况——CLI 版本号挡不住,日期能,而且比-1-2有信息量。 构建脚本里写date +%Y%m%d会让运行时算出的 tag 指向一个从没构建过的镜像, 且不报错,所以日期只读不算(有测试盯着)。- 不用
latest。 同一份代码在不同时间跑出不同行为,「验证过」三个字就没了意义。 构建脚本只推钉死的 tag。
代价是人得记得改日期,所以 build_docker.sh --push 会先查 registry,tag 已存在就
拒绝推送(同一天反复调试用 --force);build_pypi.sh --push 会反过来查每个镜像
是否都已就位,缺一个就拒绝发布。发布顺序被钉死了:先镜像,后包。
不传 image= 时用 driver 自己的默认镜像——每个 agent CLI 有自己的镜像,一个全局
默认值只能对其中一个是对的。优先级:显式 image= > SANDBOX_AGENT_IMAGE_NAME
(换私有 registry 用)> driver.default_image > 内置默认值。
本地 LLM 服务(可选)
runtime/litellm/ 是一个独立的 compose 栈,把一个 OpenAI 兼容的上游
(智谱 GLM)包装成 chat/completions、Responses、Anthropic Messages 三种协议,
codex 和 claude 都能直接接:
cd runtime/litellm && docker compose up -d # 要先 export ZHIPUAI_CODING_TOKEN, 见那边 README
跟本库的 SandboxAgent 没有代码耦合,各起各的。想让沙箱容器用上它,见那边的 README。
能干什么
| 对话 | chat() 拿可迭代的流式事件,推进主线;ask() 开旁支问一句,问完即扔 |
| 会话 | sessions.list() / .fork() / .dump() / .load() —— 会话能跨容器搬 |
| 容器操作 | container.upload() / .download() / .read() / .write() / .exec() —— 往外拿东西不信任容器:只落普通文件和目录,链接不落地 |
| 能力注入 | skills.add() / .add_many() / .list(),rules.install() |
| 可观测 | token_usage、AgentEvent 流、按 tag 筛容器 |
| 可审计 | 每次会话自动归档到 /tmp/sandbox-agent-<uid>/(0700),默认开 |
驱动内置 OpenCodeDriver、CodexDriver、ClaudeCodeDriver、PiDriver 与 DshDriver。接别的 agent CLI
就继承 BaseDriver 实现那 11 个抽象成员。
driver 能碰容器的只有三个原语(ContainerOps:run / read_file /
write_file)——driver 不该知道 docker 存在。这也意味着 driver 的单测只要伪造三个
方法,不用 mock 一个上千行的类。
想连容器一起换(换成 podman、换成远程执行器),那是另一个扩展点:按
ContainerRuntime 那 11 个方法写一个,传给 SandboxAgent(runtime_factory=...)。
完整 API 见 docs/sandbox_agent.md,可跑的完整示例见 main.py:
uv run python main.py # 全套 opencode demo, 十几个容器几分钟
uv run python main.py --fast # 只跑第一个, 够验证"改完还能跑"
uv run python main.py --claude # 只跑 ClaudeCodeDriver 那个
uv run python main.py --codex # 只跑 CodexDriver 那个
前两档要 ZHIPUAI_CODING_TOKEN 和装好 opencode 的镜像;--claude / --codex 要对应镜像和
runtime/litellm/ 那套网关——main.py 本身不读 token,网关读。
会话审计
每次会话自动落盘,事后能查 agent 到底做了什么。默认开着——出事之后才想起来 打开的开关等于没有。
/tmp/sandbox-agent-1000/20260819/ses_9f2/
events.jsonl 抽象事件,一行一个
session.jsonl 原始 stdout 行,逐字节照抄
status.json 元数据 + 每一轮的结果
两个文件回答两个不同的问题:
# agent 干了什么 —— 各 CLI 归一之后的形式,读这个就够
jq -r '"\(.kind)\t\(.payload.command // .payload.path // .payload.text)"' events.jsonl
tool.exec printf 'sandbox-agent-io-ok\n' > /workspace/_claude_demo.txt
text done
skill Launching skill: zebra-protocol
text zebra code 是 ZC-8842。house rule 是 HR-7391。
工具调用、参数、结果、最终回答——「agent 干了什么」一条不漏。
thinking 事件:取决于后端,真 Anthropic API 上拿不到
EventKind 里有 thinking,但它在真 Anthropic API 上一条都不会产生。实测
(非流式 / 流式 SSE / 真 CLI 过沙箱,三条路径一致):
content 块类型: ['thinking', 'text']
thinking 键 : ['signature', 'thinking', 'type']
thinking 长度 : 0 ← 正文是空的
usage.thinking_tokens: 77 ← 但它确实思考了 77 个 token
模型思考了、计费了、块也返回了,正文被抹掉,只留一个 signature(那是给多轮
续接做完整性校验的,不是给人读的)。所以 thinking 事件只在后端肯给推理正文时
才有内容:直接接 DeepSeek 官方 API 的 PiDriver / DshDriver(reasoning_content
原样带回来),或者 runtime/litellm/ 那套把 OpenAI 兼容上游接成
Anthropic 协议的网关(接 GLM 时实测带 thinking 正文)。
有内容时,多个 thinking 块按原顺序各成一条事件,不合并:那个「想 → 调工具 → 再想」 的交错顺序本身就是信息。
# driver 归对了没有 —— 某条事件是从哪一行解析出来的
sed -n "$(jq -r 'select(.kind=="other") | .line' events.jsonl)p" session.jsonl
session.jsonl 是逐字节照抄,不做任何过滤。system/thinking_tokens 那种进度
心跳照样全量落盘——真 Anthropic API 下一轮只有个位数,但经网关转发推理模型时能占到
97% 的行。那是它的原始输出,我们不替你判断什么重要。要过滤是读的时候的事:
jq -c 'select(.subtype != "thinking_tokens")' session.jsonl
events.jsonl 的每条带一个 line 字段回指 session.jsonl 的行号——两个文件行数
必然对不上(step_start 这类不产出事件),一个整数把对应关系还回来。
同一个 session_id 的多轮对话落进同一个目录,两个 jsonl 跨轮追加。流式写入,
跑着的会话可以直接 tail -f。
SandboxAgent(driver=driver, audit=False) # 显式关闭
目录和保留天数是 audit 模块的两个常量:
from sandbox_agent import audit
audit.AUDIT_ROOT = "/data/sandbox-audit"
audit.RETENTION_DAYS = 30 # 0 = 永不清理
清理按天数:早于 today - N 的整个日期目录删掉,每进程跑一次。日期分区让这件事
退化成一次 listdir 加比字符串,不用 walk 也不用 stat;名字不像日期的目录一概不碰。
归档失败不会影响 chat。 可观测性功能不能反过来搞死被观测的东西:磁盘满了就发 一条
RuntimeWarning然后自己降级成 no-op,该跑的照跑。
ask()的旁支在容器里删了,归档留着。 审计的意义就是留痕。
容器生命周期
容器闲置 keepalive_seconds(默认 3600s)后自杀,不是"最多活一小时"——
只要还有命令在跑就会一直续期。正常路径下 close() / with 退出即销毁,
自杀只是兜底,防止调用方崩溃后留下垃圾。
配置
优先级:环境变量 > ~/.config/sandbox-agent/config.json > 内置默认值。
| 环境变量 | JSON key | 默认值 |
|---|---|---|
SANDBOX_AGENT_IMAGE_NAME |
image |
driver 自己的 default_image(见下) |
SANDBOX_AGENT_RESOURCE_PREFIX |
prefix |
sandbox-agent |
SANDBOX_AGENT_CONTAINER_MEMORY |
memory |
1g |
SANDBOX_AGENT_CONTAINER_CPUS |
cpus |
1.0 |
SANDBOX_AGENT_KEEPALIVE_SECONDS |
keepalive_seconds |
3600(0 = 不自杀) |
SANDBOX_AGENT_MAX_CONTAINERS |
max_containers |
20 |
SANDBOX_AGENT_START_TIMEOUT |
start_timeout |
120 |
SANDBOX_AGENT_STOP_TIMEOUT |
stop_timeout |
30 |
SANDBOX_AGENT_EXEC_TIMEOUT |
exec_timeout |
60 |
SANDBOX_AGENT_CHAT_TIMEOUT |
chat_timeout |
300 |
SANDBOX_AGENT_SESSION_TIMEOUT |
session_timeout |
120 |
SANDBOX_AGENT_TRANSFER_TIMEOUT |
transfer_timeout |
300 |
所有带 timeout 的方法参数一律 None = 用这类操作的配置预算。落到哪一项取决于
操作的性质:短命令是常数耗时,会话操作随会话长度增长,传输随字节数增长 —— 三种预算,
一条规则。
开发
uv sync --all-extras
uv run pytest # 单元测试, 全 mock, 不碰 docker
uv run pytest -m integration # 集成测试, 起真容器, 约 100s
集成测试默认跳过。它们用 debian:stable-slim(不需要 agent CLI),
可用 SANDBOX_AGENT_TEST_IMAGE 换掉。busybox 的 timeout 超时不返回 124,
别拿 alpine 顶替。
镜像必须事先拉好——测试不会替你 pull(那会让一次"只跑单测"的命令悄悄卡在 一个几百秒的下载上)。没拉的话集成测试会跳过,并在输出里给出要敲的命令:
docker pull debian:stable-slim
已知限制与后续计划见 TODO.md,变更历史见 CHANGELOG.md。
发布
顺序不能反——包里的 versions.json 指向镜像,镜像还没推包就发不出去
(build_pypi.sh 会拒绝):
# 0. 改了 CLI 版本或动了 Dockerfile.base? 先 bump src/sandbox_agent/versions.json
# 的 image_date —— 它是手写的,没人替你算
./.ci/build_docker.sh --push # 1. 镜像(tag 已存在会拒绝,加 --force 覆盖)
./.ci/build_pypi.sh --push # 2. 包(镜像不齐会拒绝)
License
GPL-3.0-or-later
Metadata
Release files for sandbox-agent 0.2.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sandbox_agent-0.2.1.tar.gz | 153.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sandbox_agent-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 304.7 kB
Release files / sandbox_agent-0.2.1.tar.gz
| Download URL | sandbox_agent-0.2.1.tar.gz |
|---|---|
| Size | 153.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
162a97b9d7fb9c0a58a3cc8e1000fa59e62eab99911979cea388c959d2485f90
|
|
BLAKE2b-256 checksum How to use checksums |
636a103a3aa0df872b7f24e455a50801f6db219eef26ff014e25aa97db809cc9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / sandbox_agent-0.2.1-py3-none-any.whl
| Download URL | sandbox_agent-0.2.1-py3-none-any.whl |
|---|---|
| Size | 151.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
bf328242637190e53ffd1fd236a25f2a83d88e3a2584a3cad159e73721885e7c
|
|
BLAKE2b-256 checksum How to use checksums |
0c019f1d19452901fb993b55d102b21275f62c5e533253702237ff1d5265385e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"13","id":"trixie","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|