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
@@ -0,0 +1,224 @@
1
+ # Getting Started
2
+
3
+ 这一篇带你从零跑通三种 eval:一个会话型 agent eval(走 HTTP)、一个纯函数的语义级单测、一个沙箱里的 coding-agent eval。读完你就有了能在 CI 里跑的最小骨架。
4
+
5
+ > 注:本篇描述推荐 DX 和目标用法。若代码实现与这里的设计不一致,应进一步讨论并决定是修代码、修设计,还是记录为明确的阶段性差异。
6
+
7
+ ## 安装
8
+
9
+ ```sh
10
+ npm install -D niceeval
11
+ npx niceeval init # 生成 evals/、niceeval.config.ts、示例 eval
12
+ ```
13
+
14
+ `init` 后的目录:
15
+
16
+ ```
17
+ your-project/
18
+ ├─ niceeval.config.ts
19
+ └─ evals/
20
+ ├─ hello.eval.ts # 示例:会话型
21
+ └─ fixtures/
22
+ └─ button.eval.ts # 示例:沙箱型,起始文件在 test() 里手工写入
23
+ ```
24
+
25
+ ## 配置
26
+
27
+ ```typescript
28
+ // niceeval.config.ts
29
+ import { defineConfig } from "niceeval";
30
+ import { Console, JUnit } from "niceeval/reporters";
31
+
32
+ export default defineConfig({
33
+ judge: { model: "anthropic/claude-haiku-4-5" }, // 默认评判模型
34
+ reporters: [Console(), JUnit(".niceeval/junit.xml")],
35
+ maxConcurrency: 8,
36
+ timeoutMs: 300_000,
37
+ // 沙箱后端不在这里配 —— 它由 experiment 的 sandbox 字段决定
38
+ });
39
+ ```
40
+
41
+ ## 1. 评一个会话型 agent
42
+
43
+ 驱动一个暴露会话接口的 agent,断言它的回复与工具调用。连你的服务也是写一个 agent —— 它内部按你服务的协议发请求,URL 是它读 env 的私事(niceeval 不定义 agent 协议,所以没有 `--url`)。就算 agent 和 eval 在同一个代码库里,也照样让 adapter 走 HTTP,不要把 `fetch` 换成进程内的函数直调——直调绕过了用户实际走的链路、进程不隔离导致结果不可复现,取舍详见[接入你的 Agent · 为什么不直调](../docs-site/zh/guides/connect-your-agent.mdx):
44
+
45
+ ```typescript
46
+ // agents/weather-bot.ts —— 远程 agent,URL 是它的私事
47
+ import { defineAgent } from "niceeval/adapter";
48
+
49
+ export default defineAgent({
50
+ name: "weather-bot",
51
+ async send(input, ctx) {
52
+ const r = await fetch(`${process.env.AGENT_URL}/chat`, {
53
+ method: "POST",
54
+ body: JSON.stringify({ message: input.text }),
55
+ signal: ctx.signal,
56
+ });
57
+ const body = await r.json();
58
+ // 用 calledTool / messageIncludes 等断言时,必须把响应映射成标准事件流
59
+ return { events: toStreamEvents(body), data: body.output, status: "completed" };
60
+ },
61
+ });
62
+ ```
63
+
64
+ ```typescript
65
+ // evals/weather/brooklyn.eval.ts
66
+ import { defineEval } from "niceeval";
67
+ import { includes } from "niceeval/expect";
68
+
69
+ export default defineEval({
70
+ description: "布鲁克林天气",
71
+ async test(t) {
72
+ await t.send("布鲁克林今天天气怎么样?");
73
+ t.succeeded();
74
+ t.calledTool("get_weather", { input: { city: "Brooklyn" } });
75
+ t.check(t.reply, includes("晴"));
76
+ t.judge.autoevals.closedQA("回答是否礼貌且切题").atLeast(0.7);
77
+ },
78
+ });
79
+ ```
80
+
81
+ ```sh
82
+ AGENT_URL=https://my-agent.example.com npx niceeval exp local weather
83
+ ```
84
+
85
+ ## 2. 评一个纯函数(边缘场景:语义级单测,不测生产链路)
86
+
87
+ 只有当你确实只想把一个纯函数当"语义级单测"跑、并且清楚这测的不是用户实际走的链路时,才让 `send` 直接调用进程内代码——生产路径的评测请用上一节的 HTTP 写法:
88
+
89
+ ```typescript
90
+ // agents/classify.ts —— 进程内直调,仅用于纯函数单测场景
91
+ import { defineAgent } from "niceeval/adapter";
92
+ import { classifyIntent } from "../src/agent.js"; // 你自己的代码
93
+
94
+ export default defineAgent({
95
+ name: "classify",
96
+ async send(input) {
97
+ return { data: await classifyIntent(input.text), status: "completed" };
98
+ },
99
+ });
100
+ ```
101
+
102
+ ```typescript
103
+ // evals/classify.eval.ts
104
+ import { defineEval } from "niceeval";
105
+ import { equals } from "niceeval/expect";
106
+
107
+ export default defineEval({
108
+ description: "意图分类:退款",
109
+ async test(t) {
110
+ const turn = await t.send("我想退货退款");
111
+ t.check(turn.data, equals({ intent: "refund" }));
112
+ },
113
+ });
114
+ ```
115
+
116
+ (把 `classify` agent 放进一个 `experiments/local.ts` 运行配置。)
117
+
118
+ ```sh
119
+ npx niceeval exp local classify
120
+ ```
121
+
122
+ ## 3. 评一个塞进沙箱的 coding agent
123
+
124
+ 给一个编码任务,让 Claude Code / bub 在隔离环境里改代码,再用测试验证。起始文件、验证测试都是 `test(t)` 里手工放进沙箱——没有 `PROMPT.md` 目录约定,也没有自动发现:
125
+
126
+ ```typescript
127
+ // evals/fixtures/button.eval.ts
128
+ import { defineEval } from "niceeval";
129
+ import { commandSucceeded } from "niceeval/expect";
130
+
131
+ const PACKAGE_JSON = JSON.stringify({
132
+ name: "button-fixture",
133
+ type: "module",
134
+ scripts: { build: "tsc --noEmit", test: "vitest run" },
135
+ devDependencies: { vitest: "^2.0.0" },
136
+ });
137
+
138
+ // 验证测试的源码,agent 跑完之后才会被放进沙箱,它全程看不到
139
+ const BUTTON_TEST = `
140
+ import { test, expect } from "vitest";
141
+ import { existsSync, readFileSync } from "node:fs";
142
+
143
+ test("Button 存在", () => {
144
+ expect(existsSync("src/components/Button.tsx")).toBe(true);
145
+ });
146
+
147
+ test("接受 label / onClick", () => {
148
+ const src = readFileSync("src/components/Button.tsx", "utf-8");
149
+ expect(src).toContain("label");
150
+ expect(src).toContain("onClick");
151
+ });
152
+
153
+ // 也能断言 agent 的「行为」,而不只是结果:
154
+ test("没有暴力删库", () => {
155
+ const o11y = JSON.parse(readFileSync("__niceeval__/results.json", "utf-8")).o11y;
156
+ expect(o11y.shellCommands.map((c) => c.command)).not.toContain("rm -rf");
157
+ });
158
+ `;
159
+
160
+ export default defineEval({
161
+ description: "实现一个 Button 组件",
162
+ async test(t) {
163
+ await t.sandbox.writeFiles({ "package.json": PACKAGE_JSON });
164
+
165
+ await t.send(
166
+ "用项目现有的样式系统,在 src/components/Button.tsx 导出一个 Button 组件,接受 label 和 onClick 两个 prop,并实现 hover 态。",
167
+ );
168
+
169
+ // agent 那一轮已经结束,现在才放测试文件、才跑测试
170
+ await t.sandbox.writeFiles({ "button.test.ts": BUTTON_TEST });
171
+ const test = await t.sandbox.runCommand("npm", ["test"]);
172
+ t.check(test, commandSucceeded());
173
+ },
174
+ });
175
+ ```
176
+
177
+ `experiments/local.ts` 里给这个沙箱型 agent 加一个 `sandbox: dockerSandbox()`(从 `niceeval/sandbox` 导入)——沙箱后端没有默认值,也没有 `--sandbox` 这种 CLI 覆盖,必须写进 experiment(或 `niceeval.config.ts` 兜底)。
178
+
179
+ **跑起来:**
180
+
181
+ ```sh
182
+ # 直连 API + 本地 Docker,不需要任何云 token
183
+ export ANTHROPIC_API_KEY=sk-ant-...
184
+ npx niceeval exp local fixtures/button
185
+
186
+ # 跑 10 次取通过率,先过一次就早停
187
+ npx niceeval exp local fixtures/button --runs 10 --early-exit
188
+ ```
189
+
190
+ ## 看结果
191
+
192
+ 控制台实时输出:
193
+
194
+ ```text
195
+ Discovered 3 evals
196
+
197
+ ✓ classify (12ms)
198
+ ✓ weather/brooklyn (456ms)
199
+ ✗ fixtures/button (38s)
200
+ - gate: commandSucceeded [FAILED]
201
+ button.test.ts › 接受 label / onClick
202
+ Expected src to contain "onClick"
203
+
204
+ Results: 2 passed, 1 failed, 0 skipped
205
+ ```
206
+
207
+ 详细工件落在 `.niceeval/<时间戳>/`:run 级 `summary.json`,以及 attempt 级 `events.json`、`sources.json`、`trace.json`、`o11y.json`、`diff.json`。结构详见 [Results Format](results-format.md)。
208
+
209
+ ## 接进 CI
210
+
211
+ ```yaml
212
+ # .github/workflows/evals.yml
213
+ - run: npx niceeval exp ci --strict --junit .niceeval/junit.xml
214
+ env:
215
+ ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
216
+ ```
217
+
218
+ `--strict` 让 soft 断言低于阈值也判为 `failed`;有任何 `failed` 或 `errored` 即非零退出。
219
+
220
+ ## 接着读
221
+
222
+ - [Authoring](eval-authoring.md) —— 多轮、数据集扇出、fixture 进阶。
223
+ - [Scoring](scoring.md) —— 全部评分手段。
224
+ - [CLI](cli.md) —— 全部命令与标志。
@@ -0,0 +1,145 @@
1
+ # Multi-Agent Evals —— 设计提案(未实现)
2
+
3
+ > 状态:设计提案,未实现。落地前先过这篇的 DX 和边界;实现顺序见文末分期。
4
+
5
+ 被测对象越来越多是多 agent 系统:orchestrator 委派 subagent、planner 交接给 writer、客服 bot 对着模拟用户。这篇回答:niceeval 怎么评它们,而不破坏 core 中立、CLI 模型和现有断言作用域规则。
6
+
7
+ ## 先分清:「多 agent eval」是三件不同的事
8
+
9
+ | 场景 | 一句话 | 本文覆盖 |
10
+ |---|---|---|
11
+ | **A. 被测对象内部是多 agent** | 一次 `t.send`,里面有 planner / researcher / writer 分工 | ✅ 主体 |
12
+ | **B. eval 编排多个 agent 对手戏** | 主被测 agent 对着另一个 agent(模拟用户、谈判对手)你来我往 | ✅ 次之 |
13
+ | **C. 同一 eval 跑多个 agent 对比** | claude-code vs codex 谁做得好 | ❌ 已有,走 [experiments](experiments.md) 矩阵,不在本文 |
14
+
15
+ ## 现状与差距
16
+
17
+ 事件词汇里已经有委派:`subagent.called` / `subagent.completed`(callId 配对),断言有 `calledSubagent(name, match?)`,`noFailedActions` 也覆盖子 agent 失败。但只能评到「委派发生了」这一层:
18
+
19
+ ```ts
20
+ // 今天能写到的极限
21
+ export default defineEval({
22
+ description: "研究报告要走 researcher",
23
+ async test(t) {
24
+ await t.send("调研 WebGPU 生态并写一页纸报告");
25
+ t.calledSubagent("researcher"); // 委派发生了 ✅
26
+ // researcher 里面调了什么工具?writer 有没有偷偷联网?—— 评不到:
27
+ // 事件没有归属字段,子 agent 的行为要么不可见,要么混在主流里分不清谁干的。
28
+ },
29
+ });
30
+ ```
31
+
32
+ 差距有三个:**归属**(每条事件是谁干的)、**交接**(控制权单向转移,和委派的调用-返回是两回事)、**对手戏**(eval 里驱动第二个 agent)。
33
+
34
+ ## 场景 A:被测对象内部是多 agent
35
+
36
+ ### 目标 DX
37
+
38
+ ```ts
39
+ export default defineEval({
40
+ description: "研究报告:检索归 researcher,writer 不许联网",
41
+ async test(t) {
42
+ await t.send("调研 WebGPU 生态并写一页纸报告");
43
+
44
+ t.agentOrder(["planner", "researcher", "writer"]); // 出场顺序(子序匹配,同 toolOrder)
45
+ t.handedOff({ from: "researcher", to: "writer" }); // 控制权交接
46
+ t.agent("researcher").calledTool("web_search"); // 归属过滤 × 既有断言词汇
47
+ t.agent("writer").notCalledTool("web_search"); // 负断言:声明 agentObservability 才可信
48
+ t.agent("writer").messageIncludes("参考来源");
49
+ },
50
+ });
51
+ ```
52
+
53
+ `t.agent(name)` 不是新的断言词汇,是在既有作用域上加一层**归属过滤**:返回的接收者提供同一套作用域断言(`calledTool` / `messageIncludes` / `event` / …),只是数据换成「归属为 name 的事件子集」。作用域规则不变(作用域由接收者决定):`t.agent(x)` 看全 run、`turn.agent(x)` 看这一轮,归属 × 作用域正交。
54
+
55
+ ### 事件流怎么改
56
+
57
+ 两处,都向后兼容:
58
+
59
+ ```ts
60
+ // 1. StreamEvent 全部成员追加一个可选归属字段。
61
+ // 缺省(undefined)= 主 agent 自己 —— 单 agent adapter 一行不用改。
62
+ { type: "action.called", callId: "c7", name: "web_search", input: {...}, agent: "researcher" }
63
+ { type: "message", role: "assistant", text: "报告如下…", agent: "writer" }
64
+
65
+ // 2. 新事件类型:交接(控制权单向转移;区别于 subagent.called/completed 的调用-返回)
66
+ { type: "handoff", from: "planner", to: "researcher" }
67
+ ```
68
+
69
+ 委派和交接都保留,语义不同:`subagent.called/completed` 是父 agent 等子 agent 返回(有 callId、有 output);`handoff` 是控制权走了就不回头(OpenAI Agents SDK 的 handoff、LangGraph 的节点跳转)。adapter 按被测系统的真实语义选,不要互相模拟。
70
+
71
+ ### 能力位:`agentObservability`
72
+
73
+ 与 `toolObservability` 同一套诚实语义:声明它 = 承诺**归属完整**(多 agent 的每条事件都标了 `agent`,交接都吐了 `handoff`)。做不到就不声明——正断言照常可用,负断言(`t.agent(x).notCalledTool` 等)不可信的问题和 toolObservability 一模一样。
74
+
75
+ ### 逐断言义务矩阵(eval 调什么 ↔ adapter 给什么 ↔ 违约怎么暴露)
76
+
77
+ | 断言 | 靠什么 | 违约暴露 |
78
+ |---|---|---|
79
+ | `t.agent(x).calledTool()` 等正断言 | 事件的 `agent` 字段 | fail(响) |
80
+ | `t.agent(x).notCalledTool()` 等负断言 | 归属**完整性** | **假通过(静默)** |
81
+ | `agentOrder([...])` | 归属首次出现的顺序,子序匹配 | fail(响) |
82
+ | `handedOff({ from?, to })` | `handoff` 事件 | fail(响) |
83
+ | `calledSubagent(name)` | `subagent.called/completed`(已有) | fail(响) |
84
+
85
+ 派生事实同步扩:`DerivedFacts` 加 `agents: string[]`(按首现顺序)、`handoffs: { from?: string; to: string }[]`;`toolCalls` 各条带上归属。
86
+
87
+ ### 采集可行性(主流 agent loop 都拿得到归属)
88
+
89
+ - **Claude Agent SDK / claude-code**:`parent_tool_use_id` 标记 subagent 归属,transcript 里 sidechain 可归属到 Task 名。
90
+ - **OpenAI Agents SDK**:handoff 本质是 `transfer_to_<agent>` 工具调用,流式有 `handoff_occured` / `AgentUpdatedStreamEvent`;RunItem 天然归属当前 agent → `handoff` 事件直接映射。trace 侧印证:GenAI semconv 至今没有 handoff 词汇,官方 OTel contrib 只能自造 `agent_handoff` span(`gen_ai.handoff.from_agent/to_agent`)——说明「交接需要独立词汇」不是 niceeval 的臆造。
91
+ - **LangGraph**:节点名即归属;节点跳转即交接。
92
+ - **AI SDK 单循环**:没有多 agent,不声明 `agentObservability`,零改动。
93
+ - 上限参照:eve 协议的 RuntimeIdentity(运行时自报身份),见 [reference/eve-protocol.md](adapters/reference/eve-protocol.md)。
94
+
95
+ ### 和 trace 的分工
96
+
97
+ 分工不变:事件流回答「谁做了什么」(判对错),trace 回答「各花了多久、谁套谁」(看性能)。`SpanKind` 已有 `"agent"`(invoke_agent span),多 agent 的瀑布图 / 逐 agent 时延和成本走 tracing + view,**不往事件流里塞 per-agent usage**。`Turn.usage` 维持轮级总量。
98
+
99
+ ## 场景 B:eval 编排多个 agent 对手戏
100
+
101
+ ### 目标 DX —— 复用 `newSession`,不发明新驱动 API
102
+
103
+ ```ts
104
+ export default defineEval({
105
+ description: "客服顶住砍价:模拟用户连续压价 5 轮",
106
+ async test(t) {
107
+ const shopper = t.newSession({ agent: "bargain-user" }); // 注册表里的另一个 agent
108
+ let ask = "这台能便宜 500 吗?";
109
+ for (let i = 0; i < 5; i++) {
110
+ const sellerTurn = await t.send(ask); // 主被测:客服 bot
111
+ const shopperTurn = await shopper.send(sellerTurn.message);
112
+ ask = shopperTurn.message;
113
+ }
114
+ t.notCalledTool("apply_discount"); // 只评主被测
115
+ t.judge.autoevals.closedQA("客服是否始终礼貌且未擅自降价?").atLeast(0.8);
116
+ },
117
+ });
118
+ ```
119
+
120
+ `newSession` 已经返回带 `send` / `reply` / 作用域断言的 session,加一个可选 `{ agent }` 参数就够——不需要第二套驱动 API。
121
+
122
+ ### 三条规则
123
+
124
+ 1. **主被测唯一**。CLI 模型不破坏:`--agent` 只换主被测;对手 agent 在 eval 文件里点名,是场景的一部分,地位等同 fixture。要对比不同对手,写两条 eval 或用 `flags`,不进 agent 矩阵。
125
+ 2. **对手事件不入 `t.*` 聚合**。这是对「`newSession` 事件汇入 `t.*`」既有规则的显式例外,按「session 的 agent 是否为主被测」划线——否则 `t.notCalledTool` 会把对手的工具调用算到主被测头上,整类断言失真。对手 session 自己的 `session.*` 断言照常可用。
126
+ 3. **成本分列**。对手的 usage 单独累计,报表里与主被测分开(评测成本 ≠ 被测成本)。
127
+
128
+ ## 非目标
129
+
130
+ - 跨 agent 对比评分:experiments 矩阵已有(场景 C)。
131
+ - A2A / ACP 等 agent 间协议对接:那是某个 adapter 的活,core 不认协议。
132
+ - agent 间消息内容的自动评分:judge 已覆盖,不需要新机制。
133
+ - 多轮对手戏的循环语法糖(`converse(agent, { maxTurns })` 之类):先用裸循环写三条真实 eval,形状稳定了再提,不提前抽象。
134
+
135
+ ## 分期
136
+
137
+ 1. **归属**:`StreamEvent.agent` 字段 + `t.agent()` 过滤 + `agentObservability` 能力位 + `DerivedFacts.agents`。claude-code 的 subagent 归属立刻能吃上,是最小可用切片。
138
+ 2. **交接**:`handoff` 事件 + `handedOff` / `agentOrder` 断言。首个消费者:OpenAI Agents SDK 参考 adapter。
139
+ 3. **对手戏**:`newSession({ agent })` + 聚合例外 + 成本分列。
140
+
141
+ ## 未决问题
142
+
143
+ - `newSession({ agent })` 的对手要不要吃实验矩阵的 `model`?倾向不吃(对手是场景常量),但缺真实用例佐证。
144
+ - `agentOrder` 在循环交接(A→B→A→B)下取「首现子序」;要断完整轨迹用 `eventsSatisfy`,是否够用待验证。
145
+ - 对手 session 的 `handoff` / 归属字段是否有意义(对手自己也是多 agent 时),先按「有就收、不特殊处理」。