niceeval 0.1.1 → 0.2.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 (136) hide show
  1. package/README.md +8 -8
  2. package/README.zh.md +13 -13
  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 +39 -5
  38. package/src/agents/ai-sdk-otel.test.ts +22 -0
  39. package/src/agents/ai-sdk-otel.ts +62 -0
  40. package/src/agents/ai-sdk.test.ts +462 -0
  41. package/src/agents/ai-sdk.ts +575 -0
  42. package/src/agents/bub.ts +30 -37
  43. package/src/agents/claude-code.ts +7 -4
  44. package/src/agents/codex.ts +15 -10
  45. package/src/agents/index.ts +48 -1
  46. package/src/agents/sdk-streams.test.ts +145 -0
  47. package/src/agents/sdk-streams.ts +457 -0
  48. package/src/agents/shared.ts +77 -39
  49. package/src/agents/streaming.test.ts +152 -0
  50. package/src/agents/streaming.ts +195 -0
  51. package/src/agents/types.ts +218 -0
  52. package/src/agents/ui-message-stream.test.ts +241 -0
  53. package/src/agents/ui-message-stream.ts +368 -0
  54. package/src/cli.ts +124 -77
  55. package/src/context/context.test.ts +203 -7
  56. package/src/context/context.ts +158 -49
  57. package/src/context/session.test.ts +96 -0
  58. package/src/context/session.ts +119 -9
  59. package/src/context/types.ts +205 -0
  60. package/src/define.ts +4 -14
  61. package/src/expect/index.ts +8 -71
  62. package/src/i18n/core.ts +28 -0
  63. package/src/i18n/en.ts +47 -5
  64. package/src/i18n/index.ts +5 -17
  65. package/src/i18n/zh-CN.ts +47 -5
  66. package/src/index.ts +12 -0
  67. package/src/loaders/index.ts +8 -39
  68. package/src/o11y/otlp/canonical.ts +7 -6
  69. package/src/o11y/otlp/mappers/codex.ts +10 -8
  70. package/src/o11y/otlp/mappers/index.ts +9 -21
  71. package/src/o11y/otlp/receiver.ts +25 -7
  72. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  73. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  74. package/src/o11y/otlp/turn-otel.ts +125 -0
  75. package/src/o11y/parsers/bub.ts +13 -11
  76. package/src/o11y/parsers/claude-code.ts +27 -51
  77. package/src/o11y/parsers/codex.ts +29 -46
  78. package/src/o11y/parsers/index.ts +5 -14
  79. package/src/o11y/tool-names.test.ts +61 -0
  80. package/src/o11y/tool-names.ts +74 -0
  81. package/src/o11y/types.ts +135 -0
  82. package/src/runner/attempt.ts +456 -0
  83. package/src/runner/fingerprint.ts +59 -0
  84. package/src/runner/remote-sandbox.ts +53 -0
  85. package/src/runner/report.ts +53 -0
  86. package/src/runner/reporters/artifacts.ts +25 -4
  87. package/src/runner/reporters/console.ts +2 -8
  88. package/src/runner/reporters/live.ts +2 -8
  89. package/src/runner/reporters/shared.ts +15 -0
  90. package/src/runner/reporters/table.ts +10 -62
  91. package/src/runner/run.ts +114 -619
  92. package/src/runner/types.ts +237 -0
  93. package/src/sandbox/checkpoint.ts +15 -13
  94. package/src/sandbox/docker-stream.ts +87 -0
  95. package/src/sandbox/docker.ts +42 -120
  96. package/src/sandbox/e2b.ts +24 -75
  97. package/src/sandbox/local-files.ts +33 -0
  98. package/src/sandbox/paths.test.ts +85 -0
  99. package/src/sandbox/paths.ts +45 -0
  100. package/src/sandbox/resolve.ts +10 -34
  101. package/src/sandbox/shell.ts +17 -0
  102. package/src/sandbox/source-files.ts +42 -2
  103. package/src/sandbox/types.ts +158 -0
  104. package/src/sandbox/vercel.ts +55 -71
  105. package/src/scoring/collector.ts +3 -2
  106. package/src/scoring/judge.ts +72 -146
  107. package/src/scoring/match.ts +79 -0
  108. package/src/scoring/scoped.ts +66 -18
  109. package/src/scoring/types.ts +76 -0
  110. package/src/shared/aggregate.ts +48 -0
  111. package/src/shared/format.ts +20 -0
  112. package/src/shared/outcome.ts +48 -0
  113. package/src/shared/types.ts +37 -0
  114. package/src/types.ts +20 -911
  115. package/src/view/aggregate.ts +103 -0
  116. package/src/view/app/App.tsx +26 -5
  117. package/src/view/app/components/AttemptModal.tsx +12 -3
  118. package/src/view/app/components/CodeView.tsx +16 -19
  119. package/src/view/app/components/CopyControls.tsx +4 -4
  120. package/src/view/app/components/ExperimentTable.tsx +5 -5
  121. package/src/view/app/i18n.ts +31 -7
  122. package/src/view/app/lib/format.ts +17 -20
  123. package/src/view/app/lib/outcome.ts +4 -16
  124. package/src/view/app/lib/transcript-data.tsx +20 -4
  125. package/src/view/app/main.tsx +3 -5
  126. package/src/view/app/pages/RunsPage.tsx +2 -2
  127. package/src/view/app/pages/TracesPage.tsx +2 -2
  128. package/src/view/app/shared.ts +2 -1
  129. package/src/view/app/types.ts +9 -44
  130. package/src/view/client-dist/app.css +1 -1
  131. package/src/view/client-dist/app.js +18 -18
  132. package/src/view/index.ts +24 -401
  133. package/src/view/loader.ts +234 -0
  134. package/src/view/server.ts +136 -0
  135. package/src/view/shared/types.ts +64 -0
  136. package/src/view/styles.css +60 -9
@@ -0,0 +1,263 @@
1
+ # Adapter 契约 —— eval 调什么,adapter 返回什么
2
+
3
+ 这一篇是 adapter 的**规范参考**:接口形状、标准事件流词汇,以及核心内容——**逐 `t` API 的适配义务表**:eval 作者每调一个 `t.*`,运行器把它翻成什么、adapter 必须返回什么、返回错了或缺了会以什么方式暴露。
4
+
5
+ 怎么一步步把 adapter 写出来(分档递进、remote / sandbox 示例、采集层技巧)见 [Adapter 写法](authoring.md);Agent / Adapter 是什么、为什么没有 `--url` 见 [README](README.md)。
6
+
7
+ ## Agent 契约
8
+
9
+ 不管底下是 HTTP、还是沙箱 CLI,所有 agent 对运行器暴露同一个契约:**接过一次输入,驱动被测对象,交回一个以标准事件流为核心的 `Turn`。** 以下与 `src/agents/types.ts` 对齐:
10
+
11
+ ```typescript
12
+ interface Agent {
13
+ readonly name: string; // "my-bot" / "claude-code" / "codex"
14
+ readonly kind: "sandbox" | "remote"; // 内部判别字段,用户不声明;由 defineSandboxAgent / defineAgent 恒定写死
15
+ setup?(sandbox: Sandbox, ctx: AgentContext): Promise<void | Cleanup> | void | Cleanup;
16
+ // 每个沙箱一次:装 CLI / 写主配置;可返回 cleanup
17
+ tracing?: AgentTracing; // OTLP 导出配置(沙箱型 CLI 常用;remote agent 只要 config.telemetry 配了端口就够)
18
+ spanMapper?: SpanMapper; // 原生 span → canonical 的薄 mapper;省略走通用 heuristic;只影响瀑布图
19
+ send(input: TurnInput, ctx: AgentContext): Promise<Turn>; // 每轮一次
20
+ teardown?(sandbox: Sandbox, ctx: AgentContext): Promise<void> | void; // 收尾清理(finally 跑)
21
+ }
22
+
23
+ interface TurnInput {
24
+ readonly text: string;
25
+ readonly files?: readonly InputFile[]; // 多模态附件;不支持的 adapter 忽略即可
26
+ readonly responses?: readonly InputResponse[]; // 仅回答轮(t.respond / t.respondAll):逐请求的结构化回答
27
+ }
28
+
29
+ interface Turn {
30
+ readonly events: StreamEvent[]; // 必填。一切作用域断言的唯一数据源;纯 data agent 传 []
31
+ readonly data?: unknown; // 结构化输出(供 outputEquals / outputMatches),与 events 独立
32
+ readonly status: "completed" | "failed" | "waiting"; // waiting = 停在 HITL 输入上
33
+ readonly usage?: Usage; // token 用量;maxTokens / maxCost / 成本估算靠它
34
+ }
35
+
36
+ interface AgentContext {
37
+ readonly signal: AbortSignal;
38
+ readonly model?: string; // experiment 给;省略 → 用 agent 原生默认
39
+ readonly flags: Readonly<Record<string, unknown>>; // experiment 的 feature flags,透传
40
+ readonly sandbox: Sandbox; // 仅沙箱型 agent 有意义(运行器按 kind: "sandbox" 备好)
41
+ readonly session: AgentSession; // 本条会话线的状态槽(存取器组,见下)
42
+ readonly telemetry?: Telemetry; // 仅配置了 OTel 接入时有:OTLP 端点 + ready-to-spread env/headers
43
+ log(msg: string): void;
44
+ }
45
+
46
+ interface AgentSession {
47
+ readonly id?: string; // 会话续接·服务端记历史:本线记过的 id;新会话线是 undefined
48
+ capture(id: string | undefined): void; // 记回传的 id;只在还没记过时落地(first-writer-wins)
49
+ history<TMsg>(): { get(): TMsg[]; commit(messages: TMsg[]): void }; // 会话续接·客户端带全量历史
50
+ hold<T>(state: T): void; // HITL 停轮现场:存
51
+ take<T>(): T | undefined; // HITL 停轮现场:取,取到即清除(一次消费)
52
+ readonly state: Record<string, unknown>; // 逃生舱:自由状态槽,起始 {},框架从不写入
53
+ }
54
+ ```
55
+
56
+ `send` 是**统一动词**,`Turn.events` 是**统一产物**。区别只在 `send` 内部怎么把原始返回变成 `events`——这就是 adapter 的核心难点。
57
+
58
+ ![一次 t.send 的完整往返:eval 调用 t.send,运行器组装 TurnInput 与 ctx,adapter 调用你的应用并返回标准事件流 Turn。](../../docs-site/images/agent-turn-roundtrip-zh.svg)
59
+
60
+ `defineSandboxAgent` / `defineAgent`(见 `src/define.ts`)产出的都是这个 `Agent`,差别只在 `kind` 字段和语义:沙箱型的 `send` 在沙箱里 spawn CLI,remote 的 `send` 按你服务的协议发请求。`setup` 装 CLI 这类"每个沙箱一次就够"的动作不要放 `send`——多轮时 `send` 会被调多次,放里面等于每轮重装。
61
+
62
+ 就算 agent runtime 和 eval 在同一个代码库里,也不建议把 `send` 写成进程内直调你的函数——理由和 HTTP 的取舍见[接入你的 Agent · 为什么不直调](../../docs-site/zh/guides/connect-your-agent.mdx)。`Agent.kind` 只有 `"sandbox"` 和 `"remote"` 两种,没有独立的"进程内"分类:进程内调用只是 `defineAgent`(`kind: "remote"`)的 `send` 里发生的事,不是第三种契约。
63
+
64
+ ## 能力从哪来:构造证明,不是问卷
65
+
66
+ `t` 上解锁什么完全由**构造证据**决定,没有 `capabilities` 这样的声明式字段——`Agent` 接口里不存在它。逐项对照:
67
+
68
+ | `t` 上解锁什么 | 证据是什么 |
69
+ |---|---|
70
+ | `t.send`、`t.sendFile`、`t.check`、`t.judge`… | 任何 Agent 都有 |
71
+ | 多次 `t.send()`、`t.reply`、`t.newSession()` | `send` 里接了 `ctx.session` 的续接存取器(`history()` 或 `id` + `capture()`)——没接则每轮各是新对话 |
72
+ | `t.respond()` / `t.parked()` 等 HITL | send 返回过 `"waiting"` + `input.requested` 事件——做到了就是有 |
73
+ | `t.calledTool()` / `t.toolOrder()` 等正断言 | events 里有 `action.*` |
74
+ | `t.notCalledTool()` / `t.usedNoTools()` 等负断言**可信** | 事件来自带完整性证明的官方转换器(SDK 原生事件流透传、`fromAiSdk` 的 `result.steps`、Responses 的 `output`);手写映射没有这份证明,负断言可信度按来源判断,不按作者的自觉 |
75
+ | `t.sandbox`、`t.fileChanged()` 等 | `defineSandboxAgent` 构造(`kind: "sandbox"`) |
76
+ | `EvalResult.trace`、`niceeval view` 瀑布图 | 配置了 OTel 接入(agent 的 `tracing` 块,或 remote agent + `defineConfig({ telemetry })`)——只画瀑布图,不影响任何断言,详见[观测性](../observability.md#otlp-traces--统一瀑布图) |
77
+
78
+ 唯一仍然存在的运行时守卫是**沙箱能力**:`t.sandbox` / `t.file` / `t.fileChanged()` 等直接读沙箱文件系统,非沙箱型 agent(`kind !== "sandbox"`)调用这些方法会立即得到清晰报错(`src/context/context.ts` 的 `capabilityGuard`)——没有沙箱就没有东西可读,不报错会静默返回空结果。其余能力(多轮对话、工具观测……)不设运行时守卫:没接会话存取器的 agent 每轮各是新对话(不报错,只是断言看不到历史);没吐 `action.*` 事件的 agent 上正断言自然不命中;负断言的可信度靠事件来源判断,不靠拦截调用。
79
+
80
+ ## 标准事件流
81
+
82
+ 你能写的整套作用域断言,全部建立在这条标准的、带类型的事件流上。adapter 唯一的硬活,就是把这个 agent 的原始输出映射成这条流;映射完,所有断言都是 core 算的,与 agent 无关。
83
+
84
+ ```typescript
85
+ type StreamEvent =
86
+ | { type: "message"; role: "assistant" | "user"; text: string; loc?: SourceLoc }
87
+ | { type: "action.called"; callId: string; name: string; input: JsonValue; tool?: ToolName }
88
+ // 工具 / 技能调用发起。name = 原始名(如 "Bash"),tool = 归一后的规范名(如 "shell")
89
+ | { type: "action.result"; callId: string; output?: JsonValue;
90
+ status: "completed" | "failed" | "rejected" } // 与 called 按 callId 配对
91
+ | { type: "subagent.called"; callId: string; name: string; remoteUrl?: string } // 子 agent 委派
92
+ | { type: "subagent.completed"; callId: string; output?: JsonValue;
93
+ status: "completed" | "failed" }
94
+ | { type: "input.requested"; request: InputRequest } // HITL:agent 停下等人输入
95
+ | { type: "thinking"; text: string }
96
+ | { type: "compaction"; reason?: string } // 上下文压缩
97
+ | { type: "error"; message: string };
98
+ ```
99
+
100
+ 技能加载(`load_skill`)就是一种 `action.called`,所以 `t.loadedSkill` 只是 `t.calledTool("load_skill", …)` 的语法糖,无需单独事件类型。
101
+
102
+ 产出这条流时的三条纪律:
103
+
104
+ 1. **时序即语义。** 事件按真实发生顺序排——`eventOrder` / `toolOrder` 靠子序匹配,解析时不要按类型分桶再拼接(先全部 message 再全部 action 会让顺序断言全错)。
105
+ 2. **`callId` 配对。** 每个 `action.called` 都应有配对的 `action.result`。这是 niceeval 相对 agent-eval 的明确取舍(它靠"数组里最后一条未配对记录"的顺序假设,并发调用会错配,见[参考笔记](reference/agent-eval.md#两份-parser-对比暴露的设计事实));显式 id 在乱序 / 并发下也不错配。配对的兜底语义(`deriveRunFacts`):只有 called 没有 result → status 按 `"completed"` 算;只有 result 没有 called → 补一条 `name: "unknown"` 的占位调用。
106
+ 3. **双名字都填。** `name` 放 agent 的原始工具名,`tool` 放归一后的[规范名 `ToolName`](../observability.md#transcript--标准事件流)。派生 `ToolCall` 用 `tool` 当 `name`(缺省落 `"unknown"`)、原始名进 `originalName`;`calledTool` / `toolOrder` 两个名字都认,但跨 agent 断言(`calledTool("file_read")`)只有填了 `tool` 才能命中。
107
+
108
+ ### 派生事实(core 算,共享,agent 无关)
109
+
110
+ core 的 `deriveRunFacts(events)` 把扁平事件流折叠成结构化事实——这步对所有 agent 一样:
111
+
112
+ ```typescript
113
+ interface DerivedFacts {
114
+ readonly toolCalls: readonly ToolCall[]; // action.called + result 按 callId 合并,带最终 status
115
+ readonly subagentCalls: readonly SubagentCall[]; // subagent.called + completed 合并,带 remoteUrl
116
+ readonly inputRequests: readonly InputRequest[];
117
+ readonly parked: boolean; // 最后一条「有意义」事件是 input.requested(忽略尾随 thinking / compaction)
118
+ readonly messageCount: number;
119
+ readonly compactions: number;
120
+ }
121
+ ```
122
+
123
+ ### `InputRequest`:HITL 请求要填哪些字段
124
+
125
+ `t.requireInputRequest(filter?)` 的 filter 逐字段匹配 `InputRequest`,所以 adapter 吐 `input.requested` 时,**filter 可能匹配的字段都要尽量填**:
126
+
127
+ ```typescript
128
+ interface InputRequest {
129
+ readonly id?: string; // 请求 id(filter.id)
130
+ readonly prompt?: string; // agent 问人的问题文本(filter.prompt)
131
+ readonly display?: string; // 展示给人的摘要(filter.display)
132
+ readonly action?: string; // 停在哪个工具 / 动作上,如 "deploy"(filter.action)
133
+ readonly input?: JsonValue; // 该动作的入参(filter.input 逐键匹配)
134
+ readonly options?: readonly { id: string; label?: string }[]; // 可选项(filter.optionIds)
135
+ }
136
+ ```
137
+
138
+ ## 逐 API 适配义务
139
+
140
+ 这是本篇的核心:**eval 侧每个 API ↔ 运行器的翻译 ↔ adapter 的义务 ↔ 违约的暴露方式**。`session.*` 与 `t.*` 的同名 API 义务完全相同,只是绑定的会话线不同。
141
+
142
+ ### 驱动 API(adapter 要「做」什么)
143
+
144
+ #### `t.send(text)` / `session.send(text)`
145
+
146
+ - **运行器做什么:** 记一条 `message(role: "user")` 事件 → 调 `agent.send({ text }, ctx)`,`ctx.session` 绑定当前会话线 → 把返回 Turn 的 `events` / `usage` 累进该 session 和 run 级聚合,`input.requested` 事件收进该 session 的 pending 列表(**每次 send 前先清空**)。
147
+ - **adapter 返回:** 一个 `Turn`。`events` 按时序、`status` 如实、能拿到就带 `usage`;接了会话续接存取器的 agent 把本轮会话 id 写回 `ctx.session.capture(id)`,或把新消息 `commit` 进 `ctx.session.history()`。
148
+ - **违约表现:** `events` 空 → 文本 / 工具断言全部无数据(正断言 fail、负断言假通过);`status` 恒 `"completed"` → `succeeded` 假通过、HITL 判定失效。
149
+
150
+ #### `t.sendFile(path, text?)` / `session.sendFile`
151
+
152
+ - **运行器做什么:** 读本地文件转 base64 → `agent.send({ text, files }, ctx)`,其余同 `send`。
153
+ - **adapter 义务:** 自行决定投递方式(remote 塞进请求体;沙箱型可写进沙箱再在 prompt 里引用路径)。不支持多模态就**忽略 `files`、照常跑 `text`**——这是合法降级,不需要声明什么。
154
+ - **违约表现:** 无硬违约。但 eval 若断言图片内容,在忽略 files 的 agent 上必然失败——这是 eval 与 agent 的能力错配,报错落在断言上。
155
+
156
+ #### `t.newSession()`
157
+
158
+ - **运行器做什么:** **不调 adapter。** 只新建一条会话线(一个全新的 `ctx.session`);下一次在这个 session 上 `send` 时,adapter 看到的 `ctx.session.id` 是 `undefined`、`history().get()` 是空数组——这是新会话线的自然形态,不是需要 `if` 判断的分支。
159
+ - **adapter 义务:** 接了会话续接存取器的 adapter,新会话线上"自然发生"的就是开全新会话:`id` 未捕获过就不会往请求里塞 resume 参数,`history().get()` 为空就不会带历史。**不需要额外写判断逻辑**——存取器的初始状态本身就是正确行为。
160
+ - **违约表现:** 如果 adapter 没有正确使用存取器(比如用模块级变量而不是 `ctx.session` 保存会话状态,导致跨 session 串用同一份历史),"独立 session"会共享上下文,session 级断言全部失真且**没有任何报错**。写多轮 adapter 必须测这条。
161
+
162
+ #### `t.requireInputRequest(filter?)` / `session.requireInputRequest`
163
+
164
+ - **运行器做什么:** **不调 adapter。** 读该 session pending 列表(= **最近一轮** `Turn.events` 里的 `input.requested`;更早轮次的在下一次 send 时已被清空),要求**恰好一个**匹配 filter,返回它;不满足按 gate 失败并中止。
165
+ - **adapter 义务(发生在上一轮 `send` 里):** agent 停下等人输入时,返回 `status: "waiting"`,并且**每个待回答的问题吐一条 `input.requested`**,`request` 按上文 `InputRequest` 把 filter 能匹配的字段填上。
166
+ - **违约表现:** 只置 `waiting` 不吐事件 → `requireInputRequest` 找不到请求,直接 fail(响,但错误指向 eval 而不是 adapter);吐了多条却对应同一个问题 → "恰好一个"失败。
167
+
168
+ #### `t.respond(...responses)` / `t.respondAll(optionId)`
169
+
170
+ - **运行器做什么:** 清空 pending 列表,把每条回答翻成一条 `InputResponse`(`{ requestId, optionId }` 或 `{ requestId, text }`),**当作普通的下一轮 `send` 发给同一 session**——`input.responses` 携带结构化回答,`input.text` 是拼接的可读文本。`respondAll` = 对每个 pending 请求重复同一 `optionId`。
171
+ - **adapter 义务:** 按 `input.responses` 里的 `requestId` 把裁决交回应用(不要按顺序猜);CLI/服务有原生"回答待批准工具"协议的,在 `send` 里翻译过去。返回下一轮 `Turn`——可以又是 `waiting`(连环提问)。被人拒绝的调用,`action.result` 的 `status` 置 `"rejected"` 而不是 `"failed"`——拒绝是人的决定、不是工具故障,这样 `noFailedActions()` 不误伤,`calledTool(..., { status: "rejected" })` 可精确断言。
172
+ - **违约表现:** 忽略 `input.responses`、只解析 `input.text` 猜意图 → 多个请求并停时对不上号;把回答当成全新会话处理 → agent 根本不在等这个回答,后续断言全盘失真(静默)。
173
+
174
+ #### `setup` / `teardown`(不是 `t` API,但属于同一契约)
175
+
176
+ - **运行器做什么:** 备好沙箱(上传 / git 基线 / eval 级 setup)后、首次 `send` 前调一次 `setup`;`setup` 返回的 cleanup 和 `teardown` 都在 finally 跑。
177
+ - **adapter 义务:** `setup` 只做"每个沙箱一次"的事(装 CLI、写主配置、装 skill / plugin);失败应直接抛——那是 **errored**(基建问题),不是 agent 做题失败,见 [Skills / Plugins 的失败语义](coding-agent-skills-plugins.md#失败语义)。
178
+
179
+ ### 断言族的数据义务(adapter 要「说」什么)
180
+
181
+ 驱动 API 之外,每族断言消费一类数据。表格逐族列出:断言读什么、adapter 必须产出什么、数据缺失时的表现——**注意最后一列的"响 / 静默"之分**:
182
+
183
+ | 断言族 | 读什么 | adapter 义务 | 数据缺失时 |
184
+ |---|---|---|---|
185
+ | `succeeded()` / `parked()` / `turn.expectOk()` | `Turn.status` + 事件流(`parked` 看最后有意义事件是否 `input.requested`) | `status` 如实;HITL 停留吐 `input.requested` | `status` 恒 completed → `succeeded` **假通过(静默)** |
186
+ | `messageIncludes()` / `t.reply` / `turn.message` | `message` 且 `role: "assistant"` 的事件文本 | 每段助手文本吐一条 `message`(工具结果**不是**助手消息,别混,见[参考笔记的踩坑记录](reference/agent-eval.md#claude-code-怎么转换)) | 断言 fail(响) |
187
+ | `outputEquals()` / `outputMatches()` | `turn.data` | 结构化输出放 `data`,不要序列化塞进 `events` | fail(响) |
188
+ | `calledTool()` / `toolOrder()` / `loadedSkill()` / `calledSubagent()` | 派生 `toolCalls` / `subagentCalls` | 每次调用吐 `called` + `result`(callId 配对);`name` 原始名 + `tool` 规范名;result `status` 如实 | fail(响) |
189
+ | `notCalledTool()` / `usedNoTools()` / `maxToolCalls()` | 同上 | 同上——但这里靠的是**完整性** | **假通过(静默)** |
190
+ | `noFailedActions()` | 派生 status | `action.result.status` 区分 `failed` / `rejected`,不要一律 completed | **假通过(静默)** |
191
+ | `event()` / `eventOrder()` / `eventsSatisfy()` | 原始事件流 | 类型用标准词汇;时序保真,不合成、不重排 | `event` fail(响);`eventOrder` fail(响) |
192
+ | `notEvent()` | 原始事件流 | 同上——完整性 | **假通过(静默)** |
193
+ | `maxTokens()` / `maxCost()` | `Turn.usage` 逐轮累计 | 每轮尽量带 `usage`(transcript 里抠,见各 parser) | **假通过(静默)**(0 ≤ max 恒真) |
194
+
195
+ ### 负断言的完整性规则
196
+
197
+ 上表最后一列的规律值得单独立成一条设计规则:
198
+
199
+ > **正断言在数据缺失时"响"(fail,作者会去查);负断言与上限断言在数据缺失时"静默通过"。** `notCalledTool` / `notEvent` / `usedNoTools` / `maxToolCalls` / `maxTokens` / `maxCost` 在空流 / 半空流上全部恒真。所以事件完整性才是关键,而不是"尽力而为"——漏吐一半事件比完全不吐更危险:完全不吐时正断言会把问题暴露出来,漏吐一半时正断言碰巧通过、负断言全部假通过。**事件来源的完整性证据决定负断言的可信度**(见上文「能力从哪来」):官方转换器透传 SDK 原生事件流、或消费 Responses 这类承诺"输出即全过程"的协议,负断言可信;手写映射没有这份证明,做不到完整时,文档要如实说明这条 eval 的负断言结论不可全信,而不是假装它和正断言一样可靠。
200
+
201
+ ## HITL 握手:一次完整时序
202
+
203
+ `t.respond` 能工作,靠三个义务同时成立:`waiting` 状态、`input.requested` 事件、回答按 `requestId` 交回应用。缺一的后果见上文逐 API 表。
204
+
205
+ ```text
206
+ test(t) runner adapter.send
207
+ ────────────────────────────────────────────────────────────────────────────────
208
+ await t.send("把服务部署到 prod") → send#1(新会话线:ctx.session.id 为空)
209
+ → 跑 agent;agent 停在部署确认上
210
+ ← Turn{ status: "waiting",
211
+ events: [..., input.requested{
212
+ action: "deploy",
213
+ options: [{id:"yes"},{id:"no"}] }] }
214
+ t.parked() ✓(最后有意义事件是 input.requested)
215
+ const req = t.requireInputRequest({ action: "deploy" })
216
+ ✓(pending 列表恰好一条,返回 req)
217
+ await t.respond("yes") → send#2(同一 ctx.session,
218
+ input.responses: [{ requestId, optionId: "yes" }])
219
+ → 按 requestId 把 "yes" 交回应用
220
+ ← Turn{ status: "completed", events: [...] }
221
+ t.succeeded() ✓
222
+ ```
223
+
224
+ ## 三类配置的归属:本地配 / 实验传入 / ctx 透传
225
+
226
+ 写 agent 最容易纠结「这个设置写哪」。规则固定:
227
+
228
+ | 设置 | 归属 | 怎么拿 |
229
+ |---|---|---|
230
+ | 鉴权(API key / token / base url) | **agent 本地** —— 它怎么连自己,是私事 | 在定义里读 env / 闭包,不经 ctx |
231
+ | CLI 细节(装什么包、参数形状、transcript 在哪) | **agent 本地** | 写死在 `send` / `setup` 里 |
232
+ | **model** | **实验决定(留空)** | `ctx.model`(省略 → agent 原生默认) |
233
+ | **feature flags**(webResearch、注入哪个 skill、effort…) | **实验决定** | `ctx.flags.*` —— agent 的 `send` 与 eval 的 `t.flags` 都能读 |
234
+ | runs / earlyExit / evals / sandbox / budget | **实验决定** | 运行器据此调度 |
235
+
236
+ 一句话:**agent 只配「怎么连我自己」,不配「跑哪个模型、开哪些开关」**;后者全留给 [experiment](../experiments.md),经 `ctx`(eval 里是 `t`)透传。这样同一个 agent 能被不同实验以不同 model / flags 复用,不必改 agent。
237
+
238
+ ## `ctx`(agent 侧)与 `t`(eval 侧):同一份东西,两个名字
239
+
240
+ `send` / `setup` 收到的叫 `ctx`,`test` 收到的叫 `t`。它们不是两套数据:**`t` 是运行器在 `ctx` 之上为 eval 作者搭的高层视图**——`t.send(...)` 内部就是拿着 `ctx` 去调 `agent.send(input, ctx)`。
241
+
242
+ | 概念 | `ctx`(agent:`send` / `setup`) | `t`(eval:`test`) | 关系 |
243
+ |---|---|---|---|
244
+ | 实验 flags | `ctx.flags` | `t.flags` | **同一份**(experiment 给) |
245
+ | 模型 | `ctx.model`(用来拼 `--model`) | `t.model`(只读,知道在测谁) | 同一份 |
246
+ | 取消信号 | `ctx.signal` | `t.signal` | 同一份 |
247
+ | 日志 | `ctx.log()` | `t.log()` | 同一个 |
248
+ | 会话 | `ctx.session`(存取器组,用来续接) | `t.newSession()`(发起新会话) | `t` 发起新会话线 → 运行器构造全新 `ctx.session` |
249
+ | 沙箱 | `ctx.sandbox`(底层 `Sandbox` 句柄) | `t.sandbox`(文件 IO / 命令 / 结果断言) | eval 作者看不到 `stop`;生命周期由 runner 管 |
250
+ | OTLP 端点 | `ctx.telemetry`(endpoint + 导出 env/headers) | —(trace 只出现在结果里) | 仅配置了 OTel 接入的 agent 有 |
251
+ | 一轮结果 | `send` 返回的 `Turn`(`events` 为核心) | `t.send()` 的返回 / `turn.outputEquals` | core 把 `Turn` 转交给 eval |
252
+ | 鉴权 / CLI 细节 | agent 本地(**不在 ctx**) | — | 谁都不暴露给对方 |
253
+ | 断言 / judge 派生 | — | `t.check` / `t.calledTool` / `t.judge.*` / `t.maxTokens`… | 只在 eval 侧 |
254
+
255
+ 口诀:**`ctx` 是「驱动 AI」的低层上下文(agent 用),`t` 是「写断言」的高层上下文(作者用);共享 experiment 透传的那几样,其余各管一摊。**
256
+
257
+ ## 相关阅读
258
+
259
+ - [Adapter 写法](authoring.md) —— remote / sandbox 示例、采集层、shared 工具、三段式拆解。
260
+ - [Assertions](../assertions.md) —— 这些断言在 eval 侧的完整参考(作用域 + 来源)。
261
+ - [Observability](../observability.md) —— transcript → 标准事件流的归一化、规范工具名、OTLP trace。
262
+ - [Experiments](../experiments.md) —— model / flags 怎么经 experiment 传进 ctx。
263
+ - [docs-site Adapter 概念](../../docs-site/zh/concepts/adapter.mdx) / [Tier](../../docs-site/zh/concepts/tier.mdx) —— 面向用户的同一份契约与三档接入。
@@ -0,0 +1,215 @@
1
+ # agent-eval 具体怎么做适配转换(源码阅读记录)
2
+
3
+ **来源:** 直接读 `/Users/ctrdh/Code/agent-eval`(vercel-labs/agent-eval 本机 checkout)的源码,不是转述。关键文件:
4
+
5
+ - 目标 schema:`packages/agent-eval/src/lib/o11y/types.ts`
6
+ - 分发 + 聚合:`packages/agent-eval/src/lib/o11y/parsers/index.ts`
7
+ - Claude Code 转换:`packages/agent-eval/src/lib/o11y/parsers/claude-code.ts`
8
+ - Codex 转换:`packages/agent-eval/src/lib/o11y/parsers/codex.ts`
9
+ - 采集(怎么拿到原始 transcript):`packages/agent-eval/src/lib/agents/claude-code.ts`、`codex.ts`、`shared.ts`
10
+
11
+ 这是**学习笔记**,记录别人的具体实现,供设计归一化管线时对照;不是 niceeval 的实现描述——niceeval 自己的 Agent 契约见 [Adapter 契约](../contract.md),能力分档见 [Adapter 写法](../authoring.md)。
12
+
13
+ ## 目标层长什么样:`TranscriptEvent` / `Transcript`
14
+
15
+ 所有 agent 的 parser 都收敛到同一组类型(`o11y/types.ts`),这就是"适配"要落到的靶子:
16
+
17
+ ```typescript
18
+ type ToolName =
19
+ | 'file_read' | 'file_write' | 'file_edit'
20
+ | 'shell' | 'web_fetch' | 'web_search'
21
+ | 'glob' | 'grep' | 'list_dir' | 'agent_task' | 'unknown';
22
+
23
+ interface TranscriptEvent {
24
+ timestamp?: string;
25
+ type: 'message' | 'tool_call' | 'tool_result' | 'thinking' | 'error';
26
+ role?: 'user' | 'assistant' | 'system'; // 仅 message
27
+ content?: string; // message / thinking / error
28
+ tool?: {
29
+ name: ToolName; // 归一后的名字
30
+ originalName: string; // 原始名字,不丢
31
+ args?: Record<string, unknown>;
32
+ result?: unknown; // 仅 tool_result
33
+ durationMs?: number;
34
+ success?: boolean;
35
+ };
36
+ raw?: unknown; // 原始事件,调试用
37
+ }
38
+
39
+ interface Transcript {
40
+ agent: string;
41
+ model?: string;
42
+ events: TranscriptEvent[];
43
+ summary: TranscriptSummary; // 见下文 generateSummary
44
+ parseSuccess: boolean;
45
+ parseErrors?: string[];
46
+ }
47
+ ```
48
+
49
+ `type` 只有五种(`message` / `tool_call` / `tool_result` / `thinking` / `error`),`tool_call` 和 `tool_result` 是**两条独立事件**,不像 niceeval 的 `action.called` / `action.result` 那样带 `callId` 配对——这一点直接影响了后面聚合层怎么把 call 和 result 对上号(见"聚合层"一节的问题)。
50
+
51
+ ## 分发层:一个 agent 名对一个 parser 函数
52
+
53
+ ```typescript
54
+ // parsers/index.ts
55
+ const AGENT_PARSERS = {
56
+ 'claude-code': parseClaudeCodeTranscript,
57
+ 'codex': parseCodexTranscript,
58
+ 'opencode': parseOpenCodeTranscript,
59
+ 'gemini': parseGeminiTranscript,
60
+ 'cursor': parseCursorTranscript,
61
+ } as const;
62
+
63
+ function getParserForAgent(agent: string) {
64
+ for (const key of SUPPORTED_AGENTS) {
65
+ if (agent.includes(key)) return AGENT_PARSERS[key]; // 子串匹配,不是精确匹配
66
+ }
67
+ return null;
68
+ }
69
+ ```
70
+
71
+ 用 `agent.includes(key)` 而不是精确相等,是为了让 `vercel-ai-gateway/claude-code` 这种网关前缀变体和裸的 `claude-code` 走同一个 parser——两边其实是同一个 CLI,只是鉴权路径不同,不该因为名字里多了个前缀就找不到 parser。认不出的 agent 直接返回 `parseSuccess: false` 加一句报错(列出 `SUPPORTED_AGENTS`),不猜。
72
+
73
+ ## Claude Code 怎么转换
74
+
75
+ **输入形状:** Claude Code 把整段会话写成 JSONL,一行一个事件,顶层 `type` 是 `"user"` / `"assistant"` / `"system"`,真正的内容嵌在 `message.content`(可能是字符串,也可能是内容块数组)。
76
+
77
+ **转换逻辑(`parseClaudeCodeLine`,逐行处理):**
78
+
79
+ - `type === "assistant"`:先从 `content` 数组里挑出 `type === "text"` 的块拼成一条 `message` 事件;再挑出 `type === "tool_use"` 的块,每个转成一条独立的 `tool_call` 事件(`name` 经 `normalizeToolName` 归一,原名存进 `originalName`,`input` 整个存进 `args`);再挑出 `type === "thinking"` 的块转成 `thinking` 事件。**一行输入可能产出好几条事件**,不是一对一。
80
+ - `type === "user"`:**这是最容易踩坑的一步**——Claude Code 把工具执行结果也包装成一条 `user` 消息(`content` 数组里塞一个 `type: "tool_result"` 的块),parser 必须先检查 `content` 里有没有 `tool_result` 块,有就转成 `tool_result` 事件,没有才当成真的用户消息。搞反了就会把工具结果误判成用户在说话。
81
+ - 还兼容一种 OpenAI 风格的 `tool_calls` 数组(`message.tool_calls[].function.{name,arguments}`,`arguments` 是要再 `JSON.parse` 一次的字符串)——这是防御性代码,应对 Claude Code 某些输出路径可能改用 OpenAI 兼容格式。
82
+
83
+ **`normalizeToolName`(Claude Code 专属映射表)**,把 CLI 的私有工具名收敛成 canonical `ToolName`:
84
+
85
+ | 原始名 | canonical |
86
+ |---|---|
87
+ | `Read` / `read_file` / `ReadFile` | `file_read` |
88
+ | `Write` / `write_file` / `WriteFile` / `write_to_file` | `file_write` |
89
+ | `Edit` / `edit_file` / `EditFile` / `str_replace_editor` / `StrReplace` | `file_edit` |
90
+ | `Bash` / `bash` / `Shell` / `shell` / `execute_command` / `run_command` | `shell` |
91
+ | `WebFetch` / `web_fetch` / `fetch_url` / `mcp__fetch__fetch` | `web_fetch` |
92
+ | `WebSearch` / `web_search` | `web_search` |
93
+ | `Glob` / `glob` / `list_files` | `glob` |
94
+ | `Grep` / `grep` / `search_files` | `grep` |
95
+ | `LS` / `list_dir` / `ListDir` | `list_dir` |
96
+ | `Task` / `task` | `agent_task` |
97
+ | 认不出的 | `unknown` |
98
+
99
+ **行级容错:** 每一行包在 `try/catch` 里,解析失败静默跳过(`parseClaudeCodeTranscript` 外层再包一层,把异常收进 `errors[]`),不会让一行坏数据拖垮整份 transcript。
100
+
101
+ ## Codex 怎么转换
102
+
103
+ **输入形状和 Claude Code 完全不同。** Codex 走 OpenAI Responses API 的事件流,`--json` 输出的每一行是一个**细粒度的生命周期事件**,不是"一条消息"。而且事件的 type 字段本身就不稳定——parser 第一步是 `const eventType = data.type || data.event || data.kind`,三个字段轮流试,这是应对 Codex 跨版本 / 跨 wire API 命名漂移的防御写法。
104
+
105
+ **转换逻辑是一个大 `switch(eventType)`,不是 if/else 链**,分支比 Claude Code 多得多,因为 Codex 的事件粒度更细:
106
+
107
+ - `message` / `chat` / `response` → 一条 `message`,`role` 从 `data.role` 或者猜 `data.from === "assistant"`。
108
+ - `function_call` / `tool_call` / `tool_use` / `action`(四个同义 type 名全部映射到同一逻辑)→ 一条 `tool_call`;`name` 依次尝试 `data.function?.name || data.name || data.tool || data.action`;`args` 优先解析 OpenAI 式的 `function.arguments`(JSON 字符串,需要再 `JSON.parse`),否则退回 `data.arguments || data.input || data.params`。
109
+ - `function_result` / `tool_result` / `tool_response` / `action_result` → 一条 `tool_result`。
110
+ - `thinking` / `reasoning` / `thought` → 一条 `thinking`。
111
+ - `thread.started/completed`、`turn.started/completed/failed` → 当成控制流事件,`turn.failed` 转成 `error`,其余转成一条内容是事件名本身的 `system` 消息(纯占位,不是真正有信息量的内容)。
112
+ - `response.created/completed/cancelled/failed` → 只有 `response.failed` 才产事件(转 `error`),其余三个丢弃——**这是这份 parser 里唯一"读了但故意不产出事件"的分支**,因为这几个是流控制信号,对断言没有价值。
113
+ - `output_text.delta` / `output_text.done` → 流式文本增量,转成 `assistant` 消息(意味着一次真实回复可能在这里被拆成好几条 `message` 事件,和 Claude Code "一次性给完整文本"不一样)。
114
+ - `item.started` / `item.completed` → 再按 `data.item.type` 分二级:
115
+ - `reasoning` → `thinking`。
116
+ - `command_execution` → **`started` 产 `tool_call`(shell,`args.command`),`completed` 产 `tool_result`(`result.output` 取 `aggregated_output`,`result.exitCode` 取 `exit_code`,`success` 判 `status === "completed" && exitCode 是 0 或未定义`)**——shell 命令的开始和结束是两个独立事件,靠"先后顺序"隐式配对,不靠 id。
117
+ - `agent_message` → `assistant` 消息。
118
+ - `default`(没认出的 type):再按数据形状猜——有 `role` 字段就当消息,有 `function`/`tool` 字段且带 `result`/`output` 就当 `tool_result`,否则当 `tool_call`。这是最后一道兜底,处理型号漂移到连 `eventType` 都认不出的情况。
119
+
120
+ **`normalizeToolName`(Codex 专属映射表,和 Claude Code 的完全不共享,且 Codex 版会先 `toLowerCase()` 再查表):**
121
+
122
+ | 原始名(小写后) | canonical |
123
+ |---|---|
124
+ | `read_file` | `file_read` |
125
+ | `write_file` / `create_file` / `delete_file` | `file_write` |
126
+ | `edit_file` / `patch_file` | `file_edit` |
127
+ | `shell` / `bash` / `execute` / `run` / `exec` / `terminal` | `shell` |
128
+ | `fetch` / `http_request` / `curl` | `web_fetch` |
129
+ | `web_search` / `search` | `web_search` |
130
+ | `glob` / `find_files` / `list_files` | `glob` |
131
+ | `grep` / `search_files` / `ripgrep` | `grep` |
132
+ | `ls` / `list_directory` / `dir` | `list_dir` |
133
+ | 认不出的 | `unknown` |
134
+
135
+ ## 两份 parser 对比暴露的设计事实
136
+
137
+ - **Claude Code 的分支数(~6 个 if/else)远少于 Codex(~15 个 switch case)**,不是因为哪份代码写得更细,而是两家 CLI 的 transcript **颗粒度不同**:Claude Code 一行大致等于"一条完整消息 / 一次工具调用",Codex 的 Responses API 把同一件事拆成多个生命周期事件(`item.started` → `item.completed`,`response.created` → `response.completed`)。parser 的复杂度是被上游协议形状决定的,不是可以统一简化掉的。
138
+ - **`normalizeToolName` 映射表不共享,每个 agent 各写一份**,即便两家都有等价的"读文件""跑 shell"概念。没有一张全局的"规范名 ← 各家别名"总表,复用发生在**结构**(都产出同一个 `ToolName` 联合类型)而不是**数据**(映射表本身)上。
139
+ - **文件路径 / URL / 命令的提取函数(`extractFilePath` / `extractUrl` / `extractCommand`)在两份 parser 里几乎逐字重复**,只是取的字段名优先级顺序不同(Claude Code 先试 `args.path`,Codex 先试 `args.path` 但也认 `args.endpoint`)。新增一个 agent = 复制这一整套函数再改字段名,没有抽成共享 helper。
140
+ - **tool_call 和 tool_result 不是靠 id 配对的。** `TranscriptEvent` 没有 `callId` 这类字段,聚合层靠"数组里最后一个还没被填 `exitCode` 的 shellCommand"这种**顺序假设**去把 call 和 result 拼起来(见下一节)——如果两个工具调用交叠或乱序,这个假设会配错。
141
+
142
+ ## 聚合层:`generateSummary` 完全不认识 agent
143
+
144
+ `parsers/index.ts` 的 `generateSummary(events)` 是纯函数,输入只有 `TranscriptEvent[]`,不知道这条流是哪个 agent 产的。它靠一个"暗号字段"读文件路径:
145
+
146
+ ```typescript
147
+ // tool_call 分支
148
+ const path = (args._extractedPath || args.path || args.file) as string;
149
+ if (path) filesModified.add(path);
150
+ ```
151
+
152
+ `_extractedPath` 由两份 parser 各自在**行解析之后的一趟后处理**里补上(两边写法几乎一样):
153
+
154
+ ```typescript
155
+ // claude-code.ts 和 codex.ts 都有这一段(后处理,不在逐行解析里)
156
+ if (['file_read', 'file_write', 'file_edit'].includes(event.tool.name)) {
157
+ const path = extractFilePath(args);
158
+ if (path) event.tool.args = { ...args, _extractedPath: path };
159
+ }
160
+ if (event.tool.name === 'web_fetch') {
161
+ const url = extractUrl(args);
162
+ if (url) event.tool.args = { ...args, _extractedUrl: url };
163
+ }
164
+ if (event.tool.name === 'shell') {
165
+ const command = extractCommand(args);
166
+ if (command) event.tool.args = { ...args, _extractedCommand: command };
167
+ }
168
+ ```
169
+
170
+ `generateSummary` 只认 `_extractedPath` / `_extractedUrl` / `_extractedCommand` 这三个暗号字段,读不到才退回去猜通用字段名兜底。**提取(这个字段在这家 CLI 的私有参数里叫什么)和聚合(这些字段怎么计数 / 去重)被这个命名约定彻底切开**——新增一个 agent,只要在它自己的 parser 后处理里填好这三个暗号字段,`generateSummary` 一行不用改。
171
+
172
+ **call/result 配对是顺序性的,不是 id 性的。** shell 命令的成功状态是这样接上的:
173
+
174
+ ```typescript
175
+ case 'tool_result':
176
+ const lastCmd = shellCommands[shellCommands.length - 1];
177
+ if (lastCmd && lastCmd.exitCode === undefined) {
178
+ lastCmd.success = event.tool.success;
179
+ // 从 result 里再抠一次 exitCode
180
+ }
181
+ ```
182
+
183
+ "数组里最后一条还没写 exitCode 的记录"就是这次 result 对应的 call——这是可行的,因为 Codex/Claude Code 的工具调用在 transcript 里基本是严格顺序的(等上一个工具跑完才发起下一个),但这个假设没有 id 兜底,一旦上游并发发起多个工具调用,配对就会错。
184
+
185
+ ## 采集层:两份 parser 之前,原始数据从哪来
186
+
187
+ - **Claude Code(纯磁盘旁读):** `captureTranscript()` 把沙箱工作目录的斜杠换成短横线拼出 `~/.claude/projects/{workdir-with-dashes}`,shell 出 `ls -t *.jsonl | head -1` 找最新一份,`sandbox.readFile()` 整份读回来当 `transcript`。这份文件是 Claude Code 自己为会话续接(resume)才写的,agent-eval 只是读。
188
+ - **Codex(两条通道,各管各的用途):**
189
+ - **主 transcript:** 直接把 `codex exec --json` 的 `stdout + stderr` 拼起来,`extractTranscriptFromOutput()` 过滤出"看起来像 JSON 对象"的行(`trim().startsWith('{') && endsWith('}')`)当作 transcript——这是 stdout 捕获,不是磁盘读。
190
+ - **第二条通道,只为了拿"实际用的模型":** 从 stdout 里先抓一个 `thread.started` 事件拿到 `thread_id`,再用它去 `~/.codex/sessions` 磁盘上 `find` 对应的 session JSONL 文件读出来,从里面找 `turn_context` 事件的 `payload.model` ——因为经网关请求的模型名和网关实际路由到的模型可能不一致,只有磁盘上的 session 文件会记录真实值。**同一个 agent 的"转换用" transcript 和"读实际模型用"的数据来源是两条不同的采集路径**,不是一份数据复用两次。
191
+
192
+ ## 落地:注入沙箱给断言用
193
+
194
+ `injectTranscriptContext(sandbox, rawTranscript, agentName, model)`(`agents/shared.ts`)只做三件事:解析 transcript、取 `summary`、写文件——不写 `events`:
195
+
196
+ ```typescript
197
+ const transcript = rawTranscript ? parseTranscript(rawTranscript, agentName, model) : null;
198
+ const context = { o11y: transcript?.summary ?? null };
199
+ await sandbox.writeFiles({ '__agent_eval__/results.json': JSON.stringify(context, null, 2) });
200
+ ```
201
+
202
+ 整个函数包在 `try/catch` 里静默失败("best-effort: don't fail the eval if context injection fails")。这一步在 `agent.run()` 里、跑 `EVAL.ts` 校验**之前**调用,`EVAL.ts` 读 `__agent_eval__/results.json` 拿到的就只有聚合后的 `summary`,拿不到原始 `events`——细粒度的"第几步调用了什么工具"在这一层已经不可见了,只剩计数和去重后的列表。
203
+
204
+ ## 这次读源码对 niceeval 的启发
205
+
206
+ - **`callId` 配对是比"数组里最后一条未配对记录"更稳的设计。** niceeval 的 `action.called` / `action.result` 靠显式 `callId` 配对,不依赖工具调用严格顺序发生这一假设——agent-eval 这份实现在并发/乱序工具调用下会错配,是个真实的设计取舍对比,不是理论问题。
207
+ - **"暗号字段 + 通用兜底"这个具体写法值得抄**,尤其是"提取"和"聚合"分离这一点;但**提取函数本身在多个 agent parser 间被复制而非共享**,是可以直接避免的重复,新写 parser 时不必照抄这一部分。
208
+ - **一个 agent 的"采集"可以是多条互不相干的通道**(Codex 的 stdout 转写 vs 磁盘读实际模型),不是"一个 agent 一种采集方式"——设计 adapter 的采集逻辑时要按"这份数据要用来干什么"分别决定怎么采,而不是假设一种机制能满足所有需求。
209
+ - **`Agent.run()` 是一个每个 agent 重写一遍的单体函数**(建沙箱、传文件、装 CLI、跑命令、抓 transcript、注入上下文、跑校验、采 diff、关沙箱,claude-code.ts 和 codex.ts 里这一整套流程几乎逐行重复),生命周期没有被收进一个共享的运行器骨架里。niceeval 把这套骨架收进运行器(`setup` 一次 / `send` 每轮一次 / git 基线与 diff 由 runner 统一管),adapter 只写"这几行不同"的部分——这是两边在"core 拥有多少"这条线上最大的架构分歧。
210
+
211
+ ## 相关阅读
212
+
213
+ - [Adapter 契约](../contract.md) / [Adapter 写法](../authoring.md) —— niceeval 自己的 Agent 契约、逐 API 适配义务、能力分档、采集层设计。
214
+ - [Observability](../../observability.md) —— niceeval 的标准事件流(`callId` 配对)、OTLP trace、工件落盘。
215
+ - [References](../../references.md) —— 调研其它外部项目(如 agent-eval 的 playground/view)学到什么。
@@ -0,0 +1,69 @@
1
+ # 主流 agent loop 的接入面调研 —— API / 会话 / HITL / 遥测
2
+
3
+ **来源:** 各框架官方文档 / 源码(2026-07 抓取,URL 见各节)。这是**调研记录**:如果要把「用这些框架写的 agent」接进 niceeval,每家的 API 面和遥测面长什么样。和两篇姊妹调研对照着读:[otel-genai.md](otel-genai.md) 讲「记录 agent 行为的 schema 标准」,[otel-instrumentation.md](otel-instrumentation.md) 讲「应用侧现成 OTel 埋点里有什么数据」;本篇讲**框架原生 API 本身**。
4
+
5
+ 覆盖四家:OpenAI Agents SDK、Claude Agent SDK、LangGraph、pi。AI SDK 不重复(已是内建 adapter,见 [collection.md](../collection.md) 通道 0),但列进末尾对照表。
6
+
7
+ ## OpenAI Agents SDK(`openai-agents` / `@openai/agents`)
8
+
9
+ - **调用面**:`Runner.run(agent, input)` → `RunResult`。`final_output`(有 `output_type` 时是结构化对象)、`new_items: RunItem[]`(类型闭集:`MessageOutputItem` / `ReasoningItem` / `ToolCallItem` / `ToolCallOutputItem` / `ToolApprovalItem` / `HandoffCallItem` / `HandoffOutputItem`)、`raw_responses`、四组 guardrail results。**`ToolCallItem` / `ToolCallOutputItem` 都暴露 `call_id`,显式配对**。handoff 本质是名为 `transfer_to_<agent>` 的特殊 tool call。
10
+ - **流式**:三类事件——`RawResponsesStreamEvent`(透传 Responses API 原始事件)、`RunItemStreamEvent`(item 完整生成,`name` 语义化:`message_output_created` / `tool_called` / `tool_output` / `handoff_requested` / `handoff_occured`〔Python 侧是官方保留的拼写错误〕/ `reasoning_item_created` …)、`AgentUpdatedStreamEvent`(handoff 换 agent)。
11
+ - **会话**:四种续接——手动 `result.to_input_list()`;`session=`(SQLite / Redis / OpenAI Conversations 等多实现,自定字符串 id);服务端 `conversation_id`;`previous_response_id` 链式。原生 session id 概念齐全。
12
+ - **HITL**:工具声明 `needs_approval=True` → run 暂停,`result.interruptions` 给 `ToolApprovalItem[]`;`state.approve(...)` / `state.reject(...)` 后 `Runner.run(agent, state)` 恢复;`RunState` 可 `to_json()` 序列化跨进程。JS 先有,Python 0.17.x 已补齐(0.7 还没有,接旧版本要注意)。
13
+ - **遥测**:内置 tracing 是**私有类型化 span 树**(`agent_span` / `generation_span` / `function_span` / `handoff_span` / `guardrail_span`),默认导出到 OpenAI 后端;**`trace_include_sensitive_data` 默认 `true`**(LLM/工具输入输出默认记录)。SDK 无原生 OTLP exporter;OTel 路线是官方 contrib 的 `opentelemetry-instrumentation-openai-agents-v2`(产 **gen_ai semconv** span,内容由 `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` 控制,默认不采)或 20+ 第三方 processor(Logfire / Langfuse / Braintrust …)。
14
+ - **usage**:`result.context_wrapper.usage`(全 run 聚合:requests / input / output / total + cached/reasoning 细分 + 逐请求明细)。
15
+ - 来源:https://openai.github.io/openai-agents-python/{running_agents,results,streaming,sessions,human_in_the_loop,tracing,usage} 、https://openai.github.io/openai-agents-js/guides/ 、https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2
16
+
17
+ ## Claude Agent SDK(`@anthropic-ai/claude-agent-sdk` / `claude-agent-sdk`)
18
+
19
+ - **调用面**:`query({ prompt, options })` → `AsyncGenerator<SDKMessage>`。SDKMessage 联合类型已膨胀到 ~28 个成员(核心:`assistant` / `user` / `result` / `system:init` / `stream_event` / `compact_boundary`,外加大量运维型消息),**adapter 要按 type/subtype 白名单过滤**。tool_use 是 assistant 消息 `content` 里的标准 Anthropic 块(`{ type: "tool_use", id, name, input }`),结果以 user 消息的 `tool_result` 块回流,**`tool_use.id ↔ tool_result.tool_use_id` 显式配对**;`parent_tool_use_id` 标记 subagent 归属。`result` 消息一次给全:`result` 文本、`structured_output`(有 output schema 支持,校验失败自动重试)、`usage` / `modelUsage` / `total_cost_usd`(客户端估算)、`num_turns`、`permission_denials`(被拒工具清单)、`terminal_reason`。
20
+ - **会话**:`resume: <session_id>` / `continue: true` / `forkSession: true`(复制历史开新线,同起点分支实验直接可用)/ `resumeSessionAt: <uuid>`。transcript 落盘 `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`,且有官方读取 API(`listSessions()` / `getSessionMessages()`)——**采集可以不自己录**。V2 会话 API(`unstable_v2_*`)已在 TS SDK 0.3.142 移除。
21
+ - **HITL**:两档。同进程 `canUseTool(toolName, input, { toolUseID })` 回调,返回 allow(可改写 input)/ deny——官方明说**可以无限期挂起**,execution 一直等回调返回;跨进程用 `PreToolUse` hook 返回 `permissionDecision: "defer"` → 进程退出、result 带 `stop_reason: "tool_deferred"` + `deferred_tool_use`,之后同 session_id resume。权限评估固定六步(hooks → deny 规则 → ask 规则 → mode → allow 规则 → canUseTool),**被前面步骤 auto-approve 的调用永远到不了 `canUseTool`**。
22
+ - **遥测**:SDK 不产遥测,透传 env 给 CLI;CLI 内置 OTel 三信号独立开关——metrics、logs/events、**traces(beta,需 `CLAUDE_CODE_ENHANCED_TELEMETRY_BETA=1`)**。span 树 `claude_code.interaction` → `claude_code.llm_request` / `claude_code.tool`(内含 `tool.blocked_on_user` 等人审批时段单独成 span)。schema 是**私有 `claude_code.*` 掺部分 gen_ai semconv**(`gen_ai.request.model`、tool span 上有 `gen_ai.tool.call.id`)。**内容默认脱敏**,`OTEL_LOG_TOOL_CONTENT=1` / `OTEL_LOG_RAW_API_BODIES=file:<dir>`(原始 API body 落盘,最完整采集通道)按需开。支持 W3C trace context 传播(harness 的 span 可当 agent trace 的父节点)。
23
+ - **usage 坑**:并行 tool call 会产生多条共享同一 `message.id` 且 usage 相同的 assistant 消息,**必须按 id 去重**否则重复计数。
24
+ - 来源:https://code.claude.com/docs/en/agent-sdk/{typescript,sessions,permissions,user-input,observability,cost-tracking,structured-outputs}
25
+
26
+ ## LangGraph(Python / JS)
27
+
28
+ - **调用面**:`graph.invoke(state, config)` → 最终 state dict。messages 通道上 `AIMessage.tool_calls[]` 带 `id`,`ToolMessage.tool_call_id` 必填——**显式配对**。
29
+ - **流式**:两套并存。图级 `stream_mode`(Python 7 种:`values` / `updates` / `messages` / `custom` / `checkpoints` / `tasks` / `debug`;JS 另有 `tools`);组件级 `astream_events` / `streamEvents` v2(`on_chat_model_start|stream|end`、`on_tool_start|end`、`on_chain_*`,带 `run_id` + `parent_ids` 树坐标)。v3 typed 事件流 beta。
30
+ - **会话**:checkpointer + `config.configurable.thread_id`,原生 session id 语义;`checkpoint_id` 提供时间旅行(从历史 checkpoint 分叉)。
31
+ - **HITL**:节点内 `interrupt(payload)` → invoke 返回值带 `__interrupt__` key(`Interrupt { value, id }`);以同 thread_id 用 `Command(resume=value)` 再 invoke 恢复;多个并发 interrupt 用 `{interrupt.id: answer}` dict 一次 resume。**resume 后节点从头重放**(`interrupt()` 之前的代码再跑一遍)——eval 采集会看到中断前副作用重复出现。
32
+ - **遥测**:**零依赖 OTLP 路线是四家里最顺的**——`LANGSMITH_TRACING=true` + `LANGSMITH_OTEL_ENABLED=true` + `OTEL_EXPORTER_OTLP_ENDPOINT=<任意端点>`(`LANGSMITH_OTEL_ONLY=true` 完全绕开 LangSmith 后端)。但 span 属性是**混合方言**:gen_ai(OpenLLMetry 风格旧写法 `gen_ai.completion.0.content`)+ OpenInference(`openinference.span.kind`)+ Traceloop 属性并存,消费端不能只按单一 semconv 解析。OpenInference / OpenLLMetry 也各有 langchain instrumentation(hook 在 langchain-core 上,LangGraph 自动覆盖)。
33
+ - **usage**:`AIMessage.usage_metadata`(input/output/total + cache/reasoning 细分);跨调用聚合用 `UsageMetadataCallbackHandler`。已知坑:`stream_mode="messages"` 下 usage 可能缺(langgraph#5951)。
34
+ - 来源:https://docs.langchain.com/oss/python/langgraph/{streaming,persistence,interrupts} 、https://docs.langchain.com/langsmith/trace-with-opentelemetry
35
+
36
+ ## pi(earendil-works/pi,原 badlogic/pi-mono)
37
+
38
+ - **定位**:「minimal terminal coding harness」。默认 4 工具(`read` / `write` / `edit` / `bash`)、~1000 token 系统提示、无 sub-agent / plan mode / MCP(明确立场)。npm scope 已从 `@mariozechner/*` 迁到 `@earendil-works/*`(旧包全部 deprecated;`@mariozechner/pi` 本身是 vLLM 部署工具,**不是** coding agent,别装错)。
39
+ - **调用面**:四种运行模式全部官方支持——interactive、`--mode json`(事件 JSONL 打 stdout)、`--mode rpc`(stdin/stdout 双向 JSONL,支持 `id` 请求关联)、SDK 进程内(`createAgentSession()` + `session.subscribe()` + `session.prompt()`)。
40
+ - **事件流**:词汇闭集,层级清晰——`agent_start/end`、`turn_start/end`(**turn 概念显式**)、`message_start/update/end`(内嵌 text/thinking delta)、`tool_execution_start/update/end`(**均带显式 `toolCallId`** + args,end 带 result + isError)、`compaction_start/end`、`auto_retry_start/end`。工具结果双通道:`content` 给 LLM、`details` 给 UI(如 edit 工具的 `details.diff` 是标准 unified patch)。
41
+ - **会话**:JSONL **树**结构(首行 SessionHeader v3,条目带 `id` + `parentId`,同文件内原地分支),存 `~/.pi/agent/sessions/`;`AssistantMessage` **自带 usage(含 cache 细分)+ 分项 cost + stopReason + provider + model**——对 eval 采集是四家里最友好的 transcript。SDK 侧 `SessionManager.inMemory()` 可不落盘;`fork()` / `importFromJsonl()` 齐全。
42
+ - **HITL**:**无内置工具审批**(哲学是 YOLO + 可观测)。要拦得用 extension:`pi.on("tool_call", handler)` 返回 `{ block: true }`,`ctx.ui.confirm/select/input` 与人交互;RPC 模式有 extension UI 协议转发对话框。
43
+ - **遥测**:**今天没有**。`packages/agent/docs/observability.md` 有设计稿(自发结构化生命周期事件、不依赖 OTel、外部监听者自行转 span),但源码搜索确认未实现。现实遥测面 = 事件流 + AssistantMessage 自带的 usage/cost。
44
+ - 来源:https://github.com/earendil-works/pi (README + packages/coding-agent/docs/{sdk,json,rpc,session-format,extensions}.md + packages/agent/docs/observability.md)、https://mariozechner.at/posts/2025-11-30-pi-coding-agent/
45
+
46
+ ## 汇总对照(含 AI SDK 作参照)
47
+
48
+ | | 调用面 | call/result 配对 | 原生会话 | HITL 原语 | 原生遥测 |
49
+ |---|---|---|---|---|---|
50
+ | **AI SDK**(v7) | `generateText` → steps/content parts | `toolCallId` | 无(自管 messages) | `needsApproval` → `tool-approval-request` 停轮 | `experimental_telemetry`:`ai.*` 私有 + 部分 gen_ai(见 [otel-instrumentation.md](otel-instrumentation.md)) |
51
+ | **OpenAI Agents SDK** | `Runner.run` → `RunResult.new_items`(item 类型闭集) | `call_id` | `to_input_list` / Sessions / `conversation_id` | `needs_approval` → `interruptions` + `RunState.approve/reject`(可序列化) | 私有 span 树(敏感数据**默认采**);OTel 走官方 contrib(gen_ai,内容 opt-in) |
52
+ | **Claude Agent SDK** | `query()` → `SDKMessage` 流(~28 种,需白名单) | `tool_use.id ↔ tool_result.tool_use_id` | `resume` / `forkSession` + jsonl 落盘 + 官方读取 API | `canUseTool` 回调(可无限挂起)/ `defer` + resume | CLI 内置 OTLP 三信号,traces beta;`claude_code.*` 掺 gen_ai;内容 opt-in |
53
+ | **LangGraph** | `invoke(state)` → 最终 state | `tool_calls[].id ↔ tool_call_id` | `thread_id` + checkpointer(+时间旅行) | `interrupt()` → `__interrupt__` + `Command(resume)`;节点重放 | `LANGSMITH_OTEL_ENABLED` → 任意 OTLP;**三方言混合** |
54
+ | **pi** | SDK subscribe / `--mode json` / RPC | `toolCallId` | JSONL 树 + fork;可 inMemory | 无内置;extension `tool_call` 拦截 | 无(仅设计稿) |
55
+
56
+ ## 对 niceeval 的印证与启发
57
+
58
+ 1. **五档契约的原语每家都有对应物,写 converter 是机械工作。** 显式 call id 配对(全员)、原生 session id(全员)、HITL 停轮-恢复(pi 之外全员)——niceeval 的 `Turn` / `StreamEvent` / `waiting + input.requested` / `ctx.session` 在每家都能一一映射,没有语义鸿沟。真正贵的不是"能不能写",是 **N 家 × 版本漂移的维护**:内建 `fromAiSdk` 530 行里,近半是 v4/v5/v7 字段漂移兜底和 approval 形状适配,每家都这样写一份不可持续。
59
+ 2. **HITL 有三种形态,niceeval 的握手能表达全部,但回调型要搭桥。** 停轮返回型(AI SDK / OpenAI Agents:结果里带 interruptions,天然对应 `waiting` + 下轮 resume)最好接;**回调挂起型**(Claude `canUseTool`、pi extension)是"execution 停在 await 上",adapter 要把回调转成 promise 桥——本轮 send 存住 pending resolver 返回 `waiting`,下轮 send 拿 `t.respond` 的回答去 resolve 它,进程内可行但要小心超时;异常-重放型(LangGraph `interrupt`)注意 **resume 后节点从头重跑**,事件流会出现中断前动作的重复,转换层要按 interrupt id 去重。
60
+ 3. **会话义务通常是一行透传,fork 是意外之喜。** `thread_id` / `resume <session_id>` / Sessions 直接对接 `ctx.session.id`;Claude 的 `forkSession` 和 pi 的树形 session 还支持"同起点分支"——将来 niceeval 若做同 prompt 多分支对比,这两家有原生原语。
61
+ 4. **遥测面四家四样,"等一个统一标准"不现实。** 私有 schema(OpenAI Agents)、私有掺 gen_ai(Claude)、三方言混合(LangGraph)、没有(pi)。但共性是**都能把 span 说到任意 OTLP 端点**(原生 env / 官方 instrumentation / 第三方埋点),而且第三方埋点生态的内容默认全采(见 [otel-instrumentation.md](otel-instrumentation.md))——归一的活落在接收侧,这正是 niceeval 已有的 canonical mapper 结构(每方言一个薄 mapper)能吃下的形状。
62
+ 5. **transcript 侧仍然不可替代,而且有的框架比 OTel 侧更全。** Claude 的 jsonl + 官方读取 API、pi 的自带 usage/cost 的树形 JSONL,都比各自的遥测通道信息更全、更稳(Claude traces 还在 beta)。接这两家优先 transcript,OTel 只补 trace——与 [observability.md](../../observability.md) 的双轨结论一致。
63
+
64
+ ## 相关阅读
65
+
66
+ - [otel-instrumentation.md](otel-instrumentation.md) —— 应用侧现成 OTel 埋点里有什么数据(mixin 可行性的证据)。
67
+ - [otel-genai.md](otel-genai.md) —— 记录 agent 行为的 schema 标准对照(OTel GenAI / OpenInference / AG-UI …)。
68
+ - [Observability · OTLP traces](../../observability.md#otlp-traces--统一瀑布图) —— OTel 在当前设计里的实际角色(只画瀑布图,不产出事件)。
69
+ - [Adapter 契约](../contract.md) —— 这些原语要映射到的目标形状。