niceeval 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +8 -8
  2. package/README.zh.md +13 -13
  3. package/docs/README.md +119 -0
  4. package/docs/adapters/README.md +60 -0
  5. package/docs/adapters/authoring.md +179 -0
  6. package/docs/adapters/coding-agent-skills-plugins.md +324 -0
  7. package/docs/adapters/collection.md +128 -0
  8. package/docs/adapters/contract.md +263 -0
  9. package/docs/adapters/reference/agent-eval.md +215 -0
  10. package/docs/adapters/reference/agent-loop-apis.md +69 -0
  11. package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
  12. package/docs/adapters/reference/eve-protocol.md +127 -0
  13. package/docs/adapters/reference/otel-genai.md +107 -0
  14. package/docs/adapters/reference/otel-instrumentation.md +65 -0
  15. package/docs/adapters/targets.md +99 -0
  16. package/docs/architecture.md +140 -0
  17. package/docs/assertions.md +388 -0
  18. package/docs/capabilities-by-construction.md +48 -0
  19. package/docs/cli.md +171 -0
  20. package/docs/concepts.md +106 -0
  21. package/docs/e2e-ci.md +295 -0
  22. package/docs/eval-authoring.md +319 -0
  23. package/docs/experiments.md +154 -0
  24. package/docs/getting-started.md +224 -0
  25. package/docs/multi-agent.md +145 -0
  26. package/docs/observability.md +333 -0
  27. package/docs/origin-integration.md +195 -0
  28. package/docs/references.md +35 -0
  29. package/docs/results-format.md +228 -0
  30. package/docs/runner.md +104 -0
  31. package/docs/sandbox.md +276 -0
  32. package/docs/scoring.md +119 -0
  33. package/docs/source-map.md +106 -0
  34. package/docs/tier-sync.md +177 -0
  35. package/docs/view.md +157 -0
  36. package/docs/vision.md +88 -0
  37. package/package.json +35 -5
  38. package/src/agents/ai-sdk-otel.ts +0 -0
  39. package/src/agents/ai-sdk.test.ts +377 -0
  40. package/src/agents/ai-sdk.ts +577 -0
  41. package/src/agents/bub.ts +30 -37
  42. package/src/agents/claude-code.ts +7 -4
  43. package/src/agents/codex.ts +15 -10
  44. package/src/agents/index.ts +43 -1
  45. package/src/agents/sdk-streams.test.ts +145 -0
  46. package/src/agents/sdk-streams.ts +457 -0
  47. package/src/agents/shared.ts +77 -39
  48. package/src/agents/streaming.test.ts +152 -0
  49. package/src/agents/streaming.ts +195 -0
  50. package/src/agents/types.ts +218 -0
  51. package/src/agents/ui-message-stream.test.ts +249 -0
  52. package/src/agents/ui-message-stream.ts +372 -0
  53. package/src/cli.ts +124 -77
  54. package/src/context/context.test.ts +203 -7
  55. package/src/context/context.ts +158 -49
  56. package/src/context/session.test.ts +96 -0
  57. package/src/context/session.ts +119 -9
  58. package/src/context/types.ts +205 -0
  59. package/src/define.ts +4 -14
  60. package/src/expect/index.ts +8 -71
  61. package/src/i18n/core.ts +28 -0
  62. package/src/i18n/en.ts +47 -5
  63. package/src/i18n/index.ts +5 -17
  64. package/src/i18n/zh-CN.ts +47 -5
  65. package/src/index.ts +12 -0
  66. package/src/loaders/index.ts +8 -39
  67. package/src/o11y/otlp/canonical.ts +7 -6
  68. package/src/o11y/otlp/mappers/codex.ts +10 -8
  69. package/src/o11y/otlp/mappers/index.ts +9 -21
  70. package/src/o11y/otlp/receiver.ts +25 -7
  71. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  72. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  73. package/src/o11y/otlp/turn-otel.ts +125 -0
  74. package/src/o11y/parsers/bub.ts +13 -11
  75. package/src/o11y/parsers/claude-code.ts +27 -51
  76. package/src/o11y/parsers/codex.ts +29 -46
  77. package/src/o11y/parsers/index.ts +5 -14
  78. package/src/o11y/tool-names.test.ts +61 -0
  79. package/src/o11y/tool-names.ts +74 -0
  80. package/src/o11y/types.ts +135 -0
  81. package/src/runner/attempt.ts +456 -0
  82. package/src/runner/fingerprint.ts +59 -0
  83. package/src/runner/remote-sandbox.ts +53 -0
  84. package/src/runner/report.ts +53 -0
  85. package/src/runner/reporters/artifacts.ts +25 -4
  86. package/src/runner/reporters/console.ts +2 -8
  87. package/src/runner/reporters/live.ts +2 -8
  88. package/src/runner/reporters/shared.ts +15 -0
  89. package/src/runner/reporters/table.ts +10 -62
  90. package/src/runner/run.ts +114 -619
  91. package/src/runner/types.ts +237 -0
  92. package/src/sandbox/checkpoint.ts +15 -13
  93. package/src/sandbox/docker-stream.ts +87 -0
  94. package/src/sandbox/docker.ts +42 -120
  95. package/src/sandbox/e2b.ts +24 -75
  96. package/src/sandbox/local-files.ts +33 -0
  97. package/src/sandbox/paths.test.ts +85 -0
  98. package/src/sandbox/paths.ts +45 -0
  99. package/src/sandbox/resolve.ts +10 -34
  100. package/src/sandbox/shell.ts +17 -0
  101. package/src/sandbox/source-files.ts +42 -2
  102. package/src/sandbox/types.ts +158 -0
  103. package/src/sandbox/vercel.ts +55 -71
  104. package/src/scoring/collector.ts +3 -2
  105. package/src/scoring/judge.ts +72 -146
  106. package/src/scoring/match.ts +79 -0
  107. package/src/scoring/types.ts +76 -0
  108. package/src/shared/aggregate.ts +48 -0
  109. package/src/shared/format.ts +20 -0
  110. package/src/shared/outcome.ts +48 -0
  111. package/src/shared/types.ts +37 -0
  112. package/src/types.ts +20 -911
  113. package/src/view/aggregate.ts +103 -0
  114. package/src/view/app/App.tsx +26 -5
  115. package/src/view/app/components/AttemptModal.tsx +12 -3
  116. package/src/view/app/components/CodeView.tsx +9 -5
  117. package/src/view/app/components/CopyControls.tsx +4 -4
  118. package/src/view/app/components/ExperimentTable.tsx +5 -5
  119. package/src/view/app/i18n.ts +31 -7
  120. package/src/view/app/lib/format.ts +17 -20
  121. package/src/view/app/lib/outcome.ts +4 -16
  122. package/src/view/app/main.tsx +3 -5
  123. package/src/view/app/pages/RunsPage.tsx +2 -2
  124. package/src/view/app/pages/TracesPage.tsx +2 -2
  125. package/src/view/app/shared.ts +2 -1
  126. package/src/view/app/types.ts +6 -43
  127. package/src/view/client-dist/app.css +1 -1
  128. package/src/view/client-dist/app.js +18 -18
  129. package/src/view/index.ts +24 -401
  130. package/src/view/loader.ts +234 -0
  131. package/src/view/server.ts +136 -0
  132. package/src/view/shared/types.ts +64 -0
  133. package/src/view/styles.css +60 -9
@@ -0,0 +1,228 @@
1
+ # Results Format —— 结果保存格式
2
+
3
+ 这篇记录 `Artifacts()` 报告器写到本地磁盘的格式,也是 `niceeval view` 的离线输入契约。实现入口是 `src/runner/reporters/artifacts.ts`;核心类型在 `src/types.ts` 的 `RunSummary`、`EvalResult`、`StreamEvent`、`TraceSpan`、`O11ySummary` 和 `DiffData`。
4
+
5
+ ## 目录结构
6
+
7
+ 默认输出根目录是 `.niceeval/`。每次 run 一个时间戳目录,时间戳来自 `Date#toISOString()`,并把 `:` 与 `.` 替换成 `-`:
8
+
9
+ ```text
10
+ .niceeval/
11
+ 2026-07-02T03-10-24-123Z/
12
+ summary.json
13
+ <evalId>/<agent>/<model>/a<attempt>/
14
+ events.json
15
+ sources.json
16
+ trace.json
17
+ o11y.json
18
+ diff.json
19
+ ```
20
+
21
+ `<evalId>/<agent>/<model>/a<attempt>/` 是单个 eval attempt 的工件目录。`evalId` 里的 `/` 会保留为目录层级,其它不适合路径的字符会替换成 `_`;`agent` 和 `model` 里的非 `[\w.@-]` 字符也会替换成 `_`。没有 model 时目录名是 `default`。
22
+
23
+ 这些文件是按需写入的:某类数据为空就不生成对应 JSON 文件。`summary.json` 在 run 结束时写入;attempt 级重数据在每个 eval 完成时增量写入,所以长 run 中途失败时通常仍能留下已经完成的 attempt 工件。
24
+
25
+ ## 版本与升级设计
26
+
27
+ `summary.json` 顶层带最小的版本元数据(writer 在 `src/runner/reporters/artifacts.ts`,常量在 `src/types.ts` 的 `RESULTS_FORMAT` / `RESULTS_SCHEMA_VERSION`):
28
+
29
+ ```json
30
+ {
31
+ "format": "niceeval.results",
32
+ "schemaVersion": 1,
33
+ "producer": {
34
+ "name": "niceeval",
35
+ "version": "0.12.0"
36
+ },
37
+ "startedAt": "2026-07-02T03:10:24.123Z",
38
+ "results": []
39
+ }
40
+ ```
41
+
42
+ 设计原则是**不做兼容机制**。没有迁移函数,没有多版本 normalize loader,没有 per-artifact 版本号:整个 run(summary + 全部 attempt 工件)共用顶层这一个 `schemaVersion`。读取器只认与自己相同的版本;版本不同就是不兼容,唯一的处理是提示用写这份报告的 niceeval 版本查看:
43
+
44
+ ```bash
45
+ npx niceeval@0.3.0 view .niceeval/2026-09-10T08-00-00-000Z
46
+ ```
47
+
48
+ 字段规则:
49
+
50
+ - `format` 必须等于 `"niceeval.results"`。它既避免把其它工具的 `summary.json` 误读成 niceeval,也是版本不匹配时识别「这是一份 niceeval 报告」的依据。
51
+ - `schemaVersion` 用整数,只在**破坏兼容读取**时递增。新增可选字段、新增 artifact 文件、新增 `StreamEvent` variant 不递增;读取器必须忽略未知字段和未知 artifact 文件。
52
+ - `producer.version` 是写这份报告的 npm package 版本,唯一用途是拼 npx 提示;它不是 schema 判断依据。
53
+ - `format` / `schemaVersion` / `producer` 三个字段永久稳定:任何未来版本都不能移动、重命名或改变类型,否则版本不匹配时连 npx 提示都给不出来。
54
+ - 缺版本字段的存量文件等价于 `schemaVersion: 1`(引入版本号不改变其余格式)。这批文件没有 `producer.version`,将来不兼容时提示只能是模糊的「用 0.1.x 旧版查看」——所以 writer 越早开始写版本元数据越好。
55
+ - attempt 文件保持裸 JSON array/object。`events.json` 继续是 `StreamEvent[]`,不为塞版本号改成 `{ schemaVersion, data }` envelope;`jq`/`node` 直接读数组的体验不被打破。
56
+ - 不要用目录名表达 schema。`.niceeval/<timestamp>/` 和 attempt 目录只表达身份与定位;版本全部在 `summary.json` 里,复制、重命名、归档目录不影响解析。
57
+
58
+ ### 版本不匹配时的读取行为
59
+
60
+ 读取器不解析、不迁移、不降级渲染任何版本不同的 run,行为只分三档:
61
+
62
+ - **`schemaVersion` 相同**(或缺失,按 1 处理):正常读取渲染。
63
+ - **`format === "niceeval.results"` 但 `schemaVersion` 不同**(不论新旧):整个 run 视为不兼容。目录扫描时在列表里留一个占位条目,标出 run 目录和 `producer.version`,并提示:
64
+
65
+ ```text
66
+ ⚠ .niceeval/2026-09-10T08-00-00-000Z: written by niceeval 0.3.0 (schemaVersion 2);
67
+ this CLI reads schemaVersion 1.
68
+ Run `npx niceeval@0.3.0 view .niceeval/2026-09-10T08-00-00-000Z` to view it.
69
+ ```
70
+
71
+ 单文件模式 `niceeval view <run>/summary.json` 输出同样的提示后退出,而不是报「不是 niceeval summary」。
72
+ - **不能识别**(没有 `format`,也不满足 legacy 的 `results[]` + `startedAt` 启发式):当作无关 JSON 忽略。
73
+
74
+ 实现入口:版本判定在 `src/view/index.ts` 的 `readSummary`(抛 `IncompatibleResultsError`);目录扫描的占位数据经 `viewData.incompatibleRuns` 进前端,由 `src/view/app/App.tsx` 的 incompatible-banner 渲染;单文件模式的提示与退出在 `src/cli.ts` 的 `exitOnIncompatibleResults`;提示文案是 i18n key `cli.view.incompatible`。
75
+
76
+ 报告里最小应新增的字段是:
77
+
78
+ ```typescript
79
+ interface ResultFormatMeta {
80
+ format: "niceeval.results";
81
+ schemaVersion: number;
82
+ producer?: {
83
+ name: "niceeval";
84
+ version?: string;
85
+ commit?: string;
86
+ };
87
+ }
88
+ ```
89
+
90
+ 这组字段应该放进 `RunSummary`,但 eval 作者的运行时 API 不需要看见它们;它们属于 reporter / view 的持久化契约。
91
+
92
+ ## `summary.json`
93
+
94
+ `summary.json` 是瘦身后的 `RunSummary`,负责让控制台、`--resume` 和 `niceeval view` 先拿到榜单级信息。前三个字段(`format` / `schemaVersion` / `producer`)是上文的版本元数据:
95
+
96
+ ```typescript
97
+ interface RunSummary {
98
+ format?: "niceeval.results";
99
+ schemaVersion?: number;
100
+ producer?: { name: "niceeval"; version?: string; commit?: string };
101
+ name?: LocalizedText;
102
+ agent: string;
103
+ model?: string;
104
+ startedAt: string;
105
+ completedAt: string;
106
+ passed: number;
107
+ failed: number;
108
+ skipped: number;
109
+ errored: number;
110
+ durationMs: number;
111
+ usage?: Usage;
112
+ estimatedCostUSD?: number;
113
+ results: EvalResult[];
114
+ outputDir?: string;
115
+ }
116
+ ```
117
+
118
+ `results[]` 里的每条 `EvalResult` 仍包含判决、断言、用量、成本、错误、fingerprint 和 experiment 元数据,但不会内联大字段:
119
+
120
+ - `events`
121
+ - `sources`
122
+ - `trace`
123
+ - `o11y`
124
+ - `diff`
125
+ - `rawTranscript`
126
+
127
+ 这些字段被替换成 attempt 工件引用:
128
+
129
+ ```typescript
130
+ {
131
+ "artifactsDir": "weather/brooklyn/codex/gpt-5/a1",
132
+ "hasEvents": true,
133
+ "hasTrace": true,
134
+ "hasSources": true
135
+ }
136
+ ```
137
+
138
+ `artifactsDir` 是相对当前 run 目录的路径。`niceeval view` 读取 `summary.json` 后,会把它补成:
139
+
140
+ - `artifactBase`: 相对 view 输入根目录的路径,供前端请求 `/artifact?p=...`;
141
+ - `artifactAbsBase`: 本机绝对路径,供 UI 复制或展示。
142
+
143
+ 注意:当前 summary 只有 `hasEvents`、`hasTrace`、`hasSources` 三个存在标记。`o11y.json` 和 `diff.json` 会写盘,但 summary 里还没有对应 `hasO11y` / `hasDiff` 标记;读取方需要按路径尝试读取或先检查文件存在。
144
+
145
+ ## Attempt 级文件
146
+
147
+ ### `events.json`
148
+
149
+ 类型是 `StreamEvent[]`。这是从 agent 原始 transcript 归一化后的标准事件流,也是作用域断言、transcript 展示、工具调用统计的主要来源。
150
+
151
+ 常见事件包括:
152
+
153
+ - `message`: assistant / user 文本;
154
+ - `action.called` / `action.result`: 工具调用与结果;
155
+ - `subagent.called` / `subagent.completed`: 子 agent 调用;
156
+ - `input.requested`: HITL 输入请求;
157
+ - `thinking`: 思考块;
158
+ - `compaction`: 上下文压缩;
159
+ - `error`: 运行时或采集错误。
160
+
161
+ 文件内容是一个 JSON array,不是 JSONL / NDJSON。
162
+
163
+ ### `sources.json`
164
+
165
+ 类型是 `SourceArtifact[]`:
166
+
167
+ ```typescript
168
+ interface SourceArtifact {
169
+ path: string;
170
+ content: string;
171
+ }
172
+ ```
173
+
174
+ 它只包含本次 test/断言通过 `loc` 引用到的 eval 源码片段。`niceeval view` 用它把 `t.send`、断言和运行结果叠回源码行。
175
+
176
+ ### `trace.json`
177
+
178
+ 类型是 `TraceSpan[]`。只有 agent 声明 tracing 能力、运行器收到 OTLP span 并成功归一化时才会生成。它回答「各步骤耗时多久、父子关系是什么」,与回答「做了什么」的 `events.json` 分开。
179
+
180
+ `TraceSpan.kind` 是 view 识别的核心字段,来自 canonical GenAI 语义角色:
181
+
182
+ - `turn`
183
+ - `model`
184
+ - `tool`
185
+ - `agent`
186
+ - `other`
187
+
188
+ 原生 span 名和属性仍保留在 `name` / `attributes` 里,但 view 的分组与着色只应依赖 canonical 字段。
189
+
190
+ ### `o11y.json`
191
+
192
+ 类型是 `O11ySummary`。这是从标准事件流派生的行为摘要,包括工具调用计数、读写文件、shell 命令、web fetch、错误、思考块、压缩次数、耗时、usage 和估算成本。
193
+
194
+ 这个文件面向人和调试脚本:当一个 attempt 失败时,先看 `summary.json` 的 `outcome` / `error`,再看 `events.json` 与 `o11y.json`,通常能分清是断言没过、agent runtime 错误,还是 adapter / provider / timeout 问题。
195
+
196
+ ### `diff.json`
197
+
198
+ 类型是 `DiffData`:
199
+
200
+ ```typescript
201
+ interface DiffData {
202
+ generatedFiles: Record<string, string>;
203
+ deletedFiles: string[];
204
+ }
205
+ ```
206
+
207
+ 它只存在于有沙箱 workspace diff 的运行。coding-agent eval 常用它验证文件修改结果;remote / in-process agent 不一定有 diff。
208
+
209
+ ## 读取规则
210
+
211
+ 读取结果时优先从 `summary.json` 开始:
212
+
213
+ 1. 读 `.niceeval/<run>/summary.json`,先用 `results[]` 判断 pass / fail / error / skip、耗时、成本和断言失败。
214
+ 2. 对需要下钻的 result,用 `artifactsDir` 拼出 attempt 目录。
215
+ 3. 按 `hasEvents` / `hasTrace` / `hasSources` 拉取 `events.json`、`trace.json`、`sources.json`。
216
+ 4. 需要行为摘要或 workspace diff 时,尝试读取同目录的 `o11y.json` / `diff.json`。
217
+
218
+ `niceeval view` 的本地 server 只暴露 `.json` 工件,并把请求路径限制在 view 输入根目录内。`--out` 导出的静态 HTML 会把 summary 聚合数据烘焙进单文件;拆分工件仍是本地 view server 的按需读取能力。
219
+
220
+ ## 与其它 reporter 的边界
221
+
222
+ 这篇只描述默认 `Artifacts()` reporter 的本地目录格式。`Json(path)` reporter 写的是机器可读全量 JSON,用途不同;第三方实验平台 reporter 可以把同一批 `EvalResult` / `RunSummary` 转成自己的格式。
223
+
224
+ 因此,不要在文档或工具里假设本地结果有 `results.jsonl`、transcript NDJSON 或固定测试输出文件。当前稳定契约是:
225
+
226
+ - run 级: `summary.json`;
227
+ - attempt 级: `events.json`、`sources.json`、`trace.json`、`o11y.json`、`diff.json`;
228
+ - 每个文件都是 JSON,不是 JSONL。
package/docs/runner.md ADDED
@@ -0,0 +1,104 @@
1
+ # Runner —— 执行引擎
2
+
3
+ 运行器是把"一批 eval"变成"一份结果"的调度引擎。它拥有对所有被测对象都一样的部分:发现、有界并发、早停、缓存、报告编排。被测对象的差异它一概不管 —— 它只对着 `Agent` 接口(统一动词 `send`)驱动。
4
+
5
+ 这是 niceeval "跑得快"承诺的落点,见 [Vision](vision.md#跑得快)。
6
+
7
+ ## 职责边界
8
+
9
+ 运行器**做**:发现 eval、算指纹决定跳过、建 attempt 列表、有界并发调度、早停、把结果交给报告器、落盘工件、定退出码。
10
+
11
+ 运行器**不做**:不知道怎么驱动 agent(那是 Agent/Adapter)、不知道怎么打分(那是 Scorer)、不知道结果存哪种格式细节(那是 Reporter)。它是协调者,不是执行者。
12
+
13
+ ## 发现
14
+
15
+ `runner/discover.ts` 扫 `evals/`:
16
+
17
+ - 找所有 `*.eval.ts`,`import` 后看默认导出 —— 单个 eval 用文件 id;数组则扇出,id 加零填充索引(`sql/0000`)。没有另一种基于目录约定的隐式发现——沙箱型 eval 也必须有一个 `.eval.ts` 文件。
18
+ - 按相对路径排序,保证 id 稳定、输出可比。
19
+ - 应用过滤:`niceeval exp <组|配置>` 后的位置参数(id 前缀,如 `weather` 命中 `weather/*`)、`--tag`。
20
+ - `niceeval exp` 时另从 `experiments/` 扫实验文件(默认导出 `defineExperiment` 的 `.ts`),据路径推导实验 id;**目录段即"可对比组"** —— `niceeval exp <组>` 跑整个文件夹、同组互为对照(见 [实验怎么组织](experiments.md#实验怎么组织文件夹--一组可对比的实验))。实验的 `evals` 字段再筛要跑哪些 eval(见[矩阵展开](#矩阵展开与通过率))。
21
+
22
+ ## 调度:有界并发
23
+
24
+ 核心调度用 `Effect.forEach({ concurrency: maxConcurrency })` 实现:每个 attempt 跑在自己的 fiber,至多 `maxConcurrency` 个并发。报告回调走 **permit=1 的信号量串行化**,不阻塞执行 fiber。结果最后按**发现顺序**排序(而非完成顺序),让输出稳定可 diff。
25
+
26
+ 并发上限来源:`--max-concurrency` → 配置 `maxConcurrency` → 默认。沙箱型受沙箱后端容量约束(本地 Docker 别开太高;云后端可大)。
27
+
28
+ ## 矩阵展开与通过率
29
+
30
+ 一次 `exp` 运行把一批配置展成 attempt:通常来自**一组文件夹里的多个单一配置**(`compare/bub-gpt-5.4` + `compare/codex-gpt-5.4`,见 [实验怎么组织](experiments.md#实验怎么组织文件夹--一组可对比的实验));再 × `eval × runs`。比如 2 个实验配置 × `runs: 5` × 3 个 eval = 30 个 attempt。汇总按 `(agent, model, eval)` 分组,不再是单一判决,而是**通过率** + 平均耗时 / token / 成本:
31
+
32
+ ```text
33
+ fixtures/button claude-code pass@5 = 4/5 (80%) mean 34s · 58k tok · $0.44
34
+ fixtures/button codex pass@5 = 3/5 (60%) mean 41s · 72k tok · $0.39
35
+ ```
36
+
37
+ 用于衡量 agent 的稳定性(一次过 ≠ 可靠),以及跨 agent 的**质量 × 成本**对比。不写实验时退化成单 agent × `runs`。
38
+
39
+ ## 早停(earlyExit)
40
+
41
+ 取通过率本可以跑满 N 次,但若只关心"能不能做到",先过一次即可停其余:
42
+
43
+ - 每个 eval 配一个 `AbortController`。
44
+ - 某 attempt 通过且 `earlyExit` 开 → `abort()` 同 eval 其余 attempt;被 abort 的不计入分母。
45
+ - 某 attempt `errored`(框架/环境层面的意外,不是断言没过)且 `earlyExit` 开 → 同样 `abort()` 其余 attempt。errored 通常会确定性重复,跑满 `runs` 只是重复烧同一个错误;只有 `failed` 才是 agent 行为的样本,值得跑满 `runs` 测通过率。
46
+ - 默认开;`--no-early-exit` 关(想要完整通过率分布时)。
47
+
48
+ ## 预算护栏(budget)
49
+
50
+ 实验可设 `budget`(整轮估算成本上限 $),`--budget` 覆盖。运行器在派发每个 attempt 前检查已花成本(用量 × 价格表,见 [Observability](observability.md#用量与成本token--计费)),并给**在飞的 attempt 做预扣**:还没有任何完成样本时,同一 budget 域只放一个 attempt 在飞(先探出单次成本);有样本后按平均实测成本给每个在飞 attempt 预扣,预计总额到顶就等在飞的结算。已花到顶则**停止派发新 attempt**(在飞的跑完),整轮提前收尾并发 `run:budgetExceeded`。没有预扣的话,`maxConcurrency` 个 attempt 会在任何成本回写前全部起飞,实际花费能冲到 budget 的好几倍。借鉴 crabbox 的 spend cap,避免一次跑爆账单。
51
+
52
+ ## 缓存:指纹去重
53
+
54
+ `runner/fingerprint.ts` 对每个 eval 算 `(eval 代码 + 相关配置)` 的哈希:
55
+
56
+ - 上次已 `passed` 且指纹未变 → 默认**跳过**,直接复用结果。
57
+ - 改了 fixture、改了配置、或 `--force` → 重跑。
58
+ - 失败的结果不缓存(总会重试失败项)。
59
+
60
+ 让"改一个 case 重跑"只花那一个 case 的时间,而不是全量。
61
+
62
+ ## 超时:双层保护
63
+
64
+ - **Adapter 内层超时** —— agent CLI 自己的超时。
65
+ - **运行器外层超时** —— `Promise.race` 一个 `AbortSignal.timeout`,即使 agent 卡死也能强行收尾,标记该 attempt / eval 为 `errored`(error: timeout)并触发 abort。
66
+
67
+ 外层是兜底,保证一个卡死的 case 不会挂起整批。
68
+
69
+ ## 环境预置不进运行器
70
+
71
+ niceeval **没有框架级生命周期钩子**——运行器只固定"发现 → 调度 → 沙箱起停 / git 基线 / 采 diff → 评分 → 报告"这条主轴,不替用户跑任何环境 `setup` / `teardown`。要在跑 agent 前准备环境,分摊到三处已有职责:连 agent / 装 CLI 走 [`SandboxAgent.setup`](adapters/contract.md#agent-契约),这条 eval 的沙箱预置写在 `test(t)` 里,整轮共享的外部服务(mock API、DB)用外部编排(`docker compose` / CI 脚本)起停、经 env 传入。完整说明见 [环境预置放哪](sandbox.md#环境预置放哪)。
72
+
73
+ **下游分析**(二次评分、自定义指标)走 [reporter](observability.md#reporters),不另设运行钩子——这是从 agent-eval 的 `onRunComplete` 收敛过来的(见 [Experiments 砍字段](experiments.md#从-agent-eval-砍掉了什么以及为什么))。
74
+
75
+ ## 运行器事件
76
+
77
+ 运行器发一串事件,供 CLI dashboard、reporter、外部集成消费:
78
+
79
+ ```text
80
+ run:start { total, agent, model }
81
+ eval:start { id, attempt }
82
+ eval:complete { id, attempt, outcome, durationMs, usage, estimatedCostUSD }
83
+ run:earlyExit { id }
84
+ run:budgetExceeded { spentUSD, budgetUSD }
85
+ run:saved { outputDir }
86
+ run:summary { passed, failed, skipped, errored, durationMs, usage, estimatedCostUSD }
87
+ ```
88
+
89
+ `outcome` 是互斥的判决分类:`passed` / `failed` / `errored` / `skipped`,没有 `scored` 中间态。`run:summary.failed` 只统计断言/评分不通过,环境、超时、adapter 或 agent runtime 问题统计到 `errored`。
90
+
91
+ ## 退出码
92
+
93
+ - 全 `passed` → `0`。
94
+ - 任一 `failed`(含 `--strict` 下 soft 未达标而改判的)→ 非零。
95
+ - 任一 `errored` → 非零。
96
+
97
+ 供 CI 直接判红绿。
98
+
99
+ ## 相关阅读
100
+
101
+ - [Architecture](architecture.md) —— 运行器在四段数据流里的位置与端到端时序。
102
+ - [Sandbox](sandbox.md) —— 预热与复用的后端支持,以及环境预置放哪。
103
+ - [Observability](observability.md) —— 运行器产出的工件与报告。
104
+ - [CLI](cli.md) —— 暴露这些调度行为的标志。
@@ -0,0 +1,276 @@
1
+ # Sandbox —— 在哪里跑
2
+
3
+ 沙箱回答"在哪里、如何隔离地运行 agent 命令"。它把隔离环境的全部特殊性关进一个统一接口,让 [Adapter](adapters/README.md) 和核心都不必知道底下是 Docker 还是某个三方服务。
4
+
5
+ ## 为什么需要沙箱
6
+
7
+ 评一个 coding agent 意味着让一个 LLM 在真实文件系统上**执行任意命令**(装包、改文件、跑构建)。这必须隔离:
8
+
9
+ - **安全** —— agent 可能跑出危险命令,不能碰你的机器。
10
+ - **可复现** —— 每个 case 一套干净环境,互不污染。
11
+ - **可并发** —— 几十个 case 同时跑,各自独立。
12
+ - **可采集** —— 跑完用 `git diff` 取改动、读 transcript,环境随后销毁。
13
+
14
+ ## 后端统一接口
15
+
16
+ ```typescript
17
+ interface Sandbox {
18
+ /** agent 的默认工作目录;所有沙箱侧相对路径的解析基准。见下「路径与 workdir」。 */
19
+ readonly workdir: string;
20
+
21
+ runCommand(cmd: string, args?: string[], opts?: {
22
+ env?: Record<string, string>;
23
+ cwd?: string; // 省略 → workdir;相对路径 → 解析到 workdir 下
24
+ root?: boolean; // 以 root 跑(默认 false → 非 root);见下「用户与 root」
25
+ }): Promise<{ stdout: string; stderr: string; exitCode: number }>;
26
+
27
+ runShell(script: string, opts?): Promise<CommandResult>; // 整段 shell
28
+
29
+ readFile(path: string): Promise<string>; // 相对路径 → workdir 下
30
+ writeFiles(files: Record<string, string>, targetDir?: string): Promise<void>; // targetDir 省略 → workdir
31
+ uploadFiles(files: SandboxFile[], targetDir?: string): Promise<void>; // 批量(可含二进制);同上
32
+ uploadDirectory(localDir: string, targetDir?: string, opts?): Promise<void>; // 同上
33
+
34
+ stop(): Promise<void>;
35
+ }
36
+ ```
37
+
38
+ 这是后端实现和 runner 使用的底层接口,所以包含 `stop()`。eval 作者在 `test(t)` 里拿到的是 author-facing 的 `t.sandbox`:只暴露文件 IO、命令执行和结果断言 / diff,不暴露 `stop()`。沙箱生命周期由 runner 统一管理。
39
+
40
+ ### 为什么 `runCommand` 和 `runShell` 不合并成一个
41
+
42
+ `runCommand` 按 argv 数组传参,不经过 shell 解析——参数原样传给进程,天然不怕参数里带引号、`$`、`;`、反引号等特殊字符,也没有 shell 注入风险。`runShell` 接受一整段脚本交给 shell 解释,专门给需要管道、`&&`、通配符这类 shell 语义的场景用。
43
+
44
+ 这不是两个方法碰巧长得像,是故意保留的两种不同意图:eval 里的命令参数经常来自数据集字段或 agent 生成的输出,内容不可控——比如 `runCommand("./verify.sh", [row.filename])`,`row.filename` 就算是 `"a; rm -rf /workspace"` 这种字符串,argv 形式下也只是一个普通参数值,不会被解释成两条命令。如果合并成一个走 shell 的 `run(cmd: string)`,调用者就必须自己把每个动态值转义成安全的 shell 字符串才能拼进去,一旦漏转义就是真实的命令注入。
45
+
46
+ 参考过 eve.dev 的 `sandbox.run({ command })`(它下面所有后端都固定走 `bash -lc`,靠调用者自己用 `shellQuote()` 转义)——那套设计合理,是因为 eve 的调用方几乎都是 AI agent 自己的 bash 工具或内部工具核心,生成一整段 shell 命令本来就是它们的原生表达方式,shell 语义是刚需。niceeval 的调用方是写 eval 的人,大多数调用(`runCommand("npm", ["test"])`)根本不需要 shell 语义,不该为了少数需要管道/`&&`的场景让所有调用都背上手动转义的心智负担。
47
+
48
+ ## 路径与 workdir:一个坐标系
49
+
50
+ 每个后端的 agent 默认工作目录不同——这是**后端的知识,不是 eval 作者的负担**:
51
+
52
+ | 后端 | workdir |
53
+ | --- | --- |
54
+ | docker | `/home/sandbox/workspace` |
55
+ | E2B | `/home/user/workspace` |
56
+ | Vercel Sandbox | `/vercel/sandbox` |
57
+
58
+ 契约一句话:**API 里任何沙箱侧相对路径,一律解析到 `workdir`;省略的 `targetDir` / `cwd` 默认就是 `workdir`;绝对路径原样使用。** 本地侧(宿主机)的相对路径则解析到 eval 定义文件所在目录。两侧各只有一个锚点,学一次就够。
59
+
60
+ 为什么 workdir 是唯一正确的默认值:整条流水线都锚定在它上面——git 基线打在那里、agent 的 cwd 在那里、跑完 `git diff HEAD` 采改动在那里、`t.fileChanged(...)` 的路径也是对着那里解析的。把起始文件传到任何**别的**目录,agent 看不见它,diff 也采不到它,整条 eval 静默失效。所以对上传起始 workspace 这个最高频调用来说,workdir 不是"常见选择",是唯一能让系统其余部分正常工作的选择——一个参数如果 99% 的调用只有一个正确值,而调用者又不掌握这个值(它随后端变),强制填写就不是"显式更安全",是逼人抄错答案。
61
+
62
+ ### 用户会怎么写:before / after
63
+
64
+ 没有这个坐标系时,用户被迫自己拼两侧的绝对路径:
65
+
66
+ ```typescript
67
+ // ❌ before:用户要背下 docker 的路径,还要用 import.meta.url 拼本地绝对路径
68
+ const WORKSPACE = new URL("../workspaces/ts-starter/", import.meta.url).pathname;
69
+
70
+ export default defineEval({
71
+ description: "实现 Button 组件",
72
+ async test(t) {
73
+ await t.sandbox.uploadDirectory(WORKSPACE, "/home/sandbox/workspace"); // ← docker 专属,切 e2b 即坏
74
+ await t.send("在 src/components/Button.tsx 实现 Button,接受 label 和 onClick。");
75
+ const test = await t.sandbox.runCommand("npm", ["test"], { cwd: "/home/sandbox/workspace" });
76
+ t.check(test, commandSucceeded());
77
+ t.sandbox.fileChanged("src/components/Button.tsx");
78
+ },
79
+ });
80
+ ```
81
+
82
+ 有坐标系后,同一条 eval:
83
+
84
+ ```typescript
85
+ // ✅ after:全程零绝对路径,换 dockerSandbox() / e2bSandbox() / vercelSandbox() 零改动切换
86
+ export default defineEval({
87
+ description: "实现 Button 组件",
88
+ async test(t) {
89
+ await t.sandbox.uploadDirectory("../workspaces/ts-starter"); // 本地相对 eval 文件;远端默认 workdir
90
+ await t.send("在 src/components/Button.tsx 实现 Button,接受 label 和 onClick。");
91
+ const test = await t.sandbox.runCommand("npm", ["test"]); // cwd 默认 workdir
92
+ t.check(test, commandSucceeded());
93
+ t.sandbox.fileChanged("src/components/Button.tsx"); // diff 路径本来就是 workdir 相对
94
+ },
95
+ });
96
+ ```
97
+
98
+ 消掉的东西:`import.meta.url` 拼路径的咒语、两处 hardcode 的后端专属绝对路径,以及"切换后端文件落到 agent 视野之外"这个静默 bug 的整个物种。用户对"文件在哪"的心智模型收敛成一句话:**一切相对路径都在 workspace 里**——和 git 的 repo 相对路径同构,不需要关心物理位置。
99
+
100
+ ### 逃生舱:`sandbox.workdir`
101
+
102
+ 绝对路径不会彻底消失,三种场景会穿透坐标系:往 prompt 里告诉 agent 一个路径、对照 agent 日志/工具输出里出现的绝对路径、`docker exec` 进容器手动调试。这时用 `workdir` 属性,不要背表:
103
+
104
+ ```typescript
105
+ await t.send(`参考 ${t.sandbox.workdir}/docs/CONVENTIONS.md 里的约定实现组件。`);
106
+ ```
107
+
108
+ 注意 `$HOME` 这类环境变量不是替代品:`targetDir` 是宿主侧 JS 里拼的字符串,shell 变量根本不展开——`uploadDirectory(dir, "$HOME/workspace")` 会真的创建一个叫 `$HOME` 的目录。运行时 `runShell("pwd")` 探测也不必要:workdir 是后端构造时就确定的静态字符串,声明就能解决的问题不用运行时手段。
109
+
110
+ ### 为什么不伪造一个统一的 `/workspace`
111
+
112
+ 另一条路是让所有后端都真的提供 `/workspace`(mkdir + symlink 到真实 workdir)。不走这条:`/workspace` 不是 agent 实际的 cwd,agent 的日志、工具输出、报错里出现的全是真实路径,伪造的统一路径会让用户在对照时更糊涂;云后端(vercel/e2b)对用户目录之外的文件系统权限也未必允许。这与「用户与 root」一节是同一处理哲学:**语义跨后端一致(相对路径→workdir),物理值诚实暴露差异(`workdir` 属性)**,不假装统一。
113
+
114
+ ### 实现纪律
115
+
116
+ 路径解析规则只允许一份实现:收敛在 `src/sandbox/paths.ts`(如 `resolveSandboxPath(workdir, path)`),三个内置后端共用;不允许每个后端各自复制一遍 `startsWith("/")` 判断——规则有多份实现,就会有后端悄悄不一致。`defineSandbox` 自定义后端只需声明自己的 `workdir` 字符串即可获得同一套行为。
117
+
118
+ 旧文档曾推荐显式传 `"/workspace"`——它与任何后端的真实 workdir 都不一致,按它写的文件会落在 agent cwd 和 git 基线之外(agent 看不见、diff 采不到)。新代码和新示例都应写成"省略 `targetDir` / `cwd`",必要时通过 `sandbox.workdir` 取真实绝对路径。
119
+
120
+ ## 用户与 root
121
+
122
+ **默认非 root,按需提 root** —— 命令默认以沙箱的标准**非 root** 用户跑(agent 的自然环境:安全,且 Claude Code 等在 root 下会拒绝 `--dangerously-skip-permissions`)。需要 root 的命令(setup 装系统依赖:`apt-get install …`、`pip install --break-system-packages …`)给 `runCommand` 传 `{ root: true }`。
123
+
124
+ ```typescript
125
+ // eval setup:只有装系统依赖这步提 root;其余(含 agent、验证)默认非 root。
126
+ await sandbox.runCommand("apt-get", ["install", "-y", "openjdk-17-jdk"], { root: true });
127
+ await sandbox.runCommand("npm", ["install"]); // 默认非 root,cwd 默认 workdir
128
+ ```
129
+
130
+ **这套语义跨后端一致**,且与主流沙箱服务同构 —— 各后端把 `{ root: true }` 映射到自己的原生机制:
131
+
132
+ | 后端 | 默认用户 | `{ root: true }` 映射 |
133
+ | --- | --- | --- |
134
+ | docker | `node`(UID 1000) | `exec --user root` |
135
+ | E2B | 非 root(`user`) | `commands.run(cmd, { user: "root" })` |
136
+ | Vercel Sandbox | 非 root(`vercel-sandbox`) | `runCommand(cmd, { sudo: true })` |
137
+ | Daytona | create 时 `os_user` | per-command `user`(规划中) |
138
+ | Modal | root | 已是 root → no-op |
139
+
140
+ 约定:**默认值(非 root)与 `root` 的语义在所有后端保持一致**,不因后端而变。本就全程 root 的后端把提 root 视作 no-op;完全无法提 root 的后端可不支持(抛错)—— 但这是"不支持",不是"语义不同"。eval 因此不必感知底下是哪个后端。
141
+
142
+ ## 后端选择:没有默认值,没有按名字选
143
+
144
+ `sandbox` 字段的类型是 `SandboxOption`(= `SandboxSpec`,一个按 `backend` 区分的数据结构),**不接受裸字符串,也不会自动探测环境替你选一个**。沙箱型 agent 必须显式给 `sandbox` 一个工厂函数产出的 spec——写在 experiment 的 `sandbox` 字段,或写在 `niceeval.config.ts` 的 `sandbox` 字段做全项目兜底(`config.sandbox`,experiment 没设时用它)。两处都没设、又用了沙箱型 agent 时,`resolveSandbox()` 直接抛错,不会替你猜环境、不会静默兜底到某个后端。也没有 `--sandbox <name>` 这种 CLI 覆盖——后端选择是 experiment/config 的书面配置,不是运行时临时参数。
145
+
146
+ ```typescript
147
+ import { defineExperiment } from "niceeval";
148
+ import { dockerSandbox } from "niceeval/sandbox";
149
+ import { claudeCodeAgent } from "niceeval/adapter";
150
+
151
+ export default defineExperiment({
152
+ agent: claudeCodeAgent(),
153
+ sandbox: dockerSandbox(), // 必填,沙箱型 agent 没有它就报错
154
+ });
155
+ ```
156
+
157
+ ## Sandbox 作为数据结构(带参数)
158
+
159
+ 后端名只是个字符串,带不了参数,也没法表达"哪个是镜像、哪个是快照 ID"。和 [agent](adapters/README.md) 一样,sandbox 用**数据结构**定义:工厂函数(从 `niceeval/sandbox` 导出)产出 spec,放进 `experiment.sandbox`。
160
+
161
+ ```typescript
162
+ import { dockerSandbox, vercelSandbox, e2bSandbox } from "niceeval/sandbox";
163
+
164
+ dockerSandbox() // docker:用默认镜像
165
+ dockerSandbox({ image: "niceeval-agents:node24" }) // docker:指定镜像
166
+ vercelSandbox({ snapshotId: "snap_xxx" }) // vercel:从快照起
167
+ e2bSandbox({ template: "niceeval-agents" }) // e2b:指定模板
168
+ ```
169
+
170
+ `sandbox/resolve.ts` 把 spec 归一化成 `{ backend, image?, snapshotId?, template?, runtime? }`,再按 `backend` 派发到各后端的 `create()` —— **核心仍不按后端名分支**,参数只在对应后端的 `create()` 里消费。
171
+
172
+ 参数的典型用途是**预制模板**:把 agent CLI 烘焙进镜像/模板,让后续 eval 跳过安装直接开跑(见 [`sandbox/`](../sandbox/README.md))。
173
+
174
+ ## Docker 后端(本地,零云依赖)
175
+
176
+ 最常用、最便宜:无需任何云 token,本地有 Docker 即可。要点:
177
+
178
+ - **保活容器** —— 用 `node:24-slim` 起一个 tail 日志文件的长生命周期容器,后续命令用 `docker exec` 进去跑(`AutoRemove` 在 stop 时清理)。
179
+ - **非 root 用户(默认)** —— 默认以 `1000:1000`(node)跑命令;全局 npm 装到用户目录并入 `PATH`,避免权限问题。命令传 `{ root: true }` 时改以 root 跑(见「用户与 root」)。
180
+ - **slim 镜像补全** —— `apt-get install ca-certificates git`(slim 不带)。
181
+ - **文件上传** —— 用 tar 打包 `putArchive` 进容器,随后 `chown` 修正属主(putArchive 以 root 写入)。
182
+ - **多路复用流** —— Docker 的 exec 流把 stdout/stderr 复用在一条流上(8 字节头 + payload),需要按帧解析。
183
+ - **超时** —— 命令级超时,到点销毁流并报错。
184
+
185
+ ```typescript
186
+ const sandbox = await createSandbox({ backend: "docker", runtime: "node24", timeout });
187
+ await sandbox.uploadFiles(workspaceFiles); // targetDir 省略 → workdir
188
+ await sandbox.runCommand("npm", ["install"]); // cwd 省略 → workdir
189
+ ```
190
+
191
+ ## Vercel Sandbox 后端(云,可弹性扩并发)
192
+
193
+ 需要 Vercel Sandbox 凭据;显式环境变量路径是 `VERCEL_API_TOKEN` + `VERCEL_TEAM_ID` + `VERCEL_PROJECT_ID`。适合 CI 里大并发、不想本地起 Docker 的场景。要点:
194
+
195
+ - `VercelSandbox.create({ runtime, timeout })` 起一台微 VM。
196
+ - 处理云沙箱的 session 生命周期,必要时 snapshot + rotate,避免长命令被 session 上限截断。
197
+ - 批量 `writeFiles` 上传。
198
+
199
+ 接口与 Docker 完全一致,所以 Adapter 代码一字不改就能在两种后端间切换。
200
+
201
+ ## E2B 后端(云,微 VM)
202
+
203
+ 需要 `E2B_API_KEY`(team 级;`e2b auth login` 后 CLI 也会用它)。要点:
204
+
205
+ - `E2BSandbox.create({ template, timeout })` 起一台 [E2B](https://e2b.dev) 微 VM;省略 `template` 用 e2b 默认 `base`(自带 node20)。
206
+ - 命令经 `commands.run`(走 bash,支持 `&&` / 管道);`{ root: true }` → `{ user: "root" }`。
207
+ - 文件用 `files.read` / `files.write`(文本 + 二进制)。
208
+ - node 版本由模板决定 —— `runtime` 字段对 e2b 仅作记录。要 node24 / 烘焙好 agent CLI,用[预制模板](../sandbox/README.md) `e2bSandbox({ template: "niceeval-agents" })`。
209
+
210
+ ## 再接一个后端
211
+
212
+ 两条路,取决于新后端是不是打算贡献回 niceeval:
213
+
214
+ - **贡献进 niceeval**(像 docker/vercel/e2b 那样内置):实现 `Sandbox` 接口的一个类(`create()` + `workdir` + run/read/write/stop/up-down-load;路径解析直接用 `src/sandbox/paths.ts`,不要自己再写一份),在 `sandbox/resolve.ts` 的 `resolveSandbox` / `createBackend` 加一个 `case`,需要带参数就在 `types.ts` 加一个 `XxxSandboxSpec` 并在 `define.ts` 加工厂。
215
+ - **只在自己项目里用,不改 niceeval**:用 [`defineSandbox`](adapters/README.md)(`niceeval/sandbox` 导出)——传 `create()` 直接产出一个实现 `Sandbox` 接口的实例,`resolve.ts` 认到 `create` 就直接调用,跳过内置 backend switch,不需要 niceeval 认识这个后端的名字:
216
+
217
+ ```typescript
218
+ import { defineSandbox } from "niceeval/sandbox";
219
+
220
+ export default defineSandbox({
221
+ name: "modal", // 只用于展示 / 日志,不参与分发
222
+ recommendedConcurrency: 8, // 可选;省略默认 5
223
+ create: async ({ timeout, runtime }) => {
224
+ // 返回一个实现 Sandbox 接口(run/read/write/stop/...)的实例
225
+ return new MyModalSandbox({ timeout, runtime });
226
+ },
227
+ });
228
+ ```
229
+
230
+ **核心定义接口,后端各自实现**,两条路都不改核心其余部分。niceeval 的沙箱抽象刻意保持小(只需 run/read/write/stop),让接一个新后端的成本最低。
231
+
232
+ ## 沙箱在生命周期里的位置
233
+
234
+ 一次 agent eval 中,核心只固定两头,中间全部交给这条 eval 的 `test(t)`:
235
+
236
+ ```text
237
+ createSandbox(backend, timeout)
238
+ → git init && git commit # 打一次空基线,供之后 diff——不管 test() 里写了什么
239
+ → SandboxAgent.setup?.(sandbox, ctx) # agent 自己的一次性预置(装 CLI / 写主配置)
240
+ → test(t) # ← 交给 eval 作者,顺序由它自己决定:
241
+ │ t.sandbox.writeFiles(...) / uploadFiles(...) / uploadDirectory(...) # 默认落到 workdir
242
+ │ t.send() # 驱动 agent(Adapter 在沙箱里跑 CLI,解析成 events)
243
+ │ t.sandbox.runCommand(...) # 手工跑校验命令,cwd 默认 workdir(可以晚于 t.send(),agent 天然看不到)
244
+ │ 断言… # t.sandbox.fileChanged / t.sandbox.diff / t.check(commandSucceeded)
245
+ → collectGeneratedFiles() # git diff HEAD
246
+ → sandbox.stop() # 销毁
247
+ ```
248
+
249
+ 核心只固定两件事:**沙箱创建时打一次空 git 基线**,和**销毁前采一次 diff**——这两件事跟"里面放了什么文件"无关,核心不需要知道也不需要预设目录约定。中间"传什么文件、传到哪、什么时候调 agent、什么时候手工跑测试"全部是 `test(t)` 里的普通代码决定,不是核心的固定编排,详见 [Eval Authoring · 沙箱型](eval-authoring.md#沙箱型手工把文件放进沙箱)——Adapter 也只管 `t.send()` 触发的那一次"在沙箱里把 agent 跑起来"。author-facing 的 `t.sandbox` 同时承载立即 IO / 命令执行和最终 diff / 文件变化视图,但不暴露 `stop()`。后端保证 `workdir` 存在且对非 root 用户可写;命令工作目录用 `runCommand` / `runShell` 的 `cwd` option 表达,默认 `workdir`,不提供可变的 `setWorkingDirectory`。预置放哪见下节。
250
+
251
+ ## 环境预置放哪
252
+
253
+ 要在跑 agent 前准备环境,按职责分摊到三处已有的地方——**每一处都是普通代码,不是框架编排**:
254
+
255
+ | 要准备的东西 | 放哪 | 怎么清理 |
256
+ |---|---|---|
257
+ | 连 agent、装 CLI、写 agent 自己的主配置(每 attempt 一次) | [`SandboxAgent.setup`](adapters/contract.md#agent-契约) | 随沙箱销毁,无需手工清 |
258
+ | **这条 eval** 的沙箱预置(写 `.env`、装依赖、按 `t.flags` 注入 skill) | `test(t)` 里的普通代码(`t.sandbox.writeFiles` / `runCommand`) | 随沙箱销毁;要清沙箱外的东西用 `try/finally` |
259
+ | **整轮共享**的外部服务(mock API、共享 DB、license) | 外部编排:`docker compose up -d && niceeval exp … && docker compose down`,或 CI 脚本 | 外部编排负责,URL 经 env 传入 agent / eval |
260
+
261
+ 为什么这样够用:沙箱内的东西随沙箱销毁自动没了,不需要 teardown;整轮共享的外部资源用 `docker compose` / CI 脚本起停是业界通行做法,比一个 niceeval 专属钩子更少要维护、也不把资源起停逻辑锁进框架。这与"没有隐式 fixture 发现、起始文件手工写进 `test()`"是同一条纪律:**能用更基础的机制表达的,就不在框架里再造一层。**
262
+
263
+ ## 性能:复用与预热
264
+
265
+ 沙箱冷启动是关键路径上的大头。两个杠杆:
266
+
267
+ - **预热池** —— 提前起若干沙箱挂在池里,case 来了直接领,把冷启动移出关键路径。
268
+ - **跨 case 复用** —— 同 runtime 的沙箱在一个 case 跑完后重置(`git clean` 回基线)而非销毁,给下一个 case 用。需权衡"复用省时" vs "全新更干净",默认全新,可开复用。
269
+
270
+ 这些是 [Runner](runner.md) 的调度职责,沙箱后端只需支持 reset / 快速 create。
271
+
272
+ ## 相关阅读
273
+
274
+ - [Adapter 契约](adapters/contract.md) —— Adapter 如何通过 `Sandbox` 接口驱动 agent,以及 `setup` 的义务。
275
+ - [Runner](runner.md) —— 并发、预热、复用的调度。
276
+ - [Vision](vision.md) —— 后端名只用于路由,不进核心行为。