niceeval 0.8.1 → 0.9.1

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 (140) hide show
  1. package/INDEX.md +77 -45
  2. package/dist/agents/types.d.ts +28 -6
  3. package/dist/i18n/en.d.ts +2 -0
  4. package/dist/i18n/zh-CN.d.ts +3 -1
  5. package/dist/report/built-in/index.d.ts +3 -2
  6. package/dist/report/built-in/index.js +7 -8
  7. package/dist/report/built-in/standard.d.ts +1 -0
  8. package/dist/report/built-in/standard.js +30 -0
  9. package/dist/report/components.d.ts +69 -2
  10. package/dist/report/components.js +152 -3
  11. package/dist/report/compute.d.ts +28 -1
  12. package/dist/report/compute.js +123 -0
  13. package/dist/report/index.d.ts +4 -4
  14. package/dist/report/index.js +3 -2
  15. package/dist/report/locale.d.ts +39 -1
  16. package/dist/report/locale.js +69 -0
  17. package/dist/report/react/AttemptList.d.ts +3 -1
  18. package/dist/report/react/AttemptList.js +3 -3
  19. package/dist/report/react/CopyFixPrompt.d.ts +12 -0
  20. package/dist/report/react/CopyFixPrompt.js +12 -0
  21. package/dist/report/react/HeroCard.d.ts +13 -0
  22. package/dist/report/react/HeroCard.js +35 -0
  23. package/dist/report/react/PoweredBy.d.ts +5 -0
  24. package/dist/report/react/PoweredBy.js +7 -0
  25. package/dist/report/react/ScopeWarnings.d.ts +12 -0
  26. package/dist/report/react/ScopeWarnings.js +18 -0
  27. package/dist/report/react/TraceWaterfall.d.ts +14 -0
  28. package/dist/report/react/TraceWaterfall.js +22 -0
  29. package/dist/report/react/index.d.ts +6 -1
  30. package/dist/report/react/index.js +6 -0
  31. package/dist/report/report.d.ts +22 -6
  32. package/dist/report/report.js +68 -55
  33. package/dist/report/scope-warnings.d.ts +28 -0
  34. package/dist/report/scope-warnings.js +101 -0
  35. package/dist/report/text/faces.d.ts +19 -1
  36. package/dist/report/text/faces.js +61 -0
  37. package/dist/report/tree.d.ts +1 -1
  38. package/dist/report/tree.js +7 -2
  39. package/dist/report/types.d.ts +45 -0
  40. package/dist/report/web.d.ts +5 -4
  41. package/dist/report/web.js +7 -22
  42. package/dist/results/select.d.ts +17 -4
  43. package/dist/results/select.js +76 -11
  44. package/dist/results/types.d.ts +26 -0
  45. package/dist/runner/fingerprint.d.ts +3 -3
  46. package/dist/runner/sandbox-selection.d.ts +12 -0
  47. package/dist/runner/types.d.ts +18 -6
  48. package/dist/sandbox/types.d.ts +12 -0
  49. package/dist/shared/aggregate.d.ts +1 -1
  50. package/dist/shared/aggregate.js +1 -1
  51. package/docs-site/zh/explanation/evals.mdx +2 -1
  52. package/docs-site/zh/explanation/experiment.mdx +2 -0
  53. package/docs-site/zh/how-to/custom-reports.mdx +23 -7
  54. package/docs-site/zh/how-to/experiments.mdx +2 -2
  55. package/docs-site/zh/how-to/publish-report.mdx +11 -4
  56. package/docs-site/zh/how-to/viewing-results.mdx +17 -8
  57. package/docs-site/zh/how-to/write-experiment.mdx +40 -1
  58. package/docs-site/zh/reference/builtin-agents.mdx +40 -3
  59. package/docs-site/zh/reference/cli.mdx +3 -3
  60. package/docs-site/zh/reference/define-eval.mdx +8 -0
  61. package/docs-site/zh/reference/official-adapters.mdx +10 -5
  62. package/docs-site/zh/troubleshooting/debugging.mdx +3 -3
  63. package/package.json +2 -1
  64. package/src/agents/bub.ts +13 -1
  65. package/src/agents/claude-code.test.ts +43 -1
  66. package/src/agents/claude-code.ts +32 -14
  67. package/src/agents/codex.test.ts +168 -1
  68. package/src/agents/codex.ts +51 -15
  69. package/src/agents/mcp.ts +31 -0
  70. package/src/agents/post-setup.ts +33 -0
  71. package/src/agents/types.ts +28 -7
  72. package/src/cli.ts +15 -14
  73. package/src/define.ts +3 -0
  74. package/src/i18n/en.ts +8 -5
  75. package/src/i18n/zh-CN.ts +8 -4
  76. package/src/index.ts +1 -0
  77. package/src/report/built-in/index.tsx +8 -7
  78. package/src/report/built-in/standard.tsx +59 -0
  79. package/src/report/components.tsx +218 -2
  80. package/src/report/compute.ts +138 -1
  81. package/src/report/dual-render.test.tsx +141 -14
  82. package/src/report/index.ts +20 -0
  83. package/src/report/locale.ts +83 -1
  84. package/src/report/react/AttemptList.tsx +13 -1
  85. package/src/report/react/CopyFixPrompt.tsx +37 -0
  86. package/src/report/react/HeroCard.tsx +59 -0
  87. package/src/report/react/PoweredBy.tsx +20 -0
  88. package/src/report/react/ScopeWarnings.tsx +74 -0
  89. package/src/report/react/TraceWaterfall.tsx +78 -0
  90. package/src/report/react/enhance.js +14 -0
  91. package/src/report/react/index.tsx +11 -0
  92. package/src/report/react/styles.css +193 -7
  93. package/src/report/report.ts +99 -64
  94. package/src/report/scope-warnings.ts +155 -0
  95. package/src/report/site-components.test.tsx +526 -0
  96. package/src/report/text/faces.ts +66 -0
  97. package/src/report/tree.ts +10 -3
  98. package/src/report/types.ts +51 -0
  99. package/src/report/web.ts +7 -40
  100. package/src/results/host-equivalence.test.ts +6 -2
  101. package/src/results/open.ts +5 -4
  102. package/src/results/results.test.ts +78 -1
  103. package/src/results/select.ts +80 -12
  104. package/src/results/types.ts +27 -0
  105. package/src/runner/attempt.ts +10 -9
  106. package/src/runner/discover.test.ts +9 -1
  107. package/src/runner/discover.ts +3 -3
  108. package/src/runner/fingerprint.ts +9 -4
  109. package/src/runner/ledger.test.ts +30 -1
  110. package/src/runner/ledger.ts +26 -4
  111. package/src/runner/run.ts +5 -1
  112. package/src/runner/sandbox-selection.test.ts +131 -0
  113. package/src/runner/sandbox-selection.ts +110 -0
  114. package/src/runner/types.ts +19 -2
  115. package/src/sandbox/types.ts +6 -0
  116. package/src/shared/aggregate.ts +1 -1
  117. package/src/show/index.ts +19 -12
  118. package/src/show/render.ts +12 -12
  119. package/src/show/report-host.test.ts +34 -17
  120. package/src/show/report-host.ts +6 -5
  121. package/src/show/show.test.ts +141 -4
  122. package/src/view/app/App.test.tsx +79 -17
  123. package/src/view/app/App.tsx +25 -74
  124. package/src/view/app/components/CopyControls.tsx +4 -42
  125. package/src/view/app/i18n.ts +5 -227
  126. package/src/view/app/lib/rows.ts +3 -21
  127. package/src/view/app/shared.ts +1 -3
  128. package/src/view/app/types.ts +2 -2
  129. package/src/view/client-dist/app.css +1 -1
  130. package/src/view/client-dist/app.js +20 -20
  131. package/src/view/data.ts +21 -10
  132. package/src/view/index.ts +2 -12
  133. package/src/view/server.ts +4 -4
  134. package/src/view/shared/types.ts +11 -6
  135. package/src/view/styles.css +9 -252
  136. package/src/view/view-report.test.ts +99 -33
  137. package/src/view/app/components/LazyArtifact.tsx +0 -51
  138. package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
  139. package/src/view/app/pages/AttemptsPage.tsx +0 -80
  140. package/src/view/app/pages/TracesPage.tsx +0 -35
@@ -1,4 +1,4 @@
1
- // cases: docs/engineering/unit-tests/experiments-runner/cases.md
1
+ // cases: docs/engineering/unit-tests/sandbox/cases.md
2
2
  // 变更分类账的集成测试:用宿主 shell 扮演沙箱(真实 git),验证
3
3
  // - .git 不在 workdir 内(agent 看不到分类账;eval 自己 git init 不冲突)
4
4
  // - eval 归因(send 前写入)不进 agent diff;send 窗口内写入逐窗口归因
@@ -153,6 +153,10 @@ describe("createChangeLedger", () => {
153
153
  await mkdir(join(workdir, "node_modules"), { recursive: true });
154
154
  await writeFile(join(workdir, "node_modules", "dep.js"), "excluded\n");
155
155
  await writeFile(join(workdir, "node_modules", "keep.js"), "included back\n");
156
+ await mkdir(join(workdir, "packages/app/node_modules/dep"), { recursive: true });
157
+ await writeFile(join(workdir, "packages/app/node_modules/dep/index.js"), "nested dependency\n");
158
+ await mkdir(join(workdir, "packages/app/__pycache__"), { recursive: true });
159
+ await writeFile(join(workdir, "packages/app/__pycache__/mod.pyc"), "nested cache\n");
156
160
  await mkdir(join(workdir, "secret"), { recursive: true });
157
161
  await writeFile(join(workdir, "secret", "token.txt"), "excluded via ignore\n");
158
162
  // Python 工具链目录不依赖项目 .gitignore:任意 *venv*/ 名字都由 runner 私有清单排除。
@@ -167,10 +171,35 @@ describe("createChangeLedger", () => {
167
171
  expect(paths).toContain("output.txt");
168
172
  expect(paths).toContain("node_modules/keep.js");
169
173
  expect(paths).not.toContain("node_modules/dep.js");
174
+ expect(paths).not.toContain("packages/app/node_modules/dep/index.js");
175
+ expect(paths).not.toContain("packages/app/__pycache__/mod.pyc");
170
176
  expect(paths).not.toContain("secret/token.txt");
171
177
  expect(paths.some((path) => path.includes("venv"))).toBe(false);
172
178
  });
173
179
 
180
+ // bug: memory/ledger-gitignore-pathspec-and-gitlinks.md
181
+ it("未排除的 nested repo 明确失败;整目录 ignore 后允许作为无关环境存在", async () => {
182
+ const first = await makeDirs();
183
+ const checkout = join(first.workdir, "checkout");
184
+ await mkdir(checkout, { recursive: true });
185
+ await execAsync("git init -q && git config user.email t@t && git config user.name t", { cwd: checkout });
186
+ await writeFile(join(checkout, "app.py"), "print('hello')\n");
187
+ await execAsync("git add app.py && git commit -qm baseline", { cwd: checkout });
188
+
189
+ await expect(createChangeLedger(hostSandbox(first.workdir, first.ledgerDir))).rejects.toThrow(
190
+ /nested Git repository checkout.*sandbox\.workdir root.*diff.*ignore/,
191
+ );
192
+
193
+ const second = await makeDirs();
194
+ const ignoredCheckout = join(second.workdir, "checkout");
195
+ await mkdir(ignoredCheckout, { recursive: true });
196
+ await execAsync("git init -q && git config user.email t@t && git config user.name t", { cwd: ignoredCheckout });
197
+ await writeFile(join(ignoredCheckout, "app.py"), "print('ignored')\n");
198
+ await execAsync("git add app.py && git commit -qm baseline", { cwd: ignoredCheckout });
199
+
200
+ await expect(createChangeLedger(hostSandbox(second.workdir, second.ledgerDir), { ignore: ["checkout/"] })).resolves.toBeDefined();
201
+ });
202
+
174
203
  it("整相导出只用一条 shell 命令 + 一次文件下载,不随文件数与窗口数增长", async () => {
175
204
  const { workdir, ledgerDir } = await makeDirs();
176
205
  const counters = { shells: [] as string[], downloads: [] as string[] };
@@ -123,6 +123,23 @@ function shellQuote(s: string): string {
123
123
  return `'${s.replaceAll("'", `'\\''`)}'`;
124
124
  }
125
125
 
126
+ /**
127
+ * 把仓库根语义的 gitignore 风格规则编译成 ledger pathspec。
128
+ * 无斜杠的名字匹配任意深度;有斜杠的规则相对 workdir 根;目录本身与后代一起处理。
129
+ */
130
+ function gitignorePathspecs(pattern: string, exclude: boolean): string[] {
131
+ let normalized = pattern;
132
+ while (normalized.startsWith("./")) normalized = normalized.slice(2);
133
+ if (normalized.startsWith("/")) normalized = normalized.slice(1);
134
+ normalized = normalized.replace(/\/+$/, "");
135
+ if (!normalized) return [];
136
+
137
+ const glob = normalized.includes("/") ? normalized : `**/${normalized}`;
138
+ const globs = exclude && !glob.endsWith("/**") ? [glob, `${glob}/**`] : [glob];
139
+ const magic = exclude ? ":(glob,exclude)" : ":(glob)";
140
+ return [...new Set(globs)].map((value) => `${magic}${value}`);
141
+ }
142
+
126
143
  /** 打分类账锚点(workspace.baseline 阶段,环境层钩子之后):git init + 冻结排除清单 + 首笔 commit。 */
127
144
  export async function createChangeLedger(sandbox: Sandbox, opts?: LedgerOptions): Promise<ChangeLedger> {
128
145
  const excludes = [...DEFAULT_EXCLUDES, ...(opts?.ignore ?? [])];
@@ -131,11 +148,15 @@ export async function createChangeLedger(sandbox: Sandbox, opts?: LedgerOptions)
131
148
 
132
149
  // add -A -f:绕过项目自己的 .gitignore(项目 ignore 的文件照常记录);排除靠 pathspec
133
150
  // (runner 私有清单,agent / fixture 写 .gitignore 影响不了它);include 用第二次 add 打洞加回。
134
- const excludeSpecs = excludes.map((e) => shellQuote(`:(exclude)${e}`)).join(" ");
151
+ const excludeSpecs = excludes.flatMap((pattern) => gitignorePathspecs(pattern, true)).map(shellQuote).join(" ");
135
152
  // include 打洞:路径此刻可能还不存在(如 agent 之后才写),unmatched pathspec 不算错。
153
+ const includeSpecs = includes.flatMap((pattern) => gitignorePathspecs(pattern, false)).map(shellQuote).join(" ");
136
154
  const includeAdd =
137
- includes.length > 0 ? ` && { git add -A -f -- ${includes.map(shellQuote).join(" ")} 2>/dev/null || true; }` : "";
138
- const addAll = `git add -A -f -- . ${excludeSpecs}${includeAdd}`;
155
+ includeSpecs.length > 0 ? ` && { git -c advice.addEmbeddedRepo=false add -A -f -- ${includeSpecs} 2>/dev/null || true; }` : "";
156
+ const rejectGitlinks =
157
+ " && nested=$(git ls-files --stage | awk '$1 == \"160000\" { sub(/^[^\\t]*\\t/, \"\"); print; exit }')" +
158
+ ' && if [ -n "$nested" ]; then printf \'%s\\n\' "niceeval ledger cannot track nested Git repository $nested as file-level evidence; move the checkout to sandbox.workdir root, or add the whole path to defineEval({ diff: { ignore: [...] } }) when it is intentionally out of scope" >&2; exit 2; fi';
159
+ const addAll = `git -c advice.addEmbeddedRepo=false add -A -f -- . ${excludeSpecs}${includeAdd}${rejectGitlinks}`;
139
160
 
140
161
  const anchor = await sandbox.runShell(`git init -q "${LEDGER_GIT_DIR}" && ${addAll} && git commit -q --allow-empty -m "anchor"`, {
141
162
  env,
@@ -170,7 +191,8 @@ async function exportAgentWindows(sandbox: Sandbox, env: Record<string, string>)
170
191
 
171
192
  function ensureCommandSucceeded(result: { exitCode: number; stderr: string }, operation: string): void {
172
193
  if (result.exitCode === 0) return;
173
- const detail = result.stderr.trim().split("\n")[0];
194
+ // git 可能先输出 advisory warning,再输出 niceeval 的可操作诊断;最后一行最接近失败根因。
195
+ const detail = result.stderr.trim().split("\n").at(-1);
174
196
  throw new Error(`${operation} failed (exit ${result.exitCode})${detail ? `: ${detail}` : ""}`);
175
197
  }
176
198
 
package/src/runner/run.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { failureDetailFromResult } from "./feedback/failure.ts";
22
22
  import { encodeAttemptLocator, type AttemptLocator } from "../results/locator.ts";
23
23
  import { runWho } from "./types.ts";
24
+ import { prepareRunSandboxes, sandboxForEval } from "./sandbox-selection.ts";
24
25
  import type { Agent, EvalResult, JudgeConfig, Reporter, ReporterRegistration, RunShape, RunSummary } from "../types.ts";
25
26
  import type { AgentRun, Attempt, LifecyclePhase, AttemptRef, RunOptions } from "./types.ts";
26
27
 
@@ -69,6 +70,8 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
69
70
  const snapshotStartedAt = startedAt;
70
71
  const t0 = Date.now();
71
72
 
73
+ prepareRunSandboxes(opts.evals, opts.agentRuns, opts.config.sandbox);
74
+
72
75
  // 按 sourcePath 缓存文件内容,fingerprint 与 judge 预检共用:
73
76
  // 矩阵大时(实验 × eval)规划阶段不做串行重复文件读。
74
77
  const sourceCache = new Map<string, Promise<string>>();
@@ -88,7 +91,7 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
88
91
  // (cli.ts 在 --force 时不传 priorResults,也不算 carryPlan)。
89
92
  // carryPlan 优先用调用方(cli.ts,为了 live 表格)已经算好的那份,不重算一遍。
90
93
  const { plannedFingerprints, priorRunKeys, carriedResults } =
91
- opts.carryPlan ?? (await planCarry(opts.evals, opts.agentRuns, opts.priorResults));
94
+ opts.carryPlan ?? (await planCarry(opts.evals, opts.agentRuns, opts.priorResults, opts.config.sandbox));
92
95
 
93
96
  // 携入覆盖计数:priorRunKeys 只回答「这个 (experimentId, evalId) 组合有没有可携入的终态
94
97
  // 结果」,不回答「携入了几条」。runs 被调大(或实验改成更大的 runs)时,上次可能只留下比
@@ -131,6 +134,7 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
131
134
  attempt: i,
132
135
  key,
133
136
  fingerprint: plannedFingerprints.get(cacheKey(run, evalDef.id)) ?? "",
137
+ sandboxSpec: sandboxForEval(run, evalDef, opts.config.sandbox),
134
138
  // locator 在构造 fresh attempt plan 时即算好并作为身份贯穿执行、留存登记与落盘
135
139
  // (不是完成后写回,见 docs/cli.md);裸 run(无 experimentId)不产出。
136
140
  locator: run.experimentId
@@ -0,0 +1,131 @@
1
+ // cases: docs/engineering/unit-tests/experiments-runner/cases.md
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { defineEval, e2bSandbox, vercelSandbox } from "../define.ts";
8
+ import type { Agent, DiscoveredEval } from "../types.ts";
9
+ import type { AgentRun } from "./types.ts";
10
+ import { computeFingerprint } from "./fingerprint.ts";
11
+ import {
12
+ prepareRunSandboxes,
13
+ resolvedSandboxRecommendedConcurrency,
14
+ sandboxForEval,
15
+ sandboxProjection,
16
+ } from "./sandbox-selection.ts";
17
+
18
+ const roots: string[] = [];
19
+ afterEach(async () => {
20
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
21
+ });
22
+
23
+ function agent(kind: "sandbox" | "remote"): Agent {
24
+ return { name: `${kind}-agent`, kind } as Agent;
25
+ }
26
+
27
+ async function evalDef(id: string, environment?: string): Promise<DiscoveredEval> {
28
+ const root = await mkdtemp(join(tmpdir(), "niceeval-sandbox-selection-"));
29
+ roots.push(root);
30
+ const sourcePath = join(root, "case.eval.ts");
31
+ await writeFile(sourcePath, "export default { test() {} };\n");
32
+ return {
33
+ id,
34
+ environment,
35
+ baseDir: root,
36
+ sourcePath,
37
+ source: { path: "evals/case.eval.ts", content: "export default { test() {} };\n", sha256: "source" },
38
+ test() {},
39
+ };
40
+ }
41
+
42
+ function run(overrides: Partial<AgentRun> = {}): AgentRun {
43
+ return {
44
+ agent: agent("sandbox"),
45
+ flags: {},
46
+ runs: 1,
47
+ earlyExit: true,
48
+ evalFilter: () => true,
49
+ experimentId: "profiles/run",
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ describe("eval-level sandbox selection", () => {
55
+ it("environments 查表:profile 换预制产物,未声明的 eval 用基础产物且不进 sandboxByEval", async () => {
56
+ const py39 = await evalDef("astropy/old", "python-3.9-astropy-4.2");
57
+ const node18 = await evalDef("legacy/node", "node-18-legacy");
58
+ const plain = await evalDef("weather/basic");
59
+ const selected = run({
60
+ sandbox: e2bSandbox({
61
+ template: "niceeval-agents",
62
+ environments: {
63
+ "python-3.9-astropy-4.2": { template: "niceeval-py39-astropy42" },
64
+ "node-18-legacy": { template: "niceeval-node18" },
65
+ },
66
+ }),
67
+ });
68
+
69
+ prepareRunSandboxes([py39, node18, plain], [selected]);
70
+ expect(sandboxForEval(selected, py39)).toMatchObject({ provider: "e2b", template: "niceeval-py39-astropy42" });
71
+ expect(sandboxForEval(selected, node18)).toMatchObject({ provider: "e2b", template: "niceeval-node18" });
72
+ expect(sandboxForEval(selected, plain)).toMatchObject({ provider: "e2b", template: "niceeval-agents" });
73
+
74
+ const projection = sandboxProjection(selected);
75
+ expect(projection.sandbox).toMatchObject({ provider: "e2b", params: { template: "niceeval-agents" } });
76
+ expect(projection.sandboxByEval).toMatchObject({
77
+ "astropy/old": { provider: "e2b", params: { template: "niceeval-py39-astropy42" } },
78
+ "legacy/node": { provider: "e2b", params: { template: "niceeval-node18" } },
79
+ });
80
+ expect(projection.sandboxByEval).not.toHaveProperty("weather/basic");
81
+
82
+ const [oldFingerprint, nodeFingerprint, plainFingerprint] = await Promise.all([
83
+ computeFingerprint(py39, selected),
84
+ computeFingerprint(node18, selected),
85
+ computeFingerprint(plain, selected),
86
+ ]);
87
+ expect(oldFingerprint).not.toBe(nodeFingerprint);
88
+ expect(oldFingerprint).not.toBe(plainFingerprint);
89
+ });
90
+
91
+ it("选中 eval 的 profile 缺表项在创建 sandbox 前穷举报错;defineEval 拒绝空 profile", async () => {
92
+ expect(() => defineEval({ environment: " ", test() {} })).toThrow(/environment.*non-empty profile id/);
93
+
94
+ const missingA = await evalDef("astropy/old", "python-3.9-astropy-4.2");
95
+ const missingB = await evalDef("legacy/node", "node-18-legacy");
96
+ const bare = run({ sandbox: e2bSandbox({ template: "niceeval-agents" }) });
97
+ let thrown: Error | undefined;
98
+ try {
99
+ prepareRunSandboxes([missingA, missingB], [bare]);
100
+ } catch (error) {
101
+ thrown = error as Error;
102
+ }
103
+ expect(thrown?.message).toMatch(/profiles\/run/);
104
+ expect(thrown?.message).toMatch(/astropy\/old → "python-3\.9-astropy-4\.2"/);
105
+ expect(thrown?.message).toMatch(/legacy\/node → "node-18-legacy"/);
106
+ expect(thrown?.message).toMatch(/environments/);
107
+ });
108
+
109
+ it("provider 推荐并发取所有解析结果的最小值;remote agent 零查表", async () => {
110
+ const item = await evalDef("astropy/old", "python-3.9-astropy-4.2");
111
+ const plain = await evalDef("weather/basic");
112
+ const e2bRun = run({
113
+ sandbox: e2bSandbox({
114
+ template: "niceeval-agents",
115
+ environments: { "python-3.9-astropy-4.2": { template: "niceeval-py39-astropy42" } },
116
+ }),
117
+ });
118
+ const vercelRun = run({ experimentId: "profiles/vercel", sandbox: vercelSandbox({ snapshotId: "snap_base" }) });
119
+ expect(resolvedSandboxRecommendedConcurrency([item, plain], [e2bRun])).toBe(20);
120
+ expect(resolvedSandboxRecommendedConcurrency([plain], [e2bRun, vercelRun])).toBe(1);
121
+
122
+ const remote = run({
123
+ agent: agent("remote"),
124
+ sandbox: e2bSandbox({ template: "niceeval-agents" }),
125
+ });
126
+ expect(() => prepareRunSandboxes([item], [remote])).not.toThrow();
127
+ expect(resolvedSandboxRecommendedConcurrency([item], [remote])).toBe(10);
128
+ expect(sandboxProjection(remote)).toEqual({});
129
+ expect(remote.resolvedSandboxes).toBeUndefined();
130
+ });
131
+ });
@@ -0,0 +1,110 @@
1
+ // ExperimentDef.sandbox 的规划期解析:spec 携带 environments 表时,按每条选中 eval 的
2
+ // `environment` profile 查表派生该 eval 的具体 spec;缺表项在创建任何沙箱、计算 carry 或
3
+ // 选择全局并发之前一次性穷举报错。指纹、并发预算、attempt 创建与结果审计全部消费这同一份
4
+ // 解析结果(见 docs/feature/experiments/library.md「不同 eval 起自不同预制环境」)。
5
+
6
+ import { sandboxRecommendedConcurrency, sandboxRunInfo } from "../sandbox/resolve.ts";
7
+ import type { DiscoveredEval, SandboxOption, SandboxRunInfo } from "../types.ts";
8
+ import type { AgentRun } from "./types.ts";
9
+
10
+ /** environments 表是内置 provider spec 的数据字段;这里只做查表,不认 provider 名。 */
11
+ function specEnvironments(spec: SandboxOption): Readonly<Record<string, Record<string, unknown>>> | undefined {
12
+ const environments = (spec as { environments?: unknown }).environments;
13
+ if (typeof environments !== "object" || environments === null) return undefined;
14
+ return environments as Readonly<Record<string, Record<string, unknown>>>;
15
+ }
16
+
17
+ /** 按 profile 派生该 eval 的具体 spec(浅覆盖预制产物槽位,hooks 与其余参数共享);缺表项返回 undefined。 */
18
+ function deriveSpec(spec: SandboxOption, profile: string): SandboxOption | undefined {
19
+ const override = specEnvironments(spec)?.[profile];
20
+ if (override === undefined) return undefined;
21
+ return { ...spec, ...override } as SandboxOption;
22
+ }
23
+
24
+ function missingEnvironmentsError(run: AgentRun, missing: ReadonlyArray<readonly [string, string]>): Error {
25
+ const entries = missing.map(([id, profile]) => ` ${id} → ${JSON.stringify(profile)}`).join("\n");
26
+ return new Error(
27
+ `sandbox spec for experiment ${JSON.stringify(run.experimentId ?? run.agent.name)} has no environments entry for:\n${entries}\n` +
28
+ `add the missing profile(s) to the spec's environments table — dockerSandbox({ environments: { "<profile>": { image } } }), ` +
29
+ `e2bSandbox({ environments: { "<profile>": { template } } }), vercelSandbox({ environments: { "<profile>": { snapshotId } } }) — ` +
30
+ `or fix the eval's environment declaration`,
31
+ );
32
+ }
33
+
34
+ /** 该 eval 实际起步的 spec:未声明 environment 用基础 spec;声明了则查表派生并缓存。 */
35
+ export function sandboxForEval(run: AgentRun, evalDef: DiscoveredEval, fallback?: SandboxOption): SandboxOption | undefined {
36
+ if (run.agent.kind !== "sandbox") return undefined;
37
+ const spec = run.sandbox ?? fallback;
38
+ if (spec === undefined || evalDef.environment === undefined) return spec;
39
+
40
+ const cached = run.resolvedSandboxes?.get(evalDef.id);
41
+ if (cached !== undefined) return cached;
42
+
43
+ const derived = deriveSpec(spec, evalDef.environment);
44
+ if (derived === undefined) throw missingEnvironmentsError(run, [[evalDef.id, evalDef.environment]]);
45
+ const cache = run.resolvedSandboxes ?? new Map<string, SandboxOption>();
46
+ cache.set(evalDef.id, derived);
47
+ run.resolvedSandboxes = cache;
48
+ return derived;
49
+ }
50
+
51
+ /** 在 dry-run / carry / concurrency / attempt 展开之前一次性查表;全部缺项一次穷举,不等到花费发生后才出现。 */
52
+ export function prepareRunSandboxes(evals: DiscoveredEval[], runs: AgentRun[], fallback?: SandboxOption): void {
53
+ for (const run of runs) {
54
+ if (run.agent.kind !== "sandbox") continue;
55
+ const spec = run.sandbox ?? fallback;
56
+ if (spec === undefined) continue; // 缺 spec 的错误由既有 resolveSandbox 路径按原文案报
57
+ const missing: Array<readonly [string, string]> = [];
58
+ for (const evalDef of evals) {
59
+ if (!run.evalFilter(evalDef.id) || evalDef.environment === undefined) continue;
60
+ if (run.resolvedSandboxes?.has(evalDef.id)) continue;
61
+ const derived = deriveSpec(spec, evalDef.environment);
62
+ if (derived === undefined) {
63
+ missing.push([evalDef.id, evalDef.environment]);
64
+ continue;
65
+ }
66
+ const cache = run.resolvedSandboxes ?? new Map<string, SandboxOption>();
67
+ cache.set(evalDef.id, derived);
68
+ run.resolvedSandboxes = cache;
69
+ }
70
+ if (missing.length > 0) throw missingEnvironmentsError(run, missing);
71
+ }
72
+ }
73
+
74
+ /** ExperimentRunInfo 的 sandbox 投影:顶层恒为基础 spec;sandboxByEval 只含声明了 environment 的选中 eval。 */
75
+ export function sandboxProjection(run: AgentRun, fallback?: SandboxOption): {
76
+ sandbox?: SandboxRunInfo;
77
+ sandboxByEval?: Record<string, SandboxRunInfo>;
78
+ } {
79
+ if (run.agent.kind !== "sandbox") return {};
80
+ const sandbox = sandboxRunInfo(run.sandbox ?? fallback);
81
+ const entries = [...(run.resolvedSandboxes ?? new Map<string, SandboxOption>()).entries()].sort(([a], [b]) =>
82
+ a.localeCompare(b),
83
+ );
84
+ const sandboxByEval: Record<string, SandboxRunInfo> = {};
85
+ for (const [evalId, derived] of entries) {
86
+ const info = sandboxRunInfo(derived);
87
+ if (info !== undefined) sandboxByEval[evalId] = info;
88
+ }
89
+ return {
90
+ ...(sandbox !== undefined ? { sandbox } : {}),
91
+ ...(entries.length > 0 ? { sandboxByEval } : {}),
92
+ };
93
+ }
94
+
95
+ export function resolvedSandboxRecommendedConcurrency(
96
+ evals: DiscoveredEval[],
97
+ runs: AgentRun[],
98
+ fallback?: SandboxOption,
99
+ ): number {
100
+ prepareRunSandboxes(evals, runs, fallback);
101
+ const recommendations: number[] = [];
102
+ for (const run of runs) {
103
+ if (run.agent.kind !== "sandbox") continue;
104
+ for (const evalDef of evals) {
105
+ if (!run.evalFilter(evalDef.id)) continue;
106
+ recommendations.push(sandboxRecommendedConcurrency(sandboxForEval(run, evalDef, fallback)));
107
+ }
108
+ }
109
+ return recommendations.length > 0 ? Math.min(...recommendations) : 10;
110
+ }
@@ -31,7 +31,15 @@ export interface ExperimentRunInfo {
31
31
  /** evals 过滤器的指纹(数组内容 / 函数体哈希),供「配置没变」判断;与 selectedEvalIds 一起取代原过滤器。 */
32
32
  evalFilterFingerprint?: string;
33
33
  /** provider 名、provider 的公开参数投影与配置 fingerprint;参数只经投影落盘,token/凭据永不进来。 */
34
- sandbox?: { provider: string; params?: Record<string, JsonValue>; fingerprint?: string };
34
+ sandbox?: SandboxRunInfo;
35
+ /** spec 携带 environments 表时:声明了 environment 的选中 eval 各自解析到的产物投影,按 eval id 留审计映射;其余 eval 以 `sandbox` 为准。 */
36
+ sandboxByEval?: Record<string, SandboxRunInfo>;
37
+ }
38
+
39
+ export interface SandboxRunInfo {
40
+ provider: string;
41
+ params?: Record<string, JsonValue>;
42
+ fingerprint?: string;
35
43
  }
36
44
 
37
45
  /**
@@ -312,6 +320,8 @@ export interface EvalDef {
312
320
  description?: string;
313
321
  /** 标签,供 CLI `--tag` 过滤和 view 分类;与 id 前缀过滤是两套独立的筛选维度。 */
314
322
  tags?: string[];
323
+ /** 这条 eval 需要的环境 profile id(provider-neutral,如 `"python-3.9-astropy-4.2"`);由 sandbox spec 的 `environments` 表翻译成该 provider 的预制产物。 */
324
+ environment?: string;
315
325
  /** 覆盖项目级 Config.judge,只对这一个 eval 生效(如换个更贵的评审模型)。 */
316
326
  judge?: JudgeConfig;
317
327
  /** 覆盖 / 追加项目级 Config.reporters,只对这一个 eval 生效。 */
@@ -379,7 +389,10 @@ export interface ExperimentDef {
379
389
  evals?: "*" | string[] | ((id: string) => boolean);
380
390
  /** 覆盖项目级 / CLI 的单次 attempt 超时(毫秒),只对这个实验生效。 */
381
391
  timeoutMs?: number;
382
- /** 覆盖项目级 Config.sandbox,只对这个实验生效。 */
392
+ /**
393
+ * 覆盖项目级 Config.sandbox,只对这个实验生效。固定 SandboxSpec 对全部选中 eval 复用;
394
+ * spec 可携带 `environments` 表,按 eval 的 `environment` profile 换预制产物。
395
+ */
383
396
  sandbox?: SandboxOption;
384
397
  /**
385
398
  * 本实验的花费上限(USD)。调度器按「已完成 attempt 的实测花费」累计,到顶后跳过这个实验
@@ -479,6 +492,8 @@ export interface AgentRun {
479
492
  runs: number;
480
493
  earlyExit: boolean;
481
494
  sandbox?: SandboxOption;
495
+ /** environments 查表的规划期缓存(只含声明了 environment 的 selected eval);每条只派生一次。 */
496
+ resolvedSandboxes?: Map<string, SandboxOption>;
482
497
  timeoutMs?: number;
483
498
  budget?: number;
484
499
  evalFilter: (id: string) => boolean;
@@ -541,6 +556,8 @@ export interface Attempt {
541
556
  /** agent+model+evalId,用于首过即停。 */
542
557
  key: string;
543
558
  fingerprint: string;
559
+ /** 规划期按 eval 的 environment 查表派生的具体 spec;attempt 生命周期不再重新查表。 */
560
+ sandboxSpec?: SandboxOption;
544
561
  /**
545
562
  * 构造 fresh attempt plan 时即算好的 Attempt 定位符(不是完成后写回):由 invocation 的
546
563
  * snapshotStartedAt 与 attempt 身份派生,贯穿执行、留存登记与落盘——登记项、run 收尾反馈与
@@ -126,18 +126,24 @@ export interface DockerSandboxSpec extends SandboxHooks<DockerSandboxSpec> {
126
126
  readonly provider: "docker";
127
127
  /** 覆盖默认镜像;默认按 runtime 选 `node:*-slim`。预制模板:传烘焙好 agent CLI 的镜像名。 */
128
128
  readonly image?: string;
129
+ /** 按 eval 的 `environment` profile 覆盖预制镜像:键为 profile id,值为该 profile 起步的镜像。未声明 environment 的 eval 用 `image`。 */
130
+ readonly environments?: Readonly<Record<string, { readonly image: string }>>;
129
131
  readonly runtime?: SandboxRuntime;
130
132
  }
131
133
  export interface VercelSandboxSpec extends SandboxHooks<VercelSandboxSpec> {
132
134
  readonly provider: "vercel";
133
135
  /** 从已有快照起 microVM。预制模板:烘焙好 agent CLI 的 snapshotId。 */
134
136
  readonly snapshotId?: string;
137
+ /** 按 eval 的 `environment` profile 覆盖预制快照:键为 profile id,值为该 profile 起步的 snapshotId。未声明 environment 的 eval 用 `snapshotId`。 */
138
+ readonly environments?: Readonly<Record<string, { readonly snapshotId: string }>>;
135
139
  readonly runtime?: SandboxRuntime;
136
140
  }
137
141
  export interface E2BSandboxSpec extends SandboxHooks<E2BSandboxSpec> {
138
142
  readonly provider: "e2b";
139
143
  /** e2b 模板名/ID。预制模板:烘焙好 agent CLI 的模板(如 `"niceeval-agents"`)。省略用 e2b 默认 `"base"`。 */
140
144
  readonly template?: string;
145
+ /** 按 eval 的 `environment` profile 覆盖预制模板:键为 profile id,值为该 profile 起步的模板。未声明 environment 的 eval 用 `template`。 */
146
+ readonly environments?: Readonly<Record<string, { readonly template: string }>>;
141
147
  /** 仅作记录;e2b 的 node 版本由模板决定,不在创建时选。 */
142
148
  readonly runtime?: SandboxRuntime;
143
149
  }
@@ -47,7 +47,7 @@ export function experimentGroupOf(experimentId: string): string | undefined {
47
47
  /**
48
48
  * eval id 前缀过滤,同 CLI 位置参数语义(docs/feature/reports/show.md「打开与收窄」):
49
49
  * eval 位置参数是收窄过滤,按**裸前缀宽松匹配**——"algebra" 命中 "algebra"、"algebra/..."
50
- * 也命中 "algebra2",多命中正是它的用途(与 `--experiment` 的按路径段匹配有意不同)。
50
+ * 也命中 "algebra2",多命中正是它的用途(与 `--exp` 的按路径段匹配有意不同)。
51
51
  */
52
52
  export function evalPrefixPredicate(evals?: string | string[]): (id: string) => boolean {
53
53
  if (evals === undefined) return () => true;
package/src/show/index.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  // @<locator> 精确 attempt:无证据 flag → 紧凑全景;带 flag → 对应证据切面
8
8
  // --source / --execution / --diff[=路径] 证据切面(宿主本体):出现即走证据室,不渲染报告槽
9
9
  // --history 执行时间轴(逐 experimentId + evalId 分节),与 --report 互斥
10
- // --report <文件> 整槽换成用户报告;位置前缀 / --results / --experiment 先收窄 Scope 再注入
10
+ // --report <文件> 整槽换成用户报告;位置前缀 / --results / --exp 先收窄 Scope 再注入
11
11
  // --page <id> 多页报告选页;未命中列出可用页 id 按用法错误退出
12
- // --results <目录> 结果根换成该目录;--experiment 让 Scope 只留该实验
12
+ // --results <目录> 结果根换成该目录;--exp 让 Scope 只留该实验
13
13
  //
14
14
  // 数据全部走 niceeval/results 的读取面(openResults + 合成 Scope + loadAttemptEvidence),
15
15
  // 不自己爬目录;证据可用性只由 loadAttemptEvidence 在单 Attempt 页面计算。
@@ -38,10 +38,8 @@ import { attemptHistory } from "./compose.ts";
38
38
  import {
39
39
  HostReportError,
40
40
  loadHostReport,
41
- localizeText,
42
41
  reportMetaFor,
43
42
  renderHostPageText,
44
- resolveReportTitle,
45
43
  type HostCommandContext,
46
44
  } from "./report-host.ts";
47
45
  import {
@@ -55,7 +53,7 @@ import {
55
53
  evalDetailText,
56
54
  evalSourceText,
57
55
  executionText,
58
- pageIndexText,
56
+ otherPagesText,
59
57
  timingText,
60
58
  pickDetailAttempt,
61
59
  skippedRunsText,
@@ -324,6 +322,9 @@ async function show(
324
322
  };
325
323
  const sourceLabel = flags.report ?? "the built-in report";
326
324
 
325
+ // 初始页 = --page 指定的页,缺省第一页(docs/feature/reports/show/reports.md Case 2);
326
+ // 本地宿主只 resolve 被打开的这一页——其余页只留 id / title,不触发取数(见 shell.md
327
+ // 「行为约束」「本地宿主只 resolve 被打开的页」)。
327
328
  let page = report.pages[0];
328
329
  if (flags.page !== undefined) {
329
330
  const hit = report.pages.find((p) => p.id === flags.page);
@@ -334,12 +335,6 @@ async function show(
334
335
  );
335
336
  }
336
337
  page = hit;
337
- } else if (report.pages.length > 1) {
338
- // 多页未选页:只输出页索引与可复制的单页命令,不倾倒页内容(与可比组索引同一模式)。
339
- // 标题行走标题回退链(终点是内置文案「Eval 运行结果 / Eval Results」,恒有值)。
340
- const title = localizeText(resolveReportTitle(report.title, selection.snapshots), locale) ?? "Eval Results";
341
- io.out(pageIndexText({ report, title, command: commandContext, locale }) + "\n");
342
- return;
343
338
  }
344
339
 
345
340
  // attemptCommand 留给渲染管线的默认值:AttemptLocator 已经是可直接 `niceeval show @<locator>`
@@ -354,5 +349,17 @@ async function show(
354
349
  commandContext: { ...commandContext, ...(flags.page !== undefined ? { page: flags.page } : {}) },
355
350
  },
356
351
  );
357
- io.out(text + "\n");
352
+
353
+ // 页数大于一时尾部附「其余页」索引(只列未渲染的页,不倾倒内容);单页定义没有这段。
354
+ const remaining = report.pages.filter((p) => p.id !== page.id);
355
+ if (remaining.length === 0) {
356
+ io.out(text + "\n");
357
+ return;
358
+ }
359
+ const tail = otherPagesText({
360
+ otherPages: remaining.map((p) => ({ id: p.id, title: p.title })),
361
+ command: commandContext,
362
+ locale,
363
+ });
364
+ io.out(`${text}\n\n${tail}\n`);
358
365
  }
@@ -113,7 +113,7 @@ export function attemptsOfEval(snapshots: Snapshot[], evalId: string): AttemptHa
113
113
  /**
114
114
  * 详情块 / eval-id 前缀证据切面默认挑最新一次失败的 attempt;没有失败挑最新一次。
115
115
  * 精确选某一次 attempt 走 `@<locator>`(`resolveLocator`),不再有数字 `--attempt`——
116
- * --experiment 已在 Selection 合成时收窄。
116
+ * --exp 已在 Selection 合成时收窄。
117
117
  */
118
118
  export function pickDetailAttempt(attempts: AttemptHandle[]): AttemptHandle | undefined {
119
119
  if (attempts.length === 0) return undefined;
@@ -418,29 +418,29 @@ export function attemptHistoryText(opts: {
418
418
  return `${head}\n\n${indentBlock(table, " ")}`;
419
419
  }
420
420
 
421
- // ───────────────────────── --report 页索引 ─────────────────────────
421
+ // ───────────────────────── --report 其余页索引 ─────────────────────────
422
422
 
423
423
  /**
424
- * 多页报告的页索引(docs/feature/reports/show/reports.md Case 2):标题行 + 每页一行
425
- * (id / 本 locale 页名 / 可复制的 `--page` 命令)。索引命令携带完整上下文
426
- * (--results / --report / 位置参数),复制即可精确复现下一层视图。
424
+ * 渲染初始页之后追加的「其余页」索引(docs/feature/reports/show/reports.md Case 2):
425
+ * 只列未渲染的页 —— 每行 id / 本 locale 页名 / 可复制的 `--page` 命令,索引命令携带完整上下文
426
+ * (--results / --report / 位置参数),复制即可精确复现下一层视图。调用方只在页数大于一时
427
+ * 拼接这段(单页定义没有「其余页」段);`otherPages` 不含被渲染的那一页。
427
428
  */
428
- export function pageIndexText(opts: {
429
- report: HostReport;
430
- title: string;
429
+ export function otherPagesText(opts: {
430
+ otherPages: { id: string; title: HostReport["pages"][number]["title"] }[];
431
431
  command: HostCommandContext;
432
432
  locale: string;
433
433
  }): string {
434
- const { report, title, command, locale } = opts;
435
- const head = `${title} · ${locale === "zh-CN" ? `${report.pages.length} 页` : `${report.pages.length} pages`}`;
434
+ const { otherPages, command, locale } = opts;
435
+ const head = locale === "zh-CN" ? "其余页:" : "Other pages:";
436
436
  const table = renderAlignedRows(
437
- report.pages.map((page) => [
437
+ otherPages.map((page) => [
438
438
  page.id,
439
439
  localizeText(page.title, locale) ?? page.id,
440
440
  showCommand({ ...command, page: page.id }),
441
441
  ]),
442
442
  );
443
- return `${head}\n\n${indentBlock(table, " ")}`;
443
+ return `${head}\n${indentBlock(table, " ")}`;
444
444
  }
445
445
 
446
446
  // ───────────────────────── 截断预算(--eval / --execution / 全景共用) ─────────────────────────