niceeval 0.1.0 → 0.1.2

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 (133) hide show
  1. package/README.md +9 -9
  2. package/README.zh.md +14 -14
  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 +46 -17
  38. package/src/agents/ai-sdk-otel.ts +0 -0
  39. package/src/agents/ai-sdk.test.ts +377 -0
  40. package/src/agents/ai-sdk.ts +577 -0
  41. package/src/agents/bub.ts +30 -37
  42. package/src/agents/claude-code.ts +7 -4
  43. package/src/agents/codex.ts +15 -10
  44. package/src/agents/index.ts +43 -1
  45. package/src/agents/sdk-streams.test.ts +145 -0
  46. package/src/agents/sdk-streams.ts +457 -0
  47. package/src/agents/shared.ts +77 -39
  48. package/src/agents/streaming.test.ts +152 -0
  49. package/src/agents/streaming.ts +195 -0
  50. package/src/agents/types.ts +218 -0
  51. package/src/agents/ui-message-stream.test.ts +249 -0
  52. package/src/agents/ui-message-stream.ts +372 -0
  53. package/src/cli.ts +124 -77
  54. package/src/context/context.test.ts +203 -7
  55. package/src/context/context.ts +158 -49
  56. package/src/context/session.test.ts +96 -0
  57. package/src/context/session.ts +119 -9
  58. package/src/context/types.ts +205 -0
  59. package/src/define.ts +4 -14
  60. package/src/expect/index.ts +8 -71
  61. package/src/i18n/core.ts +28 -0
  62. package/src/i18n/en.ts +47 -5
  63. package/src/i18n/index.ts +5 -17
  64. package/src/i18n/zh-CN.ts +47 -5
  65. package/src/index.ts +12 -0
  66. package/src/loaders/index.ts +8 -39
  67. package/src/o11y/otlp/canonical.ts +7 -6
  68. package/src/o11y/otlp/mappers/codex.ts +10 -8
  69. package/src/o11y/otlp/mappers/index.ts +9 -21
  70. package/src/o11y/otlp/receiver.ts +25 -7
  71. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  72. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  73. package/src/o11y/otlp/turn-otel.ts +125 -0
  74. package/src/o11y/parsers/bub.ts +13 -11
  75. package/src/o11y/parsers/claude-code.ts +27 -51
  76. package/src/o11y/parsers/codex.ts +29 -46
  77. package/src/o11y/parsers/index.ts +5 -14
  78. package/src/o11y/tool-names.test.ts +61 -0
  79. package/src/o11y/tool-names.ts +74 -0
  80. package/src/o11y/types.ts +135 -0
  81. package/src/runner/attempt.ts +456 -0
  82. package/src/runner/fingerprint.ts +59 -0
  83. package/src/runner/remote-sandbox.ts +53 -0
  84. package/src/runner/report.ts +53 -0
  85. package/src/runner/reporters/artifacts.ts +25 -4
  86. package/src/runner/reporters/console.ts +2 -8
  87. package/src/runner/reporters/live.ts +2 -8
  88. package/src/runner/reporters/shared.ts +15 -0
  89. package/src/runner/reporters/table.ts +10 -62
  90. package/src/runner/run.ts +114 -619
  91. package/src/runner/types.ts +237 -0
  92. package/src/sandbox/checkpoint.ts +15 -13
  93. package/src/sandbox/docker-stream.ts +87 -0
  94. package/src/sandbox/docker.ts +42 -120
  95. package/src/sandbox/e2b.ts +24 -75
  96. package/src/sandbox/local-files.ts +33 -0
  97. package/src/sandbox/paths.test.ts +85 -0
  98. package/src/sandbox/paths.ts +45 -0
  99. package/src/sandbox/resolve.ts +10 -34
  100. package/src/sandbox/shell.ts +17 -0
  101. package/src/sandbox/source-files.ts +42 -2
  102. package/src/sandbox/types.ts +158 -0
  103. package/src/sandbox/vercel.ts +55 -71
  104. package/src/scoring/collector.ts +3 -2
  105. package/src/scoring/judge.ts +72 -146
  106. package/src/scoring/match.ts +79 -0
  107. package/src/scoring/types.ts +76 -0
  108. package/src/shared/aggregate.ts +48 -0
  109. package/src/shared/format.ts +20 -0
  110. package/src/shared/outcome.ts +48 -0
  111. package/src/shared/types.ts +37 -0
  112. package/src/types.ts +20 -911
  113. package/src/view/aggregate.ts +103 -0
  114. package/src/view/app/App.tsx +26 -5
  115. package/src/view/app/components/AttemptModal.tsx +12 -3
  116. package/src/view/app/components/CodeView.tsx +9 -5
  117. package/src/view/app/components/CopyControls.tsx +4 -4
  118. package/src/view/app/components/ExperimentTable.tsx +5 -5
  119. package/src/view/app/i18n.ts +31 -7
  120. package/src/view/app/lib/format.ts +17 -20
  121. package/src/view/app/lib/outcome.ts +4 -16
  122. package/src/view/app/main.tsx +3 -5
  123. package/src/view/app/pages/RunsPage.tsx +2 -2
  124. package/src/view/app/pages/TracesPage.tsx +2 -2
  125. package/src/view/app/shared.ts +2 -1
  126. package/src/view/app/types.ts +6 -43
  127. package/src/view/client-dist/app.css +1 -1
  128. package/src/view/client-dist/app.js +18 -18
  129. package/src/view/index.ts +24 -401
  130. package/src/view/loader.ts +234 -0
  131. package/src/view/server.ts +136 -0
  132. package/src/view/shared/types.ts +64 -0
  133. package/src/view/styles.css +60 -9
@@ -0,0 +1,456 @@
1
+ // 单个 attempt 的完整生命周期:资源(沙箱 / OTLP 接收器)经 Effect.Scope 的
2
+ // acquireRelease 接管,无论 body 成功 / 抛错 / 被中断,stop() / close() 都保证执行。
3
+ // 沙箱编排的固定段在 runAttemptBody(基线→setup→驱动 test→采 diff→评分→判决→收 trace),
4
+ // adapter 只填「把 agent 跑起来」一段。
5
+
6
+ import { resolve as resolvePath } from "node:path";
7
+ import { readFile as readSourceFile } from "node:fs/promises";
8
+ import { Effect, Cause, Duration } 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 type { AgentOtelChannel } from "../o11y/otlp/turn-otel.ts";
13
+ import { selectTraceSpans, enrichTraceWithIO } from "../o11y/otlp/select.ts";
14
+ import { mapGenericSpans } from "../o11y/otlp/mappers/index.ts";
15
+ import { createEvalContext } from "../context/context.ts";
16
+ import { createAgentSession } from "../context/session.ts";
17
+ import { EvalRequirementFailed, EvalSkipped, TurnFailed } from "../context/control-flow.ts";
18
+ import { computeOutcome } from "../scoring/verdict.ts";
19
+ import { deriveRunFacts, buildO11ySummary } from "../o11y/derive.ts";
20
+ import { estimateCost } from "../o11y/cost.ts";
21
+ import { t } from "../i18n/index.ts";
22
+ import { formatThrown } from "../util.ts";
23
+ import { captureGeneratedFiles, initGitAndCommit } from "./sandbox-prep.ts";
24
+ import { createRemoteSandbox, withEvalLocalPaths } from "./remote-sandbox.ts";
25
+ import type {
26
+ AgentContext,
27
+ Cleanup,
28
+ Config,
29
+ EvalResult,
30
+ JudgeConfig,
31
+ Sandbox,
32
+ ScoringContext,
33
+ ScriptResult,
34
+ SourceArtifact,
35
+ StreamEvent,
36
+ Telemetry,
37
+ TraceSpan,
38
+ } from "../types.ts";
39
+ import type { AgentRun, Attempt, RunOptions } from "./types.ts";
40
+
41
+ export function runAttemptEffect(
42
+ a: Attempt,
43
+ opts: RunOptions,
44
+ sandboxSem: Effect.Semaphore,
45
+ parentSignal?: AbortSignal,
46
+ ): Effect.Effect<EvalResult> {
47
+ const config = opts.config;
48
+ const { evalDef, run, attempt } = a;
49
+ const t0 = Date.now();
50
+
51
+ const base: EvalResult = {
52
+ id: evalDef.id,
53
+ description: evalDef.description,
54
+ experimentId: run.experimentId,
55
+ experiment: experimentRunInfo(run),
56
+ agent: run.agent.name,
57
+ model: run.model,
58
+ outcome: "errored",
59
+ fingerprint: a.fingerprint,
60
+ attempt,
61
+ startedAt: new Date(t0).toISOString(),
62
+ durationMs: 0,
63
+ assertions: [],
64
+ };
65
+
66
+ const timeoutMs = run.timeoutMs ?? evalDef.timeoutMs ?? config.timeoutMs ?? 600_000;
67
+ // timeoutSignal:给协作式 adapter / docker 命令的「软」截止信号(到点 abort,让能看 signal 的
68
+ // 提前优雅停)。但它【不是】attempt 总超时的硬保证 —— 真正的硬边界是下面的 Effect.timeoutTo:
69
+ // 它中断整段 body,触发 Scope release(停容器),从而即便 adapter 完全无视 signal 也能停掉(P1)。
70
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
71
+ const signal = parentSignal ? AbortSignal.any([parentSignal, timeoutSignal]) : timeoutSignal;
72
+
73
+ // 流式进度打到宿主 stderr(结果走 stdout,互不干扰)。容器主日志【不】放这些进度标记 ——
74
+ // 那里留给 agent 的原始输出(adapter 给 agent 命令开 { stream: true })。
75
+ const who = run.model ? `${run.agent.name}/${run.model}` : run.agent.name;
76
+ // 同时保留最近 20 条进度消息,timeout 时嵌入 error 字段方便定位卡在哪一步。
77
+ const recentLogs: string[] = [];
78
+ const log = (m: string) => {
79
+ recentLogs.push(m);
80
+ if (recentLogs.length > 20) recentLogs.shift();
81
+ if (opts.onProgress) {
82
+ opts.onProgress(evalDef.id, who, m);
83
+ } else {
84
+ process.stderr.write(` · ${evalDef.id} [${who}] ${m}\n`);
85
+ }
86
+ };
87
+
88
+ return Effect.scoped(
89
+ Effect.gen(function* () {
90
+ const sandbox =
91
+ run.agent.kind === "sandbox"
92
+ ? yield* sandboxSem.withPermits(1)(
93
+ Effect.gen(function* () {
94
+ // ── 沙箱:acquire=起,release=stop(成功 / 失败 / 中断都跑)──
95
+ // sandboxSem 只覆盖「容器创建」阶段;容器起好后立即释放,后续 npm install / agent 不占位。
96
+ log(t("runner.startSandbox"));
97
+ return yield* createSandbox({
98
+ sandbox: run.sandbox ?? config.sandbox,
99
+ timeout: timeoutMs,
100
+ runtime: "node24",
101
+ });
102
+ }),
103
+ )
104
+ : createRemoteSandbox();
105
+ if (run.agent.kind !== "sandbox") log(t("runner.useRemoteAgent"));
106
+
107
+ // ── tracing ──────────────────────────────────────────────────────────────────
108
+ // sandbox.otlpHost:
109
+ // string → docker 类沙箱,宿主开本地接收器,container 经 host.docker.internal 回连
110
+ // null → 远程云端沙箱(e2b / vercel),宿主端口不可达 → 改在沙箱内起 collector
111
+ // defineConfig({ telemetry: { host } }) 可强制覆盖(如配好 tunnel 时)。
112
+ //
113
+ // 非沙箱 agent(远程 / 进程内)不走 per-attempt receiver:被测应用是长驻进程,只有一条
114
+ // 全局 OTel 管线(OTEL_* env 进程启动时读一次)—— per-attempt 端口会在第一个 attempt
115
+ // 结束时关掉,后续 span 全丢。改走 run 级共享池,span 逐轮归属(traceparent / 窗口)。
116
+ let receiver: TraceReceiver | undefined;
117
+ let telemetry: Telemetry | undefined;
118
+ let otelChannel: AgentOtelChannel | undefined;
119
+ // 共享池仅限:config 配了 telemetry(固定端口,无侵入接入的长驻服务)或显式 tracing.scope === "run"。
120
+ // 只声明 tracing 的进程内 adapter(如 aiSdkAgent)保持 per-attempt receiver,attempt 全并发。
121
+ const wantsSharedOtel =
122
+ config.telemetry !== undefined || run.agent.tracing?.scope === "run";
123
+ if (run.agent.kind !== "sandbox" && wantsSharedOtel && opts.otelPool) {
124
+ otelChannel = yield* Effect.promise(() => opts.otelPool!.channel(run.agent.name));
125
+ const endpoint = otelChannel.receiver.endpoint(config.telemetry?.host ?? "127.0.0.1");
126
+ const env = run.agent.tracing?.env?.(endpoint);
127
+ telemetry = env ? { endpoint, env } : { endpoint };
128
+ log(t("runner.otlpShared", { endpoint }));
129
+ } else if (run.agent.tracing !== undefined) {
130
+ const forcedHost = config.telemetry?.host;
131
+ if (forcedHost) {
132
+ // 显式覆盖:走本地接收器,把指定 host 交给 agent
133
+ receiver = yield* createTraceReceiver();
134
+ const endpoint = receiver.endpoint(forcedHost);
135
+ const env = run.agent.tracing?.env?.(endpoint);
136
+ telemetry = env ? { endpoint, env } : { endpoint };
137
+ log(t("runner.otlpOverride", { endpoint }));
138
+ } else if (sandbox.otlpHost !== null) {
139
+ // 本地/docker 沙箱:宿主开接收器
140
+ receiver = yield* createTraceReceiver();
141
+ const endpoint = receiver.endpoint(sandbox.otlpHost);
142
+ const env = run.agent.tracing?.env?.(endpoint);
143
+ telemetry = env ? { endpoint, env } : { endpoint };
144
+ const proto = run.agent.tracing?.protocol;
145
+ log(t("runner.otlpReceiver", { endpoint, proto: proto ? ` (${proto})` : "" }));
146
+ } else {
147
+ // 远程沙箱(e2b / vercel):在沙箱内起 collector,agent 往 localhost 端口发
148
+ receiver = yield* createInSandboxTraceReceiver(sandbox);
149
+ const endpoint = receiver.endpoint("");
150
+ const env = run.agent.tracing?.env?.(endpoint);
151
+ telemetry = env ? { endpoint, env } : { endpoint };
152
+ const proto = run.agent.tracing?.protocol;
153
+ log(t("runner.otlpInSandbox", { endpoint, proto: proto ? ` (${proto})` : "" }));
154
+ }
155
+ }
156
+
157
+ // body 是 Promise(adapter 边界)。Effect.promise 给的 AbortSignal 在本 fiber 被中断
158
+ //(用户 Ctrl+C / 下面 timeoutTo 到点)时 abort —— 并进 signal,让真正观察 signal 的
159
+ // adapter / docker 命令随中断一起停,而不只靠 Scope release 兜底。
160
+ return yield* Effect.promise((interruptSignal) =>
161
+ runAttemptBody(a, config, t0, base, {
162
+ sandbox,
163
+ receiver,
164
+ telemetry,
165
+ otel: otelChannel,
166
+ signal: AbortSignal.any([signal, interruptSignal]),
167
+ log,
168
+ }),
169
+ );
170
+ }),
171
+ ).pipe(
172
+ // ── attempt 总超时的硬边界(P1)──
173
+ // timeoutMs 是「整个 attempt(setup+agent+脚本+评分)」的上限,不是 docker 单条命令的。
174
+ // 到点 → 中断整段 body → Scope 跑 release(停容器、关接收器)→ 产出一条 errored 结果。
175
+ // 即便 adapter / test 完全无视 signal 挂死,这一层也能把它停下来并回收资源。
176
+ Effect.timeoutTo({
177
+ duration: Duration.millis(timeoutMs),
178
+ onSuccess: (r: EvalResult) => r,
179
+ onTimeout: (): EvalResult => ({
180
+ ...base,
181
+ durationMs: Date.now() - t0,
182
+ error: t("runner.timeout", {
183
+ timeoutMs,
184
+ recentLogs: recentLogs.map((l) => ` · ${l}`).join("\n"),
185
+ }),
186
+ }),
187
+ }),
188
+ // body 自己已兜了 agent 执行错;这里兜的是资源获取 / Scope 层的意外(起沙箱失败等)。
189
+ // 中断【不】吞:此时 Scope 已跑完 release(容器已停),把中断继续上抛,让 forEach 整体停掉,
190
+ // 否则会把中断「恢复」成一条 errored 结果、并让后续 attempt 继续起 —— 那就停不下来了。
191
+ Effect.catchAllCause((cause) =>
192
+ Cause.isInterrupted(cause)
193
+ ? Effect.failCause(cause)
194
+ : Effect.succeed({ ...base, durationMs: Date.now() - t0, error: causeToError(cause) }),
195
+ ),
196
+ );
197
+ }
198
+
199
+ function causeToError(cause: Cause.Cause<never>): string {
200
+ return formatThrown(Cause.squash(cause));
201
+ }
202
+
203
+ interface AttemptResources {
204
+ sandbox: Sandbox;
205
+ receiver?: TraceReceiver;
206
+ telemetry?: Telemetry;
207
+ /** 非沙箱 tracing agent 的共享 OTLP 通道(run 级池持有,不随 attempt 关)。 */
208
+ otel?: AgentOtelChannel;
209
+ signal: AbortSignal;
210
+ log: (m: string) => void;
211
+ }
212
+
213
+ // attempt 的固定段(上传→基线→setup→驱动 agent→采 diff→脚本→评分→判决)。
214
+ // 资源已由 runAttemptEffect 的 Scope 持有;这里只在 finally 跑 agent 自己的 cleanup/teardown。
215
+ async function runAttemptBody(
216
+ a: Attempt,
217
+ config: Config,
218
+ t0: number,
219
+ base: EvalResult,
220
+ res: AttemptResources,
221
+ ): Promise<EvalResult> {
222
+ const { evalDef, run, attempt } = a;
223
+ const { sandbox, receiver, telemetry, otel, signal, log } = res;
224
+ const usesSandbox = run.agent.kind === "sandbox";
225
+ // 整个 attempt 共用一份 agent ctx(sandbox 钩子 / agent setup / tracing configure / teardown 都用它)。
226
+ const attemptCtx: AgentContext = {
227
+ signal,
228
+ model: run.model,
229
+ flags: run.flags,
230
+ sandbox,
231
+ session: createAgentSession(),
232
+ telemetry,
233
+ log,
234
+ };
235
+ let agentCleanup: Cleanup | void = undefined;
236
+ let agentDidSetup = false;
237
+ try {
238
+ if (usesSandbox) {
239
+ await initGitAndCommit(sandbox);
240
+
241
+ // eval 级 setup(starter prep:npm install / 装系统依赖等)。命令默认非 root;
242
+ // setup 里需要 root 的(apt/pip)自己传 { root: true }。
243
+ if (evalDef.setup) {
244
+ log(t("runner.evalSetup"));
245
+ await evalDef.setup(withEvalLocalPaths(sandbox, evalDef.baseDir));
246
+ }
247
+ }
248
+
249
+ // agent 自己的 lifecycle:装 CLI、写 config(每个沙箱一次,不在每轮 send 里)。
250
+ if (run.agent.setup) {
251
+ log(t("runner.startAgentSetup"));
252
+ agentDidSetup = true;
253
+ agentCleanup = await run.agent.setup(sandbox, attemptCtx);
254
+ }
255
+
256
+ // OTLP 导出配置(file-based,如 codex 的 config.toml [otel] 块):与 setup 分开,
257
+ // 在主配置写完后追加。仅当 tracing 开 + 有 endpoint 时调一次(env-based 的不实现 configure)。
258
+ if (telemetry && run.agent.tracing?.configure) {
259
+ log(t("runner.startAgentTracing"));
260
+ await run.agent.tracing.configure(sandbox, attemptCtx);
261
+ }
262
+
263
+ // 构造 t,跑 test
264
+ log(t("runner.driveAgent"));
265
+ const judge = resolveJudge(evalDef.judge, config.judge);
266
+ const { context, state } = createEvalContext({
267
+ agent: run.agent,
268
+ sandbox,
269
+ model: run.model,
270
+ flags: run.flags,
271
+ signal,
272
+ log,
273
+ judge,
274
+ telemetry,
275
+ otel,
276
+ evalBaseDir: evalDef.baseDir,
277
+ });
278
+
279
+ let error: string | undefined;
280
+ let skipReason: string | undefined;
281
+ try {
282
+ await evalDef.test(context);
283
+ } catch (e) {
284
+ if (e instanceof EvalSkipped) skipReason = e.reason;
285
+ else if (e instanceof EvalRequirementFailed) {
286
+ /* 断言已记录,非执行错误 */
287
+ } else if (e instanceof TurnFailed) {
288
+ error = e.message;
289
+ } else {
290
+ // 带 stack——eval 脚本(比如引用了已改名/删掉的 API)抛出的 TypeError 只有
291
+ // "name: message" 完全定位不到是哪一行,报告里必须能看见 eval 文件的 file:line。
292
+ error = formatThrown(e);
293
+ }
294
+ }
295
+
296
+ if (skipReason) log(t("runner.skip", { reason: skipReason }));
297
+
298
+ // 采 diff(脚本如 next build 在采集后才跑,避免 .next 污染 diff)。remote agent 没有 workspace。
299
+ const diff =
300
+ skipReason || !usesSandbox
301
+ ? { generatedFiles: {}, deletedFiles: [] }
302
+ : await captureGeneratedFiles(sandbox);
303
+ state.late.diff = diff;
304
+ if (!skipReason && usesSandbox) {
305
+ log(t("runner.diffProgress", {
306
+ changed: Object.keys(diff.generatedFiles).length,
307
+ deleted: diff.deletedFiles.length,
308
+ }));
309
+ }
310
+
311
+ const scripts: Record<string, ScriptResult> = {};
312
+ state.late.scripts = scripts;
313
+
314
+ // 评分
315
+ const events = state.manager.allEvents;
316
+ const usage = state.manager.usage;
317
+ const facts = deriveRunFacts(events);
318
+ const scoringContext: ScoringContext = {
319
+ events,
320
+ facts,
321
+ diff,
322
+ scripts,
323
+ usage,
324
+ status: state.manager.lastStatus,
325
+ readFile: async (path) => {
326
+ try {
327
+ return await sandbox!.readFile(path);
328
+ } catch {
329
+ return undefined;
330
+ }
331
+ },
332
+ };
333
+ if (!skipReason) log(t("runner.scoreJudge"));
334
+ const assertions = skipReason ? [] : await state.collector.finalize(scoringContext);
335
+ const outcome = computeOutcome({ error, assertions, skipReason, strict: run.strict });
336
+
337
+ // 收 OTLP trace:给最后一批导出留点落地时间,再 collect(空则不挂)。
338
+ // codex 的 OTLP 把内部 Rust tracing 全导出来(handle_responses / append_items … 上万条);
339
+ // 先经【每-agent mapper】把原生 span 归一到 canonical GenAI semconv(定 SpanKind),
340
+ // 再 selectTraceSpans 按 kind 挑出回合/模型/工具,丢掉 "other" 噪声(干净小 trace 整段保留)。
341
+ let trace: TraceSpan[] | undefined;
342
+ if (receiver) {
343
+ await receiver.settle(250, 1500);
344
+ const spans = receiver.collect();
345
+ if (spans.length) {
346
+ // 归一 → 选语义 span → 按 call_id 把 transcript 的工具入参/出参 join 上去(span 自身不带命令文本)。
347
+ // 对接口分发,不按名字分支:mapper 由 Agent 自己声明,缺省走通用 heuristic。
348
+ const canonical = (run.agent.spanMapper ?? mapGenericSpans)(spans);
349
+ trace = enrichTraceWithIO(selectTraceSpans(canonical), facts.toolCalls);
350
+ const note = spans.length > trace.length ? t("runner.traceSelected", { count: trace.length }) : "";
351
+ log(`trace:${spans.length} span${note}`);
352
+ }
353
+ } else if (otel) {
354
+ // 共享通道:receiver 不归本 attempt 关,trace 只取归属到本 attempt 的 span
355
+ //(逐轮攒的 + 按本 attempt traceId sweep 回的迟到批)。
356
+ const late = await otel.sweep(state.manager.otelTraceIds);
357
+ const spans = [...state.manager.otelSpans, ...late];
358
+ if (spans.length) {
359
+ const canonical = (run.agent.spanMapper ?? mapGenericSpans)(spans);
360
+ trace = enrichTraceWithIO(selectTraceSpans(canonical), facts.toolCalls);
361
+ const note = spans.length > trace.length ? t("runner.traceSelected", { count: trace.length }) : "";
362
+ log(`trace:${spans.length} span${note}`);
363
+ }
364
+ }
365
+
366
+ const durationMs = Date.now() - t0;
367
+ const o11y = buildO11ySummary(events, usage, durationMs);
368
+ // 实测成本(网关带回)优先,缺则按 model + 用量查价格表估算(见 o11y/cost.ts)。
369
+ const cost = usage.costUSD ?? estimateCost(run.model, usage);
370
+ if (cost !== undefined) o11y.estimatedCostUSD = cost;
371
+
372
+ // 收 test 引用到的 eval 源码(按 send / 断言的 loc 去重),供 view 渲染代码视图。
373
+ const sources = await collectSources(events, assertions);
374
+
375
+ return {
376
+ id: evalDef.id,
377
+ description: evalDef.description,
378
+ experimentId: run.experimentId,
379
+ experiment: experimentRunInfo(run),
380
+ agent: run.agent.name,
381
+ model: run.model,
382
+ outcome,
383
+ fingerprint: a.fingerprint,
384
+ attempt,
385
+ startedAt: new Date(t0).toISOString(),
386
+ durationMs,
387
+ assertions,
388
+ usage,
389
+ estimatedCostUSD: cost,
390
+ error,
391
+ skipReason,
392
+ events,
393
+ sources,
394
+ o11y,
395
+ trace,
396
+ diff,
397
+ };
398
+ } catch (e) {
399
+ return {
400
+ ...base,
401
+ durationMs: Date.now() - t0,
402
+ error: formatThrown(e),
403
+ };
404
+ } finally {
405
+ // teardown / cleanup 一律在 finally 跑(失败也跑),不改判决,各自兜错(diagnostic)。
406
+ // LIFO:先 agent(setup 最晚),再沙箱 Scope。
407
+ // 沙箱 stop / 接收器 close 不在这里 —— 由 runAttemptEffect 的 Scope 在本函数返回后回收。
408
+ try {
409
+ if (typeof agentCleanup === "function") await agentCleanup();
410
+ if (agentDidSetup) await run.agent.teardown?.(sandbox, attemptCtx);
411
+ } catch {
412
+ // teardown 失败只是 diagnostic,不影响已出的结果
413
+ }
414
+ }
415
+ }
416
+
417
+ /**
418
+ * 收集 test 引用到的 eval 源码:从 send(user message)与断言的 loc 去重出文件集,逐个读回。
419
+ * loc.file 相对项目根(= 进程 cwd,CLI 从那儿发现 / 跑 eval),所以按 cwd 解析。读不到就跳过。
420
+ */
421
+ async function collectSources(
422
+ events: readonly StreamEvent[],
423
+ assertions: readonly EvalResult["assertions"][number][],
424
+ ): Promise<SourceArtifact[]> {
425
+ const paths = new Set<string>();
426
+ for (const e of events) if (e.type === "message" && e.loc) paths.add(e.loc.file);
427
+ for (const a of assertions) if (a.loc) paths.add(a.loc.file);
428
+ const out: SourceArtifact[] = [];
429
+ for (const path of paths) {
430
+ try {
431
+ out.push({ path, content: await readSourceFile(resolvePath(process.cwd(), path), "utf-8") });
432
+ } catch {
433
+ // 源码读不到(路径在沙箱内 / 已删 / 权限)——跳过,view 用 loc 也能降级显示行号。
434
+ }
435
+ }
436
+ return out;
437
+ }
438
+
439
+ function experimentRunInfo(run: AgentRun): EvalResult["experiment"] {
440
+ return {
441
+ id: run.experimentId,
442
+ flags: run.flags,
443
+ runs: run.runs,
444
+ earlyExit: run.earlyExit,
445
+ sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
446
+ timeoutMs: run.timeoutMs,
447
+ budget: run.budget,
448
+ };
449
+ }
450
+
451
+ function resolveJudge(
452
+ evalJudge: JudgeConfig | undefined,
453
+ configJudge: JudgeConfig | undefined,
454
+ ): JudgeConfig | undefined {
455
+ return evalJudge ?? configJudge;
456
+ }
@@ -0,0 +1,59 @@
1
+ // 指纹缓存:用 (eval 源码 + 运行配置) 的稳定哈希标识一次 attempt 的输入。
2
+ // 上次 passed 且指纹未变的 (experimentId, evalId) 组合可以直接携入,不再重跑。
3
+
4
+ import { createHash } from "node:crypto";
5
+ import { readFile } from "node:fs/promises";
6
+ import { sandboxLabel } from "../sandbox/resolve.ts";
7
+ import type { DiscoveredEval } from "../types.ts";
8
+ import type { AgentRun } from "./types.ts";
9
+
10
+ export function cacheKey(run: AgentRun, evalId: string): string {
11
+ return `${run.experimentId ?? ""}|${evalId}`;
12
+ }
13
+
14
+ /**
15
+ * @param sourceCache 按 sourcePath 缓存文件内容:一个矩阵(实验 × eval)会对同一批源文件
16
+ * 反复算指纹,不带缓存会在任何 attempt 起跑前做 E×N 次重复文件读。
17
+ */
18
+ export async function computeFingerprint(
19
+ evalDef: DiscoveredEval,
20
+ run: AgentRun,
21
+ sourceCache?: Map<string, Promise<string>>,
22
+ ): Promise<string> {
23
+ let sourcePromise = sourceCache?.get(evalDef.sourcePath);
24
+ if (!sourcePromise) {
25
+ sourcePromise = readFile(evalDef.sourcePath, "utf-8");
26
+ sourceCache?.set(evalDef.sourcePath, sourcePromise);
27
+ }
28
+ const source = await sourcePromise;
29
+ const payload = {
30
+ source,
31
+ eval: {
32
+ id: evalDef.id,
33
+ tags: evalDef.tags ?? [],
34
+ metadata: evalDef.metadata ?? {},
35
+ timeoutMs: evalDef.timeoutMs,
36
+ },
37
+ run: {
38
+ experimentId: run.experimentId,
39
+ agent: run.agent.name,
40
+ model: run.model,
41
+ flags: run.flags,
42
+ sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
43
+ timeoutMs: run.timeoutMs,
44
+ strict: run.strict,
45
+ },
46
+ };
47
+ return createHash("sha256").update(stableJson(payload)).digest("hex");
48
+ }
49
+
50
+ /** 键序稳定的 JSON 序列化(对象键排序),保证同一 payload 永远同一指纹。 */
51
+ function stableJson(value: unknown): string {
52
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
53
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
54
+ const obj = value as Record<string, unknown>;
55
+ return `{${Object.keys(obj)
56
+ .sort()
57
+ .map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`)
58
+ .join(",")}}`;
59
+ }
@@ -0,0 +1,53 @@
1
+ // 两个 Sandbox 接口适配器,都用 Proxy 实现:接口新增方法时无需在这里逐方法同步,
2
+ // 漏一个也不会等到运行时才炸(原实现是 13×2 行手工转发样板)。
3
+
4
+ import { resolveLocalPath } from "../sandbox/paths.ts";
5
+ import type { Sandbox } from "../types.ts";
6
+ import { t } from "../i18n/index.ts";
7
+
8
+ /**
9
+ * remote / 进程内 agent 的占位 Sandbox:没有真实沙箱,除少数元数据字段外,
10
+ * 任何方法一被调用就报清晰错误(而不是静默 no-op 让断言假通过)。
11
+ */
12
+ export function createRemoteSandbox(): Sandbox {
13
+ const meta: Partial<Sandbox> = {
14
+ workdir: "",
15
+ sandboxId: "remote",
16
+ otlpHost: "127.0.0.1",
17
+ // stop 是调度器的固定清理路径,必须真 no-op 而不是抛错。
18
+ stop: async () => {},
19
+ // appendLog 是可选能力,必须显式 undefined —— 返回抛错函数会让 `sandbox.appendLog ?` 判真。
20
+ appendLog: undefined,
21
+ };
22
+ return new Proxy(meta as Sandbox, {
23
+ get(target, prop) {
24
+ if (prop in target) return target[prop as keyof Sandbox];
25
+ // 协议探测属性必须回 undefined:返回抛错函数会让这个对象变成 then() 抛错的
26
+ // thenable(await sandbox 直接 reject)、让 JSON.stringify 调到假 toJSON。
27
+ if (typeof prop !== "string" || prop === "then" || prop === "toJSON" || prop === "inspect") {
28
+ return undefined;
29
+ }
30
+ return async () => {
31
+ throw new Error(t("runner.remoteSandboxUnavailable", { method: prop }));
32
+ };
33
+ },
34
+ });
35
+ }
36
+
37
+ /**
38
+ * 给 eval 级 setup 用的 Sandbox 视图:uploadDirectory 的本地路径按 eval 文件所在目录解析
39
+ * (作者写相对路径时相对 eval 文件,而不是进程 cwd);其余成员全部透传。
40
+ */
41
+ export function withEvalLocalPaths(sandbox: Sandbox, baseDir: string): Sandbox {
42
+ return new Proxy(sandbox, {
43
+ get(target, prop, receiver) {
44
+ if (prop === "uploadDirectory") {
45
+ return (localDir: string, targetDir?: string, opts?: Parameters<Sandbox["uploadDirectory"]>[2]) =>
46
+ target.uploadDirectory(resolveLocalPath(baseDir, localDir), targetDir, opts);
47
+ }
48
+ const value = Reflect.get(target, prop, receiver);
49
+ // 方法要 bind 回原对象:后端实现(class 实例)里的 this 不能指向 Proxy。
50
+ return typeof value === "function" ? value.bind(target) : value;
51
+ },
52
+ });
53
+ }
@@ -0,0 +1,53 @@
1
+ // reporter 编排与运行级汇总。reporter 是「结果消费方」:单个 reporter 抛错只记
2
+ // diagnostic,不能让整次调度崩。
3
+
4
+ import type { EvalResult, LocalizedText, Reporter, ReporterEvent, RunSummary } from "../types.ts";
5
+ import { t } from "../i18n/index.ts";
6
+ import { formatThrown } from "../util.ts";
7
+
8
+ /** reporter 调用的统一兜错。返回 void,永不 reject(供 Promise.all 安全聚合)。 */
9
+ export async function runReporter(stage: string, fn: () => unknown): Promise<void> {
10
+ try {
11
+ await fn();
12
+ } catch (e) {
13
+ process.stderr.write(t("runner.reporterDiagnostic", { stage, message: formatThrown(e) }));
14
+ }
15
+ }
16
+
17
+ export async function emitReporterEvent(reporters: readonly Reporter[], event: ReporterEvent): Promise<void> {
18
+ await Promise.all(reporters.map((r) => runReporter(`event:${event.type}`, () => r.onEvent?.(event))));
19
+ }
20
+
21
+ /** 全局汇总:outcome 计数 + token / cost 折叠。按 attempt 计(eval 级折叠见 shared/outcome.ts)。 */
22
+ export function summarize(
23
+ results: EvalResult[],
24
+ agent: string,
25
+ startedAt: string,
26
+ durationMs: number,
27
+ name?: LocalizedText,
28
+ ): RunSummary {
29
+ const counts = { passed: 0, failed: 0, skipped: 0, errored: 0 };
30
+ let inTok = 0;
31
+ let outTok = 0;
32
+ let cost = 0;
33
+ for (const r of results) {
34
+ counts[r.outcome] += 1;
35
+ inTok += r.usage?.inputTokens ?? 0;
36
+ outTok += r.usage?.outputTokens ?? 0;
37
+ cost += r.estimatedCostUSD ?? 0;
38
+ }
39
+ return {
40
+ name,
41
+ agent,
42
+ startedAt,
43
+ completedAt: new Date().toISOString(),
44
+ passed: counts.passed,
45
+ failed: counts.failed,
46
+ skipped: counts.skipped,
47
+ errored: counts.errored,
48
+ durationMs,
49
+ usage: { inputTokens: inTok, outputTokens: outTok },
50
+ estimatedCostUSD: cost || undefined,
51
+ results,
52
+ };
53
+ }
@@ -7,9 +7,18 @@
7
7
  // events.json sources.json trace.json o11y.json diff.json
8
8
  // view 读 summary.json 渲染榜单,展开某条 trace 时再按需 fetch 它的 trace.json。
9
9
 
10
- import { mkdir, writeFile } from "node:fs/promises";
10
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
- import type { EvalResult, Reporter, RunSummary } from "../../types.ts";
12
+ import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION, type EvalResult, type Reporter, type RunSummary } from "../../types.ts";
13
+
14
+ /** niceeval 自身的 npm 版本,写进 producer.version;版本不匹配时读取器靠它拼 npx 提示。 */
15
+ let producerVersionPromise: Promise<string | undefined> | undefined;
16
+ function producerVersion(): Promise<string | undefined> {
17
+ producerVersionPromise ??= readFile(new URL("../../../package.json", import.meta.url), "utf-8")
18
+ .then((raw) => (JSON.parse(raw) as { version?: string }).version)
19
+ .catch(() => undefined);
20
+ return producerVersionPromise;
21
+ }
13
22
 
14
23
  /** 一个 attempt 的工件子目录(相对 run 根):<evalId>/<agent>/<model>/a<attempt>。 */
15
24
  function attemptDir(r: EvalResult): string {
@@ -28,6 +37,11 @@ function slimResult(r: EvalResult): EvalResult {
28
37
  void trace;
29
38
  void diff;
30
39
  void rawTranscript;
40
+ // 携带结果(跨实验复用上次 pass,见 run.ts 的 carriedResults):本轮没有任何新数据,
41
+ // rest 上已经带着 artifactBase 指向旧 run 的产物目录,hasSources/hasEvents/hasTrace
42
+ // 也是从旧 summary 带过来的真值——不能因为"这轮没数据"就重新推导成 false / 编出一个
43
+ // 这轮压根没写过文件的新 artifactsDir(会让 artifactBase 在下次 withArtifactBases 时被覆盖)。
44
+ if (rest.artifactBase) return rest;
31
45
  return {
32
46
  ...rest,
33
47
  artifactsDir: attemptDir(r),
@@ -67,10 +81,17 @@ export function Artifacts(root = ".niceeval"): Reporter {
67
81
  await Promise.all(writes);
68
82
  },
69
83
 
70
- // run 结束写瘦身 summary.json(只含榜单要的字段 + 工件引用)。
84
+ // run 结束写瘦身 summary.json(版本元数据 + 榜单要的字段 + 工件引用)。
71
85
  async onRunComplete(summary) {
72
86
  await ensureDir();
73
- const slim: RunSummary = { ...summary, outputDir, results: summary.results.map(slimResult) };
87
+ const slim: RunSummary = {
88
+ format: RESULTS_FORMAT,
89
+ schemaVersion: RESULTS_SCHEMA_VERSION,
90
+ producer: { name: "niceeval", version: await producerVersion() },
91
+ ...summary,
92
+ outputDir,
93
+ results: summary.results.map(slimResult),
94
+ };
74
95
  await writeFile(join(outputDir, "summary.json"), JSON.stringify(slim, null, 2), "utf-8");
75
96
  },
76
97
  };