niceeval 0.8.0 → 0.9.0

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 (143) 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 +2 -0
  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 +72 -6
  10. package/dist/report/components.js +159 -10
  11. package/dist/report/compute.d.ts +31 -4
  12. package/dist/report/compute.js +131 -12
  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 +66 -53
  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.js +6 -1
  38. package/dist/report/types.d.ts +45 -10
  39. package/dist/report/web.d.ts +5 -4
  40. package/dist/report/web.js +7 -22
  41. package/dist/results/select.d.ts +15 -2
  42. package/dist/results/select.js +75 -10
  43. package/dist/results/types.d.ts +26 -11
  44. package/dist/runner/fingerprint.d.ts +3 -3
  45. package/dist/runner/sandbox-selection.d.ts +12 -0
  46. package/dist/runner/types.d.ts +18 -6
  47. package/dist/sandbox/types.d.ts +12 -0
  48. package/docs-site/zh/explanation/evals.mdx +2 -1
  49. package/docs-site/zh/explanation/experiment.mdx +2 -0
  50. package/docs-site/zh/how-to/custom-reports.mdx +22 -6
  51. package/docs-site/zh/how-to/publish-report.mdx +6 -8
  52. package/docs-site/zh/how-to/viewing-results.mdx +3 -3
  53. package/docs-site/zh/how-to/write-experiment.mdx +40 -1
  54. package/docs-site/zh/reference/builtin-agents.mdx +40 -3
  55. package/docs-site/zh/reference/cli.mdx +1 -2
  56. package/docs-site/zh/reference/define-eval.mdx +8 -0
  57. package/docs-site/zh/reference/official-adapters.mdx +10 -5
  58. package/docs-site/zh/reference/report-components.mdx +2 -2
  59. package/docs-site/zh/reference/results-data.mdx +2 -4
  60. package/package.json +2 -1
  61. package/src/agents/bub.ts +13 -1
  62. package/src/agents/claude-code.test.ts +43 -1
  63. package/src/agents/claude-code.ts +32 -14
  64. package/src/agents/codex.test.ts +168 -1
  65. package/src/agents/codex.ts +51 -15
  66. package/src/agents/mcp.ts +31 -0
  67. package/src/agents/post-setup.ts +33 -0
  68. package/src/agents/types.ts +28 -7
  69. package/src/cli.ts +9 -11
  70. package/src/context/context.ts +11 -5
  71. package/src/define.ts +3 -0
  72. package/src/i18n/en.ts +5 -2
  73. package/src/i18n/zh-CN.ts +5 -1
  74. package/src/index.ts +1 -0
  75. package/src/report/built-in/index.tsx +8 -7
  76. package/src/report/built-in/standard.tsx +59 -0
  77. package/src/report/components.tsx +231 -17
  78. package/src/report/compute.ts +146 -21
  79. package/src/report/dual-render.test.tsx +139 -12
  80. package/src/report/index.ts +20 -1
  81. package/src/report/locale.ts +83 -1
  82. package/src/report/react/AttemptList.tsx +13 -1
  83. package/src/report/react/CopyFixPrompt.tsx +37 -0
  84. package/src/report/react/HeroCard.tsx +59 -0
  85. package/src/report/react/PoweredBy.tsx +20 -0
  86. package/src/report/react/ScopeWarnings.tsx +74 -0
  87. package/src/report/react/TraceWaterfall.tsx +78 -0
  88. package/src/report/react/enhance.js +14 -0
  89. package/src/report/react/index.tsx +11 -0
  90. package/src/report/react/styles.css +187 -7
  91. package/src/report/report.test.ts +2 -20
  92. package/src/report/report.ts +97 -62
  93. package/src/report/scope-warnings.ts +155 -0
  94. package/src/report/site-components.test.tsx +526 -0
  95. package/src/report/text/faces.ts +66 -0
  96. package/src/report/tree.ts +8 -1
  97. package/src/report/types.ts +51 -11
  98. package/src/report/web.ts +7 -40
  99. package/src/results/copy.ts +15 -78
  100. package/src/results/host-equivalence.test.ts +5 -1
  101. package/src/results/open.ts +5 -4
  102. package/src/results/publish.ts +4 -146
  103. package/src/results/results.test.ts +86 -9
  104. package/src/results/select.ts +78 -10
  105. package/src/results/types.ts +27 -7
  106. package/src/runner/attempt.ts +10 -9
  107. package/src/runner/discover.test.ts +9 -1
  108. package/src/runner/discover.ts +3 -3
  109. package/src/runner/fingerprint.ts +9 -4
  110. package/src/runner/ledger.test.ts +30 -1
  111. package/src/runner/ledger.ts +26 -4
  112. package/src/runner/run.ts +5 -1
  113. package/src/runner/sandbox-selection.test.ts +131 -0
  114. package/src/runner/sandbox-selection.ts +110 -0
  115. package/src/runner/types.ts +19 -2
  116. package/src/sandbox/types.ts +6 -0
  117. package/src/show/index.ts +17 -10
  118. package/src/show/render.ts +11 -11
  119. package/src/show/report-host.test.ts +32 -15
  120. package/src/show/report-host.ts +5 -4
  121. package/src/show/show.test.ts +140 -3
  122. package/src/view/app/App.test.tsx +78 -17
  123. package/src/view/app/App.tsx +17 -78
  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/artifact-serving.test.ts +1 -1
  130. package/src/view/client-dist/app.css +1 -1
  131. package/src/view/client-dist/app.js +20 -20
  132. package/src/view/data.ts +2 -13
  133. package/src/view/index.ts +1 -12
  134. package/src/view/server.ts +0 -2
  135. package/src/view/shared/types.ts +11 -6
  136. package/src/view/site-parity.test.ts +1 -1
  137. package/src/view/site.ts +1 -1
  138. package/src/view/styles.css +6 -266
  139. package/src/view/view-report.test.ts +64 -27
  140. package/src/view/app/components/LazyArtifact.tsx +0 -51
  141. package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
  142. package/src/view/app/pages/AttemptsPage.tsx +0 -80
  143. package/src/view/app/pages/TracesPage.tsx +0 -35
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
  }
package/src/show/index.ts CHANGED
@@ -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
  }
@@ -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 / 全景共用) ─────────────────────────
@@ -8,16 +8,32 @@ import {
8
8
  BUILT_IN_PAGE_TITLE,
9
9
  BUILT_IN_REPORT_TITLE,
10
10
  HostReportError,
11
+ loadHostReport,
11
12
  localizeText,
12
13
  localizedTextEquals,
13
14
  normalizeHostReport,
14
15
  resolveReportTitle,
15
16
  showCommand,
16
17
  } from "./report-host.ts";
17
- import { pageIndexText } from "./render.ts";
18
+ import { otherPagesText } from "./render.ts";
19
+ // dist-sourced:裸宿主装载的就是这份预编译产物的默认导出(show 与 view 同一条路),
20
+ // raw-src import 会是另一份模块实例,引用等同断言必须对着 dist。
21
+ import distBuiltInReport from "../../dist/report/built-in/index.js";
18
22
 
19
23
  const tree = { kind: "node" }; // 页 content 对宿主是不透明值,规范化不解析树
20
24
 
25
+ describe("裸宿主装载内建报告", () => {
26
+ it("缺省(无 --report)装载 niceeval/report/built-in 的默认导出:三页与其 content 同引用", async () => {
27
+ const host = await loadHostReport(process.cwd(), undefined);
28
+ const builtIn = distBuiltInReport as { pages: readonly { id: string; content: unknown }[] };
29
+ expect(host.pages.map((p) => p.id)).toEqual(["report", "attempts", "traces"]);
30
+ expect(builtIn.pages.map((p) => p.id)).toEqual(["report", "attempts", "traces"]);
31
+ for (let i = 0; i < host.pages.length; i++) {
32
+ expect(host.pages[i]!.content).toBe(builtIn.pages[i]!.content); // 同一份默认导出,不是复制品
33
+ }
34
+ });
35
+ });
36
+
21
37
  describe("装载规范化:外壳 + 非空页列表", () => {
22
38
  it("content 缩写展开为唯一页 id `report`,页名是内置页名「报告 / Report」", () => {
23
39
  const report = normalizeHostReport({ kind: "report", content: tree }, "reports/frontier.tsx");
@@ -45,13 +61,13 @@ describe("装载规范化:外壳 + 非空页列表", () => {
45
61
  expect(report.footer).toBe("Published nightly.");
46
62
  });
47
63
 
48
- it("content 与 pages 恰好声明一个:同给 / 同缺都报错,文案给出 <ExperimentComparison /> 下一步", () => {
64
+ it("content 与 pages 恰好声明一个:同给 / 同缺都报错,文案给出 extends: standard 下一步", () => {
49
65
  for (const bad of [
50
66
  { kind: "report", content: tree, pages: [{ id: "a", title: "A", content: tree }] },
51
67
  { kind: "report", title: "T" },
52
68
  ]) {
53
69
  expect(() => normalizeHostReport(bad, "reports/site.tsx")).toThrow(HostReportError);
54
- expect(() => normalizeHostReport(bad, "reports/site.tsx")).toThrow(/<ExperimentComparison \/>/);
70
+ expect(() => normalizeHostReport(bad, "reports/site.tsx")).toThrow(/niceeval\/report\/built-in/);
55
71
  }
56
72
  });
57
73
 
@@ -141,7 +157,7 @@ describe("LocalizedText 回退:locale → en → 键字典序第一个非空值"
141
157
  });
142
158
  });
143
159
 
144
- describe("页索引与索引命令上下文", () => {
160
+ describe("其余页索引与索引命令上下文", () => {
145
161
  const report = normalizeHostReport(
146
162
  {
147
163
  kind: "report",
@@ -155,27 +171,28 @@ describe("页索引与索引命令上下文", () => {
155
171
  "reports/site.tsx",
156
172
  );
157
173
 
158
- it("索引命令保留当前 --results / --report 与位置参数,复制即可复现下一层视图", () => {
159
- const text = pageIndexText({
160
- report,
161
- title: "记忆能力评测",
174
+ it("只列未渲染的页,索引命令保留当前 --results / --report 与位置参数,复制即可复现下一层视图", () => {
175
+ // 渲染的是 overview,其余页索引只含 exam 一行——与「渲染初始页 + 尾部附其余页索引」
176
+ // 的新行为一致(docs/feature/reports/show/reports.md Case 2)。
177
+ const text = otherPagesText({
178
+ otherPages: report.pages.filter((p) => p.id !== "overview").map((p) => ({ id: p.id, title: p.title })),
162
179
  command: { patterns: [], results: "tmp/published-results", report: "reports/site.tsx" },
163
180
  locale: "zh-CN",
164
181
  });
165
- expect(text).toContain("记忆能力评测 · 2 页");
166
- expect(text).toContain("niceeval show --results tmp/published-results --report reports/site.tsx --page overview");
182
+ expect(text).toContain("其余页:");
167
183
  expect(text).toContain("niceeval show --results tmp/published-results --report reports/site.tsx --page exam");
168
- expect(text).toContain("总览");
169
184
  expect(text).toContain("成绩单");
185
+ expect(text).not.toContain("总览");
186
+ expect(text).not.toContain("--page overview");
170
187
  });
171
188
 
172
- it("show 不消费 links:页索引不含 icon svg 与 href(icon 是 web 面属性)", () => {
173
- const text = pageIndexText({
174
- report,
175
- title: "Memory Evals",
189
+ it("show 不消费 links:其余页索引不含 icon svg 与 href(icon 是 web 面属性)", () => {
190
+ const text = otherPagesText({
191
+ otherPages: report.pages.filter((p) => p.id !== "overview").map((p) => ({ id: p.id, title: p.title })),
176
192
  command: { patterns: [] },
177
193
  locale: "en",
178
194
  });
195
+ expect(text).toContain("Other pages:");
179
196
  expect(text).not.toContain("<svg");
180
197
  expect(text).not.toContain("https://example.com");
181
198
  });
@@ -7,8 +7,8 @@
7
7
  // - 装载规范化唯一产物是「外壳 + 非空页列表」:`defineReport(树)` ≡ `{ content: 树 }` ≡
8
8
  // `pages: [{ id: "report", title: 内置页名, content: 树 }]`。
9
9
  // - 标题回退单点:def.title → Scope 中唯一且相同(LocalizedText 深相等)的非空快照 name →
10
- // 内置文案「Eval 运行结果 / Eval Results」。落点是首页 hero、浏览器标题与 show 页索引标题行;
11
- // 页头品牌位恒为 NiceEval 字标,不归 title
10
+ // 内置文案「Eval 运行结果 / Eval Results」。宿主落点是浏览器标题与 show 页索引标题行;
11
+ // 页内 hero 标题由 Hero 组件消费同一取值链(ctx.report.title),宿主页头无品牌位。
12
12
  // - LocalizedText 回退:当前 locale → en → 按 locale 键字典序的第一个非空值。
13
13
  //
14
14
  // ⚠ 集成状态:src/report/** 正被并行重写(plan/reports-redesign-implementation.md)。这里把宿主
@@ -212,11 +212,12 @@ export function normalizeHostReport(definition: unknown, sourceLabel: string): H
212
212
  const hasContent = def.content !== undefined;
213
213
  const hasPages = def.pages !== undefined;
214
214
  if (hasContent === hasPages) {
215
- // content / pages 同缺或同给:装载期完整用户反馈,下一步是 content: <ExperimentComparison />。
215
+ // content / pages 同缺或同给:装载期完整用户反馈(defineReport 产物恒已折叠,这里只拦
216
+ // 手搓 kind:"report" 的无类型输入),下一步指向内建视图的 extends 复用。
216
217
  throw new HostReportError(
217
218
  `${sourceLabel}: a report declares exactly one of "content" or "pages" — ` +
218
219
  (hasContent ? "it declares both. " : "it declares neither. ") +
219
- `To render the built-in report content, write: content: <ExperimentComparison />.`,
220
+ `To render the built-in report, write extends: standard (import { standard } from "niceeval/report/built-in").`,
220
221
  );
221
222
  }
222
223
  const pages: HostReportPage[] = hasPages