niceeval 0.5.3 → 0.5.4

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.
@@ -109,6 +109,10 @@ interface Agent {
109
109
 
110
110
  `send` 是唯一要实现的函数。它的签名里只有三个类型,分开看。
111
111
 
112
+ <Note>
113
+ 这里的 `setup` / `teardown` 是 Agent 自己"怎么连自己"的私事(装 CLI、写鉴权配置)。按实验变化的环境准备(装某个实验专属的二进制、预热、跨 attempt 保存状态)不写在 Agent 上,挂在 `sandbox` 字段那个 Sandbox spec 自己的 `.setup()` / `.teardown()` 链式方法上,见 [Sandbox 后端 · 环境钩子](/zh/guides/sandbox-backends#环境钩子)。
114
+ </Note>
115
+
112
116
  ### 传入:`TurnInput`
113
117
 
114
118
  ```ts
@@ -188,6 +192,7 @@ interface AgentContext {
188
192
  readonly reasoningEffort?: string; // 推理努力程度:experiment.reasoningEffort 透传,归属与 model 一致
189
193
  readonly telemetry?: Telemetry; // Tier 2:本轮的 W3C trace context(headers,每轮一个新 traceparent)
190
194
  readonly flags: Readonly<Record<string, unknown>>; // Tier 3 的 feature A/B:experiment.flags 透传;没配时是 {}
195
+ readonly experimentId?: string; // 路径推导的实验 id,与结果里的 experimentId 同源;不经 experiment 跑时是 undefined
191
196
 
192
197
  readonly sandbox?: Sandbox; // 仅 defineSandboxAgent 构造的 Agent 会拿到
193
198
  }
@@ -209,7 +214,7 @@ interface AgentSession {
209
214
  }
210
215
  ```
211
216
 
212
- `ctx` 里没有要你检查的标志位。三个档位字段都是"透传"语义:`model`、`flags` 由 experiment 声明、运行器原样递过来,Adapter 只负责随请求转发给应用,不解释它们的含义;`telemetry` 仅在配置了 OTel 接入时出现,`send` 里只需要把 `headers` spread 进请求头——接收端点在 `defineConfig` 里固定、由应用启动时指向,不从这里传,见[OTel 接入](/zh/guides/connect-otel)。
217
+ `ctx` 里没有要你检查的标志位。三个档位字段都是"透传"语义:`model`、`flags` 由 experiment 声明、运行器原样递过来,Adapter 只负责随请求转发给应用,不解释它们的含义;`telemetry` 仅在配置了 OTel 接入时出现,`send` 里只需要把 `headers` spread 进请求头——接收端点在 `defineConfig` 里固定、由应用启动时指向,不从这里传,见[OTel 接入](/zh/guides/connect-otel)。`experimentId` 是路径推导出的稳定标识,典型用途是在 Sandbox 的环境钩子里按实验隔离跨 attempt 的状态(缓存目录名、快照 tag 按它分区),见 [Sandbox 后端 · 环境钩子](/zh/guides/sandbox-backends#环境钩子)
213
218
 
214
219
  `session` 是本条会话线的自有状态,[NiceEval](https://niceeval.com/) 对它只承诺一件事:**同一条会话线的每次 `send` 拿到同一个 `ctx.session`,新会话线(eval 的第一轮,或 `t.newSession()` 之后)拿到一个全新的。** 会话续接(`id`/`capture`、`history`)和 HITL 停轮现场(`hold`/`take`)的存取器都在它上面,"第一轮"就是新会话线的自然形态——`id` 是 `undefined`、`history.get()` 是空数组,没有要判断的分支;`state` 是这些存取器之外的逃生舱,框架从不往里写数据。
215
220
 
@@ -25,6 +25,8 @@ export default defineExperiment({
25
25
  - `model` / `flags`:透传语义。[NiceEval](https://niceeval.com/) 不解释它们的含义,原样经 `ctx` 递给 Adapter,由 Adapter 随请求转发、应用按需切换——这正是 [Tier](/zh/concepts/tier) 里模型对比(Tier 1)和 feature A/B(Tier 3)的通道。
26
26
  - `runs`、`budget`、并发、`sandbox` 等运行参数:怎么跑、跑多少。完整字段见[写实验](/zh/guides/write-experiment)。
27
27
 
28
+ Experiment 是纯配置数据,没有 `setup` / `teardown` 这类生命周期字段。要按实验准备环境(装二进制、预热、跨 attempt 存取状态),挂在 `sandbox` 字段的 spec 上——`dockerSandbox()` 等工厂返回的对象可以链 `.setup()` / `.teardown()`,见 [Sandbox 后端 · 环境钩子](/zh/guides/sandbox-backends#环境钩子)。
29
+
28
30
  ## 矩阵对比
29
31
 
30
32
  要比较的每个变体写一个 experiment 文件:两个模型就是两个文件,只差 `model` 一行;prompt A/B 就是只差一个参数。同一批 eval 在多个 experiment 下各跑一遍,pass rate、成本、延迟就有了可比的横截面——`niceeval view` 里叠着看。适合比什么、结果怎么读,见[实验矩阵](/zh/guides/experiments)。
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: "用报告积木搭自己的口径"
3
3
  sidebarTitle: "自定义报告"
4
- description: "一份报告就是一个报告文件:官方宿主打开结果、注入数据,Row / Col 负责排版,双面组件同时渲染网页与终端——niceeval show 与 niceeval view 渲染的官方榜单,就是出厂预装的那份报告。"
4
+ description: "一份报告就是一个报告文件:官方宿主打开结果、注入数据,Row / Col 负责排版,双面组件同时渲染网页与终端——niceeval show 与 niceeval view 默认渲染的默认报告,就是内置的那份报告文件。"
5
5
  ---
6
6
 
7
7
  [查看结果](/zh/guides/viewing-results)讲「用」:官方两扇门 `niceeval show`(终端)和 `niceeval view`(网页)怎么看。本页讲「写」:官方摆法不够时,怎么写一份自己的报告——考试成绩单、代码行数榜、质量 × 成本 frontier。
@@ -10,7 +10,7 @@ description: "一份报告就是一个报告文件:官方宿主打开结果、
10
10
 
11
11
  ## 官方默认报告也是一份报告文件
12
12
 
13
- `niceeval view` 和 `niceeval show` 渲染的官方榜单不是私有实现,而是出厂预装的一份报告:`niceeval view` 就等于 `niceeval view --report <内置默认报告>`,`show` 同理。我们自己用的零件和给你的完全一样,没有私有数据通道,官方榜单上的每个数字你都能用同样几行代码原样重算出来。
13
+ `niceeval view` 和 `niceeval show` 不传 `--report` 时渲染的默认报告不是私有实现,而是内置的一份报告文件:`niceeval view` 就等于 `niceeval view --report <内置默认报告>`,`show` 同理。我们自己用的零件和给你的完全一样,没有私有数据通道,默认报告上的每个数字你都能用同样几行代码原样重算出来。
14
14
 
15
15
  | | 网页(人看) | 终端(agent 和你看) |
16
16
  | --- | --- | --- |
@@ -52,15 +52,15 @@ niceeval show --report reports/exam.tsx # 终端:同一棵树走文本面
52
52
  niceeval view --report reports/exam.tsx # 网页:同一棵树走网页面,attempt 深链直达证据室
53
53
  ```
54
54
 
55
- 注入的上下文只有两样,全部是[结果数据 API](/zh/guides/results-data) 的原语,宿主没有私有通道:`results` 就是 `openResults(".niceeval")` 的返回(实验、历次快照、attempt 级 `diff()`、`trace()` 都在上面);`selection` 就是 `results.latest()` 的返回选集——宿主按「每个实验取最新」挑好的现刻水位,与官方榜单同口径,快照在 `selection.snapshots`,挑选警告在 `selection.warnings`。组件的 `data` 函数直接吃选集,警告随行——`RunOverview` 会把残缺快照的警告如实显示,不用你另外接线;默认挑法不合口径时,拿 `results` 自己挑,手工挑的 `Snapshot[]` 数组同样能喂给每个 `data` 函数。
55
+ 注入的上下文只有两样,全部是[结果数据 API](/zh/guides/results-data) 的原语,宿主没有私有通道:`results` 就是 `openResults(".niceeval")` 的返回(实验、历次快照、attempt 级 `diff()`、`trace()` 都在上面);`selection` 就是 `results.latest()` 的返回选集——宿主按「每个实验取最新」挑好的现刻水位,与默认报告同口径,快照在 `selection.snapshots`,挑选警告在 `selection.warnings`。组件的 `data` 函数直接吃选集,警告随行——`RunOverview` 会把残缺快照的警告如实显示,不用你另外接线;默认挑法不合口径时,拿 `results` 自己挑,手工挑的 `Snapshot[]` 数组同样能喂给每个 `data` 函数。
56
56
 
57
- 命令行的范围先作用在挑选上,报告拿到的就是范围内的水位:位置参数的 eval id 前缀收窄选集覆盖的 eval(覆盖警告的分母同样收窄到范围内),`--run` 把结果根换成指定目录,`--experiment` 让选集只留该实验。`--history` 与 `--report` 互斥——趋势在报告里用 `exp.snapshots` 自己摆;证据切面(`--transcript` / `--trace` / `--diff`)走证据室,不渲染报告槽。
57
+ 命令行的范围先作用在挑选上,报告拿到的就是范围内的水位:位置参数的 eval id 前缀收窄选集覆盖的 eval(覆盖警告的分母同样收窄到范围内),`--run` 把结果根换成指定目录,`--experiment` 让选集只留该实验。`--history` 与 `--report` 互斥——趋势在报告里用 `exp.snapshots` 自己摆;证据切面(`--transcript` / `--trace` / `--diff`)只看证据,不渲染报告。
58
58
 
59
59
  `<DefaultReport />` 零 props、纯声明:官方水位只读瘦身条目(不碰 diff 这类重工件),宿主总是替你备好,摆不摆在你。它渲染的是**宿主注入的那份选集**的官方水位——跟随命令行范围,但不跟随你在报告里自己另挑的口径;这正是它的意义:当官方锚点,和你的口径并排对照。要「官方摆法 + 自选口径」,用它脚下的同一批组件自己摆——它没有任何私有零件。
60
60
 
61
61
  页面里的每个组件都是**双面**的:网页面是 React 渲染,终端面是字符渲染,两面吃同一份算好的数据。你在报告文件里只见到组件本身,从不碰某一面——走哪扇门由宿主决定。官方组件覆盖常见摆法——榜单 `MetricTable`、逐题格子 `MetricMatrix`、分组条形 `MetricBars`、成绩单 `Scoreboard`、质量 × 成本 frontier `MetricScatter`、趋势线 `MetricLine`、成对对比 `DeltaTable`、失败清单 `CaseList`——每个组件回答什么问题、怎么调用、终端字符输出长什么样,逐个见[报告组件](/zh/guides/report-components)。它们在宿主里自动接上证据室:网页面的格子深链到 attempt 弹窗,终端面在条目下印出 `niceeval show <eval id>` 下钻命令。
62
62
 
63
- 默认榜单没有特权:报告槽只有一个,`<DefaultReport />` 只是出厂填充。「官方水位 + 自己的口径」像上面那样把它摆进自己的树;不要官方水位就不摆——组合发生在你的 tsx 里,没有 tab、没有注册表、没有配置。
63
+ 默认报告没有特权:`<DefaultReport />` 只是不传 `--report` 时渲染的那份内置报告,本身也是一个普通组件。「官方水位 + 自己的口径」像上面那样把它摆进自己的树;不要官方水位就不摆——组合发生在你的 tsx 里,没有 tab、没有注册表、没有配置。
64
64
 
65
65
  ## 排版:Row 与 Col 在两个面都成立
66
66
 
@@ -90,7 +90,7 @@ codex 78.0/100 40/50 38/50 │ codex 80% $0.51
90
90
 
91
91
  ## 换口径:自定义指标
92
92
 
93
- 官方榜单的口径拆开就是 `MetricTable.data(selection, { rows: "experiment", columns: [passRate, costUSD, durationMs] })`——每个组件的数据算法挂在它自己身上,组件名后打个点就找到;换口径只是把 columns 换成自己的指标:
93
+ 默认报告里那张逐实验指标表的口径拆开就是 `MetricTable.data(selection, { rows: "experiment", columns: [passRate, costUSD, durationMs] })`——每个组件的数据算法挂在它自己身上,组件名后打个点就找到;换口径只是把 columns 换成自己的指标:
94
94
 
95
95
  ```tsx
96
96
  // reports/golf.tsx —— code-golf:谁写出能用的代码,谁写得短
@@ -45,6 +45,7 @@ npx niceeval exp compare-models # 把同组各 model 并排出报告
45
45
  - 不同模型(Tier 1 接入即可:应用接口暴露模型选择,`model` 经 `ctx.model` 透传)。
46
46
  - 不同 prompts 或 feature flags(要求 Tier 3 接入:变体在应用内部,需要应用把它暴露成 experiment 可选的 flag,经 `flags` → `ctx.flags` 透传)。
47
47
  - 不同 sandbox 后端。
48
+ - 不同运行环境条件(比如装不装某个记忆工具的二进制、有没有预置状态):环境差异写在 `sandbox` spec 的 `.setup()` / `.teardown()` 钩子里,一个变体一个 experiment 文件,见 [Sandbox 后端 · 环境钩子](/zh/guides/sandbox-backends#环境钩子)。
48
49
  - 同一任务的 pass@N。
49
50
 
50
51
  Tier 1 / Tier 2 / Tier 3 的定义见 [Tier](/zh/concepts/tier)。
@@ -4,7 +4,7 @@ sidebarTitle: "CI 发布报告"
4
4
  description: "把报告站接进 CI:.niceeval/ 提交进仓库,构建命令一行 view --out,push 即发布。发布的站和本地 niceeval view 完全一样;零可读结果时构建失败,保住上一次部署。"
5
5
  ---
6
6
 
7
- `niceeval view --out <目录>` 把查看器导出成一个纯静态目录:榜单、快照列表、transcript、trace 瀑布,和本地 `niceeval view` 看到的完全一样(导出行为见[查看结果 · 导出与静态托管](/zh/guides/viewing-results#导出与静态托管))。CI 发布只需要让结果数据到构建机手里——把 `.niceeval/` 提交进仓库,仓库本身就是数据源。本地跑完 eval,提交并 push 就是发布,不需要任何脚本或本地构建命令。
7
+ `niceeval view --out <目录>` 把查看器导出成一个纯静态目录:报告、快照列表、transcript、trace 瀑布,和本地 `niceeval view` 看到的完全一样(导出行为见[查看结果 · 导出与静态托管](/zh/guides/viewing-results#导出与静态托管))。CI 发布只需要让结果数据到构建机手里——把 `.niceeval/` 提交进仓库,仓库本身就是数据源。本地跑完 eval,提交并 push 就是发布,不需要任何脚本或本地构建命令。
8
8
 
9
9
  ## `.gitignore` 只排除 `diff.json`
10
10
 
@@ -24,7 +24,7 @@ npx niceeval view --out site
24
24
 
25
25
  ## 发布自定义报告
26
26
 
27
- 不传 `--report` 时,发布出来的首页是官方榜单。想让首页换成自己的报告,把 [`defineReport` 报告文件](/zh/guides/custom-reports)传给 `--report` 就行——attempt 证据页(transcript、trace、代码视图)仍在同一个站里,报告里的每个数字点进去就是对应证据,和本地 `view --report` 看到的一模一样:
27
+ 不传 `--report` 时,发布出来的首页是默认报告。想让首页换成自己的报告,把 [`defineReport` 报告文件](/zh/guides/custom-reports)传给 `--report` 就行——attempt 证据页(transcript、trace、代码视图)仍在同一个站里,报告里的每个数字点进去就是对应证据,和本地 `view --report` 看到的一模一样:
28
28
 
29
29
  ```bash
30
30
  npx niceeval view --report reports/exam.tsx --out site
@@ -84,7 +84,7 @@ jobs:
84
84
  uses: actions/deploy-pages@v4
85
85
  ```
86
86
 
87
- 日常循环从此只有两步:本地跑 eval,提交 `.niceeval/` 并 push。发布出去的站带完整历史——同事打开链接,看到的和你本地 `niceeval view` 一模一样;仓库、本地查看器、线上报告三处同一个真相源。仓库体积随落盘增长(`diff.json` 已排除,增长主要来自 transcript 和 trace 工件),不想保留的旧 run 直接删对应目录即可,查看器和榜单跟着仓库走。
87
+ 日常循环从此只有两步:本地跑 eval,提交 `.niceeval/` 并 push。发布出去的站带完整历史——同事打开链接,看到的和你本地 `niceeval view` 一模一样;仓库、本地查看器、线上报告三处同一个真相源。仓库体积随落盘增长(`diff.json` 已排除,增长主要来自 transcript 和 trace 工件),不想保留的旧 run 直接删对应目录即可,查看器和报告跟着仓库走。
88
88
 
89
89
  ## 发布的是完整证据
90
90
 
@@ -105,7 +105,7 @@ for (const s of results.skipped) {
105
105
 
106
106
  ## 选快照:`results.latest()`
107
107
 
108
- 多数场景先回答「现在什么水位」,选择器替你挑「每个实验最新一次」——这也是官方榜单的口径。返回的是一个**选集(Selection)**,快照和警告绑在一起走:
108
+ 多数场景先回答「现在什么水位」,选择器替你挑「每个实验最新一次」——这也是默认报告的口径。返回的是一个**选集(Selection)**,快照和警告绑在一起走:
109
109
 
110
110
  ```typescript
111
111
  const latest = results.latest({
@@ -86,17 +86,22 @@ export default defineExperiment({
86
86
 
87
87
  ```text
88
88
  createSandbox
89
+ → sandbox spec 的 .setup() 钩子? # 环境准备(按实验装东西);没挂就跳过
89
90
  → git init && git commit
90
- → adapter.setup? # 装 CLI / 写 agent 配置
91
- → test(t): uploadDirectory(...) # 写入这条 eval 的起始文件
91
+ → adapter.setup? # 装 CLI / 写 agent 配置
92
+ → test(t): uploadDirectory(...) # 写入这条 eval 的起始文件
92
93
  → adapter.send(input, ctx)
93
- → test(t): runCommand(...) # 手工运行验证命令
94
- → collectGeneratedFiles() # git diff HEAD
94
+ → test(t): runCommand(...) # 手工运行验证命令
95
+ → collectGeneratedFiles() # git diff HEAD
96
+ → adapter.teardown? # agent 收尾
97
+ → sandbox spec 的 .teardown() 钩子? # 环境收尾(如回存状态),销毁前最后跑
95
98
  → sandbox.stop()
96
99
  ```
97
100
 
98
101
  起始文件和验证命令都写在 `test(t)` 中。agent 执行阶段只能看到你已经写进 sandbox 的文件。
99
102
 
103
+ 环境钩子(`.setup()` / `.teardown()`)挂在 experiment `sandbox` 字段的 spec 上,用来做"按实验变化的环境准备"——装某个实验专属的二进制、预热、跨 attempt 载入和回存状态。它排在 git 基线之前,所以写下的文件不会被算进 agent 产出的 diff。写法和规则见 [Sandbox 后端 · 环境钩子](/zh/guides/sandbox-backends#环境钩子)。
104
+
100
105
  ## 自定义 sandbox agent
101
106
 
102
107
  ```ts
@@ -49,6 +49,33 @@ export default defineExperiment({
49
49
 
50
50
  漏装时不会静默失败:NiceEval 在创建 sandbox 的那一刻报错并直接给出上面的安装命令,例如 `Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode`。
51
51
 
52
+ ## 环境钩子
53
+
54
+ `dockerSandbox()` / `vercelSandbox()` / `e2bSandbox()` 返回的 spec 上有两个链式方法:`.setup(fn)` 和 `.teardown(fn)`。它们解决一类具体问题:**有些东西你本想直接做进 Docker 镜像或 E2B 模板,但它要按实验变化**——比如某个实验要装一个额外的二进制、预热一次模型、写一份 hook 文件,或者在多次 attempt 之间载入和回存状态。静态镜像做不到"每个实验装不同的东西",这两个钩子就是运行时补上的那一层。
55
+
56
+ ```ts
57
+ export default defineExperiment({
58
+ agent: codexAgent({ mcpServers: [mempalMcp] }),
59
+ sandbox: e2bSandbox({ template: "fasteval-agents" })
60
+ .setup(mempalSetup("codex")) // 装二进制、预热、写 hook、载入状态
61
+ .teardown(mempalTeardown("codex")), // 回存状态
62
+ maxConcurrency: 1, // 载入和回存之间不能并发,声明串行
63
+ });
64
+ ```
65
+
66
+ 规则一览:
67
+
68
+ - **签名**:钩子函数和 Adapter 的 `setup` / `teardown` 一样是 `(sandbox, ctx)`;`setup` 可以返回一个清理函数。
69
+ - **不可变**:每次 `.setup()` / `.teardown()` 都返回一个新 spec,原对象不变,可以继续链。
70
+ - **多个钩子**:多个 `.setup()` 按追加顺序执行;多个 `.teardown()` 按追加的逆序执行。
71
+ - **执行时机**:`setup` 钩子在 sandbox 创建后、git 基线之前最先跑——它写下的文件会进基线,不会被算进 agent 产出的 diff;`teardown` 钩子在 Adapter 的 `teardown` 之后、sandbox 销毁之前最后跑——把状态回存到外部正好用这个时机。
72
+ - **失败语义**:`setup` 钩子抛错,这次 attempt 记为 `errored`(环境问题,不是 agent 做错题);`teardown` 钩子报错只记日志,不改变已产出的结果。
73
+ - **不带 sandbox 的 Agent**:`defineAgent` 构造的 Agent 没有 sandbox,`sandbox` 字段对它不生效,钩子自然不会跑。
74
+
75
+ 钩子里可以用 `ctx.experimentId`(路径推导的实验 id)当状态隔离的键,比如不同实验各自维护一份跨 attempt 的缓存。跨 attempt 状态的载入和回存是你自己在钩子里写的普通代码——[NiceEval](https://niceeval.com/) 不提供状态存储;要保证同一实验的 attempt 不并发读写同一份状态,在 experiment 上声明 `maxConcurrency: 1`。
76
+
77
+ 和另外两处 setup 的分工:Adapter 的 `setup` 管"怎么连被测 agent"(装 CLI、写鉴权配置);eval 里 `test(t)` 开头的代码管"这道题需要哪些起始文件";sandbox 的 `.setup()` 管"这次实验的环境里要多装什么"。三层各写各的,互相不知道对方的内容。MCP server、Skill、model 这些被测 agent 的配置仍然只从 Adapter 工厂参数进——环境钩子不做 Adapter 的事。
78
+
52
79
  ## Docker
53
80
 
54
81
  Docker 适合本地开发和标准 CI。优点是简单、可控、无云端依赖;缺点是机器资源有限,冷启动和安装依赖可能较慢。
@@ -60,7 +60,7 @@ niceeval show weather/brooklyn --history # 跨 run 时间轴:抖动与回
60
60
 
61
61
  同一个实验多次跑会留下多份结果,默认视图只回答一个问题——「现在什么水位」:每个 experiment × eval 取时间上最新的那份判决,跨 run 合成。按前缀只重跑了部分 eval 时,其余 eval 的判决从更早的 run 补齐,榜单永远是全局水位,不会因为一次局部重跑变残缺。合成是有标注的:每行带判决产生的时间,榜单头写明合成自几个 run。合成状态可能混着不同版本的被测代码——所以收工判决以 `--force` 全量重跑为准,迭代途中的 `show` 负责快、收尾的全量跑负责真。
62
62
 
63
- 多 experiment、多 attempt 时,证据切面默认挑最新一次失败的 attempt;用 `--experiment` 与 `--attempt` 指定其它,用 `--run <目录>` 钉死看某一次历史 run,用 `--history` 看跨 run 趋势(见下文)。默认榜单只是报告槽的出厂填充:`--report <文件>` 可以把它整槽换成你自己的口径,同一个文件也能喂给 `view`,见[自定义报告](/zh/guides/custom-reports)。位置前缀、`--run`、`--experiment` 对 `--report` 同样生效(收窄报告拿到的选集);`--history` 与 `--report` 互斥,证据切面 flag 出现时走证据室、不渲染报告槽。
63
+ 多 experiment、多 attempt 时,证据切面默认挑最新一次失败的 attempt;用 `--experiment` 与 `--attempt` 指定其它,用 `--run <目录>` 钉死看某一次历史 run,用 `--history` 看跨 run 趋势(见下文)。不带 `--report` 时 `show` 渲染的是默认报告;`--report <文件>` 换成你自己的报告,同一个文件也能喂给 `view`,见[自定义报告](/zh/guides/custom-reports)。位置前缀、`--run`、`--experiment` 对 `--report` 同样生效(收窄报告拿到的选集);`--history` 与 `--report` 互斥,证据切面 flag 出现时只看证据、不渲染报告。
64
64
 
65
65
  `niceeval view` 的每个视图都有 CLI 对应物:
66
66
 
@@ -179,7 +179,7 @@ npx niceeval view
179
179
  失败后立刻运行 `npx niceeval view`,可以直接打开刚刚那次运行的 artifacts。
180
180
  </Tip>
181
181
 
182
- view 的结构是「报告槽 + 证据室」:报告槽默认装官方榜单,`--report <文件>` 整槽换成你自己的报告(与 `show --report` 吃同一个文件),见[自定义报告](/zh/guides/custom-reports);证据室(attempt 弹窗、transcript、trace 瀑布)始终保留,报告里的数字点进去就是证据。
182
+ view 的首页是一份报告:不传 `--report` 时是默认报告,传了就换成你自己的报告(与 `show --report` 吃同一个文件),见[自定义报告](/zh/guides/custom-reports);证据部分(attempt 弹窗、transcript、trace 瀑布)始终保留,报告里的数字点进去就是证据。
183
183
 
184
184
  ## 导出与静态托管
185
185
 
@@ -96,6 +96,20 @@ npx niceeval exp prompt-variants
96
96
  | `timeoutMs` | 单个 attempt 的超时 |
97
97
  | `budget` | 这一格配置的预算上限 |
98
98
  | `maxConcurrency` | 这一格配置的并发上限 |
99
- | `sandbox` | sandbox agent 使用的后端,如 `dockerSandbox()` / `e2bSandbox()` |
99
+ | `sandbox` | sandbox agent 使用的后端,如 `dockerSandbox()` / `e2bSandbox()`;spec 上可以链 `.setup()` / `.teardown()` 挂按实验变化的环境钩子 |
100
+
101
+ Experiment 本身是纯配置数据,没有 `setup` / `teardown` 这类字段。要在跑 agent 前按实验准备环境(装二进制、预热、跨 attempt 载入和回存状态),挂在 `sandbox` 字段的 spec 上:
102
+
103
+ ```ts
104
+ export default defineExperiment({
105
+ agent: codexAgent({ mcpServers: [mempalMcp] }),
106
+ sandbox: e2bSandbox({ template: "fasteval-agents" })
107
+ .setup(mempalSetup("codex")) // 装二进制、预热、写 hook、载入状态
108
+ .teardown(mempalTeardown("codex")), // 回存状态
109
+ maxConcurrency: 1, // 载入和回存之间不能并发,声明串行
110
+ });
111
+ ```
112
+
113
+ 钩子的执行时机、多钩子顺序和失败语义见 [Sandbox 后端 · 环境钩子](/zh/guides/sandbox-backends#环境钩子)。
100
114
 
101
115
  跨配置比较的设计建议见[实验矩阵](/zh/guides/experiments)。adapter 如何消费 `ctx.model` 和 `ctx.flags` 见[Adapter](/zh/concepts/adapter)。
@@ -82,7 +82,7 @@ npx niceeval exp compare-models weather
82
82
  | `--experiment` | string | `show` / `view` 命令专用:选集只留该实验。 |
83
83
  | `--attempt` | number | `show` 命令专用:指定详情 / 证据切面看第几次 attempt(与展示一致的 1 计序号)。 |
84
84
  | `--run` | string | `show` / `view` 命令专用:钉死看某一个结果目录(历史 run 或 `copySnapshots` 产物)。 |
85
- | `--report` | string | `show` / `view` 命令专用:把默认榜单整槽换成你的报告文件(默认导出 `defineReport(...)`)。 |
85
+ | `--report` | string | `show` / `view` 命令专用:用你的报告文件替换默认报告(文件默认导出 `defineReport(...)`)。 |
86
86
  | `--dry` | boolean | 只打印本次会匹配到的 eval × 运行配置,不实际执行。 |
87
87
  | `--quiet` | boolean | 关闭控制台 / live 进度输出(reporter 仍会写 artifacts)。 |
88
88
  | `--force` | boolean | 忽略上次运行结果,不跳过已通过的 (experiment, eval) 组合,强制全部重跑。 |
@@ -227,6 +227,18 @@ experiment 的 `flags` 字段原样透传,内容和结构完全由 experiment
227
227
  读取其中的字段;框架本身不解释、不校验它的内容。命名特意避开 CLI 解析出的
228
228
  `flag`(跑法层面的 --timeout/--budget 等),两者是不相关的概念。
229
229
 
230
+ #### `experimentId`
231
+
232
+ ```ts
233
+ readonly experimentId?: string;
234
+ ```
235
+
236
+ 路径推导出的实验 id(与结果归属 `runWho` / `AgentRun.experimentId` 同源);不经
237
+ experiment 跑(如脱离 CLI、直接构造 `AgentRun` 的场景)时为 undefined。典型用途:
238
+ `SandboxSpec.setup` 钩子按实验隔离跨 attempt 的状态(缓存目录名、快照 tag 等按
239
+ `ctx.experimentId` 分区),或 adapter 按实验切换鉴权 / 路由。与 `flags`(实验条件的
240
+ 具体取值)是两个维度——这里只是「跑的是哪个实验」的稳定标识,不携带条件内容。
241
+
230
242
  #### `sandbox`
231
243
 
232
244
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niceeval",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Agent-native eval tool — eval agents, services, functions, and coding-agent fixtures",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/agents/bub.ts CHANGED
@@ -89,7 +89,8 @@ async function ensureBub(sb: Sandbox, home: string): Promise<void> {
89
89
 
90
90
  const inflight = installsInProgress.get(home);
91
91
  if (inflight) {
92
- await inflight;
92
+ // leader 失败(多为沙箱瞬态网络错)不级联杀 waiter:兜掉后走下面自己的安装路径重试。
93
+ await inflight.catch(() => {});
93
94
  const after = memCheckpoints.get(home);
94
95
  if (after) { await restoreCheckpoint(sb, after); return; }
95
96
  }
@@ -97,6 +98,9 @@ async function ensureBub(sb: Sandbox, home: string): Promise<void> {
97
98
  let resolveInstall!: () => void;
98
99
  let rejectInstall!: (e: unknown) => void;
99
100
  const installPromise = new Promise<void>((res, rej) => { resolveInstall = res; rejectInstall = rej; });
101
+ // 失败经由下方 throw e 传播给本 attempt;这把锁可能自始至终没有 waiter,
102
+ // 不兜住 rejection 会变 unhandledRejection,把整个 runner 进程连同全矩阵杀掉。
103
+ installPromise.catch(() => {});
100
104
  installsInProgress.set(home, installPromise);
101
105
 
102
106
  try {
@@ -79,6 +79,8 @@ export type {
79
79
  Agent,
80
80
  AgentContext,
81
81
  AgentSession,
82
+ AgentSetup,
83
+ AgentTeardown,
82
84
  AgentTracing,
83
85
  SpanMapper,
84
86
  Telemetry,
@@ -161,6 +161,14 @@ export interface AgentContext {
161
161
  * `flag`(跑法层面的 --timeout/--budget 等),两者是不相关的概念。
162
162
  */
163
163
  readonly flags: Readonly<Record<string, unknown>>;
164
+ /**
165
+ * 路径推导出的实验 id(与结果归属 `runWho` / `AgentRun.experimentId` 同源);不经
166
+ * experiment 跑(如脱离 CLI、直接构造 `AgentRun` 的场景)时为 undefined。典型用途:
167
+ * `SandboxSpec.setup` 钩子按实验隔离跨 attempt 的状态(缓存目录名、快照 tag 等按
168
+ * `ctx.experimentId` 分区),或 adapter 按实验切换鉴权 / 路由。与 `flags`(实验条件的
169
+ * 具体取值)是两个维度——这里只是「跑的是哪个实验」的稳定标识,不携带条件内容。
170
+ */
171
+ readonly experimentId?: string;
164
172
  /** 仅沙箱型 agent 有(运行器按 --sandbox 备好)。 */
165
173
  readonly sandbox: Sandbox;
166
174
  readonly session: AgentSession;
package/src/cli.ts CHANGED
@@ -125,7 +125,7 @@ const FLAG_OPTIONS = {
125
125
  attempt: { type: "string" },
126
126
  /** `show` / `view` 命令专用:钉死看某一个结果目录(历史 run 或 `copySnapshots` 产物)。 */
127
127
  run: { type: "string" },
128
- /** `show` / `view` 命令专用:把默认榜单整槽换成你的报告文件(默认导出 `defineReport(...)`)。 */
128
+ /** `show` / `view` 命令专用:用你的报告文件替换默认报告(文件默认导出 `defineReport(...)`)。 */
129
129
  report: { type: "string" },
130
130
  /** 只打印本次会匹配到的 eval × 运行配置,不实际执行。 */
131
131
  dry: { type: "boolean" },
@@ -62,6 +62,8 @@ export interface ContextDeps {
62
62
  model?: string;
63
63
  reasoningEffort?: string;
64
64
  flags: Record<string, unknown>;
65
+ /** 路径推导出的实验 id(经 send ctx 透给 adapter,见 AgentContext.experimentId)。 */
66
+ experimentId?: string;
65
67
  signal: AbortSignal;
66
68
  log(msg: string): void;
67
69
  judge: JudgeConfig | undefined;
@@ -93,6 +95,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
93
95
  model: deps.model,
94
96
  reasoningEffort: deps.reasoningEffort,
95
97
  flags: deps.flags,
98
+ experimentId: deps.experimentId,
96
99
  signal: deps.signal,
97
100
  log: deps.log,
98
101
  telemetry: deps.telemetry,
@@ -91,6 +91,8 @@ export interface SessionDeps {
91
91
  flags: Record<string, unknown>;
92
92
  signal: AbortSignal;
93
93
  log(msg: string): void;
94
+ /** 路径推导出的实验 id(经 send ctx 透给 adapter,见 AgentContext.experimentId)。 */
95
+ experimentId?: string;
94
96
  /** tracing agent 的 OTLP 端点(经 send ctx 透给 adapter,用于注入导出 env)。 */
95
97
  telemetry?: Telemetry;
96
98
  /** 非沙箱 tracing agent 的共享 OTLP 通道(runner 从 run 级池取,经它做逐轮 span 归属)。 */
@@ -139,6 +141,7 @@ export class SessionManager {
139
141
  model: this.deps.model,
140
142
  reasoningEffort: this.deps.reasoningEffort,
141
143
  flags: this.deps.flags,
144
+ experimentId: this.deps.experimentId,
142
145
  sandbox: this.deps.sandbox,
143
146
  session,
144
147
  telemetry: this.deps.telemetry,
@@ -0,0 +1,82 @@
1
+ // SandboxSpec 链式钩子(.setup()/.teardown())的构造期契约:不可变、多次追加按顺序累加。
2
+ // 执行顺序(setup 正序 / teardown 逆序 / LIFO cleanup)是 runner 的事,见
3
+ // test/e2e-sandbox-hooks.test.ts;这里只测 dockerSandbox()/vercelSandbox()/e2bSandbox()/
4
+ // defineSandbox() 这四个工厂产出的 spec 对象本身的构造行为。
5
+ import { describe, expect, it } from "vitest";
6
+ import { dockerSandbox, e2bSandbox, vercelSandbox, defineSandbox } from "./define.ts";
7
+ import type { AgentSetup, AgentTeardown } from "./types.ts";
8
+
9
+ const noopSetup: AgentSetup = () => {};
10
+ const noopTeardown: AgentTeardown = () => {};
11
+
12
+ describe("sandbox factories: .setup()/.teardown() chain", () => {
13
+ it("dockerSandbox() starts with empty hook arrays", () => {
14
+ const spec = dockerSandbox({ image: "custom:latest" });
15
+ expect(spec.backend).toBe("docker");
16
+ expect(spec.image).toBe("custom:latest");
17
+ expect(spec.setupHooks).toEqual([]);
18
+ expect(spec.teardownHooks).toEqual([]);
19
+ });
20
+
21
+ it(".setup() returns a new spec and does not mutate the original", () => {
22
+ const base = dockerSandbox();
23
+ const withSetup = base.setup(noopSetup);
24
+ expect(withSetup).not.toBe(base);
25
+ expect(base.setupHooks).toEqual([]);
26
+ expect(withSetup.setupHooks).toEqual([noopSetup]);
27
+ });
28
+
29
+ it("multiple .setup() calls accumulate in append order", () => {
30
+ const a: AgentSetup = () => {};
31
+ const b: AgentSetup = () => {};
32
+ const spec = dockerSandbox().setup(a).setup(b);
33
+ expect(spec.setupHooks).toEqual([a, b]);
34
+ });
35
+
36
+ it("multiple .teardown() calls accumulate in append order (execution order is the runner's job)", () => {
37
+ const x: AgentTeardown = () => {};
38
+ const y: AgentTeardown = () => {};
39
+ const spec = dockerSandbox().teardown(x).teardown(y);
40
+ expect(spec.teardownHooks).toEqual([x, y]);
41
+ });
42
+
43
+ it("chaining preserves backend-specific fields and stacks across calls", () => {
44
+ const spec = dockerSandbox({ image: "img" }).setup(noopSetup).teardown(noopTeardown).setup(noopSetup);
45
+ expect(spec.backend).toBe("docker");
46
+ expect(spec.image).toBe("img");
47
+ expect(spec.setupHooks).toHaveLength(2);
48
+ expect(spec.teardownHooks).toHaveLength(1);
49
+ });
50
+
51
+ it("vercelSandbox() / e2bSandbox() chain the same way and keep their own fields", () => {
52
+ const vercel = vercelSandbox({ snapshotId: "snap-1" }).setup(noopSetup);
53
+ expect(vercel.backend).toBe("vercel");
54
+ expect(vercel.snapshotId).toBe("snap-1");
55
+ expect(vercel.setupHooks).toEqual([noopSetup]);
56
+
57
+ const e2b = e2bSandbox({ template: "niceeval-agents" }).teardown(noopTeardown);
58
+ expect(e2b.backend).toBe("e2b");
59
+ expect(e2b.template).toBe("niceeval-agents");
60
+ expect(e2b.teardownHooks).toEqual([noopTeardown]);
61
+ });
62
+
63
+ it("defineSandbox() (custom backend) chains too and keeps create()/name", async () => {
64
+ const create = async () => {
65
+ throw new Error("not called in this test");
66
+ };
67
+ const spec = defineSandbox({ name: "my-backend", create }).setup(noopSetup).setup(noopSetup);
68
+ expect(spec.backend).toBe("my-backend");
69
+ expect(spec.create).toBe(create);
70
+ expect(spec.setupHooks).toHaveLength(2);
71
+ });
72
+
73
+ it("each chain call produces an independent object (no shared mutable state)", () => {
74
+ const base = dockerSandbox();
75
+ const other: AgentSetup = () => {};
76
+ const branchA = base.setup(noopSetup);
77
+ const branchB = base.setup(other);
78
+ expect(branchA.setupHooks).toEqual([noopSetup]);
79
+ expect(branchB.setupHooks).toEqual([other]);
80
+ expect(branchA.setupHooks).not.toBe(branchB.setupHooks);
81
+ });
82
+ });
package/src/define.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  import type {
5
5
  Agent,
6
+ AgentSetup,
7
+ AgentTeardown,
6
8
  Config,
7
9
  CustomSandboxSpec,
8
10
  DockerSandboxSpec,
@@ -11,6 +13,7 @@ import type {
11
13
  ExperimentDef,
12
14
  RemoteAgentDef,
13
15
  SandboxAgentDef,
16
+ SandboxHooks,
14
17
  VercelSandboxSpec,
15
18
  } from "./types.ts";
16
19
  import { t } from "./i18n/index.ts";
@@ -71,20 +74,70 @@ export function defineConfig(config: Config): Config {
71
74
  // ───────────────────────── Sandbox 工厂 ─────────────────────────
72
75
  // Sandbox 与 agent 一样用数据结构带参数(见 docs/sandbox.md)。这些工厂只是把
73
76
  // 后端 + 参数包成 spec 对象;真正的行为在 sandbox/<backend>.ts 里,由 resolve.ts 派发。
77
+ //
78
+ // 四个工厂都要挂上 `.setup()` / `.teardown()` 链式方法(见 sandbox/types.ts 的
79
+ // SandboxHooks<Self>):累积的钩子数组随 `HookState` 传递,每次链式调用都重新调
80
+ // `build()` 产出一个新对象,原 spec 不被修改。
81
+
82
+ /** 链式追加中累积的钩子(setup 按追加顺序执行,teardown 执行时逆序,见 SandboxHooks)。 */
83
+ interface HookState {
84
+ readonly setupHooks: readonly AgentSetup[];
85
+ readonly teardownHooks: readonly AgentTeardown[];
86
+ }
87
+
88
+ /** 四个工厂共用:把当前钩子状态包成 `.setup()` / `.teardown()` 方法,调用即用新状态重新 build。 */
89
+ function hookMethods<TSpec>(
90
+ state: HookState,
91
+ rebuild: (state: HookState) => TSpec,
92
+ ): Pick<SandboxHooks<TSpec>, "setup" | "teardown"> {
93
+ return {
94
+ setup: (fn) => rebuild({ setupHooks: [...state.setupHooks, fn], teardownHooks: state.teardownHooks }),
95
+ teardown: (fn) => rebuild({ setupHooks: state.setupHooks, teardownHooks: [...state.teardownHooks, fn] }),
96
+ };
97
+ }
98
+
99
+ const EMPTY_HOOKS: HookState = { setupHooks: [], teardownHooks: [] };
74
100
 
75
101
  /** Docker 沙箱:本地容器。`image` 可覆盖默认 `node:*-slim`(预制模板:烘焙好 agent CLI 的镜像)。 */
76
- export function dockerSandbox(opts: Omit<DockerSandboxSpec, "backend"> = {}): DockerSandboxSpec {
77
- return { backend: "docker", ...opts };
102
+ export function dockerSandbox(
103
+ opts: Omit<DockerSandboxSpec, "backend" | keyof SandboxHooks<unknown>> = {},
104
+ ): DockerSandboxSpec {
105
+ const build = (state: HookState): DockerSandboxSpec => ({
106
+ backend: "docker",
107
+ ...opts,
108
+ setupHooks: state.setupHooks,
109
+ teardownHooks: state.teardownHooks,
110
+ ...hookMethods(state, build),
111
+ });
112
+ return build(EMPTY_HOOKS);
78
113
  }
79
114
 
80
115
  /** Vercel Sandbox:microVM。`snapshotId` 从已有快照起(预制模板:烘焙好 agent CLI 的快照)。 */
81
- export function vercelSandbox(opts: Omit<VercelSandboxSpec, "backend"> = {}): VercelSandboxSpec {
82
- return { backend: "vercel", ...opts };
116
+ export function vercelSandbox(
117
+ opts: Omit<VercelSandboxSpec, "backend" | keyof SandboxHooks<unknown>> = {},
118
+ ): VercelSandboxSpec {
119
+ const build = (state: HookState): VercelSandboxSpec => ({
120
+ backend: "vercel",
121
+ ...opts,
122
+ setupHooks: state.setupHooks,
123
+ teardownHooks: state.teardownHooks,
124
+ ...hookMethods(state, build),
125
+ });
126
+ return build(EMPTY_HOOKS);
83
127
  }
84
128
 
85
129
  /** E2B 沙箱。`template` 选 e2b 模板名/ID(预制模板:如 `"niceeval-agents"`);省略用 e2b 默认 `"base"`。 */
86
- export function e2bSandbox(opts: Omit<E2BSandboxSpec, "backend"> = {}): E2BSandboxSpec {
87
- return { backend: "e2b", ...opts };
130
+ export function e2bSandbox(
131
+ opts: Omit<E2BSandboxSpec, "backend" | keyof SandboxHooks<unknown>> = {},
132
+ ): E2BSandboxSpec {
133
+ const build = (state: HookState): E2BSandboxSpec => ({
134
+ backend: "e2b",
135
+ ...opts,
136
+ setupHooks: state.setupHooks,
137
+ teardownHooks: state.teardownHooks,
138
+ ...hookMethods(state, build),
139
+ });
140
+ return build(EMPTY_HOOKS);
88
141
  }
89
142
 
90
143
  /**
@@ -98,5 +151,13 @@ export function defineSandbox(def: {
98
151
  }): CustomSandboxSpec {
99
152
  if (!def.name) throw new Error(t("define.sandboxNameRequired"));
100
153
  if (typeof def.create !== "function") throw new Error(t("define.sandboxCreateRequired"));
101
- return { backend: def.name, create: def.create, recommendedConcurrency: def.recommendedConcurrency };
154
+ const build = (state: HookState): CustomSandboxSpec => ({
155
+ backend: def.name,
156
+ create: def.create,
157
+ recommendedConcurrency: def.recommendedConcurrency,
158
+ setupHooks: state.setupHooks,
159
+ teardownHooks: state.teardownHooks,
160
+ ...hookMethods(state, build),
161
+ });
162
+ return build(EMPTY_HOOKS);
102
163
  }
package/src/i18n/en.ts CHANGED
@@ -186,6 +186,8 @@ export const en = {
186
186
  "runner.startAgentSetup": "agent setup (install CLI / write config)...",
187
187
  "runner.startAgentTracing": "agent tracing (write OTEL export config)...",
188
188
  "runner.startSandbox": "starting sandbox...",
189
+ "runner.startSandboxSetup": "sandbox setup (environment provisioning hooks)...",
190
+ "runner.startSandboxTeardown": "sandbox teardown (environment provisioning hooks)...",
189
191
  "runner.timeout": "attempt timed out ({{timeoutMs}}ms)\nRecent progress:\n{{recentLogs}}",
190
192
  "runner.traceSelected": " -> kept {{count}} semantic spans",
191
193
  "runner.resumeCarry": " · reusing {{carried}} passing results from last run, re-running {{retry}} evals\n",
package/src/i18n/zh-CN.ts CHANGED
@@ -183,6 +183,8 @@ export const zhCN = {
183
183
  "runner.startAgentSetup": "agent setup(装 CLI / 写配置)…",
184
184
  "runner.startAgentTracing": "agent tracing(写 otel 导出配置)…",
185
185
  "runner.startSandbox": "起沙箱…",
186
+ "runner.startSandboxSetup": "sandbox setup(环境预置钩子)…",
187
+ "runner.startSandboxTeardown": "sandbox teardown(环境预置钩子)…",
186
188
  "runner.timeout": "attempt 超时({{timeoutMs}}ms)\n最近进度:\n{{recentLogs}}",
187
189
  "runner.traceSelected": " → 留 {{count}}(按语义)",
188
190
  "runner.resumeCarry": " · 复用上次 {{carried}} 个通过的结果,重跑 {{retry}} 个 eval\n",
@@ -18,7 +18,7 @@ import {
18
18
  import { prepareDefaultReportData, runWithDefaultReportData } from "./default-report.tsx";
19
19
 
20
20
  export interface ReportContext {
21
- /** results.latest() 挑好的选集:现刻水位快照 + 结构化挑选警告,同官方榜单口径。 */
21
+ /** results.latest() 挑好的选集:现刻水位快照 + 结构化挑选警告,同默认报告口径。 */
22
22
  selection: Selection;
23
23
  /** 默认挑法不合口径时,全量数据自己挑(见 docs/results-lib.md)。 */
24
24
  results: Results;
@@ -24,6 +24,8 @@ import { captureGeneratedFiles, initGitAndCommit } from "./sandbox-prep.ts";
24
24
  import { createRemoteSandbox, withEvalLocalPaths } from "./remote-sandbox.ts";
25
25
  import type {
26
26
  AgentContext,
27
+ AgentSetup,
28
+ AgentTeardown,
27
29
  Cleanup,
28
30
  Config,
29
31
  EvalResult,
@@ -88,6 +90,9 @@ export function runAttemptEffect(
88
90
 
89
91
  return Effect.scoped(
90
92
  Effect.gen(function* () {
93
+ // run.sandbox ?? config.sandbox 是同一个 SandboxSpec 对象,既用来起沙箱后端,
94
+ // 也是 sandbox.setup / sandbox.teardown 钩子(SandboxSpec.setup()/.teardown() 链式挂的)的来源。
95
+ const sandboxSpec = run.sandbox ?? config.sandbox;
91
96
  const sandbox =
92
97
  run.agent.kind === "sandbox"
93
98
  ? yield* sandboxSem.withPermits(1)(
@@ -96,7 +101,7 @@ export function runAttemptEffect(
96
101
  // sandboxSem 只覆盖「容器创建」阶段;容器起好后立即释放,后续 npm install / agent 不占位。
97
102
  log(t("runner.startSandbox"));
98
103
  return yield* createSandbox({
99
- sandbox: run.sandbox ?? config.sandbox,
104
+ sandbox: sandboxSpec,
100
105
  timeout: timeoutMs,
101
106
  runtime: "node24",
102
107
  });
@@ -161,6 +166,8 @@ export function runAttemptEffect(
161
166
  return yield* Effect.promise((interruptSignal) =>
162
167
  runAttemptBody(a, config, t0, base, {
163
168
  sandbox,
169
+ sandboxSetupHooks: sandboxSpec?.setupHooks ?? [],
170
+ sandboxTeardownHooks: sandboxSpec?.teardownHooks ?? [],
164
171
  receiver,
165
172
  telemetry,
166
173
  otel: otelChannel,
@@ -203,6 +210,10 @@ function causeToError(cause: Cause.Cause<never>): string {
203
210
 
204
211
  interface AttemptResources {
205
212
  sandbox: Sandbox;
213
+ /** SandboxSpec.setup() 链式挂的钩子,按追加顺序;非沙箱 agent 传空数组(usesSandbox 挡住不会跑)。 */
214
+ sandboxSetupHooks: readonly AgentSetup[];
215
+ /** SandboxSpec.teardown() 链式挂的钩子,按追加顺序保存,执行时逆序。 */
216
+ sandboxTeardownHooks: readonly AgentTeardown[];
206
217
  receiver?: TraceReceiver;
207
218
  telemetry?: Telemetry;
208
219
  /** 非沙箱 tracing agent 的共享 OTLP 通道(run 级池持有,不随 attempt 关)。 */
@@ -221,7 +232,7 @@ async function runAttemptBody(
221
232
  res: AttemptResources,
222
233
  ): Promise<EvalResult> {
223
234
  const { evalDef, run, attempt } = a;
224
- const { sandbox, receiver, telemetry, otel, signal, log } = res;
235
+ const { sandbox, sandboxSetupHooks, sandboxTeardownHooks, receiver, telemetry, otel, signal, log } = res;
225
236
  const usesSandbox = run.agent.kind === "sandbox";
226
237
  // 整个 attempt 共用一份 agent ctx(sandbox 钩子 / agent setup / tracing configure / teardown 都用它)。
227
238
  const attemptCtx: AgentContext = {
@@ -229,6 +240,7 @@ async function runAttemptBody(
229
240
  model: run.model,
230
241
  reasoningEffort: run.reasoningEffort,
231
242
  flags: run.flags,
243
+ experimentId: run.experimentId,
232
244
  sandbox,
233
245
  session: createAgentSession(),
234
246
  telemetry,
@@ -236,8 +248,20 @@ async function runAttemptBody(
236
248
  };
237
249
  let agentCleanup: Cleanup | void = undefined;
238
250
  let agentDidSetup = false;
251
+ // SandboxSpec.setup() 返回的 cleanup 闭包,按调用顺序收集;finally 里 LIFO 跑(见下)。
252
+ const sandboxCleanups: Cleanup[] = [];
239
253
  try {
240
254
  if (usesSandbox) {
255
+ // 沙箱级生命周期钩子(SandboxSpec.setup):环境预置层,先于 workspace 上传 / git 基线 /
256
+ // eval.setup 跑——改动进 git 基线,不会被误算进 agent 产出的 diff。按追加顺序依次执行;
257
+ // 单个抛错走下面的执行错误路径(与 eval.setup / agent.setup 同一条),已跑过的 cleanup /
258
+ // sandbox.teardown 钩子仍在 finally 里跑(见 catch/finally)。
259
+ if (sandboxSetupHooks.length > 0) log(t("runner.startSandboxSetup"));
260
+ for (const hook of sandboxSetupHooks) {
261
+ const cleanup = await hook(sandbox, attemptCtx);
262
+ if (typeof cleanup === "function") sandboxCleanups.push(cleanup);
263
+ }
264
+
241
265
  await initGitAndCommit(sandbox);
242
266
 
243
267
  // eval 级 setup(starter prep:npm install / 装系统依赖等)。命令默认非 root;
@@ -271,6 +295,7 @@ async function runAttemptBody(
271
295
  model: run.model,
272
296
  reasoningEffort: run.reasoningEffort,
273
297
  flags: run.flags,
298
+ experimentId: run.experimentId,
274
299
  signal,
275
300
  log,
276
301
  judge,
@@ -406,7 +431,8 @@ async function runAttemptBody(
406
431
  };
407
432
  } finally {
408
433
  // teardown / cleanup 一律在 finally 跑(失败也跑),不改判决,各自兜错(diagnostic)。
409
- // LIFO:先 agent(setup 最晚),再沙箱 Scope。
434
+ // LIFO:agent (setup 最晚 → cleanup / teardown 先跑)在前,sandbox 级(最早就绪)收尾在后
435
+ //(即 sandbox.setup 返回的 cleanup、sandbox.teardown 钩子最后跑,沙箱销毁前)。
410
436
  // 沙箱 stop / 接收器 close 不在这里 —— 由 runAttemptEffect 的 Scope 在本函数返回后回收。
411
437
  try {
412
438
  if (typeof agentCleanup === "function") await agentCleanup();
@@ -414,6 +440,27 @@ async function runAttemptBody(
414
440
  } catch {
415
441
  // teardown 失败只是 diagnostic,不影响已出的结果
416
442
  }
443
+ if (usesSandbox) {
444
+ // sandbox.setup 返回的 cleanup:LIFO(后 setup 先 cleanup),与 agent 级同构。
445
+ for (let i = sandboxCleanups.length - 1; i >= 0; i--) {
446
+ try {
447
+ await sandboxCleanups[i]();
448
+ } catch {
449
+ // 同上,只作 diagnostic
450
+ }
451
+ }
452
+ // sandbox.teardown 钩子:按追加的逆序执行,沙箱销毁前最后一步。
453
+ if (sandboxTeardownHooks.length > 0) {
454
+ log(t("runner.startSandboxTeardown"));
455
+ for (let i = sandboxTeardownHooks.length - 1; i >= 0; i--) {
456
+ try {
457
+ await sandboxTeardownHooks[i](sandbox, attemptCtx);
458
+ } catch {
459
+ // 同上,只作 diagnostic
460
+ }
461
+ }
462
+ }
463
+ }
417
464
  }
418
465
  }
419
466
 
@@ -1,6 +1,8 @@
1
1
  // sandbox 域类型:Sandbox 接口、后端 spec(可辨识联合)、命令与文件 IO 的形状。
2
2
  // 「在哪里跑、如何隔离」的全部契约在这里;后端实现见本目录各文件,分发见 resolve.ts。
3
3
 
4
+ import type { AgentSetup, AgentTeardown } from "../agents/types.ts";
5
+
4
6
  export interface CommandResult {
5
7
  stdout: string;
6
8
  stderr: string;
@@ -51,25 +53,64 @@ export type SandboxBackend = "docker" | "vercel" | "e2b";
51
53
  /** 镜像/模板里的 Node 运行时版本。 */
52
54
  export type SandboxRuntime = "node20" | "node24";
53
55
 
56
+ /**
57
+ * SandboxSpec 四个变体共有的沙箱生命周期钩子挂载点:`.setup(fn)` / `.teardown(fn)` 链式方法,
58
+ * 由 `dockerSandbox()` / `vercelSandbox()` / `e2bSandbox()` / `defineSandbox()` 产出的对象上
59
+ * 直接可调。
60
+ *
61
+ * **语义**:环境预置层,俗称「动态镜像层」——本想直接烘进镜像/快照/模板,但内容要按实验
62
+ * (`ctx.flags` / `ctx.experimentId`)动态变化的东西:装某个实验专属的二进制、预热缓存、
63
+ * 写运行期才知道内容的 hook 文件、按 `ctx.experimentId` 载入/回存跨 attempt 的状态。
64
+ *
65
+ * **与另外两层 setup 的分工**(三者都在同一个沙箱生命周期里,各管一层):
66
+ * · `sandbox.setup`(这里)—— 环境层,不知道具体跑哪个 eval / 接哪个 agent;
67
+ * · `eval.setup` —— 任务层,准备这次 eval 的素材(如 `npm install` 起始项目依赖);
68
+ * · `agent.setup` —— 协议层,接入被测 agent(装 CLI、写鉴权 / 模型配置)。
69
+ *
70
+ * **执行顺序**:沙箱就绪 → `sandbox.setup` 钩子 → workspace 上传 / git 基线 / `eval.setup` →
71
+ * `agent.setup` → `agent.tracing.configure` → 逐轮 `send`。`sandbox.setup` 特意排在 git
72
+ * 基线之前——它的改动会被提交进基线,不会被误算进 agent 产出的 diff。收尾按 LIFO:
73
+ * agent 级 cleanup / `agent.teardown` 先跑,`sandbox.teardown` 钩子最后跑(沙箱销毁前)。
74
+ *
75
+ * **多钩子**:`.setup(a).setup(b)` 按追加顺序依次执行(a 先 b 后);`.teardown(x).teardown(y)`
76
+ * 按追加的**逆序**执行(y 先 x 后)。每次调用都返回一个新 spec(不改变原对象),可继续链式。
77
+ *
78
+ * **失败语义**:`setup` 钩子抛错按执行错误计——与 `eval.setup` / `agent.setup` 抛错走同一条
79
+ * 路径,不新增错误分类;但不阻断该 attempt 已进入的收尾(已跑过的 setup 返回的 cleanup、
80
+ * `teardown` 钩子仍会在 finally 里跑)。`teardown` 钩子报错只作诊断(吞掉 / 记 log),不改变
81
+ * 已产出的结果——与 `agent.teardown` 现状一致。
82
+ */
83
+ export interface SandboxHooks<Self> {
84
+ /** 已挂载的 setup 钩子,按追加顺序保存(内部读取,一般用不到)。 */
85
+ readonly setupHooks?: readonly AgentSetup[];
86
+ /** 已挂载的 teardown 钩子,按追加顺序保存,执行时逆序(内部读取,一般用不到)。 */
87
+ readonly teardownHooks?: readonly AgentTeardown[];
88
+ /** 追加一个沙箱级 setup 钩子,返回新 spec;详细契约见 {@link SandboxHooks}。 */
89
+ setup(fn: AgentSetup): Self;
90
+ /** 追加一个沙箱级 teardown 钩子,返回新 spec;详细契约见 {@link SandboxHooks}。 */
91
+ teardown(fn: AgentTeardown): Self;
92
+ }
93
+
54
94
  /**
55
95
  * Sandbox 的「数据结构」定义 —— 与 agent 一样可带参数(见 docs/sandbox.md)。
56
96
  * 必须用工厂函数构造(`dockerSandbox()` / `vercelSandbox()` / `e2bSandbox()` / `defineSandbox()`),
57
97
  * 放进 config / experiment 的 `sandbox` 字段 —— 字段类型只接受这个数据结构,不接受裸字符串。
58
98
  * 各后端的参数互不相同 —— 这是个按 `backend` 区分的可辨识联合(discriminated union)。
99
+ * 四个变体都带 {@link SandboxHooks} 的 `.setup()` / `.teardown()` 链式方法。
59
100
  */
60
- export interface DockerSandboxSpec {
101
+ export interface DockerSandboxSpec extends SandboxHooks<DockerSandboxSpec> {
61
102
  readonly backend: "docker";
62
103
  /** 覆盖默认镜像;默认按 runtime 选 `node:*-slim`。预制模板:传烘焙好 agent CLI 的镜像名。 */
63
104
  readonly image?: string;
64
105
  readonly runtime?: SandboxRuntime;
65
106
  }
66
- export interface VercelSandboxSpec {
107
+ export interface VercelSandboxSpec extends SandboxHooks<VercelSandboxSpec> {
67
108
  readonly backend: "vercel";
68
109
  /** 从已有快照起 microVM。预制模板:烘焙好 agent CLI 的 snapshotId。 */
69
110
  readonly snapshotId?: string;
70
111
  readonly runtime?: SandboxRuntime;
71
112
  }
72
- export interface E2BSandboxSpec {
113
+ export interface E2BSandboxSpec extends SandboxHooks<E2BSandboxSpec> {
73
114
  readonly backend: "e2b";
74
115
  /** e2b 模板名/ID。预制模板:烘焙好 agent CLI 的模板(如 `"niceeval-agents"`)。省略用 e2b 默认 `"base"`。 */
75
116
  readonly template?: string;
@@ -80,7 +121,7 @@ export interface E2BSandboxSpec {
80
121
  * 用户自定义后端:`create` 直接产出一个 `Sandbox` 实例,不经 resolve.ts 的内置 backend switch。
81
122
  * 用 `defineSandbox()` 构造(见 src/define.ts)。`backend` 只用于展示 / 日志,不参与分发。
82
123
  */
83
- export interface CustomSandboxSpec {
124
+ export interface CustomSandboxSpec extends SandboxHooks<CustomSandboxSpec> {
84
125
  readonly backend: string;
85
126
  readonly runtime?: SandboxRuntime;
86
127
  readonly recommendedConcurrency?: number;
@@ -4,7 +4,7 @@
4
4
  // results.latest() 只挑「每实验最新快照」,带 eval 前缀的局部重跑会产出残缺快照;
5
5
  // 榜单承诺「不会因为一次局部重跑变残缺」,所以宿主在实验的全部历史快照上逐 eval
6
6
  // 向更早的 run 补齐,再把合成好的选集注入报告槽——内置默认报告与 --report 吃同一份,
7
- // 官方榜单口径 = 宿主注入口径。本文件只消费 niceeval/results 的读取面。
7
+ // 默认报告口径 = 宿主注入口径。本文件只消费 niceeval/results 的读取面。
8
8
 
9
9
  import { foldEvalOutcome } from "../shared/outcome.ts";
10
10
  import { evalPrefixPredicate } from "../report/aggregate.ts";
@@ -15,7 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs.t
15
15
  import { RunsView } from "./pages/RunsPage.tsx";
16
16
  import { TracesView } from "./pages/TracesPage.tsx";
17
17
 
18
- // 报告槽只有一个:默认装官方榜单(Experiments tab);--report 整槽替换成用户报告。
18
+ // 首页报告只有一份:不传 --report 时是默认报告(Experiments tab),传了整页替换成用户报告。
19
19
  // 证据室(Runs / Traces / AttemptModal)是 view 本体,两种模式下原样保留。
20
20
  export const navItems: { id: Tab; label: MessageKey }[] = [
21
21
  { id: "experiments", label: "nav.experiments" },
@@ -10,7 +10,7 @@ import { evalLevelStats } from "../../../shared/outcome.ts";
10
10
  import { displayExperimentName, fallbackExperimentLabel, sumMaybe } from "../../../shared/aggregate.ts";
11
11
  import { formatConfigValue } from "./format.ts";
12
12
 
13
- /** 官方榜单格子的列键(= 内置指标的 metric.name,见 src/report/metrics.ts)。 */
13
+ /** 默认报告格子的列键(= 内置指标的 metric.name,见 src/report/metrics.ts)。 */
14
14
  export const CELL_KEYS = {
15
15
  passRate: "pass-rate",
16
16
  duration: "duration",
@@ -48,7 +48,7 @@ export interface ViewRow {
48
48
  model?: string;
49
49
  /** 该快照的 startedAt(ISO)—— 榜单「每行带判决产生的时间」。 */
50
50
  lastRunAt: string;
51
- /** 官方榜单格子(pass-rate / duration / tokens / cost),display 已格式化。 */
51
+ /** 默认报告格子(pass-rate / duration / tokens / cost),display 已格式化。 */
52
52
  cells: Partial<Record<string, MetricCell>>;
53
53
  /** 快照内 attempt 总数。 */
54
54
  runs: number;
@@ -182,7 +182,7 @@ describe("loadViewScan · 跨快照去重(--resume 携带的复印件只算一
182
182
  });
183
183
 
184
184
  describe("loadViewScan · createRunWriter 写出的落盘直接可读(writer/reader 同一契约)", () => {
185
- it("writer roundtrip:官方榜单、快照与 attemptRef 都从写入面产物算出", async () => {
185
+ it("writer roundtrip:默认报告、快照与 attemptRef 都从写入面产物算出", async () => {
186
186
  const root = await makeRoot();
187
187
  const writer = await createRunWriter(root, { producer: { name: "niceeval", version: "0.4.6" } });
188
188
  const snap = writer.snapshot({
package/src/view/data.ts CHANGED
@@ -251,7 +251,7 @@ export async function loadViewScan(input?: string, opts: ViewScanOptions = {}):
251
251
 
252
252
  /**
253
253
  * 报告槽渲染:装载报告文件(dev server 语义 —— 文件变更下次请求整页重算,经 mtime
254
- * cache-busting)→ 注入与官方榜单同口径的选集 → web 面 renderToStaticMarkup 成静态 HTML。
254
+ * cache-busting)→ 注入与默认报告同口径的选集 → web 面 renderToStaticMarkup 成静态 HTML。
255
255
  * react-dom 只在 --report 在场时动态加载,默认 CLI 路径不背 react。
256
256
  * attemptHref 缺省即 `#/attempt/<run>/<result>`(view 的 attempt 深链路由)。
257
257
  */
@@ -72,7 +72,7 @@ export interface ViewData {
72
72
  composedRuns: number;
73
73
  /** 官方 KPI(RunOverview.data 产物):totals + 选中快照元信息 + 选集 warnings 随行。 */
74
74
  overview: OverviewData;
75
- /** 官方榜单(MetricTable.data,rows: "experiment"):每个实验最新一次快照的口径。 */
75
+ /** 默认报告(MetricTable.data,rows: "experiment"):每个实验最新一次快照的口径。 */
76
76
  table: TableData;
77
77
  /** 整体单行(MetricTable.data,常量维度):hero 的官方通过率,同一台聚合引擎。 */
78
78
  overall: TableData;