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
@@ -1,13 +1,18 @@
1
1
  // cases: docs/engineering/testing/unit/eval.md
2
- import { describe, expect, it } from "vitest";
2
+ import { describe, expect, it, vi } from "vitest";
3
3
  import {
4
4
  classifyTurnError,
5
5
  hasAgentEvidence,
6
- resolveTurnErrorClass,
6
+ resolveTurnFailureClass,
7
7
  turnErrorText,
8
- type TurnErrorClass,
9
8
  type TurnFailure,
10
9
  } from "./turn-errors.ts";
10
+ import {
11
+ EvalFatalError,
12
+ ExperimentFatalError,
13
+ type AttemptFailureInfo,
14
+ type FailureClass,
15
+ } from "../shared/failure-class.ts";
11
16
  import type { StreamEvent, Turn } from "../types.ts";
12
17
 
13
18
  function turnFailed(events: StreamEvent[]): TurnFailure {
@@ -87,30 +92,43 @@ describe("classifyTurnError(保守兜底) · thrown 形态", () => {
87
92
  });
88
93
  });
89
94
 
90
- describe("resolveTurnErrorClass · 三道分类链", () => {
95
+ describe("resolveTurnFailureClass · 五道分类链", () => {
91
96
  it("adapter 分类器返回结果时优先生效,自定义 reason 原样透出(不被塞进内建词表)", () => {
92
97
  const failure = turnFailed([errorEvent("ACME_QUEUE_FULL: too many concurrent runs")]);
93
- const result = resolveTurnErrorClass(failure, () => ({ retryable: true, reason: "acme_queue_full" }));
98
+ const result = resolveTurnFailureClass(failure, { adapter: () => ({ retryable: true, reason: "acme_queue_full" }) });
94
99
  expect(result).toEqual({ retryable: true, reason: "acme_queue_full" });
95
100
  });
96
101
 
97
102
  it("adapter 分类器返回 undefined 时回落保守兜底", () => {
98
103
  const failure = turnFailed([errorEvent("too many requests, back off")]);
99
- const result = resolveTurnErrorClass(failure, () => undefined);
104
+ const result = resolveTurnFailureClass(failure, { adapter: () => undefined });
100
105
  expect(result).toEqual({ retryable: true, reason: "rate_limit" });
101
106
  });
102
107
 
103
- it("adapter 分类器抛错按不可重试处理并被吞掉——不回落兜底,即使兜底本会判可重试", () => {
108
+ it("adapter 分类器抛错按 undefined 回落:错误被吞掉,链继续走到兜底", () => {
104
109
  const failure = turnFailed([errorEvent("too many requests, back off")]);
105
- const result = resolveTurnErrorClass(failure, () => {
106
- throw new Error("classifier bug");
110
+ const result = resolveTurnFailureClass(failure, {
111
+ adapter: () => {
112
+ throw new Error("classifier bug");
113
+ },
114
+ });
115
+ expect(result).toEqual({ retryable: true, reason: "rate_limit" });
116
+ });
117
+
118
+ it("实验分类器抛错同样按 undefined 回落,后续通道照常认领(不掩盖原始失败)", () => {
119
+ const failure = turnFailed([errorEvent("ACME_QUEUE_FULL")]);
120
+ const result = resolveTurnFailureClass(failure, {
121
+ experiment: () => {
122
+ throw new Error("classifier bug");
123
+ },
124
+ adapter: () => ({ retryable: true, reason: "acme_queue_full" }),
107
125
  });
108
- expect(result).toEqual({ retryable: false });
126
+ expect(result).toEqual({ retryable: true, reason: "acme_queue_full" });
109
127
  });
110
128
 
111
129
  it("没有 adapter 分类器时直接走保守兜底", () => {
112
130
  const failure = turnFailed([errorEvent("too many requests, back off")]);
113
- expect(resolveTurnErrorClass(failure)).toEqual({ retryable: true, reason: "rate_limit" });
131
+ expect(resolveTurnFailureClass(failure)).toEqual({ retryable: true, reason: "rate_limit" });
114
132
  });
115
133
 
116
134
  describe("受理证据门:失败 Turn 带 agent 产出事件时强制降级为不可重试", () => {
@@ -119,9 +137,9 @@ describe("resolveTurnErrorClass · 三道分类链", () => {
119
137
  ["thinking", { type: "thinking", text: "let me think" } satisfies StreamEvent],
120
138
  ["action.called", { type: "action.called", name: "bash", callId: "c1", input: {} } satisfies StreamEvent],
121
139
  ["action.result", { type: "action.result", callId: "c1", output: "", status: "completed" } satisfies StreamEvent],
122
- ])("%s 事件出现时,文本再像限流也不重试", (_label, evidenceEvent) => {
140
+ ])("%s 事件出现时,文本再像限流也不重试(reason 原样留着给人读)", (_label, evidenceEvent) => {
123
141
  const failure = turnFailed([evidenceEvent, errorEvent("Concurrency limit exceeded, please retry later")]);
124
- expect(resolveTurnErrorClass(failure)).toEqual({ retryable: false });
142
+ expect(resolveTurnFailureClass(failure)).toEqual({ retryable: false, reason: "rate_limit" });
125
143
  });
126
144
 
127
145
  it("adapter 分类器判可重试同样被否决(执行体的否决权压过分类器)", () => {
@@ -129,13 +147,24 @@ describe("resolveTurnErrorClass · 三道分类链", () => {
129
147
  { type: "action.called", name: "bash", callId: "c1", input: {} },
130
148
  errorEvent("ACME_QUEUE_FULL"),
131
149
  ]);
132
- const result = resolveTurnErrorClass(failure, () => ({ retryable: true, reason: "acme_queue_full" }));
133
- expect(result).toEqual({ retryable: false });
150
+ const result = resolveTurnFailureClass(failure, { adapter: () => ({ retryable: true, reason: "acme_queue_full" }) });
151
+ expect(result).toEqual({ retryable: false, reason: "acme_queue_full" });
152
+ });
153
+
154
+ it("只裁时间轴:分类器给的 scope 原样保留,证据门不触碰空间轴", () => {
155
+ const failure = turnFailed([
156
+ { type: "action.called", name: "bash", callId: "c1", input: {} },
157
+ errorEvent("tunnel flaky, retry later"),
158
+ ]);
159
+ const result = resolveTurnFailureClass(failure, {
160
+ experiment: () => ({ retryable: true, reason: "tunnel_flaky", scope: "experiment" }),
161
+ });
162
+ expect(result).toEqual({ retryable: false, reason: "tunnel_flaky", scope: "experiment" });
134
163
  });
135
164
 
136
165
  it("thrown 形态没有 Turn 可查,不受证据门影响", () => {
137
166
  const failure: TurnFailure = { type: "thrown", error: new Error("too many requests") };
138
- expect(resolveTurnErrorClass(failure)).toEqual({ retryable: true, reason: "rate_limit" });
167
+ expect(resolveTurnFailureClass(failure)).toEqual({ retryable: true, reason: "rate_limit" });
139
168
  });
140
169
 
141
170
  it("不可重试分类结果不受证据门触碰(门只降级可重试的判断)", () => {
@@ -143,12 +172,90 @@ describe("resolveTurnErrorClass · 三道分类链", () => {
143
172
  { type: "action.called", name: "bash", callId: "c1", input: {} },
144
173
  errorEvent("stream reset mid-response"),
145
174
  ]);
146
- const result: TurnErrorClass = resolveTurnErrorClass(failure);
175
+ const result: FailureClass = resolveTurnFailureClass(failure);
147
176
  expect(result).toEqual({ retryable: false });
148
177
  });
149
178
  });
150
179
  });
151
180
 
181
+ describe("resolveTurnFailureClass · 决议序(先非 undefined 定案)", () => {
182
+ // 区分力场景:同一条失败两个通道都认领——adapter 只能给时间轴(连接错误的通用形状),
183
+ // 只有实验作者认得这个 host 是全实验共享的隧道。实验分类器排在 adapter 之前,
184
+ // scope 才赢得下来(裁决见 memory/failure-chain-experiment-before-adapter.md)。
185
+ const tunnelFailure = turnFailed([errorEvent("connect ECONNREFUSED nowledge.trycloudflare.com:443")]);
186
+ const experimentClassifier = () => ({ retryable: false, scope: "experiment", reason: "tunnel_down" }) as const;
187
+ const adapterClassifier = () => ({ retryable: true, reason: "network" }) as const;
188
+
189
+ it("实验分类器与 adapter 同时认领时,实验的 scope 声明胜出", () => {
190
+ const result = resolveTurnFailureClass(tunnelFailure, {
191
+ experiment: experimentClassifier,
192
+ adapter: adapterClassifier,
193
+ });
194
+ expect(result).toEqual({ retryable: false, scope: "experiment", reason: "tunnel_down" });
195
+ });
196
+
197
+ it("实验分类器认领后不再询问 adapter", () => {
198
+ const adapter = vi.fn(adapterClassifier);
199
+ resolveTurnFailureClass(tunnelFailure, { experiment: experimentClassifier, adapter });
200
+ expect(adapter).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("实验分类器不认(返回 undefined)时才轮到 adapter,adapter 的时间轴答案生效", () => {
204
+ const result = resolveTurnFailureClass(tunnelFailure, {
205
+ experiment: () => undefined,
206
+ adapter: adapterClassifier,
207
+ });
208
+ expect(result).toEqual({ retryable: true, reason: "network" });
209
+ });
210
+
211
+ it("实验分类器读到的 text 与报错文案同源,phase 是 agent.run,cause 是那个失败 Turn", () => {
212
+ const turn: Turn = { status: "failed", events: [errorEvent("connect ECONNREFUSED nowledge.trycloudflare.com:443")] };
213
+ const seen: AttemptFailureInfo[] = [];
214
+ resolveTurnFailureClass(
215
+ { type: "turn-failed", turn },
216
+ {
217
+ experiment: (failure) => {
218
+ seen.push(failure);
219
+ return undefined;
220
+ },
221
+ },
222
+ );
223
+ expect(seen).toHaveLength(1);
224
+ expect(seen[0].phase).toBe("agent.run");
225
+ expect(seen[0].text).toBe(turnErrorText(turn));
226
+ expect(seen[0].cause).toBe(turn);
227
+ });
228
+
229
+ it("thrown 形态下实验分类器读到抛出的错误本身,text 是错误链串接", () => {
230
+ const error = new Error("send failed", { cause: new Error("connect ECONNREFUSED tunnel.example:443") });
231
+ const seen: { text: string; cause: unknown }[] = [];
232
+ resolveTurnFailureClass({ type: "thrown", error }, {
233
+ experiment: (failure) => {
234
+ seen.push(failure);
235
+ return undefined;
236
+ },
237
+ });
238
+ expect(seen[0].cause).toBe(error);
239
+ expect(seen[0].text).toBe("send failed · connect ECONNREFUSED tunnel.example:443");
240
+ });
241
+
242
+ it("抛出点携带的分类优先级最高:糖衣类命中即定,任何分类器都不被询问", () => {
243
+ const experiment = vi.fn(experimentClassifier);
244
+ const adapter = vi.fn(adapterClassifier);
245
+ const failure: TurnFailure = { type: "thrown", error: new ExperimentFatalError("tunnel probe failed") };
246
+ const result = resolveTurnFailureClass(failure, { experiment, adapter });
247
+ expect(result).toEqual({ retryable: false, scope: "experiment" });
248
+ expect(experiment).not.toHaveBeenCalled();
249
+ expect(adapter).not.toHaveBeenCalled();
250
+ });
251
+
252
+ it("糖衣类被上层包装再抛(cause 链)也照样命中,声明不丢失", () => {
253
+ const wrapped = new Error("adapter wrapped", { cause: new EvalFatalError("fixture missing") });
254
+ const result = resolveTurnFailureClass({ type: "thrown", error: wrapped });
255
+ expect(result).toEqual({ retryable: false, scope: "eval" });
256
+ });
257
+ });
258
+
152
259
  describe("hasAgentEvidence", () => {
153
260
  it("空事件流(无 error 事件也无产出事件)判 false", () => {
154
261
  expect(hasAgentEvidence({ status: "failed", events: [] })).toBe(false);
@@ -1,20 +1,18 @@
1
- // turn 级瞬时错误分类:把一次 send 失败(抛出 / 返回 failed Turn)按重试安全性归类。
2
- // 判据全文见 docs/feature/error-classification/README.md「分类」;这里只落三道分类链里
3
- // 「保守兜底」与「受理证据门」两道的实现,adapter 分类器本身不在这个文件(它是 Agent 的可选字段,
4
- // 挂载在 src/agents/types.ts)。执行体的重试时序在 send-retry.ts,不在这里——本模块只回答
5
- // 「这次失败能不能安全重发」,不碰次数/退避/槽位。
1
+ // turn 失败分类链:把一次 send 失败(抛出 / 返回 failed Turn)归成一份 `FailureClass`。
2
+ // 判据全文见 docs/feature/error-classification/README.md「分类」;两轴词表、糖衣类与守卫在
3
+ // src/shared/failure-class.ts(全仓单源),这里只落 turn 这条链——五道里的「保守兜底」与
4
+ // 「受理证据门」两道的实现在本文件,抛出点声明走守卫、实验分类器挂在 ExperimentDef、adapter
5
+ // 分类器挂在 Agent(src/agents/types.ts),本文件只按序询问它们。执行体的重试时序在
6
+ // send-retry.ts:本模块只回答「这次失败能不能安全重发、波及多远」,不碰次数/退避/槽位。
6
7
 
7
8
  import type { Turn } from "../types.ts";
8
-
9
- /**
10
- * 一次 send 失败的分类结果:`retryable` 是执行体唯一消费的决策轴;`reason` 是开放词表的
11
- * 细分诊断,只进 activity 与耗尽摘要,不参与策略。内建兜底产出 reason `"rate_limit"` /
12
- * `"network"`;adapter 分类器可自造词。`retryable: true` 时 `reason` 必填——可重试的失败
13
- * 一定会出现在 activity 行与可能的耗尽摘要里,那里需要一个给人读的词。
14
- */
15
- export type TurnErrorClass =
16
- | { readonly retryable: true; readonly reason: string }
17
- | { readonly retryable: false; readonly reason?: string };
9
+ import {
10
+ callClassifier,
11
+ errorChainText,
12
+ failureClassOf,
13
+ type AttemptFailureClassifier,
14
+ type FailureClass,
15
+ } from "../shared/failure-class.ts";
18
16
 
19
17
  /** 一次 send 失败的两种浮出形态:`send()` 抛出异常,或返回 `status: "failed"` 的 Turn。 */
20
18
  export type TurnFailure =
@@ -22,10 +20,10 @@ export type TurnFailure =
22
20
  | { readonly type: "turn-failed"; readonly turn: Turn };
23
21
 
24
22
  /**
25
- * adapter 可选分类器:返回 `undefined` 表示「不认识,交给保守兜底」。分类器必须快、纯、
26
- * 不抛错——执行体按「抛错等价于不可重试」处理,自身错误被吞掉,不会掩盖原始失败。
23
+ * adapter 可选分类器:返回 `undefined` 表示「不认识,交给后续链路」。分类器必须快、纯、
24
+ * 不抛错——抛错按 `undefined` 回落处理,自身错误被吞掉,不会掩盖原始失败。
27
25
  */
28
- export type TurnErrorClassifier = (failure: TurnFailure) => TurnErrorClass | undefined;
26
+ export type TurnErrorClassifier = (failure: TurnFailure) => FailureClass | undefined;
29
27
 
30
28
  /**
31
29
  * 失败 Turn 的错误摘要:取 `events` 里最后一个 `type: "error"` 事件的 message。
@@ -41,23 +39,14 @@ export function turnErrorText(turn: Turn): string | undefined {
41
39
  return undefined;
42
40
  }
43
41
 
44
- /** `thrown` 形态的错误文本:沿错误链(含 `cause`)取 message,串接成一段供分类器与摘要读的文本。 */
45
- function thrownErrorText(error: unknown): string {
46
- const parts: string[] = [];
47
- let current: unknown = error;
48
- for (let depth = 0; depth < 5 && current != null; depth++) {
49
- const message = current instanceof Error ? current.message : String(current);
50
- if (message) parts.push(message);
51
- current = current instanceof Error ? (current as { cause?: unknown }).cause : undefined;
52
- }
53
- return parts.join(" · ");
54
- }
55
-
56
42
  /** 两种 `TurnFailure` 形态统一取「给人读也给分类器看」的那段文本。 */
57
43
  export function turnFailureText(failure: TurnFailure): string {
58
- return failure.type === "thrown" ? thrownErrorText(failure.error) : (turnErrorText(failure.turn) ?? "");
44
+ return failure.type === "thrown" ? errorChainText(failure.error) : (turnErrorText(failure.turn) ?? "");
59
45
  }
60
46
 
47
+ /** turn 失败在生命周期词表里的归属:adapter send 期间打开的那一段。 */
48
+ const TURN_FAILURE_PHASE = "agent.run" as const;
49
+
61
50
  // 限流关键字 / 明示 retry later → rate_limit;正则形状对齐 sandbox IO 分类器
62
51
  // (src/sandbox/errors.ts 的 classifySandboxIoError),各自实现、不共享模块。
63
52
  const RATE_LIMIT_PATTERN = /too many requests|rate.?limit|\b429\b|retry later|concurrency limit/i;
@@ -68,10 +57,11 @@ const NETWORK_CODE_PATTERN = /^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHO
68
57
  const NETWORK_MESSAGE_PATTERN = /getaddrinfo|connection refused|certificate|tls handshake|connect etimedout|connection timeout/i;
69
58
 
70
59
  /**
71
- * 保守兜底分类器:三道分类链里的第二道。对失败文本做正则匹配,认不出的一律 `{ retryable: false }`
72
- * ——宁可判死一个 attempt,不产出不可信的 verdict(判据见 README「分类」)。
60
+ * 保守兜底分类器:turn 链里的第四道。对失败文本做正则匹配,认不出的一律 `{ retryable: false }`
61
+ * ——宁可判死一个 attempt,不产出不可信的 verdict(判据见 README「分类」)。兜底永不给出超出
62
+ * `"attempt"` 的 scope:框架无法从文案证明兄弟必死,扩 scope 只属于携带作者知识的通道。
73
63
  */
74
- export function classifyTurnError(failure: TurnFailure): TurnErrorClass {
64
+ export function classifyTurnError(failure: TurnFailure): FailureClass {
75
65
  const text = turnFailureText(failure);
76
66
  if (RATE_LIMIT_PATTERN.test(text)) return { retryable: true, reason: "rate_limit" };
77
67
  const code = errorCode(failure);
@@ -98,25 +88,45 @@ export function hasAgentEvidence(turn: Turn): boolean {
98
88
  return turn.events.some((e) => AGENT_EVIDENCE_TYPES.has(e.type));
99
89
  }
100
90
 
91
+ /** turn 链上两个可选声明通道;都省略时链退化成「抛出点 → 兜底 → 证据门」。 */
92
+ export interface TurnClassifiers {
93
+ /** 实验作者的 `ExperimentDef.classifyFailure`,按自家坐标识别共享基建死因。 */
94
+ experiment?: AttemptFailureClassifier;
95
+ /** adapter 作者的 `Agent.classifyTurnError`,识别自家协议的错误形状。 */
96
+ adapter?: TurnErrorClassifier;
97
+ }
98
+
101
99
  /**
102
- * 三道分类链的完整决议:adapter 分类器(可选,抛错按不可重试处理并吞掉)→ 保守兜底 →
103
- * 受理证据门(否决权,失败 Turn 带 agent 产出事件时强制降级)。执行体只需要调这一个函数,
104
- * 不必自己拼三道链的顺序。
100
+ * turn 失败分类链的完整决议(五道,先给出非 `undefined` 结果的一道定分类):
101
+ *
102
+ * 1. 抛出点携带的分类(`failureClassOf`,含 cause 链穿透)——作者知识优先级最高;
103
+ * 2. 实验分类器——按自家坐标(host、路径)过滤,特异性高于协议通用形状,排在 adapter 之前
104
+ * 保证「两者同时认领时 scope 赢」(裁决见 memory/failure-chain-experiment-before-adapter.md);
105
+ * 3. adapter 分类器;
106
+ * 4. 保守兜底正则;
107
+ * 5. 受理证据门(执行体的否决权,只裁时间轴):失败 Turn 里已有 agent 产出事件时 `retryable`
108
+ * 强制降为 `false`,`reason` 与 `scope` 原样保留——门裁的是重发安全性,不是波及范围。
109
+ *
110
+ * 分类器抛错按 `undefined` 回落(继续问后续通道),分类是旁路,不得用新错误掩盖原始失败。
105
111
  */
106
- export function resolveTurnErrorClass(failure: TurnFailure, adapterClassifier?: TurnErrorClassifier): TurnErrorClass {
107
- let cls: TurnErrorClass | undefined;
108
- if (adapterClassifier) {
109
- try {
110
- cls = adapterClassifier(failure);
111
- } catch {
112
- // 分类器抛错按不可重试处理:分类是旁路,不得用新错误掩盖原始失败,也不回落到兜底
113
- // (自造分类器都判断不了的形状,交给通用正则复判没有意义)。
114
- return { retryable: false };
115
- }
116
- }
117
- const resolved = cls ?? classifyTurnError(failure);
112
+ export function resolveTurnFailureClass(failure: TurnFailure, classifiers: TurnClassifiers = {}): FailureClass {
113
+ const declared = failure.type === "thrown" ? failureClassOf(failure.error) : undefined;
114
+ const resolved = declared ?? classifyByChain(failure, classifiers);
118
115
  if (resolved.retryable && failure.type === "turn-failed" && hasAgentEvidence(failure.turn)) {
119
- return { retryable: false };
116
+ return { ...resolved, retryable: false };
120
117
  }
121
118
  return resolved;
122
119
  }
120
+
121
+ function classifyByChain(failure: TurnFailure, classifiers: TurnClassifiers): FailureClass {
122
+ if (classifiers.experiment) {
123
+ const info = {
124
+ phase: TURN_FAILURE_PHASE,
125
+ text: turnFailureText(failure),
126
+ cause: failure.type === "thrown" ? failure.error : failure.turn,
127
+ };
128
+ const cls = callClassifier(classifiers.experiment, info);
129
+ if (cls) return cls;
130
+ }
131
+ return callClassifier(classifiers.adapter, failure) ?? classifyTurnError(failure);
132
+ }
package/src/define.ts CHANGED
@@ -114,6 +114,11 @@ export function defineExperiment(def: ExperimentDef): ExperimentDef {
114
114
  if (def.setup !== undefined && typeof def.setup !== "function") {
115
115
  throw new Error(t("define.experimentSetupNotFunction"));
116
116
  }
117
+ // classifyFailure 是失败分类链上的实验通道(见 runner/types.ts 的 ExperimentDef.classifyFailure):
118
+ // 传成非函数在解析时就报,不等到某条 attempt 撞死才发现这一路声明白写。
119
+ if (def.classifyFailure !== undefined && typeof def.classifyFailure !== "function") {
120
+ throw new Error(t("define.experimentClassifyFailureNotFunction"));
121
+ }
117
122
  // flags 必须可 JSON 序列化(进结果快照的 ExperimentRunInfo.flags):解析时即校验,
118
123
  // 非 JSON 值(函数 / undefined / 循环引用 / bigint)直接报错,不等到落盘才炸。
119
124
  if (def.flags !== undefined) {
package/src/i18n/en.ts CHANGED
@@ -74,6 +74,14 @@ export const en = {
74
74
  "experiment {{experimentId}}'s teardown was not triggered by the normal countdown path; it has been executed by the end-of-run sweep instead. Results are unaffected; seeing this line means an unlocated intermittent scheduling issue fired — please record this run in the memory ledger.\n",
75
75
  "runner.teardownRegistrationWriteFailed":
76
76
  "writing the crash-recovery teardown registration for experiment {{experimentId}} failed: {{message}}. The run continues normally, but a SIGKILL during this run cannot be recovered via `niceeval exp --teardown` or the startup self-heal — check disk space/permissions under .niceeval/teardowns/.\n",
77
+ "runner.lockTakenOver":
78
+ "took over an expired case lock for {{experimentId}}/{{evalId}} (previously held by pid {{pid}} on {{host}}; its heartbeat went stale) — that run likely died without releasing it; this run now owns dispatching this case.\n",
79
+ "runner.gateLeaseTakenOver":
80
+ "took over an expired concurrency-slot lease for experiment {{experimentId}} (slot {{slot}}, previously held by pid {{pid}} on {{host}}; its heartbeat went stale) — that run likely died without releasing it; this run now owns the slot.\n",
81
+ "runner.gateLeaseWaiting":
82
+ "waiting on another run for experiment {{experimentId}}'s concurrency slots: all {{effectiveN}} in use ({{holders}}). Concurrent runs share this experiment's slots, and the smallest maxConcurrency in play wins — this run declared {{declaredN}}. Nothing dispatches until a slot frees up; the other run's slots release when its attempts finish, or 30s after it dies.\n",
83
+ "runner.dispatchHaltedExperiment": "experiment halted (dispatch-halted): {{message}}\n",
84
+ "runner.dispatchHaltedEval": "eval halted: {{message}}\n",
77
85
  "judge.modelMissing":
78
86
  "No judge model configured. Set it in defineConfig({ judge: { model: \"...\" } }), the eval's judge config, or the NICEEVAL_JUDGE_MODEL environment variable (there is no built-in default model).\n" +
79
87
  " Docs: node_modules/niceeval/docs-site/zh/tutorials/scoring-guide.mdx",
@@ -198,6 +206,8 @@ export const en = {
198
206
  "cli.fallbackCleanupTimeout": "\ngraceful cleanup timed out; force-cleaning sandboxes...\n",
199
207
  "cli.forceCleanupExit": "\nForce-cleaning sandboxes and exiting...\n",
200
208
  "cli.init.done": "Ready: evals/, niceeval.config.ts, and the niceeval agent-rules block in AGENTS.md (points coding agents at node_modules/niceeval/docs-site/zh).\n",
209
+ "cli.init.esmHint":
210
+ "tip: this project's package.json has no \"type\": \"module\". niceeval runs either way, but CommonJS mode disallows top-level await in config/eval files — add \"type\": \"module\" to package.json for the smoothest path.\n",
201
211
  "cli.interruptCleanup": "\nInterrupted; cleaning up sandbox containers... (press again to force cleanup and exit)\n",
202
212
  "cli.list.header": "Discovered {{count}} evals:\n",
203
213
  "cli.noAgent": "No agent specified (use --agent <name>).\n",
@@ -234,6 +244,7 @@ export const en = {
234
244
  "define.experimentFlagNotJson": "experiment.flags.{{key}} is not JSON-serializable (functions / undefined / cycles / bigint are not allowed); flags are persisted verbatim into result snapshots and must be plain JSON.",
235
245
  "define.experimentLabelInvalid": "experiment.labels.{{key}} must be a string or a finite number; labels are report-side grouping coordinates persisted verbatim into result snapshots.",
236
246
  "define.experimentSetupNotFunction": "experiment.setup must be a function ((ctx) => void); use experiment.teardown for cleanup; to prepare the in-sandbox environment per experiment, chain .setup() hooks on the sandbox spec instead.",
247
+ "define.experimentClassifyFailureNotFunction": "experiment.classifyFailure must be a function ((failure) => FailureClass | undefined); it classifies failures that surface as third-party errors and must return undefined for anything it does not recognize.",
237
248
  "define.experimentIdRejected": "defineExperiment does not accept id; ids are derived from file paths.",
238
249
  "define.sandboxAgentNameRequired": "defineSandboxAgent requires name.",
239
250
  "define.sandboxCreateRequired": "defineSandbox requires a create() function.",
@@ -247,7 +258,8 @@ export const en = {
247
258
  "feedback.human.active": "ACTIVE",
248
259
  "feedback.human.budgetExhausted": "budget exhausted for {{experimentId}} (spent {{spent}}, unstarted {{unstarted}})",
249
260
  "feedback.human.compare": "Compare: niceeval view",
250
- "feedback.human.counts": "{{total}} total · {{reused}} reused · {{running}} running · {{queued}} queued · {{completed}} completed",
261
+ "feedback.human.counts":
262
+ "{{total}} total · {{reused}} reused · {{running}} running · {{queued}} queued · {{passed}} passed · {{failed}} failed · {{errored}} errored · {{skipped}} skipped",
251
263
  "feedback.human.diffHint": "Diff: niceeval show {{locator}} --diff",
252
264
  "feedback.human.evalHint": "Eval: niceeval show {{locator}} --source",
253
265
  "feedback.human.failuresHeader": "FAILURES",
@@ -280,6 +292,15 @@ export const en = {
280
292
  "feedback.human.hookFailed": "failed",
281
293
  "feedback.human.precheckJudge": "prechecking judge config",
282
294
  "feedback.human.precheckJudgeDone": "judge config ok",
295
+ "feedback.human.countsWithElsewhere":
296
+ "{{total}} total · {{reused}} reused · {{running}} running · {{elsewhere}} elsewhere · {{queued}} queued · {{passed}} passed · {{failed}} failed · {{errored}} errored · {{skipped}} skipped",
297
+ "feedback.human.waitingOnAnotherRun": "waiting on another run",
298
+ "feedback.human.lockWaitDetail": "{{count}} evals · pid {{pid}}",
299
+ "feedback.human.lockWaitStarted": "waiting on another run · {{experimentId}} ({{count}} evals, pid {{pid}})",
300
+ "feedback.human.lockWaitResolved": "lock wait resolved · {{experimentId}} ({{summary}}, {{elapsed}})",
301
+ "feedback.human.lockWaitCarried": "{{count}} carried",
302
+ "feedback.human.lockWaitDispatched": "{{count}} to run",
303
+ "feedback.human.lockedRowSuffix": "locked",
283
304
  "feedback.phase.sandboxSetup": "sandbox setup",
284
305
  "feedback.phase.scoring": "scoring",
285
306
  "feedback.phase.teardown": "cleaning up",
package/src/i18n/zh-CN.ts CHANGED
@@ -71,6 +71,14 @@ export const zhCN = {
71
71
  "实验 {{experimentId}} 的 teardown 未被正常计数路径触发,已在运行收尾兜底执行。结果不受影响;这行出现说明命中了一个未定位的调度间歇问题,请把本次运行信息记入 memory 台账。\n",
72
72
  "runner.teardownRegistrationWriteFailed":
73
73
  "实验 {{experimentId}} 的强杀恢复收尾登记写入失败:{{message}}。本次运行照常继续,但这次运行期间若被 SIGKILL,`niceeval exp --teardown` 与启动自愈都无法找到它——检查 .niceeval/teardowns/ 下的磁盘空间或权限。\n",
74
+ "runner.lockTakenOver":
75
+ "接管了 {{experimentId}}/{{evalId}} 的过期用例锁(原持有者 pid {{pid}}@{{host}},心跳已过期)——那次运行大概率没能正常释放它;本次运行现在接手派发这条用例。\n",
76
+ "runner.gateLeaseTakenOver":
77
+ "接管了实验 {{experimentId}} 的过期并发名额租约(槽位 {{slot}},原持有者 pid {{pid}}@{{host}},心跳已过期)——那次运行大概率没能正常释放它;本次运行现在接手这个名额。\n",
78
+ "runner.gateLeaseWaiting":
79
+ "在等别的运行让出实验 {{experimentId}} 的并发名额:生效的 {{effectiveN}} 个位子全被占着({{holders}})。并行运行共用同一实验的名额,生效值取在场声明里最小的那个——本次运行声明的是 {{declaredN}}。名额腾出来之前不会派发任何 attempt;对方的名额会在它的 attempt 跑完时释放,它若已死则 30s 后过期被接管。\n",
80
+ "runner.dispatchHaltedExperiment": "实验已止损(dispatch-halted):{{message}}\n",
81
+ "runner.dispatchHaltedEval": "eval 已止损:{{message}}\n",
74
82
  "judge.modelMissing":
75
83
  "judge 未配置模型:在 defineConfig({ judge: { model: \"...\" } })、eval 的 judge 配置或环境变量 NICEEVAL_JUDGE_MODEL 里指定裁判模型(没有内置默认模型)。\n" +
76
84
  " 文档:node_modules/niceeval/docs-site/zh/tutorials/scoring-guide.mdx",
@@ -191,6 +199,8 @@ export const zhCN = {
191
199
  "cli.fallbackCleanupTimeout": "\ngraceful 清理超时,强制清理沙箱…\n",
192
200
  "cli.forceCleanupExit": "\n强制清理沙箱并退出…\n",
193
201
  "cli.init.done": "已就绪:evals/、niceeval.config.ts,以及 AGENTS.md 里的 niceeval agent 指引区块(指向 node_modules/niceeval/docs-site/zh)。\n",
202
+ "cli.init.esmHint":
203
+ "提示:项目 package.json 没有 \"type\": \"module\"。niceeval 两种形态都能跑,但 CommonJS 形态下 config / eval 文件用不了顶层 await——建议在 package.json 加上 \"type\": \"module\"。\n",
194
204
  "cli.interruptCleanup": "\n收到中断,正在清理沙箱容器…(再按一次强制清理并退出)\n",
195
205
  "cli.list.header": "发现 {{count}} 个 eval:\n",
196
206
  "cli.noAgent": "未指定 agent(用 --agent <name>)。\n",
@@ -227,6 +237,7 @@ export const zhCN = {
227
237
  "define.experimentFlagNotJson": "experiment.flags.{{key}} 不是可 JSON 序列化的值(函数 / undefined / 循环引用 / bigint 不允许);flags 会原样进入结果快照,必须是纯 JSON。",
228
238
  "define.experimentLabelInvalid": "experiment.labels.{{key}} 必须是字符串或有限数字;labels 是报告侧的归类坐标,会原样进入结果快照。",
229
239
  "define.experimentSetupNotFunction": "experiment.setup 必须是函数((ctx) => void);要清理请挂 experiment.teardown;要按实验准备沙箱内环境请挂 sandbox spec 的 .setup() 钩子链。",
240
+ "define.experimentClassifyFailureNotFunction": "experiment.classifyFailure 必须是函数((failure) => FailureClass | undefined):它识别以第三方错误形态浮出的失败,认不出的一律返回 undefined 交给后续链路。",
230
241
  "define.experimentIdRejected": "defineExperiment 不接受 id —— id 由文件路径推导。",
231
242
  "define.sandboxAgentNameRequired": "defineSandboxAgent 需要 name。",
232
243
  "define.sandboxCreateRequired": "defineSandbox 需要一个 create() 函数。",
@@ -240,7 +251,8 @@ export const zhCN = {
240
251
  "feedback.human.active": "ACTIVE",
241
252
  "feedback.human.budgetExhausted": "{{experimentId}} 预算已耗尽(已花 {{spent}},未跑 {{unstarted}})",
242
253
  "feedback.human.compare": "Compare: niceeval view",
243
- "feedback.human.counts": "共 {{total}} · 复用 {{reused}} · 运行中 {{running}} · 排队 {{queued}} · 已完成 {{completed}}",
254
+ "feedback.human.counts":
255
+ "共 {{total}} · 复用 {{reused}} · 运行中 {{running}} · 排队 {{queued}} · 通过 {{passed}} · 失败 {{failed}} · 出错 {{errored}} · 跳过 {{skipped}}",
244
256
  "feedback.human.diffHint": "Diff: niceeval show {{locator}} --diff",
245
257
  "feedback.human.evalHint": "Eval: niceeval show {{locator}} --source",
246
258
  "feedback.human.failuresHeader": "FAILURES",
@@ -273,6 +285,15 @@ export const zhCN = {
273
285
  "feedback.human.hookFailed": "失败",
274
286
  "feedback.human.precheckJudge": "预检 judge 配置",
275
287
  "feedback.human.precheckJudgeDone": "judge 配置就绪",
288
+ "feedback.human.countsWithElsewhere":
289
+ "共 {{total}} · 复用 {{reused}} · 运行中 {{running}} · 等待中 {{elsewhere}} · 排队 {{queued}} · 通过 {{passed}} · 失败 {{failed}} · 出错 {{errored}} · 跳过 {{skipped}}",
290
+ "feedback.human.waitingOnAnotherRun": "等待另一个并行 run",
291
+ "feedback.human.lockWaitDetail": "{{count}} 条用例 · pid {{pid}}",
292
+ "feedback.human.lockWaitStarted": "等待另一个并行 run · {{experimentId}}({{count}} 条用例,pid {{pid}})",
293
+ "feedback.human.lockWaitResolved": "等待结束 · {{experimentId}}({{summary}},{{elapsed}})",
294
+ "feedback.human.lockWaitCarried": "{{count}} 条携入",
295
+ "feedback.human.lockWaitDispatched": "{{count}} 条自跑",
296
+ "feedback.human.lockedRowSuffix": "locked",
276
297
  "feedback.phase.sandboxSetup": "沙箱预置",
277
298
  "feedback.phase.scoring": "评分",
278
299
  "feedback.phase.teardown": "清理中",
package/src/index.ts CHANGED
@@ -5,6 +5,10 @@ export { defineEval, defineScoreEval, defineConfig, defineExperiment } from "./d
5
5
 
6
6
  export { requireEnv, getEnv, stripComments } from "./util.ts";
7
7
 
8
+ // 执行失败分类:抛出点糖衣类(声明死因波及多远)+ 结构守卫。判据、分类链与止损语义见
9
+ // docs/feature/error-classification/README.md;`niceeval/adapter` 复导出同一份词表类型。
10
+ export { ExperimentFatalError, EvalFatalError, failureClassOf } from "./shared/failure-class.ts";
11
+
8
12
  // 类型(eval 作者会用到;跑哪个 agent / 用哪个 sandbox 见对应子路径)
9
13
  export type {
10
14
  StreamEvent,
@@ -59,6 +63,10 @@ export type {
59
63
  TraceSpan,
60
64
  SpanKind,
61
65
  DerivedFacts,
66
+ FailureClass,
67
+ FailureScope,
68
+ AttemptFailureInfo,
69
+ AttemptFailureClassifier,
62
70
  } from "./types.ts";
63
71
 
64
72
  export type { ParsedTranscript } from "./o11y/parsers/index.ts";
package/src/o11y/cost.ts CHANGED
@@ -56,8 +56,10 @@ function lookupBuiltin(model: string): Price | undefined {
56
56
  }
57
57
 
58
58
  /**
59
- * 按 token 桶 × 单价估算一次运行的美元成本。cache 桶缺专门单价时退回 input 价
60
- * (cache token 本质也是 input)。无 model / 查不到价(用户表 + 内置快照都没有)→ undefined。
59
+ * 按 token 桶 × 单价估算一次运行的美元成本。逐桶相加成立的前提是 Usage 桶恒互斥
60
+ * (inputTokens 不含 cache 命中,归一义务在 adapter,见 docs/feature/results/architecture.md#usage);
61
+ * cache 桶缺专门单价时退回 input 价——宁可高估,不静默低估。
62
+ * 无 model / 查不到价(用户表 + 内置快照都没有)→ undefined。
61
63
  */
62
64
  export function estimateCost(
63
65
  model: string | undefined,
@@ -58,6 +58,46 @@ describe("deriveRunFacts:pending 折叠", () => {
58
58
  });
59
59
  });
60
60
 
61
+ describe("deriveRunFacts:callId 跨轮复用不覆盖", () => {
62
+ // 回归:adapter 常按轮各自编号(OpenAI 兼容 / transcript 归一复用 c1/c2…)。同一 callId 在其
63
+ // result 之后再次出现是新的一次调用——旧折叠按 callId 存 Map 会被后一轮覆盖,跨轮聚合断言
64
+ // (t.calledTool)于是「只扫最后一轮」,前几轮的工具调用被抹掉(定稿见 events.md 不变量 2)。
65
+ it("同一 callId 完成后在后一轮再次 called,折叠成两条独立调用而非覆盖成一条", () => {
66
+ const events: StreamEvent[] = [
67
+ // 第一轮:读 init、读 INDEX(都用 c1/c2 编号)
68
+ { type: "action.called", callId: "c1", name: "read", input: { path: "init.md" } },
69
+ { type: "action.result", callId: "c1", output: "init contents", status: "completed" },
70
+ { type: "action.called", callId: "c2", name: "read", input: { path: "INDEX.md" } },
71
+ { type: "action.result", callId: "c2", output: "index contents", status: "completed" },
72
+ { type: "message", role: "assistant", text: "第一轮做完" },
73
+ // 第二轮:续轮,adapter 从 c1 重新编号
74
+ { type: "action.called", callId: "c1", name: "write", input: { path: "note.md" } },
75
+ { type: "action.result", callId: "c1", output: "ok", status: "completed" },
76
+ { type: "message", role: "assistant", text: "答复" },
77
+ ];
78
+ const facts = deriveRunFacts(events);
79
+ expect(facts.toolCalls).toHaveLength(3); // 两轮共 3 次调用,后一轮不覆盖前一轮
80
+ expect(facts.toolCalls.map((tc) => tc.originalName)).toEqual(["read", "read", "write"]);
81
+ // 第一轮的两次 read 都在,且各配对到自己的 result(不是都取最后一条)
82
+ expect(facts.toolCalls[0]!.input).toEqual({ path: "init.md" });
83
+ expect(facts.toolCalls[0]!.output).toBe("init contents");
84
+ expect(facts.toolCalls[1]!.input).toEqual({ path: "INDEX.md" });
85
+ expect(facts.toolCalls[1]!.output).toBe("index contents");
86
+ });
87
+
88
+ it("subagent 折叠同理:同一 callId 完成后再次 called 起新记录", () => {
89
+ const events: StreamEvent[] = [
90
+ { type: "subagent.called", callId: "s1", name: "researcher" },
91
+ { type: "subagent.completed", callId: "s1", output: "round1", status: "completed" },
92
+ { type: "subagent.called", callId: "s1", name: "writer" },
93
+ { type: "subagent.completed", callId: "s1", output: "round2", status: "completed" },
94
+ ];
95
+ const facts = deriveRunFacts(events);
96
+ expect(facts.subagentCalls).toHaveLength(2);
97
+ expect(facts.subagentCalls.map((sc) => sc.name)).toEqual(["researcher", "writer"]);
98
+ });
99
+ });
100
+
61
101
  describe("deriveRunFacts:contextInjections 计数", () => {
62
102
  it("统计事件流里 context.injected 事件的次数,不与 messageCount 混计", () => {
63
103
  const events: StreamEvent[] = [