niceeval 0.8.1 → 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.
- package/INDEX.md +77 -45
- package/dist/agents/types.d.ts +28 -6
- package/dist/i18n/en.d.ts +2 -0
- package/dist/i18n/zh-CN.d.ts +2 -0
- package/dist/report/built-in/index.d.ts +3 -2
- package/dist/report/built-in/index.js +7 -8
- package/dist/report/built-in/standard.d.ts +1 -0
- package/dist/report/built-in/standard.js +30 -0
- package/dist/report/components.d.ts +69 -2
- package/dist/report/components.js +152 -3
- package/dist/report/compute.d.ts +28 -1
- package/dist/report/compute.js +123 -0
- package/dist/report/index.d.ts +4 -4
- package/dist/report/index.js +3 -2
- package/dist/report/locale.d.ts +39 -1
- package/dist/report/locale.js +69 -0
- package/dist/report/react/AttemptList.d.ts +3 -1
- package/dist/report/react/AttemptList.js +3 -3
- package/dist/report/react/CopyFixPrompt.d.ts +12 -0
- package/dist/report/react/CopyFixPrompt.js +12 -0
- package/dist/report/react/HeroCard.d.ts +13 -0
- package/dist/report/react/HeroCard.js +35 -0
- package/dist/report/react/PoweredBy.d.ts +5 -0
- package/dist/report/react/PoweredBy.js +7 -0
- package/dist/report/react/ScopeWarnings.d.ts +12 -0
- package/dist/report/react/ScopeWarnings.js +18 -0
- package/dist/report/react/TraceWaterfall.d.ts +14 -0
- package/dist/report/react/TraceWaterfall.js +22 -0
- package/dist/report/react/index.d.ts +6 -1
- package/dist/report/react/index.js +6 -0
- package/dist/report/report.d.ts +22 -6
- package/dist/report/report.js +66 -53
- package/dist/report/scope-warnings.d.ts +28 -0
- package/dist/report/scope-warnings.js +101 -0
- package/dist/report/text/faces.d.ts +19 -1
- package/dist/report/text/faces.js +61 -0
- package/dist/report/tree.js +6 -1
- package/dist/report/types.d.ts +45 -0
- package/dist/report/web.d.ts +5 -4
- package/dist/report/web.js +7 -22
- package/dist/results/select.d.ts +15 -2
- package/dist/results/select.js +75 -10
- package/dist/results/types.d.ts +26 -0
- package/dist/runner/fingerprint.d.ts +3 -3
- package/dist/runner/sandbox-selection.d.ts +12 -0
- package/dist/runner/types.d.ts +18 -6
- package/dist/sandbox/types.d.ts +12 -0
- package/docs-site/zh/explanation/evals.mdx +2 -1
- package/docs-site/zh/explanation/experiment.mdx +2 -0
- package/docs-site/zh/how-to/custom-reports.mdx +22 -6
- package/docs-site/zh/how-to/publish-report.mdx +5 -4
- package/docs-site/zh/how-to/viewing-results.mdx +3 -3
- package/docs-site/zh/how-to/write-experiment.mdx +40 -1
- package/docs-site/zh/reference/builtin-agents.mdx +40 -3
- package/docs-site/zh/reference/cli.mdx +1 -1
- package/docs-site/zh/reference/define-eval.mdx +8 -0
- package/docs-site/zh/reference/official-adapters.mdx +10 -5
- package/package.json +2 -1
- package/src/agents/bub.ts +13 -1
- package/src/agents/claude-code.test.ts +43 -1
- package/src/agents/claude-code.ts +32 -14
- package/src/agents/codex.test.ts +168 -1
- package/src/agents/codex.ts +51 -15
- package/src/agents/mcp.ts +31 -0
- package/src/agents/post-setup.ts +33 -0
- package/src/agents/types.ts +28 -7
- package/src/cli.ts +8 -6
- package/src/define.ts +3 -0
- package/src/i18n/en.ts +5 -2
- package/src/i18n/zh-CN.ts +5 -1
- package/src/index.ts +1 -0
- package/src/report/built-in/index.tsx +8 -7
- package/src/report/built-in/standard.tsx +59 -0
- package/src/report/components.tsx +218 -2
- package/src/report/compute.ts +138 -1
- package/src/report/dual-render.test.tsx +139 -12
- package/src/report/index.ts +20 -0
- package/src/report/locale.ts +83 -1
- package/src/report/react/AttemptList.tsx +13 -1
- package/src/report/react/CopyFixPrompt.tsx +37 -0
- package/src/report/react/HeroCard.tsx +59 -0
- package/src/report/react/PoweredBy.tsx +20 -0
- package/src/report/react/ScopeWarnings.tsx +74 -0
- package/src/report/react/TraceWaterfall.tsx +78 -0
- package/src/report/react/enhance.js +14 -0
- package/src/report/react/index.tsx +11 -0
- package/src/report/react/styles.css +187 -7
- package/src/report/report.ts +97 -62
- package/src/report/scope-warnings.ts +155 -0
- package/src/report/site-components.test.tsx +526 -0
- package/src/report/text/faces.ts +66 -0
- package/src/report/tree.ts +8 -1
- package/src/report/types.ts +51 -0
- package/src/report/web.ts +7 -40
- package/src/results/host-equivalence.test.ts +5 -1
- package/src/results/open.ts +5 -4
- package/src/results/results.test.ts +78 -1
- package/src/results/select.ts +78 -10
- package/src/results/types.ts +27 -0
- package/src/runner/attempt.ts +10 -9
- package/src/runner/discover.test.ts +9 -1
- package/src/runner/discover.ts +3 -3
- package/src/runner/fingerprint.ts +9 -4
- package/src/runner/ledger.test.ts +30 -1
- package/src/runner/ledger.ts +26 -4
- package/src/runner/run.ts +5 -1
- package/src/runner/sandbox-selection.test.ts +131 -0
- package/src/runner/sandbox-selection.ts +110 -0
- package/src/runner/types.ts +19 -2
- package/src/sandbox/types.ts +6 -0
- package/src/show/index.ts +17 -10
- package/src/show/render.ts +11 -11
- package/src/show/report-host.test.ts +32 -15
- package/src/show/report-host.ts +5 -4
- package/src/show/show.test.ts +140 -3
- package/src/view/app/App.test.tsx +78 -17
- package/src/view/app/App.tsx +17 -78
- package/src/view/app/components/CopyControls.tsx +4 -42
- package/src/view/app/i18n.ts +5 -227
- package/src/view/app/lib/rows.ts +3 -21
- package/src/view/app/shared.ts +1 -3
- package/src/view/app/types.ts +2 -2
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +20 -20
- package/src/view/data.ts +2 -2
- package/src/view/shared/types.ts +11 -6
- package/src/view/styles.css +6 -266
- package/src/view/view-report.test.ts +59 -22
- package/src/view/app/components/LazyArtifact.tsx +0 -51
- package/src/view/app/components/SkippedRunsBanner.tsx +0 -140
- package/src/view/app/pages/AttemptsPage.tsx +0 -80
- package/src/view/app/pages/TracesPage.tsx +0 -35
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// cases: docs/engineering/unit-tests/
|
|
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[] };
|
package/src/runner/ledger.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
138
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runner/types.ts
CHANGED
|
@@ -31,7 +31,15 @@ export interface ExperimentRunInfo {
|
|
|
31
31
|
/** evals 过滤器的指纹(数组内容 / 函数体哈希),供「配置没变」判断;与 selectedEvalIds 一起取代原过滤器。 */
|
|
32
32
|
evalFilterFingerprint?: string;
|
|
33
33
|
/** provider 名、provider 的公开参数投影与配置 fingerprint;参数只经投影落盘,token/凭据永不进来。 */
|
|
34
|
-
sandbox?:
|
|
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
|
-
/**
|
|
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 收尾反馈与
|
package/src/sandbox/types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/show/render.ts
CHANGED
|
@@ -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
|
-
*
|
|
425
|
-
*
|
|
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
|
|
429
|
-
|
|
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 {
|
|
435
|
-
const head =
|
|
434
|
+
const { otherPages, command, locale } = opts;
|
|
435
|
+
const head = locale === "zh-CN" ? "其余页:" : "Other pages:";
|
|
436
436
|
const table = renderAlignedRows(
|
|
437
|
-
|
|
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
|
|
443
|
+
return `${head}\n${indentBlock(table, " ")}`;
|
|
444
444
|
}
|
|
445
445
|
|
|
446
446
|
// ───────────────────────── 截断预算(--eval / --execution / 全景共用) ─────────────────────────
|