Sandbox 接口。换 Provider 只改 Experiment 里的一行,评估用例不用动。
选择 Provider
先按你在哪里跑评估来选:
在 Experiment 的
sandbox 字段里选一个:
漏装时,创建 Sandbox 的那一刻会报错并给出安装命令,例如
Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode。照着装好再运行即可。
Docker Compose:workspaceService 指定主 Sandbox
一道题需要不止一个容器时(比如应用容器加一个数据库),用 dockerComposeSandbox 声明整份 Compose 环境:
workspaceService 指定 Compose 文件里哪个 service 是主 Sandbox。Agent、t.sandbox 的命令和文件操作、workdir、文件改动记录,都只落在这一个容器上。
其它 service(数据库、mock server 之类)只是陪跑的资源:不能对它们 runCommand、上传或读写文件,也看不到它们的文件改动。要用到它们,只能靠网络访问,例如在 client 容器里通过 Compose 内置 DNS 访问 db:5432。
让 Agent 自己运行 Docker
Agent 需要执行docker build、docker run 或 docker compose 时,可以挂载宿主机已有的 Unix socket,也可以选择 raw privileged 或 managed rootless Docker-in-Docker。三种模式的适用场景、Dockerfile 和完整配置见让 Sandbox 使用 Docker。
在 Adapter 和评估用例里操作 Sandbox
不管用哪种 Provider,Adapter 和评估用例拿到的都是同一个Sandbox 接口。常用操作:
两个
OrThrow 方法抛出的错误消息会附带 stderr 的末尾几行,已经清理、脱敏并截断。stderr 为空时改用 stdout。排查时先看这段摘要,需要完整输出时再读 SandboxCommandExitError.result。
按顺序准备 Sandbox
Agent 开始工作前,Sandbox 里通常要先装工具、写配置、放好素材。用.before() 写这些准备步骤,用 .after() 写无论成败都要执行的收尾。每个 Provider 函数都返回一个可以继续追加步骤的 SandboxLayer,追加时不会修改原来的值:
changeFrequency 决定步骤的顺序:数值小、很少变的步骤排在前面,数值大、经常变的排在后面。这样只改了后面的步骤时,前面的步骤可以直接沿用上次的结果。它是一个普通的非负数,内置常量只是方便阅读:
dependsOn,再按数值从小到大执行。所以评估组里很少变的步骤,可以排在 Experiment 里经常变的步骤前面。
运行时,每个准备步骤都会显示在进度里,但不会泄露内容:
shell()显示你写的 command,不显示外面包的/bin/sh -lc;command()和exec显示可执行文件和参数。writeText()/writeBytes()只显示目标路径、字节数和摘要,不显示正文。- 上传只显示摘要、目标路径和字节数,不显示宿主机上的源路径。
- Git checkout 的地址会去掉用户信息、query 和 fragment;环境变量只列出名字。
context.progress() 自己报告。
声明准备缓存覆盖范围
在支持的 Provider 上,没变的准备步骤下次运行可以直接沿用,不重新执行。为此,每个.before(action) 都要如实说明它会改动 Sandbox 的哪些部分:
- 不写
cache.state等于sandboxState.all:这个步骤的全部改动都要能被保存和恢复。它不是让 Provider 自己挑一部分目录缓存的开关。 - 只有步骤的全部改动都限于 Sandbox 里已经停止写入的
/var/lib/docker时,才写sandboxState.dockerData。完整的 Docker profile 示例见让 Sandbox 使用 Docker。
cache.state 写在步骤定义处或单次调用处都可以,但只能写一处,调用处也不能覆盖定义处。
步骤的 ID、输入、展开后的命令、声明的范围和内容摘要都会被自动比较,任何一项变了,这一步就重新执行。cache.fingerprint 只用来补充 NiceEval 看不到的版本信息,不能代替自动比较。
各 Provider 能保存的范围不同:
沿用只能从第一步开始连续往下。遇到第一个无法保存的步骤,比如默认
all 但 Provider 不支持、写了 opaque 的回调,或者 Provider 不支持的范围,这一步和它后面的所有步骤都会真实执行。后面的步骤即使声明了 dockerData,也不能跳过,因为它依赖的前面状态没有被保存。
在本地单容器 Docker 上,只改了最后的 .env 步骤时,前面固定的 Git checkout、目录上传和工具安装都不会重跑。每个 Attempt 都从缓存结果的一份独立副本启动,不会继承上一个 Agent 留下的改动。
想确认哪些步骤会沿用,运行 pnpm exec niceeval debug <experiment> <eval> 查看计划,见运行前检查 Sandbox 计划。
定义可复用的准备步骤
内置的shell()、writeText()、uploadDirectory() 和 gitCheckout() 都是 SandboxAction。同一类准备步骤要在多处用时,可以用相同的机制定义自己的步骤:
Sandbox。上面关于缓存范围和自动比较的规则同样适用。
写入、上传与 Git checkout
这些内置函数可以直接传给.before():
uploadFile() 和 uploadDirectory() 接收 new URL("fixture/", import.meta.url) 这样的地址,不接收会随当前目录变化的相对路径。文件内容、权限和目录结构都会被自动比较。gitCheckout() 只接受不含凭据的 HTTPS 地址和完整的 commit ID。
要等 Agent 返回后才给出的隐藏内容,不要放进 .before()。先用 sandboxContent.file() 或 sandboxContent.directory() 取得一个句柄,再在 test(t) 里调用 t.sandbox.upload(handle, targetPath)。句柄只带内容摘要,不暴露宿主机路径。
动态回调与收尾
只有运行时才能决定的分支,直接写.before(async (sandbox, context) => ...)。回调每次都真实执行,它后面的步骤也不再沿用缓存。
成功取得资源后,立即用 context.onCleanup() 登记释放。闭包里可以直接保存这次拿到的句柄,不需要再写一个回调去猜资源状态:
sandbox.sandboxId 是当前 Sandbox 在 Provider 那边的原生 ID。一般只把它当作关联用的值,不要解析它。只有这个 layer 明确绑定了某个 Provider 时,你自己的可信代码才应该把它交给对应 SDK,去创建这个 Attempt 用的辅助资源。
这些辅助资源不归 NiceEval 管理,也不会被缓存;创建成功后必须立即登记可重复执行的清理。第一个回调开始前,NiceEval 可能换掉底下的 Sandbox 实例;回调开始之后,这个 Attempt 的清理和 .after() 看到的都是同一个 ID。
无论成败都要执行、而且可以重复执行的收尾,写成 .after()。所有登记过的收尾按登记顺序的相反顺序执行,前面的收尾失败也会继续执行后面的:
context.progress() 只更新当前 Attempt 的临时状态,context.diagnostic() 保存事后需要回看的问题。环境没法继续时直接抛错,NiceEval 会把这个 Attempt 记为环境错误,而不是 Agent 做错了题。
不带 Sandbox 的 defineAgent 没有准备步骤。Sandbox Agent 可以在自己的 sandbox 字段里加 sandboxLayer() 形式的命令步骤,但不能选择或替换 Provider 和 template。
预制环境
每个 Attempt 都要下载或安装的同一份内容,如果稳定、昂贵或体积大,就把它做进预制环境,让 Attempt 直接从装好的环境启动。三种 Provider 的写法统一:瞬时错误重试
内置 Provider 创建 Sandbox 时,遇到限流、fetch failed、连接重置、5xx、临时网络不可达这类临时失败,会自动按指数退避重试。模板不存在、凭据缺失这类配置错误第一次就报错。重试用完后,这个 Attempt 记为 errored。defineSandbox 自定义 Provider 的 create 是你自己的函数,NiceEval 不替它重试。
readText、readBytes、downloadFile、uploadFile、文件写入和目录上传,遇到 429、5xx、fetch failed、连接重置等临时传输错误时,会自动重试有限次。文件不存在、权限错误、取消和 Sandbox 已终止不重试。
runCommand 和 runShell 不自动重试,因为命令可能已经产生了副作用。只有你确认某条命令可以安全重复时,才在 hook 或评估用例里自己重试。
自定义 Provider
要接入其它服务,用defineSandbox。你要自己负责文件系统、进程、网络和凭据的隔离,NiceEval 不会为任意一个 Sandbox 实现自动补上这些安全边界。
create 拿到的 feedback 用来报告创建阶段的状态,比如分配实例、拉镜像或恢复快照:
Sandbox 接口即可。recommendedConcurrency 是没有其它设置时的默认并发数。
不要把 Provider SDK 的原始日志直接写到宿主进程的 stdout / stderr:临时状态用 feedback.progress,需要保留的问题用 feedback.diagnostic,无法创建环境时抛错。这样终端的进度面板不会被日志打乱,CI 里也只有一份有序的输出。
权限和执行身份
不同 Provider 对执行用户、网络、文件系统和进程生命周期的限制不同。写 Fixture 时尽量不要依赖宿主机环境,把依赖写进package.json 或 Fixture 的准备步骤。
性能建议
- 把稳定的重依赖做进 image、template 或快照,或者写成低频的
.before()步骤。 - 减小 Fixture 依赖的体积。
- 动态内容用小而明确的缓存或预先检查。
- 控制
maxConcurrency(Experiment 字段或--max-concurrency),避免本地 Docker 资源耗尽。 - 把慢的检查拆开:必须通过的保留为 gate,可有可无的用
.optional()。
在 Attempt 之间复用 Provider 启动
Sandbox 启动时间占了大头时,在 Experiment 里声明sandboxReuse: true。多个 Attempt 会依次共用一台 Sandbox:每台只创建一次,每个 Attempt 仍然执行自己的 .before() / .after(),题与题之间工作目录会被重置。Docker 上没变的准备步骤还可以直接沿用。