niceeval 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +9 -9
  2. package/README.zh.md +14 -14
  3. package/docs/README.md +119 -0
  4. package/docs/adapters/README.md +60 -0
  5. package/docs/adapters/authoring.md +179 -0
  6. package/docs/adapters/coding-agent-skills-plugins.md +324 -0
  7. package/docs/adapters/collection.md +128 -0
  8. package/docs/adapters/contract.md +263 -0
  9. package/docs/adapters/reference/agent-eval.md +215 -0
  10. package/docs/adapters/reference/agent-loop-apis.md +69 -0
  11. package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
  12. package/docs/adapters/reference/eve-protocol.md +127 -0
  13. package/docs/adapters/reference/otel-genai.md +107 -0
  14. package/docs/adapters/reference/otel-instrumentation.md +65 -0
  15. package/docs/adapters/targets.md +99 -0
  16. package/docs/architecture.md +140 -0
  17. package/docs/assertions.md +388 -0
  18. package/docs/capabilities-by-construction.md +48 -0
  19. package/docs/cli.md +171 -0
  20. package/docs/concepts.md +106 -0
  21. package/docs/e2e-ci.md +295 -0
  22. package/docs/eval-authoring.md +319 -0
  23. package/docs/experiments.md +154 -0
  24. package/docs/getting-started.md +224 -0
  25. package/docs/multi-agent.md +145 -0
  26. package/docs/observability.md +333 -0
  27. package/docs/origin-integration.md +195 -0
  28. package/docs/references.md +35 -0
  29. package/docs/results-format.md +228 -0
  30. package/docs/runner.md +104 -0
  31. package/docs/sandbox.md +276 -0
  32. package/docs/scoring.md +119 -0
  33. package/docs/source-map.md +106 -0
  34. package/docs/tier-sync.md +177 -0
  35. package/docs/view.md +157 -0
  36. package/docs/vision.md +88 -0
  37. package/package.json +46 -17
  38. package/src/agents/ai-sdk-otel.ts +0 -0
  39. package/src/agents/ai-sdk.test.ts +377 -0
  40. package/src/agents/ai-sdk.ts +577 -0
  41. package/src/agents/bub.ts +30 -37
  42. package/src/agents/claude-code.ts +7 -4
  43. package/src/agents/codex.ts +15 -10
  44. package/src/agents/index.ts +43 -1
  45. package/src/agents/sdk-streams.test.ts +145 -0
  46. package/src/agents/sdk-streams.ts +457 -0
  47. package/src/agents/shared.ts +77 -39
  48. package/src/agents/streaming.test.ts +152 -0
  49. package/src/agents/streaming.ts +195 -0
  50. package/src/agents/types.ts +218 -0
  51. package/src/agents/ui-message-stream.test.ts +249 -0
  52. package/src/agents/ui-message-stream.ts +372 -0
  53. package/src/cli.ts +124 -77
  54. package/src/context/context.test.ts +203 -7
  55. package/src/context/context.ts +158 -49
  56. package/src/context/session.test.ts +96 -0
  57. package/src/context/session.ts +119 -9
  58. package/src/context/types.ts +205 -0
  59. package/src/define.ts +4 -14
  60. package/src/expect/index.ts +8 -71
  61. package/src/i18n/core.ts +28 -0
  62. package/src/i18n/en.ts +47 -5
  63. package/src/i18n/index.ts +5 -17
  64. package/src/i18n/zh-CN.ts +47 -5
  65. package/src/index.ts +12 -0
  66. package/src/loaders/index.ts +8 -39
  67. package/src/o11y/otlp/canonical.ts +7 -6
  68. package/src/o11y/otlp/mappers/codex.ts +10 -8
  69. package/src/o11y/otlp/mappers/index.ts +9 -21
  70. package/src/o11y/otlp/receiver.ts +25 -7
  71. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  72. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  73. package/src/o11y/otlp/turn-otel.ts +125 -0
  74. package/src/o11y/parsers/bub.ts +13 -11
  75. package/src/o11y/parsers/claude-code.ts +27 -51
  76. package/src/o11y/parsers/codex.ts +29 -46
  77. package/src/o11y/parsers/index.ts +5 -14
  78. package/src/o11y/tool-names.test.ts +61 -0
  79. package/src/o11y/tool-names.ts +74 -0
  80. package/src/o11y/types.ts +135 -0
  81. package/src/runner/attempt.ts +456 -0
  82. package/src/runner/fingerprint.ts +59 -0
  83. package/src/runner/remote-sandbox.ts +53 -0
  84. package/src/runner/report.ts +53 -0
  85. package/src/runner/reporters/artifacts.ts +25 -4
  86. package/src/runner/reporters/console.ts +2 -8
  87. package/src/runner/reporters/live.ts +2 -8
  88. package/src/runner/reporters/shared.ts +15 -0
  89. package/src/runner/reporters/table.ts +10 -62
  90. package/src/runner/run.ts +114 -619
  91. package/src/runner/types.ts +237 -0
  92. package/src/sandbox/checkpoint.ts +15 -13
  93. package/src/sandbox/docker-stream.ts +87 -0
  94. package/src/sandbox/docker.ts +42 -120
  95. package/src/sandbox/e2b.ts +24 -75
  96. package/src/sandbox/local-files.ts +33 -0
  97. package/src/sandbox/paths.test.ts +85 -0
  98. package/src/sandbox/paths.ts +45 -0
  99. package/src/sandbox/resolve.ts +10 -34
  100. package/src/sandbox/shell.ts +17 -0
  101. package/src/sandbox/source-files.ts +42 -2
  102. package/src/sandbox/types.ts +158 -0
  103. package/src/sandbox/vercel.ts +55 -71
  104. package/src/scoring/collector.ts +3 -2
  105. package/src/scoring/judge.ts +72 -146
  106. package/src/scoring/match.ts +79 -0
  107. package/src/scoring/types.ts +76 -0
  108. package/src/shared/aggregate.ts +48 -0
  109. package/src/shared/format.ts +20 -0
  110. package/src/shared/outcome.ts +48 -0
  111. package/src/shared/types.ts +37 -0
  112. package/src/types.ts +20 -911
  113. package/src/view/aggregate.ts +103 -0
  114. package/src/view/app/App.tsx +26 -5
  115. package/src/view/app/components/AttemptModal.tsx +12 -3
  116. package/src/view/app/components/CodeView.tsx +9 -5
  117. package/src/view/app/components/CopyControls.tsx +4 -4
  118. package/src/view/app/components/ExperimentTable.tsx +5 -5
  119. package/src/view/app/i18n.ts +31 -7
  120. package/src/view/app/lib/format.ts +17 -20
  121. package/src/view/app/lib/outcome.ts +4 -16
  122. package/src/view/app/main.tsx +3 -5
  123. package/src/view/app/pages/RunsPage.tsx +2 -2
  124. package/src/view/app/pages/TracesPage.tsx +2 -2
  125. package/src/view/app/shared.ts +2 -1
  126. package/src/view/app/types.ts +6 -43
  127. package/src/view/client-dist/app.css +1 -1
  128. package/src/view/client-dist/app.js +18 -18
  129. package/src/view/index.ts +24 -401
  130. package/src/view/loader.ts +234 -0
  131. package/src/view/server.ts +136 -0
  132. package/src/view/shared/types.ts +64 -0
  133. package/src/view/styles.css +60 -9
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
+ import { t } from "../i18n/index.ts";
5
6
 
6
7
  export async function loadJson<T = unknown>(path: string): Promise<T> {
7
8
  const raw = await readFile(resolve(process.cwd(), path), "utf-8");
@@ -10,47 +11,15 @@ export async function loadJson<T = unknown>(path: string): Promise<T> {
10
11
 
11
12
  export async function loadYaml<T = unknown>(path: string): Promise<T> {
12
13
  const raw = await readFile(resolve(process.cwd(), path), "utf-8");
13
- // 尽量用真正的 yaml 解析器(若项目装了);否则退回极简解析。
14
- // 用变量 specifier 避免 tsc 静态解析这个可选依赖。
14
+ // yaml 是可选依赖:用变量 specifier 避免 tsc 静态解析。装了就用真解析器;
15
+ // 没装直接报错并给出下一步 —— 不再退回手写的「极简 YAML」:它对嵌套 / 多行 /
16
+ // 锚点会静默解析出错误数据,让 eval 拿着错的 case 跑起来比直接失败更糟。
15
17
  const yamlPkg = "yaml";
18
+ let parse: (s: string) => unknown;
16
19
  try {
17
- const yaml = (await import(yamlPkg)) as { parse(s: string): unknown };
18
- return yaml.parse(raw) as T;
20
+ ({ parse } = (await import(yamlPkg)) as { parse(s: string): unknown });
19
21
  } catch {
20
- return parseSimpleYaml(raw) as T;
22
+ throw new Error(t("loaders.yamlMissing", { path }));
21
23
  }
22
- }
23
-
24
- /** 极简 YAML(只够 cases 列表这类扁平结构;复杂结构请 `pnpm add yaml`)。 */
25
- function parseSimpleYaml(text: string): unknown {
26
- const lines = text.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
27
- const root: Record<string, unknown> = {};
28
- let currentKey: string | undefined;
29
- let list: Record<string, unknown>[] | undefined;
30
- let item: Record<string, unknown> | undefined;
31
- for (const line of lines) {
32
- const m = /^(\s*)(- )?([\w.-]+)?:?\s?(.*)$/.exec(line);
33
- if (!m) continue;
34
- const [, indent, dash, key, value] = m;
35
- if (indent === "" && key && !dash) {
36
- currentKey = key;
37
- list = [];
38
- root[currentKey] = list;
39
- } else if (dash) {
40
- item = {};
41
- list?.push(item);
42
- if (key) item[key] = coerce(value);
43
- } else if (key && item) {
44
- item[key] = coerce(value);
45
- }
46
- }
47
- return root;
48
- }
49
-
50
- function coerce(v: string): unknown {
51
- const t = v.trim().replace(/^["']|["']$/g, "");
52
- if (t === "true") return true;
53
- if (t === "false") return false;
54
- if (t !== "" && !Number.isNaN(Number(t))) return Number(t);
55
- return t;
24
+ return parse(raw) as T;
56
25
  }
@@ -82,16 +82,17 @@ export function heuristicTag(span: TraceSpan): SpanTag {
82
82
  const existing = a[GENAI_OP];
83
83
  if (typeof existing === "string") return { op: existing, kind: kindFromOperation(existing) };
84
84
 
85
- // 工具执行。
85
+ // 工具执行。只认通用信号;agent 专属的 span 命名(如 codex 的 exec_command/apply_patch)
86
+ // 编码在该 agent 自己的 mapper 里,不渗进通用兜底。
86
87
  if ("tool_name" in a) return { op: OP_EXECUTE_TOOL, kind: "tool" };
87
- if (/(^|[._])(exec_command|apply_patch|write_stdin|execute_tool|run_command|handle_tool_call|dispatch_tool_call)/.test(ln)) {
88
+ if (/(^|[._])(execute_tool|run_command|handle_tool_call|dispatch_tool_call)/.test(ln)) {
88
89
  return { op: OP_EXECUTE_TOOL, kind: "tool" };
89
90
  }
90
91
 
91
92
  // 模型调用。
92
93
  if (keys.some((k) => k.startsWith("gen_ai.request") || k.startsWith("gen_ai.response"))) return { op: OP_CHAT, kind: "model" };
93
- if ("wire_api" in a || ("http.method" in a && "api.path" in a)) return { op: OP_CHAT, kind: "model" };
94
- if (/(^|[._])(run_sampling_request|try_run_sampling_request|stream_responses|receiving_stream|model_client|chat|completion)(\b|_|$)/.test(ln)) {
94
+ if ("http.method" in a && "api.path" in a) return { op: OP_CHAT, kind: "model" };
95
+ if (/(^|[._])(model_client|chat|completion)(\b|_|$)/.test(ln)) {
95
96
  return { op: OP_CHAT, kind: "model" };
96
97
  }
97
98
 
@@ -100,8 +101,8 @@ export function heuristicTag(span: TraceSpan): SpanTag {
100
101
  if (/(^|[._])invoke_agent(\b|_|$)/.test(ln)) return { op: OP_INVOKE_AGENT, kind: "agent" };
101
102
 
102
103
  // 回合 / 会话骨架(GenAI 无对应操作 → 只给 kind)。
103
- if ("turn.id" in a || keys.some((k) => k.startsWith("codex.turn"))) return { kind: "turn" };
104
- if (/^(codex\.exec|session_loop|run_turn)$/.test(ln) || /session_task\.turn|agent\.step/.test(ln)) return { kind: "turn" };
104
+ if ("turn.id" in a) return { kind: "turn" };
105
+ if (/^(session_loop|run_turn)$/.test(ln) || /agent\.step/.test(ln)) return { kind: "turn" };
105
106
 
106
107
  return { kind: "other" };
107
108
  }
@@ -13,23 +13,25 @@ function codexTag(span: TraceSpan): SpanTag {
13
13
  const ln = span.name.toLowerCase();
14
14
  const a = span.attributes ?? {};
15
15
 
16
- // 工具执行:带 tool_name/call_id,或 codex 的执行/补丁/路由 span 名。
17
- if ("tool_name" in a) return { op: OP_EXECUTE_TOOL, kind: "tool" };
18
- if (/(^|[._])(exec_command|apply_patch|write_stdin|handle_tool_call|dispatch_tool_call)/.test(ln)) {
16
+ // 只编码 codex 独有、通用 heuristic 认不出的命名;通用信号(tool_name / gen_ai.* /
17
+ // handle_tool_call / run_turn …)一律交给兜底,不在这里重复一遍。
18
+
19
+ // 工具执行:codex 的执行 / 补丁 span 名。
20
+ if (/(^|[._])(exec_command|apply_patch|write_stdin)/.test(ln)) {
19
21
  return { op: OP_EXECUTE_TOOL, kind: "tool" };
20
22
  }
21
23
 
22
- // 模型调用:codex 的采样 / 流式 span,或到模型后端的 HTTP(wire_api / responses)。
24
+ // 模型调用:codex 的采样 / 流式 span,或 wire_api 属性(responses 后端)。
23
25
  if ("wire_api" in a) return { op: OP_CHAT, kind: "model" };
24
26
  if (/(^|[._])(run_sampling_request|try_run_sampling_request|stream_responses|receiving_stream)(\b|_|$)/.test(ln)) {
25
27
  return { op: OP_CHAT, kind: "model" };
26
28
  }
27
29
 
28
- // 回合 / 会话骨架:codex.exec(一次 exec 调用)、run_turn、turn.id 属性。GenAI 无对应操作 → 只给 kind。
29
- if ("turn.id" in a || Object.keys(a).some((k) => k.startsWith("codex.turn"))) return { kind: "turn" };
30
- if (/^(codex\.exec|run_turn|session_loop)$/.test(ln) || /session_task\.turn/.test(ln)) return { kind: "turn" };
30
+ // 回合 / 会话骨架:codex.exec(一次 exec 调用)、codex.turn 属性。GenAI 无对应操作 → 只给 kind。
31
+ if (Object.keys(a).some((k) => k.startsWith("codex.turn"))) return { kind: "turn" };
32
+ if (/^codex\.exec$/.test(ln) || /session_task\.turn/.test(ln)) return { kind: "turn" };
31
33
 
32
- // codex 没专门命名的(handle_responses / append_items 等内部噪声)交给通用兜底 → 多半落 "other"。
34
+ // 其余(含 handle_responses / append_items 等内部噪声)交给通用兜底。
33
35
  return heuristicTag(span);
34
36
  }
35
37
 
@@ -1,25 +1,13 @@
1
1
  // span mapper 入口:把各 agent 的原生 OTLP span 归一到 canonical GenAI semconv。
2
- // transcript 解析器(o11y/parsers/index.ts)同一个分派模式 —— 按 agent 名包含匹配,
3
- // 认不出来走通用 heuristic 兜底。每个 mapper 是纯函数(不碰沙箱),住 core o11y、可独立单测。
2
+ // 每个 mapper 是纯函数(不碰沙箱),住 core o11y、可独立单测。
4
3
  //
5
- // 职责边界:本文件只管「发回来的 span 怎么读」;「沙箱里怎么让 agent OTLP 发出来」
4
+ // 分派不在这里:mapper 由各 Agent 通过 `spanMapper` 声明(见 types.ts Agent),
5
+ // 运行器只调接口 —— core 不按 agent 名字分支(架构规矩,见 docs/architecture.md)。
6
+ // 未声明 spanMapper 的 agent 走 canonical.ts 的通用 heuristic 兜底。
7
+ //
8
+ // 职责边界:本目录只管「发回来的 span 怎么读」;「沙箱里怎么让 agent 把 OTLP 发出来」
6
9
  //(写 config.toml / 注入 OTEL_* env)是 adapter 侧的导出配置,与此分开。
7
10
 
8
- import type { TraceSpan } from "../../../types.ts";
9
- import { mapGenericSpans } from "../canonical.ts";
10
- import { mapCodexSpans } from "./codex.ts";
11
- import { mapBubSpans } from "./bub.ts";
12
-
13
- export { mapCodexSpans, mapBubSpans };
14
-
15
- /**
16
- * 按 agent 名分派到对应 span mapper;认不出来的走通用 heuristic 兜底。
17
- * agent 名做包含匹配,兼容 "vercel-ai-gateway/codex" 之类变体(同 parseTranscript)。
18
- */
19
- export function mapSpansToCanonical(spans: TraceSpan[], agent: string): TraceSpan[] {
20
- const a = (agent || "").toLowerCase();
21
- if (a.includes("codex")) return mapCodexSpans(spans);
22
- if (a.includes("bub")) return mapBubSpans(spans);
23
- // claude-code 不发 OTLP(无 span 到这);其余未知 agent 走通用兜底。
24
- return mapGenericSpans(spans);
25
- }
11
+ export { mapCodexSpans } from "./codex.ts";
12
+ export { mapBubSpans } from "./bub.ts";
13
+ export { mapGenericSpans } from "../canonical.ts";
@@ -7,6 +7,7 @@ import { gunzipSync } from "node:zlib";
7
7
  import { Effect } from "effect";
8
8
  import type { TraceSpan } from "../../types.ts";
9
9
  import { parseOtlpTraces } from "./parse.ts";
10
+ import { t } from "../../i18n/index.ts";
10
11
 
11
12
  export interface TraceReceiver {
12
13
  /** agent 应导出到的完整端点(host 由后端定:docker → host.docker.internal)。 */
@@ -23,12 +24,21 @@ export interface TraceReceiver {
23
24
  * 在 Effect.scoped / Effect.gen 里 yield* 即可。
24
25
  */
25
26
  export function createTraceReceiver() {
26
- return Effect.acquireRelease(Effect.promise(makeReceiver), (r) =>
27
- Effect.promise(() => r.close().catch(() => {})),
27
+ return Effect.acquireRelease(
28
+ Effect.promise(() => makeTraceReceiver()),
29
+ (r) => Effect.promise(() => r.close().catch(() => {})),
28
30
  );
29
31
  }
30
32
 
31
- async function makeReceiver(): Promise<TraceReceiver> {
33
+ /**
34
+ * 直接创建接收器(非 Effect 场景:run 级共享池自己管生命周期)。
35
+ * port=0 挑临时端口;固定端口模式(config telemetry.port / NICEEVAL_OTLP_PORT)传定值。
36
+ */
37
+ export async function makeTraceReceiver(port = 0): Promise<TraceReceiver> {
38
+ return makeReceiver(port);
39
+ }
40
+
41
+ async function makeReceiver(port = 0): Promise<TraceReceiver> {
32
42
  const spans: TraceSpan[] = [];
33
43
  let lastAt = 0;
34
44
 
@@ -70,17 +80,25 @@ async function makeReceiver(): Promise<TraceReceiver> {
70
80
  req.on("error", () => res.writeHead(400).end());
71
81
  });
72
82
 
73
- const port = await new Promise<number>((resolve, reject) => {
74
- server.once("error", reject);
83
+ const boundPort = await new Promise<number>((resolve, reject) => {
84
+ server.once("error", (err) => {
85
+ // 固定端口模式(defineConfig({ telemetry: { port } }))下最常见的失败:翻成可操作的
86
+ // 提示而不是裸 EADDRINUSE 栈——用户不知道这是 OTLP 接收器占端口失败,还以为是别的 bug。
87
+ if (port !== 0 && (err as NodeJS.ErrnoException).code === "EADDRINUSE") {
88
+ reject(new Error(t("otel.portInUse", { port })));
89
+ } else {
90
+ reject(err);
91
+ }
92
+ });
75
93
  // 0.0.0.0:容器经 host-gateway / host.docker.internal 回连宿主,不能只听 127.0.0.1。
76
- server.listen(0, "0.0.0.0", () => {
94
+ server.listen(port, "0.0.0.0", () => {
77
95
  const a = server.address();
78
96
  resolve(typeof a === "object" && a ? a.port : 0);
79
97
  });
80
98
  });
81
99
 
82
100
  return {
83
- endpoint: (host) => `http://${host}:${port}/v1/traces`,
101
+ endpoint: (host) => `http://${host}:${boundPort}/v1/traces`,
84
102
  collect: () => spans.slice(),
85
103
  async settle(quietMs, maxMs) {
86
104
  // 等到「收到过 span 且静默 quietMs」或「整体超 maxMs」。还没收到任何 span 时
@@ -1,31 +1,35 @@
1
1
  // 在沙箱内部起一个轻量 OTLP/HTTP 接收器,供 e2b / vercel 等远程沙箱使用。
2
2
  // 流程:
3
3
  // 1. writeFiles 把 collector 脚本上传到沙箱
4
- // 2. runShell 在后台启动它(node ... & echo $!),拿 PID
5
- // 3. agent 往 http://127.0.0.1:4318/v1/traces span(sandbox localhost)
6
- // 4. settle() 等 agent 退出后下载 spans 文件并解析,缓存到内存
4
+ // 2. runShell 在后台启动它(node ... & echo $!),拿 PID;collector 用内核分配端口
5
+ // (listen 0)并把实际端口写回端口文件,host 轮询读回 —— 不写死 4318,沙箱里已有
6
+ // 服务占用端口时不会冲突
7
+ // 3. agent 往 http://127.0.0.1:<port>/v1/traces 发 span(sandbox 内 localhost)
8
+ // 4. settle() 轮询 spans 文件大小直到静默(而不是固定 sleep:等太久拖慢每个 eval,
9
+ // 等不够漏掉在途 batch),再下载解析,缓存到内存
7
10
  // 5. collect() 返回缓存(同步,与本地 receiver 接口一致)
8
11
  // 6. close() 尝试 kill PID(沙箱本身也会停,所以 best-effort)
12
+ //
13
+ // 文件路径带随机后缀:同一沙箱跨 eval 复用时,每个 receiver 实例的脚本 / spans /
14
+ // 端口文件互不串扰,collector 也不会读到上一个 eval 的 span。
9
15
 
16
+ import { randomUUID } from "node:crypto";
10
17
  import { Effect } from "effect";
11
18
  import type { TraceSpan } from "../../types.ts";
12
19
  import type { Sandbox } from "../../types.ts";
13
20
  import type { TraceReceiver } from "./receiver.ts";
14
21
  import { parseOtlpTraces } from "./parse.ts";
15
22
 
16
- const COLLECTOR_PORT = 4318;
17
- const COLLECTOR_PATH = "/tmp/.niceeval-otlp-collector.cjs";
18
- const SPANS_PATH = "/tmp/.niceeval-otlp-spans.jsonl";
19
-
20
23
  // collector 脚本:纯 Node.js CJS,无外部依赖。
21
- // 每收一个 OTLP/HTTP 请求就把 { ct, body(base64) } 追加写一行到 SPANS_PATH,
24
+ // 每收一个 OTLP/HTTP 请求就把 { ct, body(base64) } 追加写一行到 spans 文件,
22
25
  // 同时处理 gzip 解压——这样 host 侧直接复用 parseOtlpTraces 解析。
23
- const COLLECTOR_SCRIPT = /* js */ `
26
+ function collectorScript(spansPath: string, portPath: string): string {
27
+ return /* js */ `
24
28
  'use strict';
25
29
  const http = require('http');
26
30
  const fs = require('fs');
27
31
  const zlib = require('zlib');
28
- const OUT = ${JSON.stringify(SPANS_PATH)};
32
+ const OUT = ${JSON.stringify(spansPath)};
29
33
  const server = http.createServer((req, res) => {
30
34
  if (req.method !== 'POST') { res.writeHead(405).end(); return; }
31
35
  const chunks = [];
@@ -46,8 +50,11 @@ const server = http.createServer((req, res) => {
46
50
  });
47
51
  req.on('error', () => res.writeHead(400).end());
48
52
  });
49
- server.listen(${COLLECTOR_PORT}, '127.0.0.1');
53
+ server.listen(0, '127.0.0.1', () => {
54
+ try { fs.writeFileSync(${JSON.stringify(portPath)}, String(server.address().port)); } catch {}
55
+ });
50
56
  `;
57
+ }
51
58
 
52
59
  export function createInSandboxTraceReceiver(sandbox: Sandbox) {
53
60
  return Effect.acquireRelease(
@@ -59,26 +66,55 @@ export function createInSandboxTraceReceiver(sandbox: Sandbox) {
59
66
  async function makeInSandboxReceiver(sandbox: Sandbox): Promise<TraceReceiver> {
60
67
  let cached: TraceSpan[] = [];
61
68
 
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
+
62
75
  // 上传 collector 脚本
63
- await sandbox.writeFiles({ [COLLECTOR_PATH]: COLLECTOR_SCRIPT });
76
+ await sandbox.writeFiles({ [collectorPath]: collectorScript(spansPath, portPath) });
64
77
 
65
- // 后台启动,拿 PID(shell & 返回后台 PID;echo $! 给我们)
78
+ // 后台启动 + 等端口文件,折进一次 shell 往返(远程沙箱一次 exec 100-500ms,
79
+ // host 侧逐次轮询会把几秒的启动等待放大成 N 个 API round-trip)。
80
+ // 输出两行:PID、端口(等不到则空)。
66
81
  const startResult = await sandbox.runShell(
67
- `node ${COLLECTOR_PATH} >/tmp/.niceeval-otlp-collector.log 2>&1 & echo $!`,
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`,
68
85
  );
69
- const pid = parseInt(startResult.stdout.trim(), 10);
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)"}`,
93
+ );
94
+ }
70
95
 
71
96
  return {
72
- endpoint: (_host) => `http://127.0.0.1:${COLLECTOR_PORT}/v1/traces`,
97
+ endpoint: (_host) => `http://127.0.0.1:${port}/v1/traces`,
73
98
 
74
99
  collect: () => cached.slice(),
75
100
 
76
- // agent 结束后调:等片刻(让 OTEL exporter 把最后几个 batch flush 完),再下载解析。
101
+ // agent 结束后调:等 spans 文件大小连续 quietMs 无增长(exporter flush 完)再下载。
102
+ // 等待循环整个跑在沙箱内(一次 shell 往返),不从 host 逐次轮询。
77
103
  async settle(quietMs, maxMs) {
78
- const wait = Math.min(quietMs + 300, maxMs);
79
- await new Promise((r) => setTimeout(r, wait));
104
+ const quietTicks = Math.max(1, Math.round(quietMs / 100));
105
+ const maxTicks = Math.max(quietTicks, Math.round(maxMs / 100));
106
+ await sandbox
107
+ .runShell(
108
+ `prev=-1; stable=0; i=0; ` +
109
+ `while [ $i -lt ${maxTicks} ]; do ` +
110
+ `s=$(wc -c < ${spansPath} 2>/dev/null || echo 0); ` +
111
+ `if [ "$s" = "$prev" ]; then stable=$((stable+1)); [ $stable -ge ${quietTicks} ] && break; ` +
112
+ `else stable=0; prev=$s; fi; ` +
113
+ `sleep 0.1; i=$((i+1)); done`,
114
+ )
115
+ .catch(() => {});
80
116
  try {
81
- const raw = await sandbox.downloadFile(SPANS_PATH);
117
+ const raw = await sandbox.downloadFile(spansPath);
82
118
  cached = parseSpansFile(raw);
83
119
  } catch {
84
120
  // 没有 spans 文件(agent 没发任何 trace)→ 保留空数组
@@ -94,7 +130,7 @@ async function makeInSandboxReceiver(sandbox: Sandbox): Promise<TraceReceiver> {
94
130
  };
95
131
  }
96
132
 
97
- // SPANS_PATH 每行一个 { ct: string; b: string(base64) }
133
+ // spans 文件每行一个 { ct: string; b: string(base64) }
98
134
  function parseSpansFile(raw: Buffer): TraceSpan[] {
99
135
  const spans: TraceSpan[] = [];
100
136
  const text = raw.toString("utf8");
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import type { TraceSpan } from "../../types.ts";
3
+ import type { TraceReceiver } from "./receiver.ts";
4
+ import { AgentOtelChannel } from "./turn-otel.ts";
5
+
6
+ let seq = 0;
7
+ function span(traceId: string, startMs = Date.now()): TraceSpan {
8
+ seq += 1;
9
+ return { traceId, spanId: `s${seq}`, name: `op${seq}`, startMs, endMs: startMs + 1 };
10
+ }
11
+
12
+ function fakeReceiver(): TraceReceiver & { push(s: TraceSpan): void } {
13
+ const spans: TraceSpan[] = [];
14
+ return {
15
+ push: (s) => spans.push(s),
16
+ endpoint: (host) => `http://${host}:0/v1/traces`,
17
+ collect: () => spans.slice(),
18
+ settle: async () => {},
19
+ close: async () => {},
20
+ };
21
+ }
22
+
23
+ describe("AgentOtelChannel 归属", () => {
24
+ it("traceparent 命中:按 traceId 归属并确认(解除串行)", async () => {
25
+ const r = fakeReceiver();
26
+ const ch = new AgentOtelChannel(r);
27
+ expect(ch.serialized).toBe(true);
28
+ const turn = await ch.runTurn(async (headers) => {
29
+ const traceId = headers.traceparent.split("-")[1];
30
+ r.push(span(traceId));
31
+ r.push(span("someone-else"));
32
+ return "ok";
33
+ });
34
+ expect(turn.result).toBe("ok");
35
+ expect(turn.attribution).toBe("traceparent");
36
+ expect(turn.spans).toHaveLength(1);
37
+ expect(ch.serialized).toBe(false);
38
+ });
39
+
40
+ it("无 traceparent:窗口归属拿走全部未消费 span,且不重复分给下一轮", async () => {
41
+ const r = fakeReceiver();
42
+ const ch = new AgentOtelChannel(r);
43
+ const t1 = await ch.runTurn(async () => {
44
+ r.push(span("app-trace-a"));
45
+ r.push(span("app-trace-a"));
46
+ return 1;
47
+ });
48
+ expect(t1.attribution).toBe("window");
49
+ expect(t1.spans).toHaveLength(2);
50
+
51
+ const t2 = await ch.runTurn(async () => {
52
+ r.push(span("app-trace-b"));
53
+ return 2;
54
+ });
55
+ expect(t2.spans).toHaveLength(1);
56
+ expect(t2.spans[0].traceId).toBe("app-trace-b");
57
+ });
58
+
59
+ it("未确认前轮次串行:窗口不重叠", async () => {
60
+ const r = fakeReceiver();
61
+ const ch = new AgentOtelChannel(r);
62
+ const order: string[] = [];
63
+ const p1 = ch.runTurn(async () => {
64
+ order.push("a-start");
65
+ await new Promise((res) => setTimeout(res, 30));
66
+ r.push(span("x"));
67
+ order.push("a-end");
68
+ return "a";
69
+ });
70
+ const p2 = ch.runTurn(async () => {
71
+ order.push("b-start");
72
+ r.push(span("y"));
73
+ return "b";
74
+ });
75
+ const [t1, t2] = await Promise.all([p1, p2]);
76
+ expect(order).toEqual(["a-start", "a-end", "b-start"]);
77
+ expect(t1.spans.map((s) => s.traceId)).toEqual(["x"]);
78
+ expect(t2.spans.map((s) => s.traceId)).toEqual(["y"]);
79
+ });
80
+
81
+ it("确认后只按 traceId 归属,不回退窗口(并发不混流)", async () => {
82
+ const r = fakeReceiver();
83
+ const ch = new AgentOtelChannel(r);
84
+ await ch.runTurn(async (headers) => {
85
+ r.push(span(headers.traceparent.split("-")[1]));
86
+ return 0;
87
+ });
88
+ // 应用这轮丢了 traceparent → 0 归属,而不是把别人的 span 抢来
89
+ const t = await ch.runTurn(async () => {
90
+ r.push(span("orphan"));
91
+ return 1;
92
+ });
93
+ expect(t.spans).toHaveLength(0);
94
+ expect(t.attribution).toBe("traceparent");
95
+ });
96
+
97
+ it("sweep:按本 attempt 的 traceId 捞迟到 span,只捞一次", async () => {
98
+ const r = fakeReceiver();
99
+ const ch = new AgentOtelChannel(r);
100
+ const turn = await ch.runTurn(async (headers) => {
101
+ r.push(span(headers.traceparent.split("-")[1]));
102
+ return 0;
103
+ });
104
+ // 迟到批:turn 结束后才被导出
105
+ r.push(span(turn.traceId));
106
+ const late = await ch.sweep(new Set([turn.traceId]));
107
+ expect(late).toHaveLength(1);
108
+ expect(await ch.sweep(new Set([turn.traceId]))).toHaveLength(0);
109
+ });
110
+ });
@@ -0,0 +1,125 @@
1
+ // 非沙箱 agent 的 OTLP 接收:run 级共享 receiver + 逐轮 span 归属。
2
+ //
3
+ // 为什么共享:被测应用是长驻进程,只有一条全局 OTel 管线、一个导出目标(OTEL_* env 进程
4
+ // 启动时读一次)——per-attempt receiver 会在第一个 attempt 结束时关掉端口,后续 attempt
5
+ // 的 span 全部丢失。所以粒度跟【被测进程】走:每个 agent(= 一个应用)整个 run 共享一个
6
+ // receiver;沙箱 agent 不走这里(每沙箱独立进程,per-attempt receiver 天然正确)。
7
+ //
8
+ // 共享流之下的逐轮归属(设计:docs/observability.md「span 怎么归属到轮」):
9
+ // 1. traceparent(并发安全):每轮生成新 trace context 经 ctx.telemetry.headers 交给
10
+ // adapter 随请求带上;应用埋点支持 W3C 传播的,本轮 span 挂在我们给的 traceId 下。
11
+ // 2. 窗口(兜底,仅串行可靠):没见过 traceparent 生效前,同 agent 的轮次串行执行
12
+ // (并发守卫),把「本轮结束时新到且未被消费的 span」归给本轮。
13
+ // 一旦按 traceId 命中过一次,守卫解除,后续并发随便开、只按 traceId 归属。
14
+
15
+ import { randomBytes } from "node:crypto";
16
+ import type { TraceSpan } from "../../types.ts";
17
+ import type { TraceReceiver } from "./receiver.ts";
18
+ import { makeTraceReceiver } from "./receiver.ts";
19
+
20
+ export interface TurnSpans {
21
+ /** 本轮生成的 traceId(供 attempt 末尾 sweep 迟到 span)。 */
22
+ traceId: string;
23
+ /** 归属到本轮的 span。 */
24
+ spans: TraceSpan[];
25
+ /** 本轮归属用的是 traceparent 还是时间窗口(日志/守卫用)。 */
26
+ attribution: "traceparent" | "window";
27
+ }
28
+
29
+ /** 一个 agent(= 一个被测进程)整个 run 的 OTLP 通道:receiver + 归属状态。 */
30
+ export class AgentOtelChannel {
31
+ /** traceparent 已确认生效(收到过带我们 traceId 的 span)→ 并发守卫解除。 */
32
+ private confirmed = false;
33
+ /** 串行守卫:未确认 traceparent 时,同 agent 的轮次挨个跑(promise 链当互斥锁)。 */
34
+ private chain: Promise<unknown> = Promise.resolve();
35
+ /** 已归属过的 span(spanId),共享流里不能重复分给两轮。 */
36
+ private readonly consumed = new Set<string>();
37
+
38
+ constructor(readonly receiver: TraceReceiver) {}
39
+
40
+ /**
41
+ * 跑一轮:fn 拿到本轮的 W3C trace context headers(spread 进请求头 / 注入子进程 env),
42
+ * 返回后 settle 接收器、归属本轮 span。未确认 traceparent 前整段(含 fn)串行。
43
+ */
44
+ async runTurn<T>(fn: (headers: Readonly<Record<string, string>>) => Promise<T>): Promise<{ result: T } & TurnSpans> {
45
+ const traceId = randomBytes(16).toString("hex");
46
+ const headers = { traceparent: `00-${traceId}-${randomBytes(8).toString("hex")}-01` };
47
+
48
+ const exec = async (): Promise<{ result: T } & TurnSpans> => {
49
+ const result = await fn(headers);
50
+ // 给本轮最后一批导出留落地时间(SimpleSpanProcessor 即发;Batch 的 5s 定时兜不住,
51
+ // 迟到的由 attempt 末尾 sweep 按 traceId 捞回)。
52
+ await this.receiver.settle(200, 1200);
53
+ const fresh = this.receiver.collect().filter((s) => !this.consumed.has(s.spanId));
54
+ const matched = fresh.filter((s) => s.traceId === traceId);
55
+ let spans: TraceSpan[];
56
+ let attribution: TurnSpans["attribution"];
57
+ if (matched.length > 0) {
58
+ this.confirmed = true;
59
+ spans = matched;
60
+ attribution = "traceparent";
61
+ } else if (this.confirmed) {
62
+ // 已确认过 traceparent 的通道只按 traceId 归属 —— 窗口在并发下必混流,不回退。
63
+ spans = [];
64
+ attribution = "traceparent";
65
+ } else {
66
+ spans = fresh;
67
+ attribution = "window";
68
+ }
69
+ for (const s of spans) this.consumed.add(s.spanId);
70
+ return { result, traceId, spans, attribution };
71
+ };
72
+
73
+ if (this.confirmed) return exec();
74
+
75
+ // 未确认:挂到串行链上。链上的前序轮次结束(含它的 settle)后才开跑,窗口不重叠。
76
+ const prev = this.chain;
77
+ const p = prev.then(exec, exec);
78
+ this.chain = p.catch(() => {});
79
+ return p;
80
+ }
81
+
82
+ /** 是否仍在串行守卫下(日志提示用)。 */
83
+ get serialized(): boolean {
84
+ return !this.confirmed;
85
+ }
86
+
87
+ /** attempt 末尾:按本 attempt 的 traceId 集合捞迟到的 span(Batch 导出等)。 */
88
+ async sweep(traceIds: ReadonlySet<string>): Promise<TraceSpan[]> {
89
+ if (traceIds.size === 0) return [];
90
+ await this.receiver.settle(200, 1000);
91
+ const late = this.receiver
92
+ .collect()
93
+ .filter((s) => !this.consumed.has(s.spanId) && traceIds.has(s.traceId));
94
+ for (const s of late) this.consumed.add(s.spanId);
95
+ return late;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * run 级共享池:每个 agent 一个通道(懒建)。固定端口模式(defineConfig({ telemetry: { port } }))
101
+ * 下全部 agent 共用一个通道 —— 端口只有一个,归属守卫也必须全局共享。
102
+ */
103
+ export class OtelReceiverPool {
104
+ private readonly channels = new Map<string, Promise<AgentOtelChannel>>();
105
+
106
+ constructor(private readonly fixedPort?: number) {}
107
+
108
+ channel(agentName: string): Promise<AgentOtelChannel> {
109
+ const key = this.fixedPort !== undefined ? "*fixed-port*" : agentName;
110
+ let ch = this.channels.get(key);
111
+ if (!ch) {
112
+ ch = makeTraceReceiver(this.fixedPort ?? 0).then((r) => new AgentOtelChannel(r));
113
+ this.channels.set(key, ch);
114
+ }
115
+ return ch;
116
+ }
117
+
118
+ async close(): Promise<void> {
119
+ const all = [...this.channels.values()];
120
+ this.channels.clear();
121
+ await Promise.all(
122
+ all.map((p) => p.then((ch) => ch.receiver.close()).catch(() => {})),
123
+ );
124
+ }
125
+ }
@@ -13,19 +13,21 @@
13
13
 
14
14
  import type { StreamEvent, Usage, ToolName, JsonValue } from "../../types.ts";
15
15
  import type { ParsedTranscript } from "./index.ts";
16
+ import { GENERIC_VERB_ALIASES, normalizeToolName as normalizeShared } from "../tool-names.ts";
17
+
18
+ /** Bub 特有别名(fs.* / web.* 命名空间与裸 read/write/edit)+ 裸动词;通用别名走基表。 */
19
+ export const BUB_TOOL_ALIASES: Record<string, ToolName> = {
20
+ ...GENERIC_VERB_ALIASES,
21
+ "fs.read": "file_read", fs_read: "file_read", read: "file_read",
22
+ "fs.write": "file_write", fs_write: "file_write", write: "file_write",
23
+ "fs.edit": "file_edit", fs_edit: "file_edit", edit: "file_edit",
24
+ "web.fetch": "web_fetch",
25
+ "web.search": "web_search",
26
+ update_todos: "agent_task",
27
+ };
16
28
 
17
29
  function normalizeToolName(name: string): ToolName {
18
- const toolMap: Record<string, ToolName> = {
19
- "fs.read": "file_read", fs_read: "file_read", read_file: "file_read", read: "file_read",
20
- "fs.write": "file_write", fs_write: "file_write", write_file: "file_write", write: "file_write", create_file: "file_write",
21
- "fs.edit": "file_edit", fs_edit: "file_edit", edit_file: "file_edit", edit: "file_edit", apply_patch: "file_edit",
22
- bash: "shell", shell: "shell", exec: "shell", command_execution: "shell",
23
- "web.fetch": "web_fetch", web_fetch: "web_fetch", fetch: "web_fetch", curl: "web_fetch",
24
- "web.search": "web_search", web_search: "web_search",
25
- glob: "glob", grep: "grep", ls: "list_dir", list_dir: "list_dir",
26
- task: "agent_task", update_todos: "agent_task",
27
- };
28
- return toolMap[name] || toolMap[name.toLowerCase()] || "unknown";
30
+ return normalizeShared(name, BUB_TOOL_ALIASES);
29
31
  }
30
32
 
31
33
  function get(obj: unknown, key: string): unknown {