niceeval 0.1.1 → 0.2.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 (136) hide show
  1. package/README.md +8 -8
  2. package/README.zh.md +13 -13
  3. package/docs/README.md +119 -0
  4. package/docs/adapters/README.md +60 -0
  5. package/docs/adapters/authoring.md +179 -0
  6. package/docs/adapters/coding-agent-skills-plugins.md +324 -0
  7. package/docs/adapters/collection.md +128 -0
  8. package/docs/adapters/contract.md +263 -0
  9. package/docs/adapters/reference/agent-eval.md +215 -0
  10. package/docs/adapters/reference/agent-loop-apis.md +69 -0
  11. package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
  12. package/docs/adapters/reference/eve-protocol.md +127 -0
  13. package/docs/adapters/reference/otel-genai.md +107 -0
  14. package/docs/adapters/reference/otel-instrumentation.md +65 -0
  15. package/docs/adapters/targets.md +99 -0
  16. package/docs/architecture.md +140 -0
  17. package/docs/assertions.md +388 -0
  18. package/docs/capabilities-by-construction.md +48 -0
  19. package/docs/cli.md +171 -0
  20. package/docs/concepts.md +106 -0
  21. package/docs/e2e-ci.md +295 -0
  22. package/docs/eval-authoring.md +319 -0
  23. package/docs/experiments.md +154 -0
  24. package/docs/getting-started.md +224 -0
  25. package/docs/multi-agent.md +145 -0
  26. package/docs/observability.md +333 -0
  27. package/docs/origin-integration.md +195 -0
  28. package/docs/references.md +35 -0
  29. package/docs/results-format.md +228 -0
  30. package/docs/runner.md +104 -0
  31. package/docs/sandbox.md +276 -0
  32. package/docs/scoring.md +119 -0
  33. package/docs/source-map.md +106 -0
  34. package/docs/tier-sync.md +177 -0
  35. package/docs/view.md +157 -0
  36. package/docs/vision.md +88 -0
  37. package/package.json +39 -5
  38. package/src/agents/ai-sdk-otel.test.ts +22 -0
  39. package/src/agents/ai-sdk-otel.ts +62 -0
  40. package/src/agents/ai-sdk.test.ts +462 -0
  41. package/src/agents/ai-sdk.ts +575 -0
  42. package/src/agents/bub.ts +30 -37
  43. package/src/agents/claude-code.ts +7 -4
  44. package/src/agents/codex.ts +15 -10
  45. package/src/agents/index.ts +48 -1
  46. package/src/agents/sdk-streams.test.ts +145 -0
  47. package/src/agents/sdk-streams.ts +457 -0
  48. package/src/agents/shared.ts +77 -39
  49. package/src/agents/streaming.test.ts +152 -0
  50. package/src/agents/streaming.ts +195 -0
  51. package/src/agents/types.ts +218 -0
  52. package/src/agents/ui-message-stream.test.ts +241 -0
  53. package/src/agents/ui-message-stream.ts +368 -0
  54. package/src/cli.ts +124 -77
  55. package/src/context/context.test.ts +203 -7
  56. package/src/context/context.ts +158 -49
  57. package/src/context/session.test.ts +96 -0
  58. package/src/context/session.ts +119 -9
  59. package/src/context/types.ts +205 -0
  60. package/src/define.ts +4 -14
  61. package/src/expect/index.ts +8 -71
  62. package/src/i18n/core.ts +28 -0
  63. package/src/i18n/en.ts +47 -5
  64. package/src/i18n/index.ts +5 -17
  65. package/src/i18n/zh-CN.ts +47 -5
  66. package/src/index.ts +12 -0
  67. package/src/loaders/index.ts +8 -39
  68. package/src/o11y/otlp/canonical.ts +7 -6
  69. package/src/o11y/otlp/mappers/codex.ts +10 -8
  70. package/src/o11y/otlp/mappers/index.ts +9 -21
  71. package/src/o11y/otlp/receiver.ts +25 -7
  72. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  73. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  74. package/src/o11y/otlp/turn-otel.ts +125 -0
  75. package/src/o11y/parsers/bub.ts +13 -11
  76. package/src/o11y/parsers/claude-code.ts +27 -51
  77. package/src/o11y/parsers/codex.ts +29 -46
  78. package/src/o11y/parsers/index.ts +5 -14
  79. package/src/o11y/tool-names.test.ts +61 -0
  80. package/src/o11y/tool-names.ts +74 -0
  81. package/src/o11y/types.ts +135 -0
  82. package/src/runner/attempt.ts +456 -0
  83. package/src/runner/fingerprint.ts +59 -0
  84. package/src/runner/remote-sandbox.ts +53 -0
  85. package/src/runner/report.ts +53 -0
  86. package/src/runner/reporters/artifacts.ts +25 -4
  87. package/src/runner/reporters/console.ts +2 -8
  88. package/src/runner/reporters/live.ts +2 -8
  89. package/src/runner/reporters/shared.ts +15 -0
  90. package/src/runner/reporters/table.ts +10 -62
  91. package/src/runner/run.ts +114 -619
  92. package/src/runner/types.ts +237 -0
  93. package/src/sandbox/checkpoint.ts +15 -13
  94. package/src/sandbox/docker-stream.ts +87 -0
  95. package/src/sandbox/docker.ts +42 -120
  96. package/src/sandbox/e2b.ts +24 -75
  97. package/src/sandbox/local-files.ts +33 -0
  98. package/src/sandbox/paths.test.ts +85 -0
  99. package/src/sandbox/paths.ts +45 -0
  100. package/src/sandbox/resolve.ts +10 -34
  101. package/src/sandbox/shell.ts +17 -0
  102. package/src/sandbox/source-files.ts +42 -2
  103. package/src/sandbox/types.ts +158 -0
  104. package/src/sandbox/vercel.ts +55 -71
  105. package/src/scoring/collector.ts +3 -2
  106. package/src/scoring/judge.ts +72 -146
  107. package/src/scoring/match.ts +79 -0
  108. package/src/scoring/scoped.ts +66 -18
  109. package/src/scoring/types.ts +76 -0
  110. package/src/shared/aggregate.ts +48 -0
  111. package/src/shared/format.ts +20 -0
  112. package/src/shared/outcome.ts +48 -0
  113. package/src/shared/types.ts +37 -0
  114. package/src/types.ts +20 -911
  115. package/src/view/aggregate.ts +103 -0
  116. package/src/view/app/App.tsx +26 -5
  117. package/src/view/app/components/AttemptModal.tsx +12 -3
  118. package/src/view/app/components/CodeView.tsx +16 -19
  119. package/src/view/app/components/CopyControls.tsx +4 -4
  120. package/src/view/app/components/ExperimentTable.tsx +5 -5
  121. package/src/view/app/i18n.ts +31 -7
  122. package/src/view/app/lib/format.ts +17 -20
  123. package/src/view/app/lib/outcome.ts +4 -16
  124. package/src/view/app/lib/transcript-data.tsx +20 -4
  125. package/src/view/app/main.tsx +3 -5
  126. package/src/view/app/pages/RunsPage.tsx +2 -2
  127. package/src/view/app/pages/TracesPage.tsx +2 -2
  128. package/src/view/app/shared.ts +2 -1
  129. package/src/view/app/types.ts +9 -44
  130. package/src/view/client-dist/app.css +1 -1
  131. package/src/view/client-dist/app.js +18 -18
  132. package/src/view/index.ts +24 -401
  133. package/src/view/loader.ts +234 -0
  134. package/src/view/server.ts +136 -0
  135. package/src/view/shared/types.ts +64 -0
  136. package/src/view/styles.css +60 -9
package/src/runner/run.ts CHANGED
@@ -1,86 +1,18 @@
1
- // 运行器:发现产出的 eval × agent × runs → attempt,有界并发调度,把每个 attempt
2
- // 跑成一个 EvalResult。沙箱编排的固定段在这里(起沙箱→上传→基线→setup→驱动 agent→
3
- // diff→跑脚本→评分→判决→停沙箱),adapter 只填「把 agent 跑起来」一段。
1
+ // 运行器主调度:发现产出的 eval × agent × runs → attempt,有界并发调度。
2
+ // 职责只有编排:指纹缓存在 fingerprint.ts,单 attempt 生命周期在 attempt.ts,
3
+ // reporter 编排 / 汇总在 report.ts,Sandbox 适配器在 remote-sandbox.ts。
4
4
 
5
- import { resolve as resolvePath } from "node:path";
6
- import { readFile as readSourceFile } from "node:fs/promises";
7
- import { createHash } from "node:crypto";
8
5
  import { Effect, Cause, Duration, Exit } from "effect";
9
- import { createSandbox, sandboxLabel } from "../sandbox/resolve.ts";
10
- import { createTraceReceiver, type TraceReceiver } from "../o11y/otlp/receiver.ts";
11
- import { createInSandboxTraceReceiver } from "../o11y/otlp/sandbox-receiver.ts";
12
- import { selectTraceSpans, enrichTraceWithIO } from "../o11y/otlp/select.ts";
13
- import { mapSpansToCanonical } from "../o11y/otlp/mappers/index.ts";
14
- import { createEvalContext } from "../context/context.ts";
15
- import { EvalRequirementFailed, EvalSkipped, TurnFailed } from "../context/control-flow.ts";
16
- import { computeOutcome } from "../scoring/verdict.ts";
17
6
  import { probeJudge } from "../scoring/judge.ts";
18
- import { deriveRunFacts, buildO11ySummary } from "../o11y/derive.ts";
19
- import { estimateCost } from "../o11y/cost.ts";
20
7
  import { t } from "../i18n/index.ts";
21
- import { formatThrown } from "../util.ts";
22
- import {
23
- captureGeneratedFiles,
24
- initGitAndCommit,
25
- } from "./sandbox-prep.ts";
26
- import type {
27
- Agent,
28
- AgentContext,
29
- Cleanup,
30
- Config,
31
- DiscoveredEval,
32
- EvalResult,
33
- JudgeConfig,
34
- LocalizedText,
35
- Reporter,
36
- ReporterEvent,
37
- RunShape,
38
- RunSummary,
39
- Sandbox,
40
- SandboxOption,
41
- ScoringContext,
42
- ScriptResult,
43
- SourceArtifact,
44
- StreamEvent,
45
- Telemetry,
46
- TraceSpan,
47
- } from "../types.ts";
8
+ import { cacheKey, computeFingerprint } from "./fingerprint.ts";
9
+ import { OtelReceiverPool } from "../o11y/otlp/turn-otel.ts";
10
+ import { runAttemptEffect } from "./attempt.ts";
11
+ import { runReporter, emitReporterEvent, summarize } from "./report.ts";
12
+ import type { Agent, EvalResult, JudgeConfig, RunShape, RunSummary } from "../types.ts";
13
+ import type { Attempt, RunOptions } from "./types.ts";
48
14
 
49
- /** 一个 (agent, model, flags) 的运行配置 —— 由 CLI / 实验展开。 */
50
- export interface AgentRun {
51
- agent: Agent;
52
- model?: string;
53
- flags: Record<string, unknown>;
54
- runs: number;
55
- earlyExit: boolean;
56
- sandbox?: SandboxOption;
57
- timeoutMs?: number;
58
- budget?: number;
59
- evalFilter: (id: string) => boolean;
60
- experimentId?: string;
61
- strict?: boolean;
62
- }
63
-
64
- export interface RunOptions {
65
- config: Config;
66
- evals: DiscoveredEval[];
67
- agentRuns: AgentRun[];
68
- reporters: Reporter[];
69
- maxConcurrency: number;
70
- signal?: AbortSignal;
71
- /** TTY live display 的进度回调;设置后 attempt 的 log 消息路由到它而不是 stderr。 */
72
- onProgress?: (evalId: string, who: string, msg: string) => void;
73
- /** 上次运行的结果。outcome === "passed" 的 (experimentId, evalId) 组合跳过重跑,结果直接合入本次汇总。 */
74
- priorResults?: EvalResult[];
75
- }
76
-
77
- interface Attempt {
78
- evalDef: DiscoveredEval;
79
- run: AgentRun;
80
- attempt: number;
81
- key: string; // agent+model+evalId,用于早停
82
- fingerprint: string;
83
- }
15
+ export type { AgentRun, RunOptions } from "./types.ts";
84
16
 
85
17
  export async function runEvals(opts: RunOptions): Promise<RunSummary> {
86
18
  const startedAt = new Date().toISOString();
@@ -107,10 +39,20 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
107
39
  }
108
40
 
109
41
  const plannedFingerprints = new Map<string, string>();
110
- for (const run of opts.agentRuns) {
111
- for (const evalDef of opts.evals.filter((e) => run.evalFilter(e.id))) {
112
- plannedFingerprints.set(cacheKey(run, evalDef.id), await computeFingerprint(evalDef, run));
42
+ {
43
+ // 并行 + sourcePath 缓存:矩阵大时(实验 × eval)规划阶段不做串行重复文件读。
44
+ const sourceCache = new Map<string, Promise<string>>();
45
+ const jobs: Promise<void>[] = [];
46
+ for (const run of opts.agentRuns) {
47
+ for (const evalDef of opts.evals.filter((e) => run.evalFilter(e.id))) {
48
+ jobs.push(
49
+ computeFingerprint(evalDef, run, sourceCache).then((fp) => {
50
+ plannedFingerprints.set(cacheKey(run, evalDef.id), fp);
51
+ }),
52
+ );
53
+ }
113
54
  }
55
+ await Promise.all(jobs);
114
56
  }
115
57
 
116
58
  // 跨实验结果复用:只有上次 passed 且 fingerprint 匹配的 (experimentId, evalId) 组合直接携入。
@@ -127,16 +69,19 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
127
69
  }
128
70
  for (const r of opts.priorResults) {
129
71
  if (!r.experimentId || !priorRunKeys.has(`${r.experimentId}|${r.id}`)) continue;
130
- // 去掉工件引用:工件文件在旧 run 目录,新 summary 里的相对路径会失效。
131
- carriedResults.push({ ...r, artifactsDir: undefined, artifactBase: undefined, artifactAbsBase: undefined });
72
+ // artifactsDir 是相对"本次 run 自己目录"的路径,新 summary 换了目录就会失效,必须清掉。
73
+ // artifactBase 不一样:它已经是 loadMostRecentResults(经 withArtifactBases)拼好的、
74
+ // 相对稳定的 .niceeval 根的路径,指向旧 run 的产物目录依然可解析,原样带过来
75
+ // ——不然 view 就再也找不到这条携带结果的源码/转录/trace 了。
76
+ carriedResults.push({ ...r, artifactsDir: undefined });
132
77
  }
133
78
  }
134
79
 
135
80
  // 展开 attempts
136
- // 外层按「round」(run index)迭代,内层按 eval 迭代,目的是让同一 key 的第 i+1 次
137
- // attempt 排在所有 eval 的第 i 次之后 —— 这样当 earlyExit 开启时,第 0 轮某 eval 通过后、
138
- // 1 轮该 eval 才进入队列,earlyExit 检查能生效。若内外层相反(先 eval 后 round),
139
- // 则同一 eval 的所有 runs 连续入队,高并发下会全部同时启动,earlyExit 永远来不及跳过。
81
+ // 外层按「round」(run index)迭代,内层按 eval 迭代:同一 key 的第 i+1 次 attempt 排在
82
+ // 所有 eval 的第 i 次之后,earlyExit 开启时第 0 轮通过的 eval,其后续轮大多还没入池就被跳过。
83
+ // 注意这只是省钱的吞吐优化,不是正确性前提 —— 即便同 key attempt 同时在飞,
84
+ // 首个通过会 abort key 其余 attempt,且它们的结果被下面的去重检查丢弃,不会重复计入。
140
85
  const attempts: Attempt[] = [];
141
86
  for (const run of opts.agentRuns) {
142
87
  const evals = opts.evals.filter((e) => run.evalFilter(e.id));
@@ -186,7 +131,30 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
186
131
  // 同 key 一旦 errored 就会确定性地重复 error,再跑 runs 里剩下的次数纯烧钱;只有 failed(断言
187
132
  // 真的没过)才代表 agent 行为的样本,值得跑满 runs 去测通过率。earlyExit 开时两者都提前收尾。
188
133
  const erroredKeys = new Set<string>();
189
- const budgetSpent = new Map<string, number>();
134
+
135
+ // budget 护栏带「在飞预扣」:实测成本只有 attempt 完成后才知道,若只检查已完成花费,
136
+ // maxConcurrency 个 attempt 会在任何成本回写前全部起飞,实际花费能冲到 budget 的十几倍。
137
+ // 口径:还没有任何【带成本】的完成样本时,同一 budgetKey 只放一个 attempt 在飞(探单次成本);
138
+ // 有样本后,用平均实测成本给每个在飞 attempt 预扣,预计总额到顶就等,已花到顶就停。
139
+ // costSamples 只数报了成本的 attempt —— 不报成本的完成(agent 无用量、模型不在价格表、
140
+ // 早期 errored)不能算 0 元样本,否则均值被拉成 0,护栏彻底失效。连续多次完成都拿不到
141
+ // 成本时,说明这个 agent 的 budget 根本不可执行:警告一次然后放行,而不是永远串行装样子。
142
+ interface BudgetState {
143
+ spent: number;
144
+ inflight: number;
145
+ costSamples: number;
146
+ completedNoCost: number;
147
+ unenforceableWarned: boolean;
148
+ }
149
+ const budgetStates = new Map<string, BudgetState>();
150
+ const budgetState = (key: string): BudgetState => {
151
+ let s = budgetStates.get(key);
152
+ if (!s) {
153
+ s = { spent: 0, inflight: 0, costSamples: 0, completedNoCost: 0, unenforceableWarned: false };
154
+ budgetStates.set(key, s);
155
+ }
156
+ return s;
157
+ };
190
158
  const budgetReported = new Set<string>();
191
159
 
192
160
  // reporter 的 onEvalComplete 要「每个 attempt 完成即时触发」(保流式输出),又不能让
@@ -196,6 +164,12 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
196
164
  // 未显式指定时跟 maxConcurrency 走——各 backend 的推荐值已在 cli 层写进 maxConcurrency 默认值。
197
165
  const sandboxSem = Effect.runSync(Effect.makeSemaphore(opts.maxConcurrency));
198
166
 
167
+ // 非沙箱 tracing agent 的共享 OTLP 接收池:被测应用是长驻进程,端点不能随
168
+ // attempt 换 —— receiver 粒度跟被测进程走(每 agent 一个,整个 run 复用),run 结束回收。
169
+ if (!opts.otelPool) {
170
+ opts = { ...opts, otelPool: new OtelReceiverPool(opts.config.telemetry?.port) };
171
+ }
172
+
199
173
  // earlyExit:为每个 key 各建一个 AbortController。某 attempt 通过或 errored 时 abort 它,
200
174
  // 让并发进行中的同 key attempt 通过 signal 尽早退出,而不只是等排队的才能被跳过。
201
175
  const evalAbortControllers = new Map<string, AbortController>();
@@ -238,15 +212,39 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
238
212
 
239
213
  const budget = a.run.budget;
240
214
  const budgetKey = a.run.experimentId ?? a.run.agent.name;
241
- const spent = budgetSpent.get(budgetKey) ?? 0;
242
- if (budget !== undefined && spent >= budget) {
243
- if (!budgetReported.has(budgetKey)) {
244
- budgetReported.add(budgetKey);
245
- yield* Effect.promise(() =>
246
- emitReporterEvent(opts.reporters, { type: "run:budgetExceeded", budget, spent }),
247
- );
215
+ if (budget !== undefined) {
216
+ // 预扣循环:预计花费(已花 + 在飞×均值)到顶就等在飞的结算,已花到顶就整段停。
217
+ // 注意等待的 fiber 仍占一个并发位(简单可靠;混跑「带 budget + 不带 budget」
218
+ // 的多实验时会牺牲些吞吐 —— budget 本就是拿速度换花费上限的模式)
219
+ for (;;) {
220
+ const s = budgetState(budgetKey);
221
+ if (s.spent >= budget) {
222
+ if (!budgetReported.has(budgetKey)) {
223
+ budgetReported.add(budgetKey);
224
+ yield* Effect.promise(() =>
225
+ emitReporterEvent(opts.reporters, { type: "run:budgetExceeded", budget, spent: s.spent }),
226
+ );
227
+ }
228
+ return;
229
+ }
230
+ if (s.costSamples === 0 && s.completedNoCost >= 3 && !s.unenforceableWarned) {
231
+ // 连续几次完成都拿不到成本:budget 对这个 agent 不可执行,说清楚再放行。
232
+ s.unenforceableWarned = true;
233
+ process.stderr.write(t("runner.budgetUnenforceable", { budgetKey }));
234
+ }
235
+ if (s.costSamples === 0 && s.unenforceableWarned) {
236
+ s.inflight += 1;
237
+ break;
238
+ }
239
+ const avg = s.costSamples > 0 ? s.spent / s.costSamples : undefined;
240
+ const projected =
241
+ avg === undefined ? (s.inflight > 0 ? Number.POSITIVE_INFINITY : 0) : s.spent + s.inflight * avg;
242
+ if (projected < budget) {
243
+ s.inflight += 1;
244
+ break;
245
+ }
246
+ yield* Effect.sleep(Duration.millis(200));
248
247
  }
249
- return;
250
248
  }
251
249
 
252
250
  // 合并全局信号与本 eval 的早停信号:任一 abort → 本 attempt 的信号 abort。
@@ -265,8 +263,25 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
265
263
  experimentId: a.run.experimentId,
266
264
  }),
267
265
  );
268
- const result = yield* runAttemptEffect(a, opts, sandboxSem, attemptSignal);
269
- budgetSpent.set(budgetKey, (budgetSpent.get(budgetKey) ?? 0) + (result.estimatedCostUSD ?? 0));
266
+ const result = yield* runAttemptEffect(a, opts, sandboxSem, attemptSignal).pipe(
267
+ Effect.ensuring(
268
+ Effect.sync(() => {
269
+ if (a.run.budget !== undefined) {
270
+ const s = budgetState(budgetKey);
271
+ s.inflight = Math.max(0, s.inflight - 1);
272
+ }
273
+ }),
274
+ ),
275
+ );
276
+ if (a.run.budget !== undefined) {
277
+ const s = budgetState(budgetKey);
278
+ if (result.estimatedCostUSD !== undefined) {
279
+ s.spent += result.estimatedCostUSD;
280
+ s.costSamples += 1;
281
+ } else {
282
+ s.completedNoCost += 1;
283
+ }
284
+ }
270
285
 
271
286
  if (result.outcome === "passed") {
272
287
  passedKeys.add(a.key);
@@ -309,6 +324,7 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
309
324
  ),
310
325
  { signal: opts.signal },
311
326
  );
327
+ await opts.otelPool?.close();
312
328
  if (Exit.isFailure(exit)) {
313
329
  // signal abort 或 cause 含中断 → 当作用户中断,走部分汇总;否则是真·缺陷,照常抛出。
314
330
  if (opts.signal?.aborted || Cause.isInterrupted(exit.cause)) {
@@ -337,524 +353,3 @@ export async function runEvals(opts: RunOptions): Promise<RunSummary> {
337
353
  await emitReporterEvent(opts.reporters, { type: "run:saved", summary });
338
354
  return summary;
339
355
  }
340
-
341
- // reporter / 生命周期钩子调用的统一兜错:它们是「消费方 / 资源起停」,单个失败只记 diagnostic
342
- // 到 stderr,不能让整次调度崩。返回 void,永不 reject(供 Promise.all 安全聚合)。
343
- async function runReporter(stage: string, fn: () => unknown): Promise<void> {
344
- try {
345
- await fn();
346
- } catch (e) {
347
- const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
348
- process.stderr.write(t("runner.reporterDiagnostic", { stage, message: msg }));
349
- }
350
- }
351
-
352
- async function emitReporterEvent(reporters: readonly Reporter[], event: ReporterEvent): Promise<void> {
353
- await Promise.all(reporters.map((r) => runReporter(`event:${event.type}`, () => r.onEvent?.(event))));
354
- }
355
-
356
- function summarize(
357
- results: EvalResult[],
358
- agent: string,
359
- startedAt: string,
360
- durationMs: number,
361
- name?: LocalizedText,
362
- ): RunSummary {
363
- const counts = { passed: 0, failed: 0, skipped: 0, errored: 0 };
364
- let inTok = 0;
365
- let outTok = 0;
366
- let cost = 0;
367
- for (const r of results) {
368
- counts[r.outcome] += 1;
369
- inTok += r.usage?.inputTokens ?? 0;
370
- outTok += r.usage?.outputTokens ?? 0;
371
- cost += r.estimatedCostUSD ?? 0;
372
- }
373
- return {
374
- name,
375
- agent,
376
- startedAt,
377
- completedAt: new Date().toISOString(),
378
- passed: counts.passed,
379
- failed: counts.failed,
380
- skipped: counts.skipped,
381
- errored: counts.errored,
382
- durationMs,
383
- usage: { inputTokens: inTok, outputTokens: outTok },
384
- estimatedCostUSD: cost || undefined,
385
- results,
386
- };
387
- }
388
-
389
- // 单个 attempt 的资源生命周期用 Effect.Scope 接管:沙箱 + OTLP 接收器经 acquireRelease
390
- // 注册,无论 body 成功 / 抛错 / 被中断,stop() / close() 都保证执行(治容器与端口泄漏)。
391
- // remote agent 没有沙箱资源,但仍走同一条 Promise 边界 / 超时 / 评分路径。
392
- function runAttemptEffect(
393
- a: Attempt,
394
- opts: RunOptions,
395
- sandboxSem: Effect.Semaphore,
396
- parentSignal?: AbortSignal,
397
- ): Effect.Effect<EvalResult> {
398
- const config = opts.config;
399
- const { evalDef, run, attempt } = a;
400
- const t0 = Date.now();
401
-
402
- const base: EvalResult = {
403
- id: evalDef.id,
404
- description: evalDef.description,
405
- experimentId: run.experimentId,
406
- experiment: experimentRunInfo(run),
407
- agent: run.agent.name,
408
- model: run.model,
409
- outcome: "errored",
410
- fingerprint: a.fingerprint,
411
- attempt,
412
- startedAt: new Date(t0).toISOString(),
413
- durationMs: 0,
414
- assertions: [],
415
- };
416
-
417
- const timeoutMs = run.timeoutMs ?? evalDef.timeoutMs ?? config.timeoutMs ?? 600_000;
418
- // timeoutSignal:给协作式 adapter / docker 命令的「软」截止信号(到点 abort,让能看 signal 的
419
- // 提前优雅停)。但它【不是】attempt 总超时的硬保证 —— 真正的硬边界是下面的 Effect.timeoutTo:
420
- // 它中断整段 body,触发 Scope release(停容器),从而即便 adapter 完全无视 signal 也能停掉(P1)。
421
- const timeoutSignal = AbortSignal.timeout(timeoutMs);
422
- const signal = parentSignal ? AbortSignal.any([parentSignal, timeoutSignal]) : timeoutSignal;
423
-
424
- // 流式进度打到宿主 stderr(结果走 stdout,互不干扰)。容器主日志【不】放这些进度标记 ——
425
- // 那里留给 agent 的原始输出(adapter 给 agent 命令开 { stream: true })。
426
- const who = run.model ? `${run.agent.name}/${run.model}` : run.agent.name;
427
- // 同时保留最近 20 条进度消息,timeout 时嵌入 error 字段方便定位卡在哪一步。
428
- const recentLogs: string[] = [];
429
- const log = (m: string) => {
430
- recentLogs.push(m);
431
- if (recentLogs.length > 20) recentLogs.shift();
432
- if (opts.onProgress) {
433
- opts.onProgress(evalDef.id, who, m);
434
- } else {
435
- process.stderr.write(` · ${evalDef.id} [${who}] ${m}\n`);
436
- }
437
- };
438
-
439
- return Effect.scoped(
440
- Effect.gen(function* () {
441
- const sandbox =
442
- run.agent.capabilities.sandbox === true
443
- ? yield* sandboxSem.withPermits(1)(
444
- Effect.gen(function* () {
445
- // ── 沙箱:acquire=起,release=stop(成功 / 失败 / 中断都跑)──
446
- // sandboxSem 只覆盖「容器创建」阶段;容器起好后立即释放,后续 npm install / agent 不占位。
447
- log(t("runner.startSandbox"));
448
- return yield* createSandbox({
449
- sandbox: run.sandbox ?? config.sandbox,
450
- timeout: timeoutMs,
451
- runtime: "node24",
452
- });
453
- }),
454
- )
455
- : createRemoteSandbox();
456
- if (run.agent.capabilities.sandbox !== true) log(t("runner.useRemoteAgent"));
457
-
458
- // ── tracing ──────────────────────────────────────────────────────────────────
459
- // sandbox.otlpHost:
460
- // string → docker 类沙箱,宿主开本地接收器,container 经 host.docker.internal 回连
461
- // null → 远程云端沙箱(e2b / vercel),宿主端口不可达 → 改在沙箱内起 collector
462
- // NICEEVAL_OTLP_HOST 可强制覆盖(如配好 tunnel 时)。
463
- let receiver: TraceReceiver | undefined;
464
- let telemetry: Telemetry | undefined;
465
- if (run.agent.capabilities.tracing) {
466
- const forcedHost = process.env.NICEEVAL_OTLP_HOST;
467
- if (forcedHost) {
468
- // 显式覆盖:走本地接收器,把指定 host 交给 agent
469
- receiver = yield* createTraceReceiver();
470
- const endpoint = receiver.endpoint(forcedHost);
471
- const env = run.agent.tracing?.env?.(endpoint);
472
- telemetry = env ? { endpoint, env } : { endpoint };
473
- log(t("runner.otlpOverride", { endpoint }));
474
- } else if (sandbox.otlpHost !== null) {
475
- // 本地/docker 沙箱:宿主开接收器
476
- receiver = yield* createTraceReceiver();
477
- const endpoint = receiver.endpoint(sandbox.otlpHost);
478
- const env = run.agent.tracing?.env?.(endpoint);
479
- telemetry = env ? { endpoint, env } : { endpoint };
480
- const proto = run.agent.tracing?.protocol;
481
- log(t("runner.otlpReceiver", { endpoint, proto: proto ? ` (${proto})` : "" }));
482
- } else {
483
- // 远程沙箱(e2b / vercel):在沙箱内起 collector,agent 往 localhost:4318 发
484
- receiver = yield* createInSandboxTraceReceiver(sandbox);
485
- const endpoint = receiver.endpoint("");
486
- const env = run.agent.tracing?.env?.(endpoint);
487
- telemetry = env ? { endpoint, env } : { endpoint };
488
- const proto = run.agent.tracing?.protocol;
489
- log(t("runner.otlpInSandbox", { endpoint, proto: proto ? ` (${proto})` : "" }));
490
- }
491
- }
492
-
493
- // body 是 Promise(adapter 边界)。Effect.promise 给的 AbortSignal 在本 fiber 被中断
494
- //(用户 Ctrl+C / 下面 timeoutTo 到点)时 abort —— 并进 signal,让真正观察 signal 的
495
- // adapter / docker 命令随中断一起停,而不只靠 Scope release 兜底。
496
- return yield* Effect.promise((interruptSignal) =>
497
- runAttemptBody(a, config, t0, base, {
498
- sandbox,
499
- receiver,
500
- telemetry,
501
- signal: AbortSignal.any([signal, interruptSignal]),
502
- log,
503
- }),
504
- );
505
- }),
506
- ).pipe(
507
- // ── attempt 总超时的硬边界(P1)──
508
- // timeoutMs 是「整个 attempt(setup+agent+脚本+评分)」的上限,不是 docker 单条命令的。
509
- // 到点 → 中断整段 body → Scope 跑 release(停容器、关接收器)→ 产出一条 errored 结果。
510
- // 即便 adapter / test 完全无视 signal 挂死,这一层也能把它停下来并回收资源。
511
- Effect.timeoutTo({
512
- duration: Duration.millis(timeoutMs),
513
- onSuccess: (r: EvalResult) => r,
514
- onTimeout: (): EvalResult => ({
515
- ...base,
516
- durationMs: Date.now() - t0,
517
- error: t("runner.timeout", {
518
- timeoutMs,
519
- recentLogs: recentLogs.map((l) => ` · ${l}`).join("\n"),
520
- }),
521
- }),
522
- }),
523
- // body 自己已兜了 agent 执行错;这里兜的是资源获取 / Scope 层的意外(起沙箱失败等)。
524
- // 中断【不】吞:此时 Scope 已跑完 release(容器已停),把中断继续上抛,让 forEach 整体停掉,
525
- // 否则会把中断「恢复」成一条 errored 结果、并让后续 attempt 继续起 —— 那就停不下来了。
526
- Effect.catchAllCause((cause) =>
527
- Cause.isInterrupted(cause)
528
- ? Effect.failCause(cause)
529
- : Effect.succeed({ ...base, durationMs: Date.now() - t0, error: causeToError(cause) }),
530
- ),
531
- );
532
- }
533
-
534
- function causeToError(cause: Cause.Cause<never>): string {
535
- return formatThrown(Cause.squash(cause));
536
- }
537
-
538
- interface AttemptResources {
539
- sandbox: Sandbox;
540
- receiver?: TraceReceiver;
541
- telemetry?: Telemetry;
542
- signal: AbortSignal;
543
- log: (m: string) => void;
544
- }
545
-
546
- // attempt 的固定段(上传→基线→setup→驱动 agent→采 diff→脚本→评分→判决)。
547
- // 资源已由 runAttemptEffect 的 Scope 持有;这里只在 finally 跑 agent 自己的 cleanup/teardown。
548
- async function runAttemptBody(
549
- a: Attempt,
550
- config: Config,
551
- t0: number,
552
- base: EvalResult,
553
- res: AttemptResources,
554
- ): Promise<EvalResult> {
555
- const { evalDef, run, attempt } = a;
556
- const { sandbox, receiver, telemetry, signal, log } = res;
557
- const usesSandbox = run.agent.capabilities.sandbox === true;
558
- // 整个 attempt 共用一份 agent ctx(sandbox 钩子 / agent setup / tracing configure / teardown 都用它)。
559
- const attemptCtx: AgentContext = {
560
- signal,
561
- model: run.model,
562
- flags: run.flags,
563
- sandbox,
564
- session: { id: undefined, isNew: true },
565
- telemetry,
566
- log,
567
- };
568
- let agentCleanup: Cleanup | void = undefined;
569
- let agentDidSetup = false;
570
- try {
571
- if (usesSandbox) {
572
- await initGitAndCommit(sandbox);
573
-
574
- // eval 级 setup(starter prep:npm install / 装系统依赖等)。命令默认非 root;
575
- // setup 里需要 root 的(apt/pip)自己传 { root: true }。
576
- if (evalDef.setup) {
577
- log(t("runner.evalSetup"));
578
- await evalDef.setup(sandbox);
579
- }
580
- }
581
-
582
- // agent 自己的 lifecycle:装 CLI、写 config(每个沙箱一次,不在每轮 send 里)。
583
- if (run.agent.setup) {
584
- log(t("runner.startAgentSetup"));
585
- agentDidSetup = true;
586
- agentCleanup = await run.agent.setup(sandbox, attemptCtx);
587
- }
588
-
589
- // OTLP 导出配置(file-based,如 codex 的 config.toml [otel] 块):与 setup 分开,
590
- // 在主配置写完后追加。仅当 tracing 开 + 有 endpoint 时调一次(env-based 的不实现 configure)。
591
- if (telemetry && run.agent.tracing?.configure) {
592
- log(t("runner.startAgentTracing"));
593
- await run.agent.tracing.configure(sandbox, attemptCtx);
594
- }
595
-
596
- // 构造 t,跑 test
597
- log(t("runner.driveAgent"));
598
- const judge = resolveJudge(evalDef.judge, config.judge);
599
- const { context, state } = createEvalContext({
600
- agent: run.agent,
601
- sandbox,
602
- model: run.model,
603
- flags: run.flags,
604
- signal,
605
- log,
606
- judge,
607
- telemetry,
608
- });
609
-
610
- let error: string | undefined;
611
- let skipReason: string | undefined;
612
- try {
613
- await evalDef.test(context);
614
- } catch (e) {
615
- if (e instanceof EvalSkipped) skipReason = e.reason;
616
- else if (e instanceof EvalRequirementFailed) {
617
- /* 断言已记录,非执行错误 */
618
- } else if (e instanceof TurnFailed) {
619
- error = e.message;
620
- } else {
621
- // 带 stack——eval 脚本(比如引用了已改名/删掉的 API)抛出的 TypeError 只有
622
- // "name: message" 完全定位不到是哪一行,报告里必须能看见 eval 文件的 file:line。
623
- error = formatThrown(e);
624
- }
625
- }
626
-
627
- if (skipReason) log(t("runner.skip", { reason: skipReason }));
628
-
629
- // 采 diff(脚本如 next build 在采集后才跑,避免 .next 污染 diff)。remote agent 没有 workspace。
630
- const diff =
631
- skipReason || !usesSandbox
632
- ? { generatedFiles: {}, deletedFiles: [] }
633
- : await captureGeneratedFiles(sandbox);
634
- state.late.diff = diff;
635
- if (!skipReason && usesSandbox) {
636
- log(t("runner.diffProgress", {
637
- changed: Object.keys(diff.generatedFiles).length,
638
- deleted: diff.deletedFiles.length,
639
- }));
640
- }
641
-
642
- const scripts: Record<string, ScriptResult> = {};
643
- state.late.scripts = scripts;
644
-
645
- // 评分
646
- const events = state.manager.allEvents;
647
- const usage = state.manager.usage;
648
- const facts = deriveRunFacts(events);
649
- const scoringContext: ScoringContext = {
650
- events,
651
- facts,
652
- diff,
653
- scripts,
654
- usage,
655
- status: state.manager.lastStatus,
656
- readFile: async (path) => {
657
- try {
658
- return await sandbox!.readFile(path);
659
- } catch {
660
- return undefined;
661
- }
662
- },
663
- };
664
- if (!skipReason) log(t("runner.scoreJudge"));
665
- const assertions = skipReason ? [] : await state.collector.finalize(scoringContext);
666
- const outcome = computeOutcome({ error, assertions, skipReason, strict: run.strict });
667
-
668
- // 收 OTLP trace:给最后一批导出留点落地时间,再 collect(空则不挂)。
669
- // codex 的 OTLP 把内部 Rust tracing 全导出来(handle_responses / append_items … 上万条);
670
- // 先经【每-agent mapper】把原生 span 归一到 canonical GenAI semconv(定 SpanKind),
671
- // 再 selectTraceSpans 按 kind 挑出回合/模型/工具,丢掉 "other" 噪声(干净小 trace 整段保留)。
672
- let trace: TraceSpan[] | undefined;
673
- if (receiver) {
674
- await receiver.settle(250, 1500);
675
- const spans = receiver.collect();
676
- if (spans.length) {
677
- // 归一 → 选语义 span → 按 call_id 把 transcript 的工具入参/出参 join 上去(span 自身不带命令文本)。
678
- const canonical = mapSpansToCanonical(spans, run.agent.name);
679
- trace = enrichTraceWithIO(selectTraceSpans(canonical), facts.toolCalls);
680
- const note = spans.length > trace.length ? t("runner.traceSelected", { count: trace.length }) : "";
681
- log(`trace:${spans.length} span${note}`);
682
- }
683
- }
684
-
685
- const durationMs = Date.now() - t0;
686
- const o11y = buildO11ySummary(events, usage, durationMs);
687
- // 实测成本(网关带回)优先,缺则按 model + 用量查价格表估算(见 o11y/cost.ts)。
688
- const cost = usage.costUSD ?? estimateCost(run.model, usage);
689
- if (cost !== undefined) o11y.estimatedCostUSD = cost;
690
-
691
- // 收 test 引用到的 eval 源码(按 send / 断言的 loc 去重),供 view 渲染代码视图。
692
- const sources = await collectSources(events, assertions);
693
-
694
- return {
695
- id: evalDef.id,
696
- description: evalDef.description,
697
- experimentId: run.experimentId,
698
- experiment: experimentRunInfo(run),
699
- agent: run.agent.name,
700
- model: run.model,
701
- outcome,
702
- fingerprint: a.fingerprint,
703
- attempt,
704
- startedAt: new Date(t0).toISOString(),
705
- durationMs,
706
- assertions,
707
- usage,
708
- estimatedCostUSD: cost,
709
- error,
710
- skipReason,
711
- events,
712
- sources,
713
- o11y,
714
- trace,
715
- diff,
716
- };
717
- } catch (e) {
718
- return {
719
- ...base,
720
- durationMs: Date.now() - t0,
721
- error: formatThrown(e),
722
- };
723
- } finally {
724
- // teardown / cleanup 一律在 finally 跑(失败也跑),不改判决,各自兜错(diagnostic)。
725
- // LIFO:先 agent(setup 最晚),再沙箱 Scope。
726
- // 沙箱 stop / 接收器 close 不在这里 —— 由 runAttemptEffect 的 Scope 在本函数返回后回收。
727
- try {
728
- if (typeof agentCleanup === "function") await agentCleanup();
729
- if (agentDidSetup) await run.agent.teardown?.(sandbox, attemptCtx);
730
- } catch {
731
- // teardown 失败只是 diagnostic,不影响已出的结果
732
- }
733
- }
734
- }
735
-
736
- function createRemoteSandbox(): Sandbox {
737
- const unavailable = (method: string): never => {
738
- throw new Error(t("runner.remoteSandboxUnavailable", { method }));
739
- };
740
-
741
- return {
742
- sandboxId: "remote",
743
- otlpHost: "127.0.0.1",
744
- async runCommand() {
745
- return unavailable("runCommand");
746
- },
747
- async runShell() {
748
- return unavailable("runShell");
749
- },
750
- async readFile() {
751
- return unavailable("readFile");
752
- },
753
- async fileExists() {
754
- return unavailable("fileExists");
755
- },
756
- async readSourceFiles() {
757
- return unavailable("readSourceFiles");
758
- },
759
- async writeFiles() {
760
- unavailable("writeFiles");
761
- },
762
- async uploadFiles() {
763
- unavailable("uploadFiles");
764
- },
765
- async uploadDirectory() {
766
- unavailable("uploadDirectory");
767
- },
768
- async stop() {
769
- // no-op:remote agent 生命周期由它自己的进程管理。
770
- },
771
- async downloadFile() {
772
- return unavailable("downloadFile");
773
- },
774
- async uploadFile() {
775
- unavailable("uploadFile");
776
- },
777
- };
778
- }
779
-
780
- /**
781
- * 收集 test 引用到的 eval 源码:从 send(user message)与断言的 loc 去重出文件集,逐个读回。
782
- * loc.file 相对项目根(= 进程 cwd,CLI 从那儿发现 / 跑 eval),所以按 cwd 解析。读不到就跳过。
783
- */
784
- async function collectSources(
785
- events: readonly StreamEvent[],
786
- assertions: readonly EvalResult["assertions"][number][],
787
- ): Promise<SourceArtifact[]> {
788
- const paths = new Set<string>();
789
- for (const e of events) if (e.type === "message" && e.loc) paths.add(e.loc.file);
790
- for (const a of assertions) if (a.loc) paths.add(a.loc.file);
791
- const out: SourceArtifact[] = [];
792
- for (const path of paths) {
793
- try {
794
- out.push({ path, content: await readSourceFile(resolvePath(process.cwd(), path), "utf-8") });
795
- } catch {
796
- // 源码读不到(路径在沙箱内 / 已删 / 权限)——跳过,view 用 loc 也能降级显示行号。
797
- }
798
- }
799
- return out;
800
- }
801
-
802
- function experimentRunInfo(run: AgentRun): EvalResult["experiment"] {
803
- return {
804
- id: run.experimentId,
805
- flags: run.flags,
806
- runs: run.runs,
807
- earlyExit: run.earlyExit,
808
- sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
809
- timeoutMs: run.timeoutMs,
810
- budget: run.budget,
811
- };
812
- }
813
-
814
- function cacheKey(run: AgentRun, evalId: string): string {
815
- return `${run.experimentId ?? ""}|${evalId}`;
816
- }
817
-
818
- async function computeFingerprint(evalDef: DiscoveredEval, run: AgentRun): Promise<string> {
819
- const source = await readSourceFile(evalDef.sourcePath, "utf-8");
820
- const payload = {
821
- source,
822
- eval: {
823
- id: evalDef.id,
824
- tags: evalDef.tags ?? [],
825
- metadata: evalDef.metadata ?? {},
826
- timeoutMs: evalDef.timeoutMs,
827
- },
828
- run: {
829
- experimentId: run.experimentId,
830
- agent: run.agent.name,
831
- model: run.model,
832
- flags: run.flags,
833
- sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
834
- timeoutMs: run.timeoutMs,
835
- strict: run.strict,
836
- },
837
- };
838
- return createHash("sha256").update(stableJson(payload)).digest("hex");
839
- }
840
-
841
- function stableJson(value: unknown): string {
842
- if (value === null || typeof value !== "object") return JSON.stringify(value);
843
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
844
- const obj = value as Record<string, unknown>;
845
- return `{${Object.keys(obj)
846
- .sort()
847
- .map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`)
848
- .join(",")}}`;
849
- }
850
-
851
- function resolveJudge(
852
- evalJudge: JudgeConfig | undefined,
853
- configJudge: JudgeConfig | undefined,
854
- ): JudgeConfig | undefined {
855
- return evalJudge ?? configJudge;
856
- }
857
-
858
- function tail(s: string, lines = 40): string {
859
- return s.trim().split("\n").slice(-lines).join("\n");
860
- }