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
@@ -61,10 +61,16 @@ function pickExitCode(output: JsonValue | undefined): number | undefined {
61
61
  // ───────────────────────── deriveRunFacts ─────────────────────────
62
62
 
63
63
  export function deriveRunFacts(events: readonly StreamEvent[]): DerivedFacts {
64
- const toolCallMap = new Map<string, ToolCall>();
65
- const toolCallOrder: string[] = [];
66
- const subagentMap = new Map<string, SubagentCall>();
67
- const subagentOrder: string[] = [];
64
+ // 折叠是逐条按发生顺序进行的:called 追加一条新调用,result 回填「当前还没配上 result 的
65
+ // 同 callId 调用」。callId 只在一个 called→result 配对内保证稳定,不保证跨轮唯一——adapter
66
+ // 常按轮各自编号(OpenAI 兼容协议、transcript 归一都会复用 c1/c2…)。所以一个 callId 在其
67
+ // result 之后再次以 called 出现,是新的一次调用,起一条新记录,不覆盖前一轮那条(否则跨轮
68
+ // 聚合会把前面几轮的工具调用抹成「只剩最后一轮」)。用 open*ByCallId 只跟踪各 callId 当前
69
+ // 敞口的那条,配上 result 即关闭。
70
+ const toolCalls: ToolCall[] = [];
71
+ const openToolByCallId = new Map<string, number>();
72
+ const subagentCalls: SubagentCall[] = [];
73
+ const openSubagentByCallId = new Map<string, number>();
68
74
  const inputRequests: InputRequest[] = [];
69
75
  let messageCount = 0;
70
76
  let compactions = 0;
@@ -81,8 +87,8 @@ export function deriveRunFacts(events: readonly StreamEvent[]): DerivedFacts {
81
87
  break;
82
88
 
83
89
  case "action.called": {
84
- if (!toolCallMap.has(ev.callId)) toolCallOrder.push(ev.callId);
85
- toolCallMap.set(ev.callId, {
90
+ openToolByCallId.set(ev.callId, toolCalls.length);
91
+ toolCalls.push({
86
92
  callId: ev.callId,
87
93
  name: ev.tool ?? "unknown",
88
94
  originalName: ev.name,
@@ -94,14 +100,14 @@ export function deriveRunFacts(events: readonly StreamEvent[]): DerivedFacts {
94
100
  }
95
101
 
96
102
  case "action.result": {
97
- const existing = toolCallMap.get(ev.callId);
98
- if (existing) {
99
- existing.output = ev.output;
100
- existing.status = ev.status;
103
+ const idx = openToolByCallId.get(ev.callId);
104
+ if (idx !== undefined) {
105
+ toolCalls[idx].output = ev.output;
106
+ toolCalls[idx].status = ev.status;
107
+ openToolByCallId.delete(ev.callId);
101
108
  } else {
102
109
  // 只有结果、没配上调用:补一条占位 ToolCall。
103
- toolCallOrder.push(ev.callId);
104
- toolCallMap.set(ev.callId, {
110
+ toolCalls.push({
105
111
  callId: ev.callId,
106
112
  name: "unknown",
107
113
  input: null,
@@ -113,8 +119,8 @@ export function deriveRunFacts(events: readonly StreamEvent[]): DerivedFacts {
113
119
  }
114
120
 
115
121
  case "subagent.called": {
116
- if (!subagentMap.has(ev.callId)) subagentOrder.push(ev.callId);
117
- subagentMap.set(ev.callId, {
122
+ openSubagentByCallId.set(ev.callId, subagentCalls.length);
123
+ subagentCalls.push({
118
124
  callId: ev.callId,
119
125
  name: ev.name,
120
126
  remoteUrl: ev.remoteUrl,
@@ -125,13 +131,13 @@ export function deriveRunFacts(events: readonly StreamEvent[]): DerivedFacts {
125
131
  }
126
132
 
127
133
  case "subagent.completed": {
128
- const existing = subagentMap.get(ev.callId);
129
- if (existing) {
130
- existing.output = ev.output;
131
- existing.status = ev.status;
134
+ const idx = openSubagentByCallId.get(ev.callId);
135
+ if (idx !== undefined) {
136
+ subagentCalls[idx].output = ev.output;
137
+ subagentCalls[idx].status = ev.status;
138
+ openSubagentByCallId.delete(ev.callId);
132
139
  } else {
133
- subagentOrder.push(ev.callId);
134
- subagentMap.set(ev.callId, {
140
+ subagentCalls.push({
135
141
  callId: ev.callId,
136
142
  name: "unknown",
137
143
  output: ev.output,
@@ -164,8 +170,8 @@ export function deriveRunFacts(events: readonly StreamEvent[]): DerivedFacts {
164
170
  }
165
171
 
166
172
  return {
167
- toolCalls: toolCallOrder.map((id) => toolCallMap.get(id)!),
168
- subagentCalls: subagentOrder.map((id) => subagentMap.get(id)!),
173
+ toolCalls,
174
+ subagentCalls,
169
175
  inputRequests,
170
176
  parked,
171
177
  messageCount,
@@ -0,0 +1,201 @@
1
+ // cases: docs/engineering/testing/unit/experiments-runner.md
2
+ // bug: memory/insandbox-otlp-port-wait-3s-no-retry.md
3
+ // 沙箱内 OTLP 采集器的启动韧性:这条路径外面没有任何重试兜底(命令执行不进 IO 重试、
4
+ // provision 重试只覆盖 create、runner 没有 attempt 级重试),起不来就是一条 errored attempt。
5
+ // 两层 fixture:
6
+ // · 脚本化 fake sandbox —— 重试轮次、每轮换路径、重试前杀上一轮,确定性断言;
7
+ // · 真实 /bin/sh 执行 —— 生成的 shell 脚本本身能跑(语法、算术、退出边),以及采集器进程
8
+ // 一起来就死时不空等满预算(观察实测耗时,不看脚本字节)。
9
+
10
+ import { mkdtemp, rm, writeFile, chmod, readdir } from "node:fs/promises";
11
+ import { spawn } from "node:child_process";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { Effect, Exit, Cause } from "effect";
15
+ import { afterEach, describe, expect, it } from "vitest";
16
+ import type { CommandResult, Sandbox } from "../../types.ts";
17
+ import { createInSandboxTraceReceiver } from "./sandbox-receiver.ts";
18
+
19
+ const notUsed = () => {
20
+ throw new Error("not used by the in-sandbox receiver");
21
+ };
22
+
23
+ function baseSandbox(overrides: Partial<Sandbox>): Sandbox {
24
+ return {
25
+ workdir: "/work",
26
+ sandboxId: "fake",
27
+ otlpHost: null,
28
+ runCommand: notUsed,
29
+ runShell: notUsed,
30
+ readFile: notUsed,
31
+ fileExists: notUsed,
32
+ writeFiles: async () => {},
33
+ uploadFiles: notUsed,
34
+ uploadDirectory: notUsed,
35
+ downloadDirectory: notUsed,
36
+ downloadFile: notUsed,
37
+ uploadFile: notUsed,
38
+ stop: async () => {},
39
+ ...overrides,
40
+ } as Sandbox;
41
+ }
42
+
43
+ const isStart = (script: string) => script.includes("niceeval-otlp-collector") && script.includes("&");
44
+ const isKill = (script: string) => script.trimStart().startsWith("kill ");
45
+
46
+ /**
47
+ * 启动脚本按轮次回放一条预置 stdout(`PID\n端口`,端口空表示这一轮没等到),日志读取回放
48
+ * 固定内容,kill 一律成功。按脚本形态派发而不是按调用序号——被测实现每轮发几条命令是它自己
49
+ * 的事,fixture 不该把这个数字焊死。
50
+ */
51
+ function scriptedSandbox(startOutputs: string[], log = "") {
52
+ const shells: string[] = [];
53
+ const written: string[] = [];
54
+ const sandbox = baseSandbox({
55
+ writeFiles: async (files: Record<string, string>) => {
56
+ written.push(...Object.keys(files));
57
+ },
58
+ runShell: async (script: string): Promise<CommandResult> => {
59
+ shells.push(script);
60
+ const stdout = isStart(script) ? (startOutputs.shift() ?? "") : script.startsWith("cat ") ? log : "";
61
+ return { stdout, stderr: "", exitCode: 0 };
62
+ },
63
+ });
64
+ return { sandbox, shells, written };
65
+ }
66
+
67
+ /** 在 Scope 内取端点,并把「此刻为止发过的命令」快照出来——release 阶段的收尾 kill 不混进来。 */
68
+ async function receiverExit(sandbox: Sandbox, shells: string[] = []) {
69
+ return Effect.runPromiseExit(
70
+ Effect.scoped(
71
+ Effect.gen(function* () {
72
+ const receiver = yield* createInSandboxTraceReceiver(sandbox);
73
+ return { endpoint: receiver.endpoint(""), shells: [...shells] };
74
+ }),
75
+ ),
76
+ );
77
+ }
78
+
79
+ describe("沙箱内 OTLP 采集器启动:脚本化 fixture", () => {
80
+ it("第一轮拿不到端口时重试,换一套路径并先杀掉上一轮的进程", async () => {
81
+ // 第一轮:拿到 PID 但端口行为空(采集器慢/死);第二轮:端口写回来了。
82
+ const { sandbox, shells, written } = scriptedSandbox(["4242\n", "4243\n61000"]);
83
+
84
+ const exit = await receiverExit(sandbox, shells);
85
+
86
+ expect(Exit.isSuccess(exit) && exit.value.endpoint).toBe("http://127.0.0.1:61000/v1/traces");
87
+ const sent = Exit.isSuccess(exit) ? exit.value.shells : [];
88
+ expect(sent.filter(isStart)).toHaveLength(2);
89
+ // 上一轮那个「慢但还活着」的采集器必须被杀掉:留着它会占内存,还会在重试之后才把端口
90
+ // 写进它自己那份文件。
91
+ expect(sent.filter(isKill)).toEqual([expect.stringContaining("kill 4242")]);
92
+ // 每轮换一套带随机后缀的脚本路径:上一轮迟到的采集器写不进新一轮的端口 / spans 文件。
93
+ expect(written).toHaveLength(2);
94
+ expect(written[0]).not.toBe(written[1]);
95
+ });
96
+
97
+ it("首轮就拿到端口时只起一次,不杀任何进程", async () => {
98
+ const { sandbox, shells } = scriptedSandbox(["4242\n61001"]);
99
+
100
+ const exit = await receiverExit(sandbox, shells);
101
+
102
+ expect(Exit.isSuccess(exit) && exit.value.endpoint).toBe("http://127.0.0.1:61001/v1/traces");
103
+ const sent = Exit.isSuccess(exit) ? exit.value.shells : [];
104
+ expect(sent.filter(isStart)).toHaveLength(1);
105
+ expect(sent.filter(isKill)).toEqual([]);
106
+ });
107
+
108
+ it("重试用尽后抛错,带上预算、轮次与采集器自己的日志", async () => {
109
+ const { sandbox, shells } = scriptedSandbox(["4242\n", "4243\n"], "node: not found");
110
+
111
+ const exit = await receiverExit(sandbox, shells);
112
+
113
+ expect(Exit.isFailure(exit)).toBe(true);
114
+ const message = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
115
+ expect(String(message)).toContain("within 20s");
116
+ expect(String(message)).toContain("2 attempts");
117
+ expect(String(message)).toContain("node: not found");
118
+ expect(shells.filter(isStart)).toHaveLength(2);
119
+ });
120
+ });
121
+
122
+ describe("沙箱内 OTLP 采集器启动:真实 /bin/sh 执行", () => {
123
+ const dirs: string[] = [];
124
+ const pids: number[] = [];
125
+
126
+ afterEach(async () => {
127
+ for (const pid of pids.splice(0)) {
128
+ try {
129
+ process.kill(pid);
130
+ } catch {
131
+ // 已经退出
132
+ }
133
+ }
134
+ for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true });
135
+ // 本用例真的会往 /tmp 写采集器脚本 / 端口文件,跑完清掉自己那批。
136
+ for (const name of await readdir(tmpdir())) {
137
+ if (name.startsWith(".niceeval-otlp-")) await rm(join(tmpdir(), name), { force: true }).catch(() => {});
138
+ }
139
+ });
140
+
141
+ /** runShell 真的交给 /bin/sh 跑,writeFiles 真的落盘——生成的脚本语法错误在这里现形。 */
142
+ function shellSandbox(pathPrefix?: string) {
143
+ return baseSandbox({
144
+ writeFiles: async (files: Record<string, string>) => {
145
+ for (const [path, content] of Object.entries(files)) await writeFile(path, content);
146
+ },
147
+ runShell: (script: string) =>
148
+ new Promise<CommandResult>((resolve) => {
149
+ const child = spawn("/bin/sh", ["-c", script], {
150
+ env: { ...process.env, ...(pathPrefix ? { PATH: `${pathPrefix}:${process.env.PATH}` } : {}) },
151
+ });
152
+ let stdout = "";
153
+ let stderr = "";
154
+ child.stdout.on("data", (c) => (stdout += c));
155
+ child.stderr.on("data", (c) => (stderr += c));
156
+ child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? 0 }));
157
+ }),
158
+ });
159
+ }
160
+
161
+ it("真实 shell 下起得来:端口写回宿主,采集器确实在监听", async () => {
162
+ const sandbox = shellSandbox();
163
+
164
+ const result = await Effect.runPromise(
165
+ Effect.scoped(
166
+ Effect.gen(function* () {
167
+ const receiver = yield* createInSandboxTraceReceiver(sandbox);
168
+ const endpoint = receiver.endpoint("");
169
+ const status = yield* Effect.promise(async () => {
170
+ const res = await fetch(endpoint, {
171
+ method: "POST",
172
+ headers: { "content-type": "application/json" },
173
+ body: "{}",
174
+ });
175
+ return res.status;
176
+ });
177
+ return { endpoint, status };
178
+ }),
179
+ ),
180
+ );
181
+
182
+ expect(result.endpoint).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1\/traces$/);
183
+ expect(result.status).toBe(200);
184
+ });
185
+
186
+ it("采集器一起来就死时不空等满预算,错误里带着它的日志", async () => {
187
+ const shimDir = await mkdtemp(join(tmpdir(), "niceeval-node-shim-"));
188
+ dirs.push(shimDir);
189
+ await writeFile(join(shimDir, "node"), '#!/bin/sh\necho "node: simulated crash" >&2\nexit 1\n');
190
+ await chmod(join(shimDir, "node"), 0o755);
191
+
192
+ const startedAt = Date.now();
193
+ const exit = await receiverExit(shellSandbox(shimDir));
194
+ const elapsed = Date.now() - startedAt;
195
+
196
+ expect(Exit.isFailure(exit)).toBe(true);
197
+ expect(String(Exit.isFailure(exit) ? Cause.squash(exit.cause) : "")).toContain("node: simulated crash");
198
+ // 进程已死时循环立刻 break,两轮加起来也远小于一轮的等待预算(20s)。
199
+ expect(elapsed).toBeLessThan(5_000);
200
+ });
201
+ });
@@ -11,7 +11,8 @@
11
11
  // 6. close() 尝试 kill PID(沙箱本身也会停,所以 best-effort)
12
12
  //
13
13
  // 文件路径带随机后缀:同一沙箱跨 eval 复用时,每个 receiver 实例的脚本 / spans /
14
- // 端口文件互不串扰,collector 也不会读到上一个 eval 的 span。
14
+ // 端口文件互不串扰,collector 也不会读到上一个 eval 的 span。启动重试同样每轮换一套后缀
15
+ // (见 startCollector)。
15
16
 
16
17
  import { randomUUID } from "node:crypto";
17
18
  import { Effect } from "effect";
@@ -56,6 +57,16 @@ server.listen(0, '127.0.0.1', () => {
56
57
  `;
57
58
  }
58
59
 
60
+ // 端口等待预算。这条路径外面没有任何一层重试兜着——`runShell` 不进 `withSandboxIoRetry`
61
+ // (命令执行有不可重复副作用),`withProvisionRetry` 只覆盖沙箱 create,runner 也没有
62
+ // attempt 级重试:等不到端口就是一条 errored attempt,agent 一次都没跑
63
+ // (memory/insandbox-otlp-port-wait-3s-no-retry.md)。所以预算按冷沙箱首次起 node 的最坏
64
+ // 情况给,而不是按热沙箱的常见值给。
65
+ const PORT_WAIT_MS = 20_000;
66
+ // 启动整体的重试次数。慢不需要重试(一次等满预算就够),这里兜的是 collector 起来就死
67
+ // (镜像里没有 node、脚本被 OOM kill 等)——那种情况下沙箱侧循环会立刻 break,重试很便宜。
68
+ const START_ATTEMPTS = 2;
69
+
59
70
  export function createInSandboxTraceReceiver(sandbox: Sandbox) {
60
71
  return Effect.acquireRelease(
61
72
  Effect.promise(() => makeInSandboxReceiver(sandbox)),
@@ -63,35 +74,70 @@ export function createInSandboxTraceReceiver(sandbox: Sandbox) {
63
74
  );
64
75
  }
65
76
 
66
- async function makeInSandboxReceiver(sandbox: Sandbox): Promise<TraceReceiver> {
67
- let cached: TraceSpan[] = [];
77
+ interface StartedCollector {
78
+ pid: number;
79
+ port: number;
80
+ spansPath: string;
81
+ }
68
82
 
69
- const tag = randomUUID().slice(0, 8);
70
- const collectorPath = `/tmp/.niceeval-otlp-collector-${tag}.cjs`;
71
- const spansPath = `/tmp/.niceeval-otlp-spans-${tag}.jsonl`;
72
- const portPath = `/tmp/.niceeval-otlp-port-${tag}`;
73
- const logPath = `/tmp/.niceeval-otlp-collector-${tag}.log`;
74
-
75
- // 上传 collector 脚本
76
- await sandbox.writeFiles({ [collectorPath]: collectorScript(spansPath, portPath) });
77
-
78
- // 后台启动 + 等端口文件,折进一次 shell 往返(远程沙箱一次 exec 要 100-500ms,
79
- // host 侧逐次轮询会把几秒的启动等待放大成 N 个 API round-trip)。
80
- // 输出两行:PID、端口(等不到则空)。
81
- const startResult = await sandbox.runShell(
82
- `node ${collectorPath} >${logPath} 2>&1 & echo $!; ` +
83
- `i=0; while [ $i -lt 30 ] && [ ! -s ${portPath} ]; do sleep 0.1; i=$((i+1)); done; ` +
84
- `cat ${portPath} 2>/dev/null || true`,
85
- );
86
- const [pidLine, portLine] = startResult.stdout.trim().split("\n");
87
- const pid = parseInt((pidLine ?? "").trim(), 10);
88
- const port = parseInt((portLine ?? "").trim(), 10) || 0;
89
- if (!port) {
90
- const log = await sandbox.runShell(`cat ${logPath} 2>/dev/null || true`).catch(() => undefined);
91
- throw new Error(
92
- `in-sandbox OTLP collector failed to report its port within 3s. Collector log:\n${log?.stdout.trim() || "(empty)"}`,
83
+ /**
84
+ * 上传脚本 + 后台起 collector + 等端口文件,失败重试。每一轮换一套带随机后缀的路径:
85
+ * 上一轮那个「慢但还活着」的 collector 如果在重试之后才起来,会写回它自己那份端口 / spans
86
+ * 文件,不会污染新一轮。
87
+ */
88
+ async function startCollector(sandbox: Sandbox): Promise<StartedCollector> {
89
+ let lastLog = "";
90
+ for (let attempt = 1; ; attempt++) {
91
+ const tag = randomUUID().slice(0, 8);
92
+ const collectorPath = `/tmp/.niceeval-otlp-collector-${tag}.cjs`;
93
+ const spansPath = `/tmp/.niceeval-otlp-spans-${tag}.jsonl`;
94
+ const portPath = `/tmp/.niceeval-otlp-port-${tag}`;
95
+ const logPath = `/tmp/.niceeval-otlp-collector-${tag}.log`;
96
+
97
+ await sandbox.writeFiles({ [collectorPath]: collectorScript(spansPath, portPath) });
98
+
99
+ // 后台启动 + 等端口文件,折进一次 shell 往返(远程沙箱一次 exec 要 100-500ms,
100
+ // host 侧逐次轮询会把几秒的启动等待放大成 N 个 API round-trip)。循环两条退出边:
101
+ // · `kill -0` 失败 → collector 已经死了,别再空等满预算,立刻回 host 重试;
102
+ // · 到 deadline → 真的太慢。
103
+ // 预算按 `date +%s` 的墙钟算而不是数 tick:`sleep` 的小数秒支持因镜像而异,数 tick 会让
104
+ // 不支持小数的镜像瞬间跑完循环、伪装成「等了 20s」。
105
+ // 输出两行:PID、端口(等不到则空)。
106
+ const startResult = await sandbox.runShell(
107
+ `node ${collectorPath} >${logPath} 2>&1 & pid=$!; echo $pid; ` +
108
+ `end=$(( $(date +%s) + ${Math.ceil(PORT_WAIT_MS / 1000)} )); ` +
109
+ `while [ ! -s ${portPath} ]; do ` +
110
+ `kill -0 $pid 2>/dev/null || break; ` +
111
+ `[ "$(date +%s)" -lt "$end" ] || break; ` +
112
+ `sleep 0.1 2>/dev/null || sleep 1; ` +
113
+ `done; ` +
114
+ `cat ${portPath} 2>/dev/null || true`,
93
115
  );
116
+ const [pidLine, portLine] = startResult.stdout.trim().split("\n");
117
+ const pid = parseInt((pidLine ?? "").trim(), 10);
118
+ const port = parseInt((portLine ?? "").trim(), 10) || 0;
119
+ if (port) return { pid, port, spansPath };
120
+
121
+ const log = await sandbox.runShell(`cat ${logPath} 2>/dev/null || true`).catch(() => undefined);
122
+ lastLog = log?.stdout.trim() ?? "";
123
+ // 这一轮可能只是慢、进程还活着:重试前先杀掉,不留孤儿 collector 占内存
124
+ //(沙箱复用 / --keep-sandbox 下它会一直在)。
125
+ if (Number.isFinite(pid) && pid > 0) {
126
+ await sandbox.runShell(`kill ${pid} 2>/dev/null || true`).catch(() => {});
127
+ }
128
+ if (attempt >= START_ATTEMPTS) {
129
+ throw new Error(
130
+ `in-sandbox OTLP collector failed to report its port within ${Math.round(PORT_WAIT_MS / 1000)}s ` +
131
+ `(${attempt} attempts). Collector log:\n${lastLog || "(empty)"}`,
132
+ );
133
+ }
94
134
  }
135
+ }
136
+
137
+ async function makeInSandboxReceiver(sandbox: Sandbox): Promise<TraceReceiver> {
138
+ let cached: TraceSpan[] = [];
139
+
140
+ const { pid, port, spansPath } = await startCollector(sandbox);
95
141
 
96
142
  return {
97
143
  endpoint: (_host) => `http://127.0.0.1:${port}/v1/traces`,
@@ -0,0 +1,30 @@
1
+ // cases: docs/engineering/testing/unit/results.md
2
+ // 「Usage、facts 与失败命令证据落盘」桶恒互斥归一:bub tape 的 usage 是 Chat Completions
3
+ // 形状,prompt_tokens_details.cached_tokens 是 prompt_tokens 子集,聚合前扣减;
4
+ // 同对象的 cost 是实测计费,照常累进 costUSD。
5
+ // bug: memory/estimatecost-openai-inclusive-cache-double-billed.md
6
+
7
+ import { describe, expect, it } from "vitest";
8
+
9
+ import { parseBubTranscript } from "./bub.ts";
10
+
11
+ describe("parseBubTranscript usage 归一(OpenAI 口径)", () => {
12
+ it("cached_tokens 从 prompt_tokens 里扣出,cost 累进实测 costUSD", () => {
13
+ const line = JSON.stringify({
14
+ kind: "event",
15
+ payload: {
16
+ name: "run",
17
+ data: {
18
+ usage: {
19
+ prompt_tokens: 1000,
20
+ completion_tokens: 20,
21
+ prompt_tokens_details: { cached_tokens: 900 },
22
+ cost: 0.05,
23
+ },
24
+ },
25
+ },
26
+ });
27
+ const parsed = parseBubTranscript(line);
28
+ expect(parsed.usage).toMatchObject({ inputTokens: 100, cacheReadTokens: 900, outputTokens: 20, costUSD: 0.05 });
29
+ });
30
+ });
@@ -93,12 +93,15 @@ export function parseBubTranscript(raw: string | undefined): ParsedTranscript {
93
93
  // 从 event/run 的 data.usage(或 legacy 顶层 usage)累加用量。
94
94
  const addUsage = (usage: unknown): void => {
95
95
  if (!usage || typeof usage !== "object") return;
96
- const input = num(usage, "input_tokens", "prompt_tokens", "inputTokens");
96
+ const rawInput = num(usage, "input_tokens", "prompt_tokens", "inputTokens");
97
97
  const output = num(usage, "output_tokens", "completion_tokens", "outputTokens");
98
98
  let cache = num(usage, "cached_input_tokens", "cache_read_input_tokens", "cacheReadTokens");
99
99
  if (cache === 0) cache = num(get(usage, "prompt_tokens_details"), "cached_tokens");
100
100
  const cost = num(usage, "cost");
101
- if (input === 0 && output === 0 && cache === 0 && cost === 0) return;
101
+ if (rawInput === 0 && output === 0 && cache === 0 && cost === 0) return;
102
+ // Chat Completions 口径的 prompt_tokens 含缓存命中,cached_tokens 是其子集;
103
+ // 落互斥桶前扣掉(docs/feature/adapters/sdk/bub/cost.md)
104
+ const input = Math.max(0, rawInput - cache);
102
105
  inputTokens += input;
103
106
  outputTokens += output;
104
107
  cacheReadTokens += cache;
@@ -0,0 +1,19 @@
1
+ // cases: docs/engineering/testing/unit/results.md
2
+ // 「Usage、facts 与失败命令证据落盘」桶恒互斥归一:codex transcript 的 cached_input_tokens
3
+ // 是 input_tokens 子集,聚合前逐请求扣减。
4
+ // bug: memory/estimatecost-openai-inclusive-cache-double-billed.md
5
+
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { parseCodexTranscript } from "./codex.ts";
9
+
10
+ describe("parseCodexTranscript usage 归一(OpenAI 口径)", () => {
11
+ it("cached_input_tokens 从 input_tokens 里扣出,cache 单独成桶", () => {
12
+ const raw = [
13
+ JSON.stringify({ type: "turn.completed", usage: { input_tokens: 1000, cached_input_tokens: 900, output_tokens: 50 } }),
14
+ JSON.stringify({ type: "turn.completed", usage: { input_tokens: 2000, cached_input_tokens: 1700, output_tokens: 30 } }),
15
+ ].join("\n");
16
+ const parsed = parseCodexTranscript(raw);
17
+ expect(parsed.usage).toMatchObject({ inputTokens: 400, cacheReadTokens: 2600, outputTokens: 80, requests: 2 });
18
+ });
19
+ });
@@ -80,14 +80,17 @@ function readUsage(u: unknown): { input: number; output: number; cacheRead: numb
80
80
  }
81
81
  return 0;
82
82
  };
83
- const input = num("input_tokens", "prompt_tokens", "inputTokens", "promptTokens");
84
- const output = num("output_tokens", "completion_tokens", "outputTokens", "completionTokens");
83
+ const rawInput = num("input_tokens", "prompt_tokens", "inputTokens", "promptTokens");
85
84
  const cacheRead = num(
86
85
  "cached_input_tokens",
87
86
  "cache_read_input_tokens",
88
87
  "cache_read_tokens",
89
88
  "cacheReadTokens",
90
89
  );
90
+ // codex-rs TokenUsage 的 cached_input_tokens 是 input_tokens 的子集,落互斥桶前扣掉
91
+ // (docs/feature/adapters/sdk/codex-cli/cost.md)
92
+ const input = Math.max(0, rawInput - cacheRead);
93
+ const output = num("output_tokens", "completion_tokens", "outputTokens", "completionTokens");
91
94
  // codex-rs TokenUsage 结构体与 Responses API usage.output_tokens_details 都拆出这项:
92
95
  // 推理模型(o-series/gpt-5 系列)的思考 token,已计入 output 但值得单列展示。
93
96
  const reasoning = num("reasoning_output_tokens", "reasoning_tokens", "reasoningOutputTokens", "reasoningTokens");
package/src/o11y/types.ts CHANGED
@@ -6,18 +6,21 @@ import type { JsonValue, SourceLoc } from "../shared/types.ts";
6
6
  /**
7
7
  * 一次运行的 token 用量(沙箱型从 transcript/OTel span 的 `gen_ai.usage.*` 属性抠,remote 由
8
8
  * send 的 `Turn.usage` 直接返回)。每个字段只在协议真实提供该值时存在——原始协议没有 usage 时
9
- * 省略,不编造数值;不存在「默认 0」或「默认 1」的字段(docs/feature/results/architecture.md#usage)。
9
+ * 省略,不编造数值;不存在「默认 0」或「默认 1」的字段。三个输入侧桶恒互斥:相加才是送进模型的
10
+ * 完整上下文量;把协议原生口径归一到互斥是 adapter 的落值义务——OpenAI 系报「含缓存的输入总量 +
11
+ * 缓存命中子集」,落 inputTokens 前先扣掉子集(docs/feature/results/architecture.md#usage,
12
+ * 各协议明细见 docs/feature/adapters/sdk/<name>/cost.md)。
10
13
  */
11
14
  export interface Usage {
12
- /** 计费口径的输入 token 总量(协议报什么记什么,含 cache read 时如实包含,不换算)。 */
15
+ /** 未命中缓存、按全价计费的输入 token;与两个 cache 桶互斥。 */
13
16
  inputTokens?: number;
14
17
  /** 输出(completion)token 数。 */
15
18
  outputTokens?: number;
16
- /** 从缓存命中的输入部分;与 inputTokens 同一计量口径(省略表示该 agent 不上报此项)。 */
19
+ /** 从提示缓存命中的输入 token;独立计价桶,不包含在 inputTokens 里(省略表示该 agent 不上报此项)。 */
17
20
  cacheReadTokens?: number;
18
- /** 写入 prompt 缓存创建的 token 数(省略表示该 agent 不上报此项)。 */
21
+ /** 写入提示缓存的输入 token;独立计价桶,不包含在 inputTokens 里(省略表示该 agent 不上报此项)。 */
19
22
  cacheCreationTokens?: number;
20
- /** 推理(thinking)token 数,只在协议真实提供时存在(省略表示该 agent 不上报此项)。 */
23
+ /** 推理(thinking)token 数,outputTokens 的已含明细,单列展示用;只在协议真实提供时存在。 */
21
24
  reasoningTokens?: number;
22
25
  /** 真实发生的模型请求数。协议不提供请求计数就省略,绝不写 1 凑数。 */
23
26
  requests?: number;
@@ -12,12 +12,10 @@ export function UsageTable({ data, className }: { data: UsageTableData | null; c
12
12
  const rows: [string, string][] = [];
13
13
  if (data.turns !== undefined) rows.push(["turns", String(data.turns)]);
14
14
  if (data.toolCalls !== undefined) rows.push(["tool calls", String(data.toolCalls)]);
15
- // 未缓存输入是消费端派生量,只在两个输入都在场时显示;缺任一个回退显示原始
16
- // inputTokens(不猜 0),两种来源用不同行标签区分。
17
- if (data.uncachedInputTokens !== undefined) {
18
- rows.push(["uncached in", data.uncachedInputTokens.toLocaleString()]);
19
- } else if (usage?.inputTokens !== undefined) {
20
- rows.push(["in", usage.inputTokens.toLocaleString()]);
15
+ // 桶恒互斥,inputTokens 就是未缓存输入;"uncached in" 标签只在 cache 拆分在场时用,
16
+ // cache 桶缺席的数字不贴标注。
17
+ if (usage?.inputTokens !== undefined) {
18
+ rows.push([usage.cacheReadTokens !== undefined ? "uncached in" : "in", usage.inputTokens.toLocaleString()]);
21
19
  }
22
20
  if (usage?.cacheReadTokens !== undefined) rows.push(["cache read", usage.cacheReadTokens.toLocaleString()]);
23
21
  if (usage?.outputTokens !== undefined) rows.push(["out", usage.outputTokens.toLocaleString()]);
@@ -422,15 +422,12 @@ describe("Attempt 详情组件族:非空/空证据矩阵", () => {
422
422
  expect("turns" in data).toBe(true);
423
423
  });
424
424
 
425
- it("UsageTable:uncachedInputTokens 只在 inputTokens 与 cacheReadTokens 都存在时派生,缺任一不猜 0", () => {
425
+ it("UsageTable:桶恒互斥,inputTokens 落盘原样透传,不派生第二个输入字段", () => {
426
+ // inputTokens 本身就是未缓存输入(docs/feature/results/architecture.md#usage),
427
+ // "uncached in" 只是 face 层在 cacheReadTokens 在场时给的标注,data 不多一个字段。
426
428
  const both = usageTableData(evidenceOf({ result: resultOf({ usage: { inputTokens: 100, outputTokens: 1, cacheReadTokens: 40 } }) }))!;
427
- expect(both.uncachedInputTokens).toBe(60);
428
-
429
- // 只缺 cacheReadTokens(该 agent 不上报缓存命中):不派生,整字段省略(不回退猜 0)——
430
- // text 面此时回退显示原始 inputTokens,见 faces.ts::usageTableText。
431
- const noCacheRead = usageTableData(evidenceOf({ result: resultOf({ usage: { inputTokens: 100, outputTokens: 1 } }) }))!;
432
- expect(noCacheRead.uncachedInputTokens).toBeUndefined();
433
- expect("uncachedInputTokens" in noCacheRead).toBe(false);
429
+ expect(both.usage?.inputTokens).toBe(100);
430
+ expect("uncachedInputTokens" in both).toBe(false);
434
431
  });
435
432
 
436
433
  it("UsageTable:requests 缺失时不出现在 usage 对象里(落盘原样透传,不由 usageTableData 凑值)", () => {
@@ -486,8 +486,8 @@ export function attemptDiagnosticsData(evidence: AttemptEvidence): AttemptDiagno
486
486
  * identity 字段(locator/experimentId/evalId/attempt/verdict)恒有;turns/toolCalls 是 events
487
487
  * 派生(与 o11y.json 行为摘要同源,buildO11ySummary 与 o11y.json 落盘走同一份纯函数),没有
488
488
  * events 就整对省略——不因为其中一个恰好是 0 就当作"缺失"处理,0 是观测到的事实。
489
- * uncachedInputTokens 只在 inputTokens 与 cacheReadTokens 都存在时派生,缺任一个不猜 0
490
- * (text 面回退显示原始 inputTokens)。turns/toolCalls/usage 三者全部缺失时返回 null——
489
+ * token 桶恒互斥,inputTokens 本身就是未缓存输入,不派生第二个字段。
490
+ * turns/toolCalls/usage 三者全部缺失时返回 null——
491
491
  * 没有任何用量事实可摆,与其余叶子同一条"没有 usage 时零输出"规则。
492
492
  */
493
493
  export function usageTableData(evidence: AttemptEvidence): UsageTableData | null {
@@ -498,10 +498,6 @@ export function usageTableData(evidence: AttemptEvidence): UsageTableData | null
498
498
  const usage = result.usage;
499
499
  if (turns === undefined && toolCalls === undefined && usage === undefined) return null;
500
500
 
501
- const uncachedInputTokens =
502
- usage && typeof usage.inputTokens === "number" && typeof usage.cacheReadTokens === "number"
503
- ? usage.inputTokens - usage.cacheReadTokens
504
- : undefined;
505
501
  const estimatedCostUSD = attemptCostUSD(result);
506
502
 
507
503
  return {
@@ -513,7 +509,6 @@ export function usageTableData(evidence: AttemptEvidence): UsageTableData | null
513
509
  ...(turns !== undefined ? { turns } : {}),
514
510
  ...(toolCalls !== undefined ? { toolCalls } : {}),
515
511
  ...(usage !== undefined ? { usage } : {}),
516
- ...(uncachedInputTokens !== undefined ? { uncachedInputTokens } : {}),
517
512
  ...(estimatedCostUSD !== null ? { estimatedCostUSD } : {}),
518
513
  };
519
514
  }
@@ -314,14 +314,14 @@ export function usageTableText(data: UsageTableData | null, _ctx: TextContext):
314
314
  if (data === null) return "";
315
315
  const usage = data.usage;
316
316
 
317
- // 未缓存输入是消费端派生量,只在两个输入都在场时显示;缺任一个回退显示原始 inputTokens
318
- // (不猜 0),这里用 label 区分"派生值"与"原始值",不假装两者是同一件事。
317
+ // 桶恒互斥,inputTokens 就是未缓存输入;"uncached" 标注只在 cache 拆分真实在场时给,
318
+ // cache 桶缺席的数字不贴标注——不给没有拆分事实的数字暗示拆分。
319
319
  const inFragment =
320
- data.uncachedInputTokens !== undefined
321
- ? `${formatMetricValue(data.uncachedInputTokens)} uncached in`
322
- : usage?.inputTokens !== undefined
323
- ? `${formatMetricValue(usage.inputTokens)} in`
324
- : undefined;
320
+ usage?.inputTokens !== undefined
321
+ ? usage.cacheReadTokens !== undefined
322
+ ? `${formatMetricValue(usage.inputTokens)} uncached in`
323
+ : `${formatMetricValue(usage.inputTokens)} in`
324
+ : undefined;
325
325
  const cacheFragment = usage?.cacheReadTokens !== undefined ? `${formatMetricValue(usage.cacheReadTokens)} cache read` : undefined;
326
326
  const outFragment = usage?.outputTokens !== undefined ? `${formatMetricValue(usage.outputTokens)} out` : undefined;
327
327
  const inCache = [inFragment, cacheFragment].filter((s): s is string => s !== undefined).join(" + ");
@@ -594,9 +594,6 @@ export function validateUsageData(data: unknown): string | null {
594
594
  const usageProb = usageProblem(data.usage, "usage");
595
595
  if (usageProb !== null) return usageProb;
596
596
  }
597
- if (data.uncachedInputTokens !== undefined && typeof data.uncachedInputTokens !== "number") {
598
- return '"uncachedInputTokens" must be a number';
599
- }
600
597
  if (data.estimatedCostUSD !== undefined && typeof data.estimatedCostUSD !== "number") {
601
598
  return '"estimatedCostUSD" must be a number';
602
599
  }