niceeval 0.10.3-canary.6 → 0.11.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 (122) hide show
  1. package/bin/niceeval.js +9 -4
  2. package/dist/agents/types.d.ts +5 -4
  3. package/dist/context/turn-errors.d.ts +27 -23
  4. package/dist/i18n/en.d.ts +15 -0
  5. package/dist/i18n/en.js +16 -1
  6. package/dist/i18n/zh-CN.d.ts +16 -1
  7. package/dist/i18n/zh-CN.js +16 -1
  8. package/dist/o11y/derive.js +28 -24
  9. package/dist/o11y/types.d.ts +8 -5
  10. package/dist/report/components/attempt-detail/UsageTable.js +4 -7
  11. package/dist/report/components/attempt-detail/compute.d.ts +2 -2
  12. package/dist/report/components/attempt-detail/compute.js +2 -6
  13. package/dist/report/components/attempt-detail/faces.js +7 -7
  14. package/dist/report/components/attempt-detail/index.js +0 -3
  15. package/dist/report/components/entity-lists/EvalList.js +0 -0
  16. package/dist/report/components/metric-views/compute.js +1 -1
  17. package/dist/report/model/types.d.ts +3 -4
  18. package/dist/results/locator.js +0 -0
  19. package/dist/results/select.d.ts +6 -0
  20. package/dist/results/select.js +8 -0
  21. package/dist/runner/feedback/sink.d.ts +26 -1
  22. package/dist/runner/fingerprint.d.ts +23 -0
  23. package/dist/runner/types.d.ts +105 -7
  24. package/dist/sandbox/errors.d.ts +29 -0
  25. package/dist/sandbox/resolve.d.ts +9 -0
  26. package/dist/shared/failure-class.d.ts +91 -0
  27. package/dist/types.d.ts +1 -0
  28. package/dist/util.d.ts +3 -2
  29. package/dist/util.js +31 -5
  30. package/docs-site/zh/explanation/runner.mdx +35 -0
  31. package/docs-site/zh/reference/cli.mdx +10 -1
  32. package/docs-site/zh/reference/events.mdx +4 -4
  33. package/docs-site/zh/troubleshooting/debugging.mdx +11 -0
  34. package/docs-site/zh/tutorials/viewing-results.mdx +35 -3
  35. package/package.json +28 -24
  36. package/src/agents/ai-sdk.test.ts +26 -0
  37. package/src/agents/ai-sdk.ts +7 -4
  38. package/src/agents/index.ts +5 -3
  39. package/src/agents/langgraph.test.ts +30 -0
  40. package/src/agents/langgraph.ts +5 -2
  41. package/src/agents/openai-compat.test.ts +35 -0
  42. package/src/agents/openai-compat.ts +16 -4
  43. package/src/agents/sdk-streams.test.ts +66 -0
  44. package/src/agents/sdk-streams.ts +3 -1
  45. package/src/agents/types.ts +5 -4
  46. package/src/cli.ts +81 -16
  47. package/src/context/context.test.ts +34 -0
  48. package/src/context/context.ts +14 -5
  49. package/src/context/send-retry.test.ts +86 -0
  50. package/src/context/send-retry.ts +37 -12
  51. package/src/context/session.ts +24 -0
  52. package/src/context/turn-errors.test.ts +124 -17
  53. package/src/context/turn-errors.ts +60 -50
  54. package/src/define.ts +5 -0
  55. package/src/i18n/en.ts +22 -1
  56. package/src/i18n/zh-CN.ts +22 -1
  57. package/src/index.ts +8 -0
  58. package/src/o11y/cost.ts +4 -2
  59. package/src/o11y/derive.test.ts +40 -0
  60. package/src/o11y/derive.ts +28 -22
  61. package/src/o11y/otlp/sandbox-receiver.test.ts +201 -0
  62. package/src/o11y/otlp/sandbox-receiver.ts +73 -27
  63. package/src/o11y/parsers/bub.test.ts +30 -0
  64. package/src/o11y/parsers/bub.ts +5 -2
  65. package/src/o11y/parsers/codex.test.ts +19 -0
  66. package/src/o11y/parsers/codex.ts +5 -2
  67. package/src/o11y/types.ts +8 -5
  68. package/src/report/components/attempt-detail/UsageTable.tsx +4 -6
  69. package/src/report/components/attempt-detail/attempt-components.test.tsx +5 -8
  70. package/src/report/components/attempt-detail/compute.ts +2 -7
  71. package/src/report/components/attempt-detail/faces.ts +7 -7
  72. package/src/report/components/attempt-detail/index.tsx +0 -3
  73. package/src/report/components/entity-lists/EvalList.tsx +0 -0
  74. package/src/report/components/metric-views/compute.ts +1 -1
  75. package/src/report/model/types.ts +3 -4
  76. package/src/results/format.ts +9 -2
  77. package/src/results/index.ts +2 -0
  78. package/src/results/locator.ts +0 -0
  79. package/src/results/open.ts +132 -21
  80. package/src/results/select.ts +12 -0
  81. package/src/results/skipped-notice.ts +0 -0
  82. package/src/runner/attempt.test.ts +116 -0
  83. package/src/runner/attempt.ts +89 -8
  84. package/src/runner/discover.ts +21 -3
  85. package/src/runner/feedback/coordinator.ts +24 -2
  86. package/src/runner/feedback/eval-conclusions.ts +6 -3
  87. package/src/runner/feedback/human.test.ts +300 -6
  88. package/src/runner/feedback/human.ts +132 -30
  89. package/src/runner/feedback/json.test.ts +127 -2
  90. package/src/runner/feedback/json.ts +51 -3
  91. package/src/runner/feedback/reducer.test.ts +328 -29
  92. package/src/runner/feedback/reducer.ts +84 -9
  93. package/src/runner/feedback/sink.ts +39 -1
  94. package/src/runner/fingerprint.ts +49 -19
  95. package/src/runner/gate-lease.test.ts +510 -0
  96. package/src/runner/gate-lease.ts +350 -0
  97. package/src/runner/lock.test.ts +454 -0
  98. package/src/runner/lock.ts +288 -0
  99. package/src/runner/report.test.ts +1 -0
  100. package/src/runner/run.test.ts +2044 -9
  101. package/src/runner/run.ts +825 -61
  102. package/src/runner/teardown-registry.ts +20 -78
  103. package/src/runner/types.ts +103 -7
  104. package/src/sandbox/errors.test.ts +72 -0
  105. package/src/sandbox/errors.ts +83 -0
  106. package/src/sandbox/keep-registry.ts +22 -46
  107. package/src/sandbox/resolve.test.ts +100 -0
  108. package/src/sandbox/resolve.ts +84 -27
  109. package/src/shared/entry-file-store.test.ts +149 -0
  110. package/src/shared/entry-file-store.ts +117 -0
  111. package/src/shared/failure-class.test.ts +137 -0
  112. package/src/shared/failure-class.ts +175 -0
  113. package/src/show/index.ts +5 -6
  114. package/src/show/render.test.ts +116 -15
  115. package/src/show/render.ts +124 -35
  116. package/src/types.ts +9 -0
  117. package/src/util.ts +31 -4
  118. package/src/view/app/App.tsx +5 -1
  119. package/src/view/client-dist/app.js +1 -1
  120. package/src/view/data.ts +5 -9
  121. package/src/view/shared/types.ts +7 -1
  122. package/src/view/view-report.test.ts +54 -0
@@ -421,7 +421,7 @@ export async function metricLineData(input: ReportInput, options: MetricLineOpti
421
421
  );
422
422
  }
423
423
  const series = options.series ? seriesKey(options.series, item) : undefined;
424
- const bucketKey = `${series ?? ""}${x === null ? "null" : String(x)}`;
424
+ const bucketKey = `${series ?? ""}\u0000${x === null ? "null" : String(x)}`;
425
425
  const bucket = buckets.get(bucketKey);
426
426
  if (bucket) bucket.items.push(item);
427
427
  else buckets.set(bucketKey, { series, x, items: [item] });
@@ -766,9 +766,9 @@ export interface AttemptDiagnosticsData {
766
766
  *
767
767
  * - `turns`/`toolCalls`:events 派生(与 `o11y.json` 行为摘要同源),只在有非空 events 时出现,
768
768
  * 哪怕派生值恰好是 0(有 events 但零轮/零工具调用是观测到的事实,不是缺失)。
769
- * - `usage`:落盘 `Usage` 原样,只在 `result.usage` 存在时出现。
770
- * - `uncachedInputTokens`:`usage.inputTokens − usage.cacheReadTokens`,只在两个输入都存在时派生;
771
- * 缺任一个不回退猜 0,整字段省略(text 面回退显示原始 `inputTokens`)。
769
+ * - `usage`:落盘 `Usage` 原样,只在 `result.usage` 存在时出现。桶恒互斥,`inputTokens` 本身
770
+ * 就是未缓存输入(契约见 docs/feature/results/architecture.md#usage);"uncached in" 标注只在
771
+ * `cacheReadTokens` 在场时由 face 层给出,不派生第二个字段。
772
772
  * - `estimatedCostUSD`:能算出成本(`usage.costUSD` 或 `result.estimatedCostUSD`)时才出现。
773
773
  *
774
774
  * `turns`/`toolCalls`/`usage` 三者全部缺失时(没有 events 也没有落盘 usage)整个 data 为
@@ -783,7 +783,6 @@ export interface UsageTableData {
783
783
  turns?: number;
784
784
  toolCalls?: number;
785
785
  usage?: Usage;
786
- uncachedInputTokens?: number;
787
786
  estimatedCostUSD?: number;
788
787
  }
789
788
 
@@ -13,10 +13,17 @@ export const RESULT_FILE = "result.json";
13
13
  /** 快照元数据文件名。 */
14
14
  export const SNAPSHOT_FILE = "snapshot.json";
15
15
 
16
+ /** eval 目录(相对快照根):evalId 里的 / 保留作目录层级,其余危险字符替换。 */
17
+ export function evalDirOf(evalId: string): string {
18
+ return evalId.replace(/[^\w./@-]/g, "_");
19
+ }
20
+
21
+ /** attempt 目录名前缀:`a<attempt>`,同一 eval 目录下按序号并列。 */
22
+ export const ATTEMPT_DIR_PREFIX = "a";
23
+
16
24
  /** attempt 目录(相对快照根):`<evalId>/a<attempt>`;evalId 里的 / 保留作目录层级,其余危险字符替换。 */
17
25
  export function attemptDirOf(r: Pick<EvalResult, "id" | "attempt">): string {
18
- const id = r.id.replace(/[^\w./@-]/g, "_");
19
- return `${id}/a${r.attempt}`;
26
+ return `${evalDirOf(r.id)}/${ATTEMPT_DIR_PREFIX}${r.attempt}`;
20
27
  }
21
28
 
22
29
  /** 实验目录名:experimentId 里的 / 与其它非 [\w.@-] 字符替换成 _。 */
@@ -9,6 +9,8 @@
9
9
 
10
10
  export {
11
11
  openResults,
12
+ loadLatestResultsForCase,
13
+ withArtifactBase,
12
14
  experimentOfSnapshot,
13
15
  resolveLocator,
14
16
  LocatorNotFoundError,
Binary file
@@ -12,8 +12,16 @@ import type { EvalResult } from "../types.ts";
12
12
  import type { FailedCommandEvidence, O11ySummary, StreamEvent, TraceSpan } from "../types.ts";
13
13
  import type { AgentSetupManifest, DiffData, SourceArtifact } from "../types.ts";
14
14
  import { deriveDiffData } from "../scoring/diff.ts";
15
- import { RESULT_FILE, SNAPSHOT_FILE, artifactFileOf, classifySnapshot } from "./format.ts";
16
- import { isNewerSnapshot, selectCurrentResults, selectLatest } from "./select.ts";
15
+ import {
16
+ ATTEMPT_DIR_PREFIX,
17
+ RESULT_FILE,
18
+ SNAPSHOT_FILE,
19
+ artifactFileOf,
20
+ classifySnapshot,
21
+ evalDirOf,
22
+ experimentDirOf,
23
+ } from "./format.ts";
24
+ import { isNewerSnapshot, isNewerSnapshotPlacement, selectCurrentResults, selectLatest } from "./select.ts";
17
25
  import {
18
26
  encodeAttemptLocator,
19
27
  resolveAttemptLocator,
@@ -267,9 +275,9 @@ async function handleMetaFile(path: string, state: ScanState): Promise<void> {
267
275
 
268
276
  // ───────────────────────── 快照读取 ─────────────────────────
269
277
 
270
- /** 快照目录:递归收集全部 result.json,组装成 evals / attempts;单个 result.json 坏 JSON 不拖垮快照。 */
271
- async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState): Promise<Snapshot> {
272
- const snapshot: Snapshot = {
278
+ /** snapshot.json 的元数据 → 空壳 Snapshot(evals / attempts 待填);全量扫描与收窄读共用。 */
279
+ function makeSnapshotShell(dir: string, meta: SnapshotMeta): Snapshot {
280
+ return {
273
281
  experimentId: meta.experimentId,
274
282
  startedAt: meta.startedAt,
275
283
  ...(meta.completedAt !== undefined ? { completedAt: meta.completedAt } : {}),
@@ -286,6 +294,124 @@ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState
286
294
  dir,
287
295
  ...(meta.knownEvalIds?.length ? { knownEvalIds: [...meta.knownEvalIds] } : {}),
288
296
  };
297
+ }
298
+
299
+ /**
300
+ * 快照级字段拼合:「缺才补」,条目自带的值(携带条目的 startedAt)优先。全量扫描与收窄读
301
+ * 共用这一份——收窄读若自己拼一遍,携入的条目会缺 experimentId / locator 一类字段。
302
+ */
303
+ function applySnapshotDefaults(record: EvalResult, meta: SnapshotMeta): void {
304
+ record.experimentId ??= meta.experimentId;
305
+ record.agent ??= meta.agent;
306
+ if (record.model === undefined && meta.model !== undefined) record.model = meta.model;
307
+ record.startedAt ??= meta.startedAt;
308
+ if (record.experiment === undefined && meta.experiment !== undefined) record.experiment = meta.experiment;
309
+ // locator 同理「缺才补」:niceeval 自己的 writer(schemaVersion 5 起)恒会写这个字段,
310
+ // 携带条目原样携带上一轮的值——只有真缺失(第三方 harness 没实现 locator,或手工构造的
311
+ // 落盘)才按当前身份兜底算一份;这份兜底不保证跨未来的 --resume 稳定,但至少确定性、
312
+ // 可解析,不比完全没有 locator 差。
313
+ record.locator ??= encodeAttemptLocator({
314
+ experimentId: record.experimentId,
315
+ snapshotStartedAt: meta.startedAt,
316
+ evalId: record.id,
317
+ attempt: record.attempt,
318
+ });
319
+ }
320
+
321
+ /**
322
+ * 携带条目 / 新算的 artifactBase(相对结果根):条目自带值优先(`--resume` 携入的条目指向
323
+ * 原快照),缺则按所在 attempt 的 ref 现拼。携带条目要能被 view 找回 artifact,少了这一步
324
+ * 报告里的携带行会静默丢 artifact——所以全量扫描(view/data.ts)与收窄读共用这一份公式。
325
+ */
326
+ export function withArtifactBase(attempt: AttemptHandle): EvalResult {
327
+ const r = attempt.result;
328
+ if (r.artifactBase !== undefined) return r;
329
+ return { ...r, artifactBase: `${attempt.ref.snapshot}/${attempt.ref.attempt}` };
330
+ }
331
+
332
+ /**
333
+ * 收窄读:只取某条 `(experimentId, evalId)` 的「当前可用」attempt,不扫全根。
334
+ *
335
+ * 口径与 `loadLatestResultsPerEval` 完全一致——整批取自**含它的最新快照**,不跨快照混装;
336
+ * 「哪份最新」走 `isNewerSnapshotPlacement`(snapshot.json 的 `startedAt` 优先、同刻按目录名),
337
+ * 目录名倒序只作为候选遍历顺序,不作判据。**不检查快照有没有 `completedAt`**:被中断或强杀的
338
+ * 未收尾快照里已落盘的终态照常可读(docs/runner.md「缓存:指纹去重」的「携带来源不要求快照收尾」)。
339
+ *
340
+ * 代价与该实验的历史快照数近似无关:没跑过这条 eval 的快照只付一次 readdir,不读任何文件;
341
+ * 只有命中的候选才读 `snapshot.json`。派发路径上的携带重查用它替代全树扫描
342
+ * (`openResults` 会读并 parse 全根每一个 `result.json`)。
343
+ */
344
+ export async function loadLatestResultsForCase(
345
+ root: string,
346
+ experimentId: string,
347
+ evalId: string,
348
+ ): Promise<EvalResult[]> {
349
+ const experimentDir = join(resolve(root), experimentDirOf(experimentId));
350
+ let snapshotDirNames: string[];
351
+ try {
352
+ snapshotDirNames = (await readdir(experimentDir, { withFileTypes: true }))
353
+ .filter((e) => e.isDirectory())
354
+ .map((e) => e.name);
355
+ } catch {
356
+ return []; // 这个实验还没跑过:不是错误
357
+ }
358
+ // 目录名带时间戳前缀,倒序 = 最可能命中的先试;权威判定仍在下面的 isNewerSnapshotPlacement。
359
+ snapshotDirNames.sort((a, b) => b.localeCompare(a));
360
+ const evalSegment = evalDirOf(evalId);
361
+
362
+ let best: { dir: string; meta: SnapshotMeta; attemptDirNames: string[] } | undefined;
363
+ for (const name of snapshotDirNames) {
364
+ const snapshotDir = join(experimentDir, name);
365
+ let attemptDirNames: string[];
366
+ try {
367
+ attemptDirNames = (await readdir(join(snapshotDir, evalSegment), { withFileTypes: true }))
368
+ .filter((e) => e.isDirectory() && ATTEMPT_DIR_RE.test(e.name))
369
+ .map((e) => e.name);
370
+ } catch {
371
+ continue; // 这份快照没跑过这条 eval:只付一次 readdir
372
+ }
373
+ if (attemptDirNames.length === 0) continue;
374
+ let meta: SnapshotMeta;
375
+ try {
376
+ const classified = classifySnapshot(JSON.parse(await readFile(join(snapshotDir, SNAPSHOT_FILE), "utf-8")));
377
+ if (classified.kind !== "ok") continue; // 坏 / 不兼容版本的快照:跳过,不拖垮这次读
378
+ meta = classified.meta;
379
+ } catch {
380
+ continue;
381
+ }
382
+ if (best === undefined || isNewerSnapshotPlacement({ startedAt: meta.startedAt, dir: snapshotDir }, { startedAt: best.meta.startedAt, dir: best.dir })) {
383
+ best = { dir: snapshotDir, meta, attemptDirNames };
384
+ }
385
+ }
386
+ if (best === undefined) return [];
387
+
388
+ const snapshot = makeSnapshotShell(best.dir, best.meta);
389
+ const out: EvalResult[] = [];
390
+ for (const attemptDirName of best.attemptDirNames.sort(byAttemptIndex)) {
391
+ const attemptDir = join(best.dir, evalSegment, attemptDirName);
392
+ let record: EvalResult;
393
+ try {
394
+ record = JSON.parse(await readFile(join(attemptDir, RESULT_FILE), "utf-8")) as EvalResult;
395
+ } catch {
396
+ continue; // 缺 result.json / 坏 JSON:如实跳过(与 openResults 的 skipped 同一「不拖垮整次读」精神)
397
+ }
398
+ // 目录名是被清洗过的 evalId,两条不同的 id 理论上可以洗成同一段;记录自报的 id 才是权威。
399
+ if (record.id !== evalId) continue;
400
+ applySnapshotDefaults(record, best.meta);
401
+ out.push(withArtifactBase(makeAttempt(snapshot, best.dir, attemptDir, record)));
402
+ }
403
+ return out;
404
+ }
405
+
406
+ const ATTEMPT_DIR_RE = new RegExp(`^${ATTEMPT_DIR_PREFIX}\\d+$`);
407
+
408
+ function byAttemptIndex(a: string, b: string): number {
409
+ return Number(a.slice(ATTEMPT_DIR_PREFIX.length)) - Number(b.slice(ATTEMPT_DIR_PREFIX.length));
410
+ }
411
+
412
+ /** 快照目录:递归收集全部 result.json,组装成 evals / attempts;单个 result.json 坏 JSON 不拖垮快照。 */
413
+ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState): Promise<Snapshot> {
414
+ const snapshot = makeSnapshotShell(dir, meta);
289
415
 
290
416
  const resultPaths = (await findResultFiles(dir)).sort();
291
417
  const evalsById = new Map<string, Eval>();
@@ -301,22 +427,7 @@ async function readSnapshotDir(dir: string, meta: SnapshotMeta, state: ScanState
301
427
  continue;
302
428
  }
303
429
 
304
- // 快照级字段拼合:「缺才补」,条目自带的值(携带条目的 startedAt)优先。
305
- record.experimentId ??= meta.experimentId;
306
- record.agent ??= meta.agent;
307
- if (record.model === undefined && meta.model !== undefined) record.model = meta.model;
308
- record.startedAt ??= meta.startedAt;
309
- if (record.experiment === undefined && meta.experiment !== undefined) record.experiment = meta.experiment;
310
- // locator 同理「缺才补」:niceeval 自己的 writer(schemaVersion 5 起)恒会写这个字段,
311
- // 携带条目原样携带上一轮的值——只有真缺失(第三方 harness 没实现 locator,或手工构造的
312
- // 落盘)才按当前身份兜底算一份;这份兜底不保证跨未来的 --resume 稳定,但至少确定性、
313
- // 可解析,不比完全没有 locator 差。
314
- record.locator ??= encodeAttemptLocator({
315
- experimentId: record.experimentId,
316
- snapshotStartedAt: meta.startedAt,
317
- evalId: record.id,
318
- attempt: record.attempt,
319
- });
430
+ applySnapshotDefaults(record, meta);
320
431
 
321
432
  const attempt = makeAttempt(snapshot, dir, attemptDir, record);
322
433
  let ev = evalsById.get(record.id);
@@ -385,6 +385,18 @@ export function dedupeAttempts(attempts: AttemptHandle[]): { attempts: AttemptHa
385
385
 
386
386
  /** 快照新旧比较:startedAt 优先,同刻按快照目录名(时间戳 + 随机后缀,字典序即时序)。 */
387
387
  export function isNewerSnapshot(a: Snapshot, b: Snapshot): boolean {
388
+ return isNewerSnapshotPlacement(a, b);
389
+ }
390
+
391
+ /**
392
+ * isNewerSnapshot 的原始口径,供还没组装成 Snapshot 的读取面共用同一判定(收窄读
393
+ * `loadLatestResultsForCase` 只拿到目录名 + snapshot.json 的 startedAt)。同一份磁盘在两条
394
+ * 读取面上必须给出同一个「哪份最新」的答案,否则携带与报告会分叉。
395
+ */
396
+ export function isNewerSnapshotPlacement(
397
+ a: Pick<Snapshot, "startedAt" | "dir">,
398
+ b: Pick<Snapshot, "startedAt" | "dir">,
399
+ ): boolean {
388
400
  const byStart = a.startedAt.localeCompare(b.startedAt);
389
401
  if (byStart !== 0) return byStart > 0;
390
402
  return a.dir.localeCompare(b.dir) > 0;
Binary file
@@ -20,6 +20,7 @@
20
20
  import { describe, expect, it, vi } from "vitest";
21
21
  import { Effect } from "effect";
22
22
  import { runAttemptEffect } from "./attempt.ts";
23
+ import { activateFeedbackSink, type DiagnosticInput, type FeedbackSink } from "./feedback/sink.ts";
23
24
  import { defineSandboxAgent, defineSandbox } from "../define.ts";
24
25
  import { writeAgentSetupManifest, AGENT_SETUP_MANIFEST_PATH } from "../agents/manifest.ts";
25
26
  import { equals } from "../expect/index.ts";
@@ -921,3 +922,118 @@ describe("runAttemptEffect · 失败命令证据包装(公开 runCommand/runShel
921
922
  expect(result.commands).toBeUndefined();
922
923
  });
923
924
  });
925
+
926
+ // cases: docs/engineering/testing/unit/experiments-runner.md「attempt 级诊断的对外词法与阶段标注」
927
+ // bug: memory/diagnostic-key-doubles-as-json-warning-code.md
928
+ describe("runAttemptEffect · attempt 级诊断进反馈流的 code 与 phase", () => {
929
+ /** 捕获 reportDiagnostic 的入参:其余 sink 方法一律 no-op,这里只关心诊断这一条通路。 */
930
+ function captureDiagnostics(): { seen: DiagnosticInput[]; deactivate: () => void } {
931
+ const seen: DiagnosticInput[] = [];
932
+ const noop = () => {};
933
+ const sink: FeedbackSink = {
934
+ activity: noop,
935
+ diagnostic: (input) => {
936
+ seen.push(input);
937
+ },
938
+ interrupted: noop,
939
+ reporterError: noop,
940
+ failure: noop,
941
+ budgetExhausted: noop,
942
+ kept: noop,
943
+ experimentHook: noop,
944
+ precheck: noop,
945
+ lockWait: noop,
946
+ experimentProgress: noop,
947
+ lifecycle: noop,
948
+ };
949
+ return { seen, deactivate: activateFeedbackSink(sink) };
950
+ }
951
+
952
+ it("作者不传 dedupeKey:折叠 key 编进 attempt 身份,code 仍是作者给的干净字面量,phase 是报上来那一刻的阶段", async () => {
953
+ const agent = defineSandboxAgent({
954
+ name: "fake-agent-diagnostic-code",
955
+ send: async () => ({ events: [], status: "completed" }),
956
+ });
957
+ const { seen, deactivate } = captureDiagnostics();
958
+ try {
959
+ await runOnce(agent, new FakeSandbox(), {
960
+ experimentId: "compare/codex",
961
+ evalDefOverrides: {
962
+ setup: async (_sandbox, ctx) => {
963
+ ctx.diagnostic({
964
+ code: "memory-warmup-degraded",
965
+ level: "warning",
966
+ message: "Memory warmup failed; continuing with a cold index",
967
+ });
968
+ },
969
+ },
970
+ });
971
+ } finally {
972
+ deactivate();
973
+ }
974
+
975
+ expect(seen).toHaveLength(1);
976
+ const [forwarded] = seen;
977
+ expect(forwarded!.code).toBe("memory-warmup-degraded");
978
+ expect(forwarded!.key).toBe("memory-warmup-degraded:compare/codex|fake/eval|0");
979
+ expect(forwarded!.data?.phase).toBe("eval.setup");
980
+ expect(forwarded!.identity).toEqual({ experimentId: "compare/codex", evalId: "fake/eval", attempt: 0 });
981
+ });
982
+
983
+ it("作者传了 dedupeKey:折叠按作者的 key,code 与 phase 不受影响", async () => {
984
+ const agent = defineSandboxAgent({
985
+ name: "fake-agent-diagnostic-dedupe-key",
986
+ send: async () => ({ events: [], status: "completed" }),
987
+ });
988
+ const { seen, deactivate } = captureDiagnostics();
989
+ try {
990
+ await runOnce(agent, new FakeSandbox(), {
991
+ experimentId: "compare/codex",
992
+ evalDefOverrides: {
993
+ test: async (t) => {
994
+ t.diagnostic({
995
+ code: "index-rebuilt",
996
+ level: "warning",
997
+ message: "rebuilt the index",
998
+ dedupeKey: "index-rebuilt:compare/codex",
999
+ });
1000
+ },
1001
+ },
1002
+ });
1003
+ } finally {
1004
+ deactivate();
1005
+ }
1006
+
1007
+ expect(seen).toHaveLength(1);
1008
+ expect(seen[0]!.key).toBe("index-rebuilt:compare/codex");
1009
+ expect(seen[0]!.code).toBe("index-rebuilt");
1010
+ expect(seen[0]!.data?.phase).toBe("eval.run");
1011
+ });
1012
+
1013
+ it("作者 data 里冒充的 phase 被运行器的实际阶段压过,其余 data 字段原样保留", async () => {
1014
+ const agent = defineSandboxAgent({
1015
+ name: "fake-agent-diagnostic-forged-phase",
1016
+ send: async () => ({ events: [], status: "completed" }),
1017
+ });
1018
+ const { seen, deactivate } = captureDiagnostics();
1019
+ try {
1020
+ await runOnce(agent, new FakeSandbox(), {
1021
+ evalDefOverrides: {
1022
+ setup: async (_sandbox, ctx) => {
1023
+ ctx.diagnostic({
1024
+ code: "warmup-degraded",
1025
+ level: "warning",
1026
+ message: "cold index",
1027
+ data: { phase: "scoring.evaluate", indexAgeDays: 12 },
1028
+ });
1029
+ },
1030
+ },
1031
+ });
1032
+ } finally {
1033
+ deactivate();
1034
+ }
1035
+
1036
+ expect(seen).toHaveLength(1);
1037
+ expect(seen[0]!.data).toEqual({ phase: "eval.setup", indexAgeDays: 12 });
1038
+ });
1039
+ });
@@ -30,6 +30,11 @@ import { createChangeLedger, type ChangeLedger } from "./ledger.ts";
30
30
  import { deriveDiffData, emptyDiffData } from "../scoring/diff.ts";
31
31
  import { createRemoteSandbox, withEvalLocalPaths } from "./remote-sandbox.ts";
32
32
  import { recordFact, type FactValue } from "../shared/facts.ts";
33
+ import {
34
+ attemptFailureInfo,
35
+ resolveAttemptFailureClass,
36
+ type FailureClass,
37
+ } from "../shared/failure-class.ts";
33
38
  import type { CapturedEvalSource } from "./eval-source.ts";
34
39
  import type {
35
40
  AgentContext,
@@ -66,6 +71,18 @@ import type {
66
71
  RunOptions,
67
72
  } from "./types.ts";
68
73
 
74
+ /**
75
+ * 一次终局失败的空间轴回执:止损闸的消费点(见 docs/feature/error-classification/architecture.md
76
+ * 「止损执行体」)。`class` 是分类链决议出的 `FailureClass`,`text` 与报错文案同源(作者的修复
77
+ * 提示),`phase` 是失败所在的生命周期阶段。回执是旁路:不改 verdict、不改 `AttemptError` 形状,
78
+ * 也不走错误通道传播(attempt fiber 的 `E` 仍恒为 `never`)。
79
+ */
80
+ export interface AttemptFailureDeclaration {
81
+ readonly class: FailureClass;
82
+ readonly phase: LifecyclePhase;
83
+ readonly text: string;
84
+ }
85
+
69
86
  export interface RunAttemptEffectOptions {
70
87
  /** 父级调度器的中断信号;测试直调时省略。 */
71
88
  parentSignal?: AbortSignal;
@@ -79,13 +96,19 @@ export interface RunAttemptEffectOptions {
79
96
  * 省略时(如测试直调)退避不释放槽位。
80
97
  */
81
98
  concurrencySlot?: ConcurrencySlot;
99
+ /**
100
+ * 终局失败的空间轴回执(每条终局失败各回调一次,含 per-attempt teardown 的失败——teardown
101
+ * 声明照常落闸、不改 verdict,见 architecture.md「生命周期边界」)。省略(如测试直调)时
102
+ * 分类照常决议、只是无人消费。回调必须不抛错:它跑在 attempt 的失败路径上,不得掩盖原始失败。
103
+ */
104
+ onFailureClass?: (declaration: AttemptFailureDeclaration) => void;
82
105
  }
83
106
 
84
107
  export function runAttemptEffect(
85
108
  a: Attempt,
86
109
  opts: RunOptions,
87
110
  sandboxSem: Effect.Semaphore,
88
- { parentSignal, onPhase, concurrencySlot }: RunAttemptEffectOptions = {},
111
+ { parentSignal, onPhase, concurrencySlot, onFailureClass }: RunAttemptEffectOptions = {},
89
112
  ): Effect.Effect<EvalResult> {
90
113
  const config = opts.config;
91
114
  const { evalDef, run, attempt } = a;
@@ -155,6 +178,21 @@ export function runAttemptEffect(
155
178
  onPhase?.(phase);
156
179
  reportAttemptLifecycle({ type: "attempt:phase", at: Date.now(), identity, phase });
157
180
  };
181
+ /**
182
+ * 终局失败的分类与回执 —— **必须在把错误折成纯数据 `AttemptError` 之前调**:折完之后原错误
183
+ * 对象(连同它携带的 `_tag`/`class` 与 cause 链)就不再是任何人的引用,空间轴声明再也读不出来。
184
+ * 走生命周期链的三道决议(抛出点声明 → 实验分类器 → 缺省不可重试,见
185
+ * src/shared/failure-class.ts 的 `resolveAttemptFailureClass`);turn 失败的时间轴已由 send 重试
186
+ * 执行体在 context 层消费完,浮出到这里的一定是终局失败,这里只读空间轴。
187
+ * 缺省档(`scope` 省略或 `"attempt"`)不回执:那是「死因只属于本次执行」,没有闸可落。
188
+ */
189
+ const declareFailure = (phase: LifecyclePhase, e: unknown): void => {
190
+ if (!onFailureClass) return;
191
+ const info = attemptFailureInfo(phase, e);
192
+ const cls = resolveAttemptFailureClass(info, run.classifyFailure);
193
+ if (cls.scope !== "eval" && cls.scope !== "experiment") return;
194
+ onFailureClass({ class: cls, phase, text: info.text });
195
+ };
158
196
  // 本 attempt 累计的运行事实(与 verdict/diagnostics 独立):sandbox hook / eval.setup·teardown /
159
197
  // agent setup·send·teardown 经 ctx.fact() 上报的都落这里(同一 attempt 内后写覆盖先写),
160
198
  // 收尾时并入结果的 facts 字段(见 finally 末尾,与 diagnostics 同一种「累加器 + finally 并入」模式)。
@@ -188,12 +226,21 @@ export function runAttemptEffect(
188
226
  if (input.dedupeKey !== undefined) dedupeIndex.set(input.dedupeKey, record);
189
227
  diagnostics.push(record);
190
228
  // 同时进运行级永久事件流(human 撤下 dashboard 后追加、agent/ci 各追加一条,去重按 key)。
229
+ // `key` 只管折叠到多细(作者没给 dedupeKey 时折到「这一条 attempt 的这种诊断」,身份因此
230
+ // 编进去);对外稳定词法始终单独给 `code`,不让消费方从 key 反推(见 sink.ts 的
231
+ // DiagnosticInput.code、docs/feature/experiments/cli.md 的 WarningEvent)。
191
232
  reportDiagnostic({
192
233
  key: input.dedupeKey ?? `${input.code}:${encodeAttemptKey(identity)}`,
234
+ code: input.code,
193
235
  severity: input.level,
194
236
  message: input.message,
195
237
  identity,
196
- data: input.data,
238
+ // phase 由运行器给,且**压过**作者 `data` 里的同名字段:`WarningEvent.phase` 是
239
+ // `LifecyclePhase` 闭集,取值只能由「这条诊断报上来时运行器正处在哪一步」决定。
240
+ // 作者的 `data` 是开放词表,让它盖住这个字段等于允许从 eval / adapter 代码里冒充一个
241
+ // 别的(甚至不在闭集里的)阶段,消费方按 phase 分支就此失效——与 ScopedFeedback
242
+ // 「两个方法都不接受 phase」是同一条纪律(见 src/shared/types.ts 的接口注释)。
243
+ data: { ...input.data, phase },
197
244
  });
198
245
  };
199
246
  // 作用域反馈:progress 走 attempt:progress(短命状态,归因由 runner 的当前阶段决定),
@@ -355,7 +402,12 @@ export function runAttemptEffect(
355
402
  const proto = run.agent.tracing?.protocol;
356
403
  log(t("runner.otlpReceiver", { endpoint, proto: proto ? ` (${proto})` : "" }));
357
404
  } else {
358
- // 远程沙箱(e2b / vercel):在沙箱内起 collector,agent 往 localhost 端口发
405
+ // 远程沙箱(e2b / vercel):在沙箱内起 collector,agent 往 localhost 端口发。
406
+ // 这一步是「创建本次 tracing 出口」(telemetry.configure 的定义,见
407
+ // docs/feature/experiments/cli.md 的阶段表),而且是唯一一条要往沙箱里传脚本、
408
+ // 起进程、等端口的 tracing 出口——秒级耗时与失败都归它自己,不能顺着上一个还开着的
409
+ // 阶段记成 sandbox.create(那样 collector 起不来会伪装成「起沙箱失败」)。
410
+ enterPhase("telemetry.configure");
359
411
  receiver = yield* createInSandboxTraceReceiver(sandbox);
360
412
  const endpoint = receiver.endpoint("");
361
413
  const env = run.agent.tracing?.env?.(endpoint);
@@ -429,6 +481,7 @@ export function runAttemptEffect(
429
481
  facts,
430
482
  commands,
431
483
  concurrencySlot,
484
+ declareFailure,
432
485
  registerEvidence: (getEvents, getUsage) => {
433
486
  liveEvents = getEvents;
434
487
  liveUsage = getUsage;
@@ -543,11 +596,18 @@ export function runAttemptEffect(
543
596
  Effect.catchAllCause((cause) =>
544
597
  Cause.isInterrupted(cause)
545
598
  ? Effect.failCause(cause)
546
- : Effect.succeed({
547
- ...base,
548
- durationMs: Date.now() - t0,
549
- error: errorFromThrown(Cause.squash(cause), sendActive ? "agent.run" : lastPhase),
550
- ...(commands.length > 0 ? { commands: [...commands] } : {}),
599
+ : Effect.suspend(() => {
600
+ // 资源获取 / Scope 层的意外(起沙箱失败、provisioning 的确定性配置死因)同样是终局
601
+ // 失败:先读空间轴回执,再折成纯数据 AttemptError(顺序不可换,见 declareFailure)。
602
+ const raw = Cause.squash(cause);
603
+ const phase = (sendActive ? "agent.run" : lastPhase) ?? "eval.run";
604
+ declareFailure(phase, raw);
605
+ return Effect.succeed({
606
+ ...base,
607
+ durationMs: Date.now() - t0,
608
+ error: errorFromThrown(raw, sendActive ? "agent.run" : lastPhase),
609
+ ...(commands.length > 0 ? { commands: [...commands] } : {}),
610
+ });
551
611
  }),
552
612
  ),
553
613
  // 结果封口在 Scope release 完成之后:sandbox.stop 已由 finalizer 写进 recorder,
@@ -624,6 +684,9 @@ interface AttemptResources {
624
684
  commands: FailedCommandEvidence[];
625
685
  /** turn 级重试退避期间释放/收回的全局并发槽位;透传给 createEvalContext。 */
626
686
  concurrencySlot?: ConcurrencySlot;
687
+ /** 终局失败的空间轴回执(runAttemptEffect 持有的同一个闭包):body 的失败路径与 finally 里的
688
+ * per-attempt teardown 失败都经它上报,止损闸据此落闸(见 runAttemptEffect 的 declareFailure)。 */
689
+ declareFailure: (phase: LifecyclePhase, e: unknown) => void;
627
690
  /** SessionManager 一建好就登记事件/用量的读取句柄回外层(超时证据保全用,见
628
691
  * runAttemptEffect 顶部 liveEvents/liveUsage 的注释与 docs/runner.md「超时:双层保护」)。 */
629
692
  registerEvidence: (getEvents: () => readonly StreamEvent[], getUsage: () => Usage) => void;
@@ -660,6 +723,7 @@ async function runAttemptBody(
660
723
  facts,
661
724
  commands,
662
725
  concurrencySlot,
726
+ declareFailure,
663
727
  registerEvidence,
664
728
  registerLedger,
665
729
  } = res;
@@ -826,6 +890,10 @@ async function runAttemptBody(
826
890
  feedback,
827
891
  fact,
828
892
  concurrencySlot,
893
+ // 实验分类器:turn 链上排在 adapter 的 classifyTurnError 之前(见
894
+ // docs/feature/error-classification/architecture.md「分类链」)。与本文件 declareFailure
895
+ // 走的生命周期链是同一个函数,两条链的决议序各自单源在 turn-errors.ts / failure-class.ts。
896
+ experimentClassifier: run.classifyFailure,
829
897
  // 题型:计分制下句柄上的 .gate() 是前置(就地求值 + 挂了中止 test()),见 collector。
830
898
  scoring: evalDef.scoring ?? "pass",
831
899
  // 前置断言就地求值要看当前已提交窗口的 agent diff(非沙箱型没有分类账,省略)。
@@ -876,10 +944,15 @@ async function runAttemptBody(
876
944
  // TurnFailed 是 eval 驱动 agent 时的可读失败(message 首行是一层摘要,后续行是
877
945
  // diagnose 的 output tail 等下钻证据,单行面各自取首行);稳定 code
878
946
  // `turn-failed`,不带控制流 stack(那指向 control-flow.ts,对定位无益)。
947
+ // 分类先读:turn 链决议出的 FailureClass 由 expectOk() 铸造 TurnFailed 时挂在错误上,
948
+ // 下一行折成纯数据后就读不到了(见 declareFailure)。
949
+ declareFailure(getPhase() ?? "eval.run", e);
879
950
  error = { code: "turn-failed", message: e.message, phase: getPhase() ?? "eval.run" };
880
951
  } else {
881
952
  // eval 脚本(比如引用了已改名/删掉的 API)抛出的 TypeError:message 是一层原因,完整 stack
882
953
  // 单独进 `error.stack`,niceeval show 展开时才看得到 eval 文件的 file:line。
954
+ // 作者从 test(t) 体内抛的 ExperimentFatalError / EvalFatalError 也走这条分支。
955
+ declareFailure(getPhase() ?? "eval.run", e);
883
956
  error = errorFromThrown(e, getPhase());
884
957
  }
885
958
  }
@@ -1043,6 +1116,9 @@ async function runAttemptBody(
1043
1116
  return value;
1044
1117
  } catch (e) {
1045
1118
  recorder.failCurrent();
1119
+ // sandbox 钩子 / agent.setup / eval.setup / 评分链路抛出的终局失败:同样先读空间轴回执,
1120
+ // 再折成纯数据 AttemptError(顺序不可换,见 declareFailure)。
1121
+ declareFailure(getPhase() ?? "eval.run", e);
1046
1122
  const value: EvalResult = {
1047
1123
  ...base,
1048
1124
  durationMs: Date.now() - t0,
@@ -1069,6 +1145,9 @@ async function runAttemptBody(
1069
1145
  await withCleanupTimeout(() => evalDef.teardown!(withEvalLocalPaths(sandbox, evalDef.baseDir), hookCtx));
1070
1146
  } catch (e) {
1071
1147
  // 收尾失败只是 diagnostic,不改判定 —— 挂到 attempt.diagnostics(见 finally 末尾并入)。
1148
+ // 但空间轴声明照常落闸:知识就是知识,兄弟 attempt 还在派发中(architecture.md
1149
+ // 「生命周期边界」)。落闸不改 verdict,两件事互不影响。
1150
+ declareFailure("eval.teardown", e);
1072
1151
  diagnostics.push(teardownDiagnostic("eval.teardown", e));
1073
1152
  throw e; // 让 measureClosing 把这段标 failed
1074
1153
  }
@@ -1082,6 +1161,7 @@ async function runAttemptBody(
1082
1161
  try {
1083
1162
  await withCleanupTimeout(() => run.agent.teardown!(sandbox, attemptCtx));
1084
1163
  } catch (e) {
1164
+ declareFailure("agent.teardown", e);
1085
1165
  diagnostics.push(teardownDiagnostic("agent.teardown", e));
1086
1166
  throw e;
1087
1167
  }
@@ -1101,6 +1181,7 @@ async function runAttemptBody(
1101
1181
  try {
1102
1182
  await withCleanupTimeout(() => sandboxTeardownHooks[i](sandbox, hookCtx));
1103
1183
  } catch (e) {
1184
+ declareFailure("sandbox.teardown", e);
1104
1185
  diagnostics.push(teardownDiagnostic("sandbox.teardown", e));
1105
1186
  }
1106
1187
  }
@@ -12,6 +12,24 @@ import type { DiscoveredEval, DiscoveredExperiment, EvalDef, ExperimentDef } fro
12
12
 
13
13
  const SKIP_DIRS = new Set(["node_modules", ".git", ".niceeval", "dist", ".next"]);
14
14
 
15
+ /**
16
+ * 发现阶段的动态 import 会执行被加载文件的**顶层代码**(配置文件里现拉 registry、读 .env、
17
+ * 连服务都很常见)。裸抛出去的话用户只看到一个不知从何而来的 `TypeError: fetch failed`——
18
+ * 发现要遍历整棵 `evals/` / `experiments/` 树,一个文件炸了并不会告诉你是哪一个。
19
+ * 这里把文件路径钉进 message,原错误挂 `cause`(`formatThrown` 会展开成 `caused by:` 链)。
20
+ */
21
+ async function importDiscovered<T>(file: string, root: string, kind: "eval" | "experiment"): Promise<T> {
22
+ try {
23
+ return (await import(pathToFileURL(file).href)) as T;
24
+ } catch (e) {
25
+ throw new Error(
26
+ `Failed to load ${kind} file ${relative(root, file)}: its top-level code threw while being imported. ` +
27
+ `Fix the error below, or move the work into the ${kind} body so it only runs when this ${kind} is selected.`,
28
+ { cause: e },
29
+ );
30
+ }
31
+ }
32
+
15
33
  async function walkFiles(dir: string, match: (name: string) => boolean): Promise<string[]> {
16
34
  const out: string[] = [];
17
35
  async function walk(current: string): Promise<void> {
@@ -40,9 +58,9 @@ export async function discoverEvals(root: string): Promise<DiscoveredEval[]> {
40
58
  const files = (await walkFiles(dir, (n) => n.endsWith(".eval.ts") || n.endsWith(".eval.tsx"))).sort();
41
59
  const out: DiscoveredEval[] = [];
42
60
  for (const file of files) {
43
- const mod = (await import(pathToFileURL(file).href)) as {
61
+ const mod = await importDiscovered<{
44
62
  default?: EvalDef | EvalDef[] | Record<string, EvalDef>;
45
- };
63
+ }>(file, root, "eval");
46
64
  const def = mod.default;
47
65
  if (!def) continue;
48
66
  const baseId = relative(dir, file).replace(/\.eval\.tsx?$/, "").split(sep).join("/");
@@ -107,7 +125,7 @@ export async function discoverExperiments(root: string): Promise<DiscoveredExper
107
125
  const files = (await walkFiles(dir, (n) => n.endsWith(".ts") && !n.endsWith(".d.ts"))).sort();
108
126
  const out: DiscoveredExperiment[] = [];
109
127
  for (const file of files) {
110
- const mod = (await import(pathToFileURL(file).href)) as { default?: ExperimentDef };
128
+ const mod = await importDiscovered<{ default?: ExperimentDef }>(file, root, "experiment");
111
129
  const def = mod.default;
112
130
  if (!def || !def.agent) continue;
113
131
  const id = relative(dir, file)