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
package/src/cli.ts CHANGED
@@ -7,8 +7,8 @@
7
7
  import { spawn } from "node:child_process";
8
8
  import { hostname } from "node:os";
9
9
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
10
- import { existsSync } from "node:fs";
11
- import { join, relative, resolve as resolvePath } from "node:path";
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { dirname, join, relative, resolve as resolvePath } from "node:path";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { parseArgs as nodeParseArgs } from "node:util";
14
14
  import { discoverEvals, discoverExperiments } from "./runner/discover.ts";
@@ -19,6 +19,8 @@ import { fingerprintEvalsFilter, resolveExperimentEvals, selectedEvalsForRun, sp
19
19
  import { failureDetailFromResult } from "./runner/feedback/failure.ts";
20
20
  import { stopAllSandboxes, liveSandboxCount } from "./sandbox/registry.ts";
21
21
  import { drainExperimentTeardowns } from "./runner/experiment-cleanup-registry.ts";
22
+ import { drainHeldCaseLocks, isCaseLockStale, readCaseLock } from "./runner/lock.ts";
23
+ import { drainHeldGateLeases } from "./runner/gate-lease.ts";
22
24
  import { CLEANUP_TIMEOUT_MS, withCleanupTimeout } from "./runner/cleanup-timeout.ts";
23
25
  import type { ExperimentHookContext } from "./runner/types.ts";
24
26
  import { evalLevelStats } from "./shared/verdict.ts";
@@ -175,7 +177,7 @@ const FLAG_OPTIONS = {
175
177
  // 数字 `--attempt`,选哪个 attempt 由 locator 精确指名,不是「先选 eval 再挑第几次」。
176
178
  /** `show` 命令专用:该 attempt 运行时保存的 Eval 源码,gate/soft 断言标回源码行(证据切面)。 */
177
179
  source: { type: "boolean" },
178
- /** `show` 命令专用:该 attempt 的标准执行事件流(消息、thinking、Skill load、工具调用/结果);有 OTel 时同一节点补时间(证据切面)。 */
180
+ /** `show` 命令专用:该 attempt 的标准执行事件流(消息、thinking、Skill load、工具调用/结果);有 OTel 时同一节点补时间(证据切面)。每个内容段最多预览前 3 行,截断尾巴自带 `--expand` 展开句柄。 */
179
181
  execution: { type: "boolean" },
180
182
  /** `show` 命令专用:整个 Attempt 的统一时间树;裸 `--timing` 给有界诊断投影,`--timing=full` 逐节点展开全部 runner/已关联 OTel 节点。 */
181
183
  timing: { type: "boolean" },
@@ -453,6 +455,29 @@ function resolveAgentDocPath(cwd: string): string {
453
455
  return agentsPath;
454
456
  }
455
457
 
458
+ // init 提示用:从 cwd 向上找最近的 package.json,判断宿主是否 ESM 形态。装载本身不挑形态
459
+ // (bin 注册了 tsx 的 ESM+CJS 双 hook,exports 全出口带 require 条件,见 docs/cli.md
460
+ // 「装载用户 .ts」),但 CJS 编译面下 config / eval 文件用不了顶层 await,ESM 仍是推荐形态;
461
+ // 找不到 package.json 或解析失败按非 ESM 处理(tsx/Node 的缺省语义就是 CJS)。只提示,
462
+ // 不改用户的 package.json。
463
+ function hostPrefersEsm(cwd: string): boolean {
464
+ let dir = resolvePath(cwd);
465
+ while (true) {
466
+ const pkgPath = join(dir, "package.json");
467
+ if (existsSync(pkgPath)) {
468
+ try {
469
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { type?: unknown };
470
+ return pkg.type === "module";
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+ const parent = dirname(dir);
476
+ if (parent === dir) return false;
477
+ dir = parent;
478
+ }
479
+ }
480
+
456
481
  async function initProject(cwd: string): Promise<void> {
457
482
  await mkdir(join(cwd, "evals"), { recursive: true });
458
483
  const configPath = join(cwd, "niceeval.config.ts");
@@ -522,19 +547,32 @@ async function openBrowser(url: string): Promise<boolean> {
522
547
  function assembleInvocationCompletion(state: RunFeedbackState): InvocationCompletion {
523
548
  let unstarted = 0;
524
549
  let failFastSkipped = 0;
550
+ let haltedSkipped = 0;
525
551
  let interrupted = false;
526
552
  const reporterErrors: ReporterError[] = [];
527
553
  for (const d of state.diagnostics) {
528
- if (d.key === "interrupted") {
554
+ // 归类按**稳定词法** `code`,不按 `key`:`key` 里编着折叠身份(experimentId / evalId /
555
+ // reporter 名),拿它做前缀匹配会在「把身份从 key 里摘出去」时静默失配——记账悄悄归零,
556
+ // 没有任何测试或类型会报警。`code` 省略时回落到 key 的首段:缺省 key 恒是
557
+ // `${code}:${identity}`(见 sink.ts 的 DiagnosticInput),首段即 code。
558
+ const code = d.code ?? d.key.split(":", 1)[0];
559
+ if (code === "interrupted") {
529
560
  interrupted = true;
530
- } else if (d.key.startsWith("budget-exhausted:")) {
561
+ } else if (code === "budget-exhausted") {
531
562
  unstarted += d.count;
532
- } else if (d.key.startsWith("fail-fast:")) {
563
+ } else if (code === "fail-fast") {
533
564
  // run 级 fail-fast 造成的未派发同样计入 unstarted(结论落 incomplete,见
534
565
  // docs/feature/experiments/architecture.md「Completion 与退出」)。
535
566
  unstarted += d.count;
536
567
  failFastSkipped += d.count;
537
- } else if (d.key.startsWith("reporter-error:")) {
568
+ } else if (code === "dispatch-halted") {
569
+ // 止损闸停派发造成的未派发(见 docs/feature/error-classification/architecture.md
570
+ // 「记账」)。这条诊断的 count 是「同一死因被声明了几次」(重复声明折叠),不是未派发数——
571
+ // 未派发数由 emitter 累计后写在 data.unstarted 里(与 budget-exhausted 同一口径)。
572
+ const halted = typeof d.data?.unstarted === "number" ? d.data.unstarted : 0;
573
+ unstarted += halted;
574
+ haltedSkipped += halted;
575
+ } else if (code === "reporter-error") {
538
576
  // required 决定这条错误是否写进 InvocationCompletion.reporterErrors 并让 completion 非 complete
539
577
  // (见 docs/cli.md「required reporter」);best-effort reporter 的失败只保留为 diagnostic。
540
578
  if (d.data?.required !== true) continue;
@@ -548,9 +586,9 @@ function assembleInvocationCompletion(state: RunFeedbackState): InvocationComple
548
586
  // 中断造成的未派发(仍在 queued 的 attempt)同样计入 unstarted(见 docs/feature/experiments/
549
587
  // architecture.md「Completion 与退出」:budget 耗尽、fail-fast 或中断造成的未派发都不伪装成全绿)。
550
588
  if (interrupted) unstarted += state.queued;
551
- // attempt:early-exit 计数含 fail-fast 的未派发(反馈层同一事件驱动计数守恒);
552
- // 「省下的重复验证」= 总数减去 fail-fast 那部分。
553
- const earlyExitUnstarted = Math.max(0, state.earlyExitSkipped - failFastSkipped);
589
+ // attempt:early-exit 计数含 fail-fast 与止损闸的未派发(反馈层同一事件驱动计数守恒);
590
+ // 「省下的重复验证」= 总数减去那两部分。
591
+ const earlyExitUnstarted = Math.max(0, state.earlyExitSkipped - failFastSkipped - haltedSkipped);
554
592
  const status: CompletionStatus = interrupted
555
593
  ? "interrupted"
556
594
  : unstarted > 0 || reporterErrors.length > 0
@@ -668,6 +706,7 @@ async function main(): Promise<void> {
668
706
  if (command === "init") {
669
707
  await initProject(cwd);
670
708
  process.stdout.write(t("cli.init.done"));
709
+ if (!hostPrefersEsm(cwd)) process.stdout.write(t("cli.init.esmHint"));
671
710
  process.exit(0);
672
711
  }
673
712
 
@@ -851,6 +890,9 @@ async function main(): Promise<void> {
851
890
  maxConcurrency: exp.maxConcurrency,
852
891
  setup: exp.setup,
853
892
  teardown: exp.teardown,
893
+ // 实验级失败分类器:随 AgentRun 进 attempt(turn 链与生命周期链共用同一份),
894
+ // 产出的 scope 由止损闸消费(见 docs/feature/error-classification/architecture.md)。
895
+ classifyFailure: exp.classifyFailure,
854
896
  });
855
897
  }
856
898
  } else {
@@ -902,21 +944,36 @@ async function main(): Promise<void> {
902
944
  // 人读 `--dry` 首行的携入摘要(见 docs/feature/experiments/cli.md 开头示例与「事件与计划
903
945
  // 文档的 TypeScript 形状」),口径必须与真正开跑时一致。
904
946
  const priorResults = flags.force ? undefined : await loadLatestResultsPerEval(join(cwd, ".niceeval"));
905
- const carryPlan = priorResults?.length ? await planCarry(evals, agentRuns, priorResults, config.sandbox) : undefined;
947
+ const carryPlan = priorResults?.length
948
+ ? await planCarry(evals, agentRuns, priorResults, config.sandbox, config.timeoutMs)
949
+ : undefined;
906
950
 
907
951
  if (flags.dry) {
908
952
  // --dry 只按所选形态打印计划,不运行、不落盘——一次完成的读取,不是事件流
909
953
  // (见 docs/feature/experiments/cli.md「机器怎么读:--json」)。两种形态共用同一份摊平
910
954
  // 矩阵——(experimentId, evalId) 逐行,携带同一口径的 reused 预测——不是各自重算一遍。
911
955
  const dryRuns = Math.max(1, ...agentRuns.map((r) => r.runs));
912
- const matrix: JsonPlanRow[] = [];
956
+ const rowInputs: { experimentId: string; evalId: string; reused: boolean }[] = [];
913
957
  for (let i = 0; i < agentRuns.length; i++) {
914
958
  const run = agentRuns[i]!;
915
959
  for (const e of matchedByRun[i]!) {
916
960
  const carriedCount = carryPlan?.carriedAttemptsByKey.get(cacheKey(run, e.id))?.size ?? 0;
917
- matrix.push({ experimentId: run.experimentId ?? "", evalId: e.id, reused: carriedCount >= run.runs });
961
+ rowInputs.push({ experimentId: run.experimentId ?? "", evalId: e.id, reused: carriedCount >= run.runs });
918
962
  }
919
963
  }
964
+ // 只读锁目录,不取锁、不等待(见 docs/feature/experiments/architecture.md「并发
965
+ // Invocation:用例锁」);过期(无人续心跳)的锁不算"正被持锁运行",不标注。裸 run(没有
966
+ // experimentId)不参与锁,恒不标注。并行读——矩阵行数可能不小,不逐行串行等磁盘。
967
+ const niceevalRootForDry = resolvePath(cwd, ".niceeval");
968
+ const now = Date.now();
969
+ const lockedFlags = await Promise.all(
970
+ rowInputs.map(async (row) => {
971
+ if (!row.experimentId) return false;
972
+ const lock = await readCaseLock(niceevalRootForDry, row.experimentId, row.evalId).catch(() => undefined);
973
+ return lock !== undefined && !isCaseLockStale(lock, now);
974
+ }),
975
+ );
976
+ const matrix: JsonPlanRow[] = rowInputs.map((row, i) => ({ ...row, ...(lockedFlags[i] ? { locked: true } : {}) }));
920
977
  if (outputForm === "json") {
921
978
  process.stdout.write(
922
979
  renderJsonPlanDocument({
@@ -935,7 +992,7 @@ async function main(): Promise<void> {
935
992
  configs: agentRuns.length,
936
993
  runs: dryRuns,
937
994
  reused: carryPlan?.carriedResults.length ?? 0,
938
- rows: matrix.map((row) => ({ experimentId: row.experimentId, evalId: row.evalId })),
995
+ rows: matrix.map((row) => ({ experimentId: row.experimentId, evalId: row.evalId, locked: row.locked })),
939
996
  }),
940
997
  );
941
998
  }
@@ -1001,6 +1058,8 @@ async function main(): Promise<void> {
1001
1058
  const settled = Promise.allSettled([
1002
1059
  ...(runInFlight ? [runInFlight] : []),
1003
1060
  drainExperimentTeardowns(),
1061
+ drainHeldCaseLocks(),
1062
+ drainHeldGateLeases(),
1004
1063
  ]);
1005
1064
  await Promise.race([
1006
1065
  settled.then(() => {}),
@@ -1077,9 +1136,12 @@ async function main(): Promise<void> {
1077
1136
  }
1078
1137
 
1079
1138
  // 正常返回(含被中断后走部分汇总)后再兜一刀:Scope finalizer 没停掉的残留沙箱、没被运行
1080
- // 路径消费的实验级 cleanup 在这里强清。跑顺利时两份登记表都已空,是 no-op。
1139
+ // 路径消费的实验级 cleanup、没被 per-attempt Effect.ensuring 释放的用例锁与实验闸租约在这里
1140
+ // 强清。跑顺利时四份登记表都已空,是 no-op。
1081
1141
  await stopAllSandboxes();
1082
1142
  await drainExperimentTeardowns();
1143
+ await drainHeldCaseLocks();
1144
+ await drainHeldGateLeases();
1083
1145
 
1084
1146
  // completion 要先算好,--junit 是否"这次真的写出"才有依据(见下)。
1085
1147
  const completion = assembleInvocationCompletion(coordinator.state);
@@ -1116,8 +1178,11 @@ async function main(): Promise<void> {
1116
1178
 
1117
1179
  main().catch(async (e) => {
1118
1180
  process.stderr.write(t("cli.error", { error: formatThrown(e) }));
1119
- // 真·崩溃路径也别留孤儿:强清还活着的沙箱(带超时)、排空实验级 cleanup 注册表,再退。
1181
+ // 真·崩溃路径也别留孤儿:强清还活着的沙箱(带超时)、排空实验级 cleanup 注册表、用例锁与
1182
+ // 实验闸租约,再退。
1120
1183
  await stopAllSandboxes();
1121
1184
  await drainExperimentTeardowns();
1185
+ await drainHeldCaseLocks();
1186
+ await drainHeldGateLeases();
1122
1187
  process.exit(2);
1123
1188
  });
@@ -355,6 +355,40 @@ function baseScoringContext(state: ContextState) {
355
355
  };
356
356
  }
357
357
 
358
+ describe("t.* 作用域断言聚合全部轮次(callId 跨轮复用)", () => {
359
+ // 回归:续轮场景下 adapter 常按轮各自编号(复用 c1)。第一轮读了 INDEX,第二轮才给答复;
360
+ // t.calledTool 聚合全部轮次,应命中第一轮的 read——旧折叠按 callId 覆盖会让它「只扫最后一轮」而 miss。
361
+ it("t.calledTool 命中发生在第一轮、callId 被第二轮复用的工具调用", async () => {
362
+ const agent = scriptedAgent([
363
+ {
364
+ status: "completed",
365
+ events: [
366
+ { type: "action.called", callId: "c1", name: "read", input: { path: "INDEX.md" } },
367
+ { type: "action.result", callId: "c1", output: "index contents", status: "completed" },
368
+ { type: "message", role: "assistant", text: "读完了 INDEX,继续" },
369
+ ],
370
+ },
371
+ {
372
+ status: "completed",
373
+ events: [
374
+ { type: "action.called", callId: "c1", name: "write", input: { path: "note.md" } },
375
+ { type: "action.result", callId: "c1", output: "ok", status: "completed" },
376
+ { type: "message", role: "assistant", text: "答复" },
377
+ ],
378
+ },
379
+ ]);
380
+ const { context, state } = makeContext(agent);
381
+ await context.send("第一轮"); // 读 INDEX
382
+ await context.send("第二轮"); // 续轮,复用 callId c1
383
+
384
+ context.calledTool("read", { input: { path: "INDEX.md" } });
385
+
386
+ const [result] = await state.collector.finalize(baseScoringContext(state));
387
+ expect(result.name).toBe("calledTool(read)");
388
+ expect(result.outcome).toBe("passed");
389
+ });
390
+ });
391
+
358
392
  describe("TurnHandle scoped assertions (parked/loadedSkill/noFailedActions/maxTokens/maxCost)", () => {
359
393
  it("mirror t/session scope: turn.parked() reflects this turn's own waiting status", async () => {
360
394
  const agent = scriptedAgent([
@@ -12,6 +12,7 @@ import * as Scoped from "../scoring/scoped.ts";
12
12
  import { buildJudge } from "../scoring/judge.ts";
13
13
  import { EvalSkipped, EvalRequirementFailed, TurnFailed } from "./control-flow.ts";
14
14
  import { turnErrorText } from "./turn-errors.ts";
15
+ import { attachFailureClass, type FailureClass } from "../shared/failure-class.ts";
15
16
  import type { ConcurrencySlot } from "./send-retry.ts";
16
17
  import { deriveRunFacts } from "../o11y/derive.ts";
17
18
  import { diffIsEmpty, diffMatches, emptyDiffData } from "../scoring/diff.ts";
@@ -91,6 +92,8 @@ export interface ContextDeps {
91
92
  onTurn?: import("./session.ts").SessionDeps["onTurn"];
92
93
  /** turn 级重试退避期间释放/收回的全局并发槽位;透传给 SessionManager。 */
93
94
  concurrencySlot?: ConcurrencySlot;
95
+ /** 实验声明的失败分类器(`ExperimentDef.classifyFailure`);透传给 SessionManager。 */
96
+ experimentClassifier?: import("./session.ts").SessionDeps["experimentClassifier"];
94
97
  /** 仅供确定性单测注入:透传给 SessionManager 的 turn 重试随机数/睡眠(生产路径省略)。 */
95
98
  retryRandom?: import("./session.ts").SessionDeps["retryRandom"];
96
99
  retrySleep?: import("./session.ts").SessionDeps["retrySleep"];
@@ -131,6 +134,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
131
134
  onTurn: deps.onTurn,
132
135
  ledgerHooks: deps.ledgerHooks,
133
136
  concurrencySlot: deps.concurrencySlot,
137
+ experimentClassifier: deps.experimentClassifier,
134
138
  retryRandom: deps.retryRandom,
135
139
  retrySleep: deps.retrySleep,
136
140
  });
@@ -332,11 +336,11 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
332
336
  const text = typeof input === "string" ? input : input.text;
333
337
  const files = typeof input === "string" ? undefined : input.files;
334
338
  const turn = await send(session, text, files);
335
- return makeTurnHandle(turn, collector, deps, text, manager.resolveTurnCoverage(turn));
339
+ return makeTurnHandle(turn, collector, deps, text, manager.resolveTurnCoverage(turn), manager.resolveTurnFailureClass(turn));
336
340
  },
337
341
  sendFile: async (path, text) => {
338
342
  const turn = await send(session, text ?? "", [await readInputFile(path)]);
339
- return makeTurnHandle(turn, collector, deps, text ?? "", manager.resolveTurnCoverage(turn));
343
+ return makeTurnHandle(turn, collector, deps, text ?? "", manager.resolveTurnCoverage(turn), manager.resolveTurnFailureClass(turn));
340
344
  },
341
345
  requireInputRequest: (filter) => requireInputRequest(session, filter),
342
346
  respond: async (...answers) => {
@@ -344,7 +348,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
344
348
  const built = buildRespondInput(session, answers);
345
349
  session.pendingInputRequests.length = 0;
346
350
  const turn = await send(session, built.text, undefined, built.responses);
347
- return makeTurnHandle(turn, collector, deps, built.text, manager.resolveTurnCoverage(turn));
351
+ return makeTurnHandle(turn, collector, deps, built.text, manager.resolveTurnCoverage(turn), manager.resolveTurnFailureClass(turn));
348
352
  },
349
353
  respondAll: async (optionId) => {
350
354
  if (session.pendingInputRequests.length === 0) {
@@ -359,7 +363,7 @@ export function createEvalContext(deps: ContextDeps): { context: TestContext; st
359
363
  session.pendingInputRequests.length = 0;
360
364
  const input = requests.map(() => optionId).join("\n");
361
365
  const turn = await send(session, input, undefined, responses);
362
- return makeTurnHandle(turn, collector, deps, input, manager.resolveTurnCoverage(turn));
366
+ return makeTurnHandle(turn, collector, deps, input, manager.resolveTurnCoverage(turn), manager.resolveTurnFailureClass(turn));
363
367
  },
364
368
  get reply() {
365
369
  return session.lastMessage;
@@ -565,6 +569,7 @@ function makeTurnHandle(
565
569
  deps: ContextDeps,
566
570
  input: string,
567
571
  coverage: ResolvedCoverage,
572
+ failureClass?: FailureClass,
568
573
  ): TurnHandle {
569
574
  const message = lastAssistantText(turn.events) ?? "";
570
575
  const facts = deriveRunFacts(turn.events);
@@ -598,7 +603,11 @@ function makeTurnHandle(
598
603
  // 与保守兜底分类器、turn 级重试摘要读的同一段文本(见 turn-errors.ts 的 turnErrorText)——
599
604
  // 不出现「报错说 A、分类看 B」。
600
605
  const message = turnErrorText(turn);
601
- throw new TurnFailed(message !== undefined ? t("context.turnFailed", { message }) : undefined);
606
+ const error = new TurnFailed(message !== undefined ? t("context.turnFailed", { message }) : undefined);
607
+ // 终局失败的分类随错误浮出:attempt 封口据此落止损闸(见
608
+ // docs/feature/error-classification/architecture.md「止损执行体」)。没有分类
609
+ // (未经重试执行体的手工构造 Turn)时不标记,落缺省 attempt 档。
610
+ throw failureClass ? attachFailureClass(error, failureClass) : error;
602
611
  }
603
612
  return handle;
604
613
  },
@@ -9,6 +9,7 @@ import {
9
9
  type SendRetryDeps,
10
10
  type TurnLens,
11
11
  } from "./send-retry.ts";
12
+ import { failureClassOf, type FailureClass } from "../shared/failure-class.ts";
12
13
  import type { Turn } from "../types.ts";
13
14
 
14
15
  // 直调执行体,不用真实 setTimeout 睡眠——sleep 注入即返回(受控时钟),random 注入固定值,
@@ -265,6 +266,91 @@ describe("sendWithTurnRetry · adapter 分类器接入", () => {
265
266
  });
266
267
  });
267
268
 
269
+ describe("sendWithTurnRetry · 终局失败携带分类浮出(止损闸的进料)", () => {
270
+ const tunnelDown = { retryable: false, scope: "experiment", reason: "tunnel_down" } as const;
271
+
272
+ it("thrown 形态:终局失败的分类标在浮出的错误上,经 failureClassOf 读得到", async () => {
273
+ const original = new Error("connect ECONNREFUSED tunnel.example:443");
274
+ const promise = sendWithTurnRetry(
275
+ async () => {
276
+ throw original;
277
+ },
278
+ identityLens,
279
+ baseDeps({ experimentClassifier: () => tunnelDown }),
280
+ );
281
+ await expect(promise).rejects.toBe(original); // 浮出的仍是原始错误对象,不被包装替换
282
+ expect(failureClassOf(original)).toEqual(tunnelDown);
283
+ });
284
+
285
+ it("turn-failed 形态:分类经 onFinalFailure 回执转交调用方(Turn 上不留字段)", async () => {
286
+ const seen: { cls: FailureClass; turn?: Turn }[] = [];
287
+ const result = await sendWithTurnRetry(
288
+ async () => failedTurn("connect ECONNREFUSED tunnel.example:443"),
289
+ identityLens,
290
+ baseDeps({
291
+ experimentClassifier: () => tunnelDown,
292
+ onFinalFailure: (cls, failure) => {
293
+ seen.push({ cls, turn: failure.type === "turn-failed" ? failure.turn : undefined });
294
+ },
295
+ }),
296
+ );
297
+ expect(seen).toEqual([{ cls: tunnelDown, turn: result }]);
298
+ expect(failureClassOf(result)).toBeUndefined();
299
+ });
300
+
301
+ it("被重试吸收的失败不外泄:重试后成功时没有任何终局分类回执", async () => {
302
+ const seen: FailureClass[] = [];
303
+ let calls = 0;
304
+ const result = await sendWithTurnRetry(
305
+ async () => {
306
+ calls++;
307
+ return calls === 1 ? failedTurn("too many requests, retry later") : completedTurn();
308
+ },
309
+ identityLens,
310
+ baseDeps({
311
+ experimentClassifier: () => ({ retryable: true, reason: "tunnel_flaky", scope: "experiment" }),
312
+ onFinalFailure: (cls) => seen.push(cls),
313
+ }),
314
+ );
315
+ expect(result.status).toBe("completed");
316
+ expect(seen).toEqual([]);
317
+ });
318
+
319
+ it("重试耗尽时 scope 随失败携带浮出,回执报的是带耗尽摘要的那个 Turn", async () => {
320
+ const seen: { cls: FailureClass; turn?: Turn }[] = [];
321
+ const result = await sendWithTurnRetry(
322
+ async () => failedTurn("too many requests, retry later"),
323
+ identityLens,
324
+ baseDeps({
325
+ experimentClassifier: () => ({ retryable: true, reason: "tunnel_flaky", scope: "experiment" }),
326
+ onFinalFailure: (cls, failure) => {
327
+ seen.push({ cls, turn: failure.type === "turn-failed" ? failure.turn : undefined });
328
+ },
329
+ }),
330
+ );
331
+ expect(seen).toHaveLength(1);
332
+ expect(seen[0].cls).toEqual({ retryable: true, reason: "tunnel_flaky", scope: "experiment" });
333
+ expect(seen[0].turn).toBe(result);
334
+ expect((result.events[0] as { message: string }).message).toContain("tunnel_flaky");
335
+ });
336
+
337
+ it("实验分类器排在 adapter 之前:两者同时认领时携带的是实验的 scope", async () => {
338
+ const original = new Error("connect ECONNREFUSED tunnel.example:443");
339
+ const promise = sendWithTurnRetry(
340
+ async () => {
341
+ throw original;
342
+ },
343
+ identityLens,
344
+ baseDeps({
345
+ experimentClassifier: () => tunnelDown,
346
+ classifier: () => ({ retryable: true, reason: "network" }),
347
+ }),
348
+ );
349
+ await expect(promise).rejects.toBe(original);
350
+ expect(failureClassOf(original)).toEqual(tunnelDown);
351
+ });
352
+ });
353
+
268
354
  describe("sendWithTurnRetry · 中断", () => {
269
355
  it("退避睡眠期间 signal abort:干净打断,不等满整段延迟,槽位仍被收回", async () => {
270
356
  const ac = new AbortController();
@@ -6,7 +6,8 @@
6
6
 
7
7
  import type { Turn } from "../types.ts";
8
8
  import { t } from "../i18n/index.ts";
9
- import { resolveTurnErrorClass, type TurnErrorClass, type TurnErrorClassifier, type TurnFailure } from "./turn-errors.ts";
9
+ import { attachFailureClass, type AttemptFailureClassifier, type FailureClass } from "../shared/failure-class.ts";
10
+ import { resolveTurnFailureClass, type TurnErrorClassifier, type TurnFailure } from "./turn-errors.ts";
10
11
 
11
12
  /** 单次 send 调用封顶的尝试次数(首次 + 至多 3 次重试)。 */
12
13
  export const SEND_MAX_ATTEMPTS = 4;
@@ -47,6 +48,14 @@ export interface TurnLens<T> {
47
48
  export interface SendRetryDeps {
48
49
  /** adapter 声明的分类器(可选),undefined 回落保守兜底。 */
49
50
  classifier?: TurnErrorClassifier;
51
+ /** 实验声明的分类器(`ExperimentDef.classifyFailure`,可选),排在 adapter 之前询问。 */
52
+ experimentClassifier?: AttemptFailureClassifier;
53
+ /**
54
+ * 终局失败(不重试 / 重试耗尽)的分类回执:被重试吸收的失败不回调——只有真正浮出的失败
55
+ * 携带分类,止损闸消费的空间轴由此抵达 attempt 封口。`turn-failed` 形态的失败没有错误对象
56
+ * 可标记(错误在 `expectOk()` 才铸造),经这条回执转交调用方。
57
+ */
58
+ onFinalFailure?: (cls: FailureClass, failure: TurnFailure) => void;
50
59
  /** attempt 级预算,持续扣减(调用方在 attempt 生命周期内只创建一份并跨多次 send 复用)。 */
51
60
  budget: AttemptRetryBudget;
52
61
  /** 省略时不释放槽位(测试 / 无并发闸场景)。 */
@@ -93,7 +102,7 @@ function appendToLastErrorEvent(turn: Turn, suffix: string): Turn | undefined {
93
102
 
94
103
  /**
95
104
  * turn 级重试执行体:反复调 `callOnce()`(每次都是原样重发同一个 `TurnInput`),对失败结果
96
- * 走三道分类链(见 turn-errors.ts 的 `resolveTurnErrorClass`),可重试则退避后重试,否则把
105
+ * 走分类链(见 turn-errors.ts 的 `resolveTurnFailureClass`),可重试则退避后重试,否则把
97
106
  * 失败原样(或带耗尽摘要)浮出。
98
107
  */
99
108
  export async function sendWithTurnRetry<T>(
@@ -116,10 +125,15 @@ export async function sendWithTurnRetry<T>(
116
125
  failure = { type: "thrown", error: e };
117
126
  }
118
127
 
119
- const cls = resolveTurnErrorClass(failure, deps.classifier);
120
- if (!cls.retryable) return finalize(failure, result, lens);
121
- if (sendAttempt >= SEND_MAX_ATTEMPTS) return finalize(failure, result, lens, { layer: "send", cls });
122
- if (deps.budget.remaining <= 0) return finalize(failure, result, lens, { layer: "attempt", cls });
128
+ const cls = resolveTurnFailureClass(failure, {
129
+ experiment: deps.experimentClassifier,
130
+ adapter: deps.classifier,
131
+ });
132
+ // 终局失败才携带分类浮出:被吸收的失败尝试不留痕(见 architecture.md「重试执行体」),
133
+ // 它的 scope 永远到不了止损闸。
134
+ if (!cls.retryable) return finalize(failure, result, lens, deps, cls);
135
+ if (sendAttempt >= SEND_MAX_ATTEMPTS) return finalize(failure, result, lens, deps, cls, { layer: "send", cls });
136
+ if (deps.budget.remaining <= 0) return finalize(failure, result, lens, deps, cls, { layer: "attempt", cls });
123
137
 
124
138
  deps.budget.remaining -= 1;
125
139
  const delayMs = BASE_DELAY_MS * 2 ** (sendAttempt - 1) * random();
@@ -143,15 +157,22 @@ export async function sendWithTurnRetry<T>(
143
157
  }
144
158
  }
145
159
 
146
- /** 循环收口:未耗尽的非重试失败原样浮出;耗尽时按耗尽层追加摘要文本。 */
160
+ /**
161
+ * 循环收口:未耗尽的非重试失败原样浮出;耗尽时按耗尽层追加摘要文本。两条路径都先把终局分类
162
+ * 挂到浮出的失败上(`thrown` 形态标在错误对象上,沿 cause 链可读;两种形态都经 `onFinalFailure`
163
+ * 回执),抛出点自己声明过分类的错误不被覆盖。
164
+ */
147
165
  function finalize<T>(
148
166
  failure: TurnFailure,
149
167
  result: T | undefined,
150
168
  lens: TurnLens<T>,
151
- exhausted?: { layer: "send" | "attempt"; cls: Extract<TurnErrorClass, { retryable: true }> },
169
+ deps: SendRetryDeps,
170
+ cls: FailureClass,
171
+ exhausted?: { layer: "send" | "attempt"; cls: Extract<FailureClass, { retryable: true }> },
152
172
  ): T {
153
173
  if (!exhausted) {
154
- if (failure.type === "thrown") throw failure.error;
174
+ deps.onFinalFailure?.(cls, failure);
175
+ if (failure.type === "thrown") throw attachFailureClass(failure.error, cls);
155
176
  return result as T;
156
177
  }
157
178
  const suffix =
@@ -160,11 +181,15 @@ function finalize<T>(
160
181
  : t("session.turnRetryBudgetExhausted", { maxRetries: ATTEMPT_MAX_RETRIES, reason: exhausted.cls.reason });
161
182
 
162
183
  if (failure.type === "thrown") {
184
+ deps.onFinalFailure?.(cls, failure);
163
185
  const e = failure.error;
164
- // adapter 抛出的 Error 仍可能被上层保留、复用或记录;不要原地篡改它。
165
- if (e instanceof Error) throw new Error(e.message + suffix, { cause: e });
166
- throw e;
186
+ // adapter 抛出的 Error 仍可能被上层保留、复用或记录;不要原地篡改它(分类标记挂在外层)。
187
+ if (e instanceof Error) throw attachFailureClass(new Error(e.message + suffix, { cause: e }), cls);
188
+ throw attachFailureClass(e, cls);
167
189
  }
190
+ // 追加摘要产出的是一个新的 Turn 对象;回执必须报浮出的那个,分类才登记在调用方拿到的 Turn 上。
168
191
  const withSuffix = appendToLastErrorEvent(failure.turn, suffix);
192
+ const finalTurn = withSuffix ?? failure.turn;
193
+ deps.onFinalFailure?.(cls, { type: "turn-failed", turn: finalTurn });
169
194
  return withSuffix ? lens.set(result as T, withSuffix) : (result as T);
170
195
  }
@@ -12,6 +12,8 @@ import {
12
12
  type AttemptRetryBudget,
13
13
  type ConcurrencySlot,
14
14
  } from "./send-retry.ts";
15
+ import type { AttemptFailureClassifier, FailureClass } from "../shared/failure-class.ts";
16
+ import type { TurnFailure } from "./turn-errors.ts";
15
17
  import { recordFact } from "../shared/facts.ts";
16
18
 
17
19
  /**
@@ -144,6 +146,11 @@ export interface SessionDeps {
144
146
  * architecture.md「退避与槽位」)。省略时退避不释放槽位(测试 / 无并发闸场景)。
145
147
  */
146
148
  concurrencySlot?: ConcurrencySlot;
149
+ /**
150
+ * 实验声明的失败分类器(`ExperimentDef.classifyFailure`):turn 链上排在 adapter 分类器
151
+ * 之前询问(决议序见 docs/feature/error-classification/architecture.md「分类链」)。
152
+ */
153
+ experimentClassifier?: AttemptFailureClassifier;
147
154
  /** 仅供确定性单测注入:turn 重试执行体的随机数与睡眠(生产路径省略,走真实退避)。 */
148
155
  retryRandom?: () => number;
149
156
  retrySleep?: (ms: number, signal: AbortSignal) => Promise<void>;
@@ -194,6 +201,17 @@ export class SessionManager {
194
201
  return downgradeCoverage(this.agentCoverage, turn.coverage);
195
202
  }
196
203
 
204
+ /**
205
+ * 终局失败 Turn 的分类:失败 Turn 本身不是错误(作者不调 `expectOk()` 就不算失败),分类
206
+ * 因此不能挂在 Turn 上,只在 `expectOk()` 铸造 `TurnFailed` 时随错误浮出——这里按 Turn 身份
207
+ * 登记,`makeTurnHandle` 取用。被重试吸收的失败 Turn 从不外泄,也就不会被登记。
208
+ */
209
+ resolveTurnFailureClass(turn: Turn): FailureClass | undefined {
210
+ return this.turnFailureClasses.get(turn);
211
+ }
212
+
213
+ private readonly turnFailureClasses = new WeakMap<Turn, FailureClass>();
214
+
197
215
  async send(
198
216
  session: RunSession,
199
217
  text: string,
@@ -271,6 +289,12 @@ export class SessionManager {
271
289
  // 因此能被 Effect interruption 干净打断,不新增超时语义。
272
290
  const retryDeps = {
273
291
  classifier: this.deps.agent.classifyTurnError,
292
+ experimentClassifier: this.deps.experimentClassifier,
293
+ // 终局失败的分类落账:thrown 形态由执行体标在错误对象上,turn-failed 形态在这里按 Turn
294
+ // 身份登记,`expectOk()` 铸造 TurnFailed 时取出随错误浮出(止损闸的消费点在 attempt 封口)。
295
+ onFinalFailure: (cls: FailureClass, failure: TurnFailure) => {
296
+ if (failure.type === "turn-failed") this.turnFailureClasses.set(failure.turn, cls);
297
+ },
274
298
  budget: this.retryBudget,
275
299
  slot: this.deps.concurrencySlot,
276
300
  reportRetry: (message: string) => ctx.progress({ message }),