niceeval 0.2.1 → 0.4.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.
- package/README.md +34 -34
- package/README.zh.md +29 -15
- package/docs/README.md +2 -1
- package/docs/adapters/collection.md +1 -1
- package/docs/adapters/contract.md +6 -5
- package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
- package/docs/architecture.md +20 -20
- package/docs/assertions.md +0 -1
- package/docs/capabilities-by-construction.md +2 -2
- package/docs/cli.md +2 -2
- package/docs/concepts.md +2 -2
- package/docs/e2e-ci.md +135 -98
- package/docs/eval-authoring.md +3 -3
- package/docs/experiments.md +7 -4
- package/docs/observability.md +9 -5
- package/docs/origin-integration.md +18 -18
- package/docs/references.md +1 -1
- package/docs/reports.md +551 -0
- package/docs/results-format.md +3 -3
- package/docs/results-lib.md +191 -0
- package/docs/runner.md +1 -1
- package/docs/sandbox.md +1 -1
- package/docs/source-map.md +22 -2
- package/docs/tier-sync.md +74 -58
- package/docs/view.md +41 -4
- package/package.json +29 -3
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/claude-code.ts +18 -1
- package/src/agents/codex.ts +3 -2
- package/src/agents/index.ts +16 -0
- package/src/agents/openai-compat.test.ts +56 -0
- package/src/agents/openai-compat.ts +151 -0
- package/src/agents/types.ts +3 -1
- package/src/cli.ts +2 -1
- package/src/context/context.test.ts +78 -1
- package/src/context/context.ts +19 -11
- package/src/context/session.ts +2 -0
- package/src/context/types.ts +11 -5
- package/src/i18n/en.ts +8 -4
- package/src/i18n/zh-CN.ts +6 -3
- package/src/o11y/cost.test.ts +40 -0
- package/src/o11y/cost.ts +27 -5
- package/src/o11y/derive.ts +2 -2
- package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
- package/src/o11y/otlp/mappers/claude-code.ts +24 -0
- package/src/o11y/otlp/mappers/index.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +127 -0
- package/src/o11y/prices.json +176 -119
- package/src/o11y/types.ts +2 -1
- package/src/report/aggregate.ts +215 -0
- package/src/report/compute.ts +405 -0
- package/src/report/format.ts +47 -0
- package/src/report/index.ts +40 -0
- package/src/report/metrics.ts +96 -0
- package/src/report/react/CaseList.tsx +70 -0
- package/src/report/react/DeltaTable.tsx +79 -0
- package/src/report/react/MetricMatrix.tsx +75 -0
- package/src/report/react/MetricScatter.tsx +217 -0
- package/src/report/react/MetricTable.tsx +71 -0
- package/src/report/react/RunOverview.tsx +87 -0
- package/src/report/react/Scoreboard.tsx +87 -0
- package/src/report/react/cell.tsx +49 -0
- package/src/report/react/colors.ts +42 -0
- package/src/report/react/data.ts +17 -0
- package/src/report/react/fixtures.ts +236 -0
- package/src/report/react/format.ts +34 -0
- package/src/report/react/index.tsx +34 -0
- package/src/report/react/render.test.tsx +303 -0
- package/src/report/react/styles.css +302 -0
- package/src/report/report.test.ts +667 -0
- package/src/report/types.ts +210 -0
- package/src/results/copy.ts +200 -0
- package/src/results/format.ts +75 -0
- package/src/results/index.ts +27 -0
- package/src/results/open.ts +207 -0
- package/src/results/results.test.ts +359 -0
- package/src/results/select.ts +101 -0
- package/src/results/types.ts +98 -0
- package/src/runner/attempt.ts +3 -1
- package/src/runner/report.test.ts +111 -0
- package/src/runner/report.ts +52 -1
- package/src/runner/reporters/artifacts.ts +1 -1
- package/src/runner/reporters/braintrust.test.ts +106 -0
- package/src/runner/reporters/braintrust.ts +197 -0
- package/src/runner/reporters/console.ts +3 -3
- package/src/runner/reporters/index.ts +3 -1
- package/src/runner/reporters/live.ts +17 -5
- package/src/runner/reporters/shared.ts +3 -0
- package/src/runner/reporters/table.ts +1 -0
- package/src/runner/run.ts +31 -18
- package/src/runner/types.ts +20 -1
- package/src/sandbox/types.ts +3 -3
- package/src/shared/format.ts +8 -2
- package/src/view/app/App.tsx +82 -17
- package/src/view/app/components/AttemptModal.tsx +9 -2
- package/src/view/app/components/CodeView.tsx +2 -2
- package/src/view/app/components/CostScoreChart.tsx +127 -0
- package/src/view/app/components/LazyArtifact.tsx +2 -1
- package/src/view/app/i18n.ts +13 -1
- package/src/view/app/lib/artifact-url.ts +6 -0
- package/src/view/app/lib/attempt-route.test.ts +89 -0
- package/src/view/app/lib/attempt-route.ts +52 -0
- package/src/view/app/lib/chart.ts +72 -0
- package/src/view/app/lib/outcome.ts +1 -1
- package/src/view/app/types.ts +4 -5
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +36 -7
- package/src/view/loader.ts +14 -7
- package/src/view/server.ts +7 -0
- package/src/view/shared/types.ts +14 -1
- package/src/view/styles.css +43 -0
- package/src/view/template.html +0 -1
package/docs/view.md
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
niceeval view # 起本地 web,自动打开浏览器,读 .niceeval/ 下所有历史运行
|
|
7
7
|
niceeval view .niceeval/<run>/summary.json
|
|
8
8
|
niceeval view --no-open # 只打印 URL,不打开浏览器
|
|
9
|
-
niceeval view --out .
|
|
9
|
+
niceeval view --out site # 目录式静态导出:index.html + artifact/
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
架构上是**一次性烘焙进单个 HTML+JSON 的静态产物**(`src/view/index.ts` 的 `renderHtml`),不是常驻的多页面 server——`niceeval view` 起的 web 服务每次请求现读现渲染,`--out`
|
|
12
|
+
架构上是**一次性烘焙进单个 HTML+JSON 的静态产物**(`src/view/index.ts` 的 `renderHtml`),不是常驻的多页面 server——`niceeval view` 起的 web 服务每次请求现读现渲染,`--out` 则直接导出。这是刻意的取舍,详见 [References](references.md#调研过判断不值得抄的及理由)。
|
|
13
|
+
|
|
14
|
+
`--out` 只有目录式一种形态:写 `<out>/index.html`,并把前端会 fetch 的三类工件(`sources.json` / `events.json` / `trace.json`)复制到 `<out>/artifact/<base>/`,与本地 server 的 `/artifact/<rel>` 路径路由同一布局,同一份前端产物在两种托管下用同一个相对 URL(`src/view/app/lib/artifact-url.ts`)。`diff.json` / `o11y.json` 刻意不复制:查看器从不读取,且 diff 可达上百 MB,带上只会拖垮静态部署体积。
|
|
15
|
+
|
|
16
|
+
> 单文件导出(`--out report.html`)曾经存在,已移除:代码/transcript/trace 视图依赖工件文件,单文件注定是残缺体验,而 coding eval 恰恰最依赖这些视图——这个形态的存在本身就在诱导用户导出一份看不了证据的报告。「传一个文件给同事」的需求,答案是把整站导出托管起来发链接,或用 [Reports](reports.md) 积木在 CI 里落判决数据。`--out` 目标以 `.html` 结尾时 CLI 直接报错并给出改法。
|
|
13
17
|
|
|
14
18
|
## 结果版本机制
|
|
15
19
|
|
|
@@ -93,7 +97,7 @@ npx niceeval@<producer.version> view .niceeval/<run>/summary.json
|
|
|
93
97
|
|
|
94
98
|
这两条之前被 [Observability](observability.md) 的能力列表当成已实现的写了,这次审查代码(`src/view/index.ts`、`src/view/app/`)发现对不上,已经从那边挪过来,归到下面「计划中」:
|
|
95
99
|
|
|
96
|
-
- **"跨运行趋势"实际是合并,不是可对比的历史。** `aggregateRows`(`src/view/index.ts`)把 `.niceeval/` 下**所有**历史 `summary.json` 按 `experimentId` 揉进同一行——通过率、平均耗时、成本都是跨全部历史 run 的累计值,不是"最新一次"或"某一次"的快照,更谈不上画成随时间变化的线。
|
|
100
|
+
- **"跨运行趋势"实际是合并,不是可对比的历史。** `aggregateRows`(`src/view/index.ts`)把 `.niceeval/` 下**所有**历史 `summary.json` 按 `experimentId` 揉进同一行——通过率、平均耗时、成本都是跨全部历史 run 的累计值,不是"最新一次"或"某一次"的快照,更谈不上画成随时间变化的线。(合流后由选择器 + 跨快照去重取代,见[用 Reports 积木重建 view](#用-reports-积木重建-view设计提案)。)
|
|
97
101
|
- **"质量 × 成本散点图"没有实现。** `src/view/app` 下没有任何图表 / scatter / canvas 组件,现有可视化都是表格和文字指标。
|
|
98
102
|
|
|
99
103
|
## 外部参考
|
|
@@ -141,14 +145,47 @@ npx niceeval@<producer.version> view .niceeval/<run>/summary.json
|
|
|
141
145
|
|
|
142
146
|
**明确不做的:** 不做时间序列折线图(历史快照一多不适合塞进单个静态 HTML,而且这次要补的是"挑两点"这个最小能力);不改 Experiments tab 现有的"累计历史"默认语义(这是另一个值得讨论的问题,不在这次一起改)。
|
|
143
147
|
|
|
148
|
+
实现形态已并入[用 Reports 积木重建 view](#用-reports-积木重建-view设计提案):两个下拉选快照的数据模型照本节,表格与 delta 单元直接用 Reports 的组件。
|
|
149
|
+
|
|
144
150
|
### 质量 × 成本散点图
|
|
145
151
|
|
|
146
|
-
之前文档写过、实际没做。没有具体设计,先记一句:每个 eval(或每个 agent)一个点,一眼看出"贵且不准"的角落——值得补,但优先级低于 Compare
|
|
152
|
+
之前文档写过、实际没做。没有具体设计,先记一句:每个 eval(或每个 agent)一个点,一眼看出"贵且不准"的角落——值得补,但优先级低于 Compare。点位与轴向的契约不用另起炉灶:[Reports 提案](reports.md#计算函数与数据契约)的 `scatter()` / `MetricScatter` 已经钉了「点 = 维度分组的聚合、系列连线、轴向随 better(右上恒为好)」,view 落地时直接吃同一份 `ScatterData`。
|
|
147
153
|
|
|
148
154
|
### Eval 目录页
|
|
149
155
|
|
|
150
156
|
独立于"跑过的结果",单纯浏览 `evals/` 目录下每个 fixture 的 `PROMPT.md` 和文件列表,不用先跑一次才能看"有哪些 eval、prompt 写的什么"(learnings 见 [References](references.md#vercel-agent-eval--packagesplayground))。没有具体设计,优先级低于 Compare。
|
|
151
157
|
|
|
158
|
+
## 用 Reports 积木重建 view(设计提案)
|
|
159
|
+
|
|
160
|
+
> 状态:设计提案。依赖的两个提案已部分落地(results 读取面、report 计算与组件,见 [Source Map](source-map.md#results-lib-与-reports));三步迁移(读取层 / 统计层 / 渲染层)未动,attempt 深链路由已先行实现。落地前,本文其余部分描述的现状照旧成立。
|
|
161
|
+
|
|
162
|
+
[Reports](reports.md) 把「自己搭报告页」拆成组件 + 计算函数 + 结果库三种零件之后,view 的正确定位随之改变:**view 不再是一套并行实现,而是用同一批零件搭出来的「默认报告页 + 证据室」**——用户搭页面用什么零件,view 自己就用什么。view 因此成为这套积木的第一个常驻消费者,组件与计算函数的正确性被它天天验证;反过来,上面「计划中的小功能」里的三件事(跳过列表、Compare、散点图)也全部退化成「拼现成积木」,不再是专项开发。
|
|
163
|
+
|
|
164
|
+
逐层替换:
|
|
165
|
+
|
|
166
|
+
| view 今天 | 合流后 | 顺带兑现 |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| `loadSummaries` / `readSummary`,版本判定散在 loader | [`openResults`](results-lib.md#读openresults) | 「结果版本提示与跳过列表」= 渲染 `results.skipped`,不再专门做;计划中的 normalize 层就是 results lib |
|
|
169
|
+
| `aggregateRows` 把全部历史揉成一行 | 选择器 + 计算函数(`overview` / `table(rows: "experiment")`) | 跨快照去重白捡——`--resume` 合入结果被重复计数的问题顺带修掉;「累计 vs 最新」从 loader 的既成事实变成 UI 上可切换的两次显式调用 |
|
|
170
|
+
| `ExperimentTable` / Runs 表 / 总览指标(自绘) | `MetricTable` / `RunOverview` | 覆盖率角标、缺数据渲染、better 排序这些诚实细节自动继承 |
|
|
171
|
+
| 质量 × 成本散点图(计划中,没做) | `MetricScatter` | 见上节 |
|
|
172
|
+
| Compare(计划中,没做) | 两个下拉选快照 + 时间轴 `DeltaTable` 变体 | Reports 待定的「时间轴 delta」在 view 首发,两边永远同一套「对比」语义 |
|
|
173
|
+
| AttemptModal / Traces 瀑布 / 导航壳 | **保留,这是 view 的本体** | 证据室:transcript、断言、代码视图、trace 瀑布——报告积木不重造它们 |
|
|
174
|
+
|
|
175
|
+
导出机制同样合流:
|
|
176
|
+
|
|
177
|
+
- `view --out <目录>` 的工件复制换成 [`copyRun`](results-lib.md#复制与瘦身copyrun)(sources/events/trace,diff/o11y 照旧不带);单文件导出已整个移除(见上文「静态导出」的移除记录),合流时不必再背这个形态。
|
|
178
|
+
- 烘进 HTML 的 `__NICEEVAL_VIEW_DATA__` 从私有 rows 换成**官方数据契约**(OverviewData / TableData / ScatterData + 快照元信息)。外部脚本从此没有理由扒 HTML——coding-agent-memory-evals 曾用字符串标记从 index.html 里抠内嵌 JSON、再正则消毒构建机路径,那类 hack 的存在本身就是数据契约缺位的证据;但内嵌数据仍不是承诺的持久化格式,要数据走 [Reports 场景三](reports.md#dx-模拟)自己算。
|
|
179
|
+
- 补 `#/attempt/<run>/<result>` 路由,路由参数就是 `AttemptRef`——报告页(前门)与 view(证据室)靠同一个身份契约打通,`attemptHref` 从此有确定的去处(对应 Reports 待定问题 5)。**已实现**:`src/view/app/lib/attempt-route.ts` + `App.tsx` 接线,loader 的 `withViewRefs` 给每条 result 注入 `attemptRef`;旧 `?modal=` 参数保留为只读回退。
|
|
180
|
+
|
|
181
|
+
迁移顺序即依赖顺序,每步独立可交付:
|
|
182
|
+
|
|
183
|
+
1. **换读取层。** results lib 落地,view 的 loader 删掉,skipped 列表 UI 直接上——行为中立的重构,外加诚实增强。
|
|
184
|
+
2. **换统计层。** 计算函数落地,`aggregateRows` 删掉;榜单数字会因去重而变(变得更对),在 changelog 里明说。
|
|
185
|
+
3. **换渲染层 + 补两个 tab。** 榜单换 `MetricTable`,新增 scatter,Compare 以时间轴 delta 首发;AttemptModal / Traces 原样保留。
|
|
186
|
+
|
|
187
|
+
界线不变:view 是**一份固定摆法的默认报告页**,不长配置、不长插件——想换摆法,去写自己的页面([Reports](reports.md)),零件是同一批。
|
|
188
|
+
|
|
152
189
|
## 相关阅读
|
|
153
190
|
|
|
154
191
|
- [Observability](observability.md#结果可视化niceeval-view) —— 事件流、trace、usage/cost 这些 view 渲染的数据从哪来。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niceeval",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Agent-native eval tool — eval agents, services, functions, and coding-agent fixtures",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,7 +43,20 @@
|
|
|
43
43
|
"./loaders": {
|
|
44
44
|
"types": "./src/loaders/index.ts",
|
|
45
45
|
"import": "./src/loaders/index.ts"
|
|
46
|
-
}
|
|
46
|
+
},
|
|
47
|
+
"./results": {
|
|
48
|
+
"types": "./src/results/index.ts",
|
|
49
|
+
"import": "./src/results/index.ts"
|
|
50
|
+
},
|
|
51
|
+
"./report": {
|
|
52
|
+
"types": "./src/report/index.ts",
|
|
53
|
+
"import": "./src/report/index.ts"
|
|
54
|
+
},
|
|
55
|
+
"./report/react": {
|
|
56
|
+
"types": "./src/report/react/index.tsx",
|
|
57
|
+
"import": "./src/report/react/index.tsx"
|
|
58
|
+
},
|
|
59
|
+
"./report/react/styles.css": "./src/report/react/styles.css"
|
|
47
60
|
},
|
|
48
61
|
"files": [
|
|
49
62
|
"src",
|
|
@@ -59,6 +72,7 @@
|
|
|
59
72
|
"devDependencies": {
|
|
60
73
|
"@ai-sdk/otel": "^1.0.9",
|
|
61
74
|
"@vercel/sandbox": "^2.2.1",
|
|
75
|
+
"braintrust": "^3.20.0",
|
|
62
76
|
"dockerode": "^4.0.2",
|
|
63
77
|
"e2b": "^2.31.0",
|
|
64
78
|
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
|
@@ -95,8 +109,11 @@
|
|
|
95
109
|
"@opentelemetry/sdk-trace-node": ">=2.0.0",
|
|
96
110
|
"@vercel/sandbox": ">=2.0.0",
|
|
97
111
|
"ai": ">=5.0.0",
|
|
112
|
+
"braintrust": ">=0.0.150",
|
|
98
113
|
"dockerode": ">=4.0.0",
|
|
99
|
-
"e2b": ">=2.0.0"
|
|
114
|
+
"e2b": ">=2.0.0",
|
|
115
|
+
"react": ">=18",
|
|
116
|
+
"react-dom": ">=18"
|
|
100
117
|
},
|
|
101
118
|
"peerDependenciesMeta": {
|
|
102
119
|
"@ai-sdk/otel": {
|
|
@@ -108,6 +125,9 @@
|
|
|
108
125
|
"ai": {
|
|
109
126
|
"optional": true
|
|
110
127
|
},
|
|
128
|
+
"braintrust": {
|
|
129
|
+
"optional": true
|
|
130
|
+
},
|
|
111
131
|
"@opentelemetry/exporter-trace-otlp-http": {
|
|
112
132
|
"optional": true
|
|
113
133
|
},
|
|
@@ -119,6 +139,12 @@
|
|
|
119
139
|
},
|
|
120
140
|
"e2b": {
|
|
121
141
|
"optional": true
|
|
142
|
+
},
|
|
143
|
+
"react": {
|
|
144
|
+
"optional": true
|
|
145
|
+
},
|
|
146
|
+
"react-dom": {
|
|
147
|
+
"optional": true
|
|
122
148
|
}
|
|
123
149
|
},
|
|
124
150
|
"scripts": {
|
package/src/agents/ai-sdk.ts
CHANGED
|
@@ -364,6 +364,8 @@ export interface AiSdkGenerateContext<M = unknown> {
|
|
|
364
364
|
readonly messages: M[];
|
|
365
365
|
/** 实验钉的 model(ctx.model);省略 → 用应用自己的默认。 */
|
|
366
366
|
readonly model?: string;
|
|
367
|
+
/** 实验钉的推理努力程度(ctx.reasoningEffort);省略 → 用应用自己的默认。应用自己决定怎么塞进 providerOptions(如 OpenAI 的 reasoningEffort)。 */
|
|
368
|
+
readonly reasoningEffort?: string;
|
|
367
369
|
readonly signal: AbortSignal;
|
|
368
370
|
readonly flags: Readonly<Record<string, unknown>>;
|
|
369
371
|
/**
|
|
@@ -501,6 +503,7 @@ export function aiSdkAgent<M = unknown>(options: AiSdkAgentOptions<M>): Agent {
|
|
|
501
503
|
result = await options.generate({
|
|
502
504
|
messages: state.messages,
|
|
503
505
|
model: ctx.model,
|
|
506
|
+
reasoningEffort: ctx.reasoningEffort,
|
|
504
507
|
signal: ctx.signal,
|
|
505
508
|
flags: ctx.flags,
|
|
506
509
|
telemetry: otel?.settings,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { defineSandboxAgent } from "../define.ts";
|
|
2
2
|
import { requireEnv, getEnv } from "../util.ts";
|
|
3
3
|
import { shared } from "./shared.ts";
|
|
4
|
+
import { mapClaudeCodeSpans } from "../o11y/otlp/mappers/claude-code.ts";
|
|
4
5
|
import type { Agent, McpServer } from "../types.ts";
|
|
5
6
|
|
|
6
7
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -43,6 +44,22 @@ export function claudeCodeAgent(config?: ClaudeCodeConfig): Agent {
|
|
|
43
44
|
|
|
44
45
|
return defineSandboxAgent({
|
|
45
46
|
name: "claude-code",
|
|
47
|
+
spanMapper: mapClaudeCodeSpans,
|
|
48
|
+
|
|
49
|
+
// claude CLI 原生 OTLP trace spans(beta):interaction / llm_request / tool 层级,
|
|
50
|
+
// 需要 CLAUDE_CODE_ENHANCED_TELEMETRY_BETA 开关。
|
|
51
|
+
tracing: {
|
|
52
|
+
protocol: "http/protobuf",
|
|
53
|
+
env: (endpoint) => ({
|
|
54
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: "1",
|
|
55
|
+
CLAUDE_CODE_ENHANCED_TELEMETRY_BETA: "1",
|
|
56
|
+
OTEL_TRACES_EXPORTER: "otlp",
|
|
57
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: endpoint,
|
|
58
|
+
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: "http/protobuf",
|
|
59
|
+
// CLI 是短命进程:压短导出间隔,别让 span 死在退出前的批处理队列里。
|
|
60
|
+
OTEL_TRACES_EXPORT_INTERVAL: "1000",
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
46
63
|
|
|
47
64
|
async setup(sb) {
|
|
48
65
|
// 预制模板已把 claude 烘焙进镜像(PATH 上)就跳过安装;否则 npm 全局装。
|
|
@@ -78,7 +95,7 @@ export function claudeCodeAgent(config?: ClaudeCodeConfig): Agent {
|
|
|
78
95
|
if (ctx.session.id) args.push("--resume", ctx.session.id);
|
|
79
96
|
args.push(input.text);
|
|
80
97
|
|
|
81
|
-
const env: Record<string, string> = { ANTHROPIC_API_KEY: getApiKey() };
|
|
98
|
+
const env: Record<string, string> = { ANTHROPIC_API_KEY: getApiKey(), ...ctx.telemetry?.env };
|
|
82
99
|
const baseUrl = getBaseUrl();
|
|
83
100
|
if (baseUrl) env["ANTHROPIC_BASE_URL"] = baseUrl;
|
|
84
101
|
|
package/src/agents/codex.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type { Agent, McpServer } from "../types.ts";
|
|
|
8
8
|
// OpenAI Codex CLI 的 agent adapter(沙箱型)。
|
|
9
9
|
//
|
|
10
10
|
// 连接方式:在沙箱里 spawn `codex exec --json`,stdout JSONL → parseCodex → 标准事件流。
|
|
11
|
-
// 配置:鉴权本地(config / env),模型交给实验(ctx.model)
|
|
11
|
+
// 配置:鉴权本地(config / env),模型交给实验(ctx.model),推理努力程度经 ctx.reasoningEffort
|
|
12
|
+
// (兼容旧的 ctx.flags.effort),其余 feature flags 经 ctx.flags。
|
|
12
13
|
// ───────────────────────────────────────────────────────────────────────────
|
|
13
14
|
|
|
14
15
|
export interface CodexConfig {
|
|
@@ -45,7 +46,7 @@ export function codexAgent(config?: CodexConfig): Agent {
|
|
|
45
46
|
// model 归属:实验决定(ctx.model);省略时不写 model 行,交给 codex CLI 原生默认,
|
|
46
47
|
// 不在 adapter 里硬编码一个会过期的模型名。
|
|
47
48
|
const modelLine = ctx.model ? `model = "${ctx.model}"\n` : "";
|
|
48
|
-
const effort = (ctx.flags.effort as string | undefined) ?? "medium";
|
|
49
|
+
const effort = ctx.reasoningEffort ?? (ctx.flags.effort as string | undefined) ?? "medium";
|
|
49
50
|
const base = getBaseUrl();
|
|
50
51
|
|
|
51
52
|
if (base) {
|
package/src/agents/index.ts
CHANGED
|
@@ -15,6 +15,22 @@ export { mapCodexSpans } from "../o11y/otlp/mappers/codex.ts";
|
|
|
15
15
|
export { uiMessageStreamAgent } from "./ui-message-stream.ts";
|
|
16
16
|
export type { UiMessageStreamAgentOptions, UIMessageLike, UIMessagePartLike } from "./ui-message-stream.ts";
|
|
17
17
|
|
|
18
|
+
// 两种 OpenAI 响应形状(不限于 OpenAI 官方,任何声明兼容这两种协议形状的服务都能用)的
|
|
19
|
+
// 官方转换器:整段响应 → Turn,零映射。
|
|
20
|
+
export { fromChatCompletion, fromResponses } from "./openai-compat.ts";
|
|
21
|
+
export type {
|
|
22
|
+
ChatCompletionLike,
|
|
23
|
+
ChatCompletionMessageLike,
|
|
24
|
+
ChatCompletionToolCallLike,
|
|
25
|
+
ChatCompletionUsageLike,
|
|
26
|
+
ResponseFunctionCallItemLike,
|
|
27
|
+
ResponseLike,
|
|
28
|
+
ResponseMessageItemLike,
|
|
29
|
+
ResponseOutputItemLike,
|
|
30
|
+
ResponseOutputTextLike,
|
|
31
|
+
ResponseUsageLike,
|
|
32
|
+
} from "./openai-compat.ts";
|
|
33
|
+
|
|
18
34
|
// SDK 原生事件流 → 标准事件的官方转换器(无侵入 adapter 只剩传输粘合)+ 通用 SSE 读帧器。
|
|
19
35
|
export { sseJsonFrames, fromClaudeSdkMessages, fromPiAgentEvents, fromCodexThreadEvents } from "./sdk-streams.ts";
|
|
20
36
|
export type {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { fromChatCompletion, fromResponses } from "./openai-compat.ts";
|
|
4
|
+
|
|
5
|
+
describe("fromChatCompletion", () => {
|
|
6
|
+
it("把 tool_calls + content 翻成 action.called + message,usage 顺手带上", () => {
|
|
7
|
+
const turn = fromChatCompletion({
|
|
8
|
+
choices: [
|
|
9
|
+
{
|
|
10
|
+
message: {
|
|
11
|
+
role: "assistant",
|
|
12
|
+
content: "Beijing is 21°C today.",
|
|
13
|
+
tool_calls: [{ id: "c1", function: { name: "get_weather", arguments: '{"city":"Beijing"}' } }],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
usage: { prompt_tokens: 120, completion_tokens: 30, prompt_tokens_details: { cached_tokens: 40 } },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(turn.status).toBe("completed");
|
|
21
|
+
expect(turn.events).toEqual([
|
|
22
|
+
{ type: "action.called", callId: "c1", name: "get_weather", input: { city: "Beijing" } },
|
|
23
|
+
{ type: "message", role: "assistant", text: "Beijing is 21°C today." },
|
|
24
|
+
]);
|
|
25
|
+
expect(turn.usage).toEqual({ inputTokens: 120, outputTokens: 30, cacheReadTokens: 40 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("没有 tool_calls / content 时给空 events", () => {
|
|
29
|
+
const turn = fromChatCompletion({ choices: [{ message: {} }] });
|
|
30
|
+
expect(turn.events).toEqual([]);
|
|
31
|
+
expect(turn.status).toBe("completed");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("fromResponses", () => {
|
|
36
|
+
it("把 function_call + message(output_text) 翻成对应事件", () => {
|
|
37
|
+
const turn = fromResponses({
|
|
38
|
+
output: [
|
|
39
|
+
{ type: "function_call", call_id: "c1", name: "get_weather", arguments: '{"city":"Beijing"}' },
|
|
40
|
+
{ type: "message", content: [{ type: "output_text", text: "Beijing is 21°C today." }] },
|
|
41
|
+
],
|
|
42
|
+
usage: { input_tokens: 120, output_tokens: 30 },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(turn.events).toEqual([
|
|
46
|
+
{ type: "action.called", callId: "c1", name: "get_weather", input: { city: "Beijing" } },
|
|
47
|
+
{ type: "message", role: "assistant", text: "Beijing is 21°C today." },
|
|
48
|
+
]);
|
|
49
|
+
expect(turn.usage).toEqual({ inputTokens: 120, outputTokens: 30 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("认不出的 item 类型原样跳过,不抛异常", () => {
|
|
53
|
+
const turn = fromResponses({ output: [{ type: "reasoning", summary: [] }] });
|
|
54
|
+
expect(turn.events).toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// OpenAI 两种响应形状(Chat Completions / Responses)的官方转换器 —— 写 send 指南「零映射」
|
|
2
|
+
// 表格里的 fromChatCompletion(res) / fromResponses(res)。和 fromAiSdk 同一先例:结构化
|
|
3
|
+
// *Like 类型,不依赖 openai 包,兼容任何声明自己走这两种协议形状的服务(不止 OpenAI 官方)。
|
|
4
|
+
//
|
|
5
|
+
// 两种形状对负断言的可信度不同(见 docs-site/zh/guides/write-send.mdx):
|
|
6
|
+
// · Chat Completions 不承诺「响应 = 完整过程」(应用可能在服务端跑完工具循环,只把最终
|
|
7
|
+
// 答案给你),所以 notCalledTool 这类负断言只能当「没看到」,不能当「确实没发生」。
|
|
8
|
+
// · Responses 的协议契约里 output 数组记录了模型这一轮决定做的全部事(包括每个
|
|
9
|
+
// function_call),负断言可信。
|
|
10
|
+
// niceeval 目前没有「事件完整性证明」这个跨事件流的元数据字段,所以这条差异只体现在
|
|
11
|
+
// 文档提示里,两个转换器产出的 Turn 形状本身相同。
|
|
12
|
+
|
|
13
|
+
import type { JsonValue, StreamEvent, Turn, Usage } from "../types.ts";
|
|
14
|
+
|
|
15
|
+
/** tool_calls / function_call 的 `arguments` 恒为 JSON 字符串;解析失败(极少见)原样退回字符串,不吞异常。 */
|
|
16
|
+
function parseArgs(raw: string | undefined): JsonValue {
|
|
17
|
+
if (!raw) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(raw) as JsonValue;
|
|
20
|
+
} catch {
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ───────────────────────── Chat Completions ─────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface ChatCompletionToolCallLike {
|
|
28
|
+
id: string;
|
|
29
|
+
function: { name: string; arguments: string };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ChatCompletionMessageLike {
|
|
33
|
+
role?: string;
|
|
34
|
+
content?: string | null;
|
|
35
|
+
tool_calls?: ChatCompletionToolCallLike[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChatCompletionUsageLike {
|
|
39
|
+
prompt_tokens?: number;
|
|
40
|
+
completion_tokens?: number;
|
|
41
|
+
prompt_tokens_details?: { cached_tokens?: number };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChatCompletionLike {
|
|
45
|
+
choices: { message: ChatCompletionMessageLike }[];
|
|
46
|
+
usage?: ChatCompletionUsageLike;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function chatCompletionUsage(usage: ChatCompletionUsageLike | undefined): Usage | undefined {
|
|
50
|
+
if (!usage) return undefined;
|
|
51
|
+
const u: Usage = { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0 };
|
|
52
|
+
if (usage.prompt_tokens_details?.cached_tokens) u.cacheReadTokens = usage.prompt_tokens_details.cached_tokens;
|
|
53
|
+
return u;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Chat Completions 形状的响应 → `Turn`。零映射:`res.choices[0].message` 的
|
|
58
|
+
* `tool_calls` / `content` 直接变成 `action.called` / `message`,`usage` 顺手带上。
|
|
59
|
+
*/
|
|
60
|
+
export function fromChatCompletion(res: ChatCompletionLike): Turn {
|
|
61
|
+
const message = res.choices[0]?.message;
|
|
62
|
+
const events: StreamEvent[] = [];
|
|
63
|
+
for (const call of message?.tool_calls ?? []) {
|
|
64
|
+
events.push({
|
|
65
|
+
type: "action.called",
|
|
66
|
+
callId: call.id,
|
|
67
|
+
name: call.function.name,
|
|
68
|
+
input: parseArgs(call.function.arguments),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (message?.content) events.push({ type: "message", role: "assistant", text: message.content });
|
|
72
|
+
return { events, status: "completed", usage: chatCompletionUsage(res.usage) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ───────────────────────── Responses ─────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface ResponseOutputTextLike {
|
|
78
|
+
type: "output_text";
|
|
79
|
+
text: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResponseMessageItemLike {
|
|
83
|
+
type: "message";
|
|
84
|
+
content?: ResponseOutputTextLike[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ResponseFunctionCallItemLike {
|
|
88
|
+
type: "function_call";
|
|
89
|
+
call_id: string;
|
|
90
|
+
name: string;
|
|
91
|
+
arguments: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 其余 output item 类型(reasoning、内建工具调用……)按需扩展;认不出的原样跳过。 */
|
|
95
|
+
export interface ResponseOtherItemLike {
|
|
96
|
+
type: string;
|
|
97
|
+
[key: string]: unknown;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ResponseOutputItemLike = ResponseMessageItemLike | ResponseFunctionCallItemLike | ResponseOtherItemLike;
|
|
101
|
+
|
|
102
|
+
function isMessageItem(item: ResponseOutputItemLike): item is ResponseMessageItemLike {
|
|
103
|
+
return item.type === "message";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isFunctionCallItem(item: ResponseOutputItemLike): item is ResponseFunctionCallItemLike {
|
|
107
|
+
return item.type === "function_call";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface ResponseUsageLike {
|
|
111
|
+
input_tokens?: number;
|
|
112
|
+
output_tokens?: number;
|
|
113
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ResponseLike {
|
|
117
|
+
output: ResponseOutputItemLike[];
|
|
118
|
+
usage?: ResponseUsageLike;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function responsesUsage(usage: ResponseUsageLike | undefined): Usage | undefined {
|
|
122
|
+
if (!usage) return undefined;
|
|
123
|
+
const u: Usage = { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0 };
|
|
124
|
+
if (usage.input_tokens_details?.cached_tokens) u.cacheReadTokens = usage.input_tokens_details.cached_tokens;
|
|
125
|
+
return u;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Responses 形状的响应 → `Turn`。零映射:`res.output` 逐项翻译——
|
|
130
|
+
* `message`(`content` 里的 `output_text`)变成 `message`,`function_call` 变成 `action.called`。
|
|
131
|
+
*/
|
|
132
|
+
export function fromResponses(res: ResponseLike): Turn {
|
|
133
|
+
const events: StreamEvent[] = [];
|
|
134
|
+
for (const item of res.output ?? []) {
|
|
135
|
+
if (isMessageItem(item)) {
|
|
136
|
+
const text = (item.content ?? [])
|
|
137
|
+
.filter((c): c is ResponseOutputTextLike => c.type === "output_text")
|
|
138
|
+
.map((c) => c.text)
|
|
139
|
+
.join("");
|
|
140
|
+
if (text) events.push({ type: "message", role: "assistant", text });
|
|
141
|
+
} else if (isFunctionCallItem(item)) {
|
|
142
|
+
events.push({
|
|
143
|
+
type: "action.called",
|
|
144
|
+
callId: item.call_id,
|
|
145
|
+
name: item.name,
|
|
146
|
+
input: parseArgs(item.arguments),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { events, status: "completed", usage: responsesUsage(res.usage) };
|
|
151
|
+
}
|
package/src/agents/types.ts
CHANGED
|
@@ -145,6 +145,8 @@ export interface AgentSession {
|
|
|
145
145
|
export interface AgentContext {
|
|
146
146
|
readonly signal: AbortSignal;
|
|
147
147
|
readonly model?: string;
|
|
148
|
+
/** 模型推理努力程度;归属同 model——实验决定,省略时不覆盖 agent 原生默认。 */
|
|
149
|
+
readonly reasoningEffort?: string;
|
|
148
150
|
readonly flags: Readonly<Record<string, unknown>>;
|
|
149
151
|
/** 仅沙箱型 agent 有(运行器按 --sandbox 备好)。 */
|
|
150
152
|
readonly sandbox: Sandbox;
|
|
@@ -184,7 +186,7 @@ export interface Agent {
|
|
|
184
186
|
/**
|
|
185
187
|
* 内部判别字段(用户不声明):`defineSandboxAgent` 恒设 "sandbox",`defineAgent` 恒设
|
|
186
188
|
* "remote"。t 上的能力不再是问卷式声明,而是构造证据——sandbox 型才解锁
|
|
187
|
-
* `t.sandbox`/`t.fileChanged()` 等文件系统断言,见 docs-site「能力从哪来」一节。
|
|
189
|
+
* `t.sandbox`/`t.sandbox.fileChanged()` 等文件系统断言,见 docs-site「能力从哪来」一节。
|
|
188
190
|
*/
|
|
189
191
|
readonly kind: "sandbox" | "remote";
|
|
190
192
|
setup?: AgentSetup;
|
package/src/cli.ts
CHANGED
|
@@ -248,7 +248,7 @@ async function main(): Promise<void> {
|
|
|
248
248
|
if (command === "view") {
|
|
249
249
|
if (flags.out) {
|
|
250
250
|
const out = await buildView({ input: positionals[0], out: flags.out }).catch(exitOnIncompatibleResults);
|
|
251
|
-
process.stdout.write(t("cli.view.
|
|
251
|
+
process.stdout.write(t("cli.view.exportedDir", { out }));
|
|
252
252
|
process.exit(0);
|
|
253
253
|
}
|
|
254
254
|
const server = await startViewServer({ input: positionals[0], port: flags.port }).catch(exitOnIncompatibleResults);
|
|
@@ -314,6 +314,7 @@ async function main(): Promise<void> {
|
|
|
314
314
|
agentRuns.push({
|
|
315
315
|
agent: exp.agent,
|
|
316
316
|
model: exp.model,
|
|
317
|
+
reasoningEffort: exp.reasoningEffort,
|
|
317
318
|
flags: exp.flags ?? {},
|
|
318
319
|
runs: flags.runs ?? envNumber("NICEEVAL_RUNS") ?? exp.runs ?? 1,
|
|
319
320
|
earlyExit: flags.earlyExit ?? exp.earlyExit ?? true,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createEvalContext } from "./context.ts";
|
|
2
|
+
import { createEvalContext, type ContextState } from "./context.ts";
|
|
3
3
|
import { includes } from "../expect/index.ts";
|
|
4
4
|
import type { Agent, AgentContext, InputRequest, Sandbox, StreamEvent, Turn, TurnInput } from "../types.ts";
|
|
5
5
|
|
|
@@ -290,3 +290,80 @@ describe("t.respond() / t.respondAll(): structured InputResponse", () => {
|
|
|
290
290
|
]);
|
|
291
291
|
});
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
function baseScoringContext(state: ContextState) {
|
|
295
|
+
return {
|
|
296
|
+
events: [],
|
|
297
|
+
facts: { toolCalls: [], subagentCalls: [], inputRequests: [], parked: false, messageCount: 0, compactions: 0 },
|
|
298
|
+
diff: state.late.diff,
|
|
299
|
+
scripts: state.late.scripts,
|
|
300
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
301
|
+
status: "completed" as const,
|
|
302
|
+
readFile: async () => undefined,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
describe("TurnHandle scoped assertions (parked/loadedSkill/noFailedActions/maxTokens/maxCost)", () => {
|
|
307
|
+
it("mirror t/session scope: turn.parked() reflects this turn's own waiting status", async () => {
|
|
308
|
+
const agent = scriptedAgent([
|
|
309
|
+
waitingTurn({ id: "req_1", action: "send_email", options: [{ id: "approve" }] }),
|
|
310
|
+
]);
|
|
311
|
+
const { context, state } = makeContext(agent);
|
|
312
|
+
const turn = await context.send("draft an email");
|
|
313
|
+
turn.parked();
|
|
314
|
+
|
|
315
|
+
const [result] = await state.collector.finalize(baseScoringContext(state));
|
|
316
|
+
expect(result.name).toBe("parked");
|
|
317
|
+
expect(result.passed).toBe(true);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("turn.noFailedActions() looks only at this turn's own tool calls", async () => {
|
|
321
|
+
const agent = scriptedAgent([
|
|
322
|
+
{
|
|
323
|
+
status: "completed",
|
|
324
|
+
events: [
|
|
325
|
+
{ type: "action.called", callId: "c1", name: "shell", input: { cmd: "false" } },
|
|
326
|
+
{ type: "action.result", callId: "c1", output: {}, status: "failed" },
|
|
327
|
+
],
|
|
328
|
+
},
|
|
329
|
+
]);
|
|
330
|
+
const { context, state } = makeContext(agent);
|
|
331
|
+
const turn = await context.send("run a command");
|
|
332
|
+
turn.noFailedActions();
|
|
333
|
+
|
|
334
|
+
const [result] = await state.collector.finalize(baseScoringContext(state));
|
|
335
|
+
expect(result.name).toBe("noFailedActions");
|
|
336
|
+
expect(result.passed).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("turn.maxTokens()/turn.maxCost() read this turn's own Turn.usage, not the session total", async () => {
|
|
340
|
+
const agent = scriptedAgent([
|
|
341
|
+
{ status: "completed", events: [{ type: "message", role: "assistant", text: "ok" }], usage: { inputTokens: 100, outputTokens: 50, costUSD: 0.02 } },
|
|
342
|
+
]);
|
|
343
|
+
const { context, state } = makeContext(agent);
|
|
344
|
+
const turn = await context.send("hi");
|
|
345
|
+
turn.maxTokens(1000);
|
|
346
|
+
turn.maxCost(0.01);
|
|
347
|
+
|
|
348
|
+
const [tokens, cost] = await state.collector.finalize(baseScoringContext(state));
|
|
349
|
+
expect(tokens.name).toBe("maxTokens(1000)");
|
|
350
|
+
expect(tokens.passed).toBe(true);
|
|
351
|
+
expect(cost.name).toBe("maxCost(0.01)");
|
|
352
|
+
expect(cost.passed).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("turn.loadedSkill() checks a load_skill call scoped to this turn", async () => {
|
|
356
|
+
const agent = scriptedAgent([
|
|
357
|
+
{
|
|
358
|
+
status: "completed",
|
|
359
|
+
events: [{ type: "action.called", callId: "c1", name: "load_skill", input: { skill: "pdf-export" } }],
|
|
360
|
+
},
|
|
361
|
+
]);
|
|
362
|
+
const { context, state } = makeContext(agent);
|
|
363
|
+
const turn = await context.send("export as pdf");
|
|
364
|
+
turn.loadedSkill("pdf-export");
|
|
365
|
+
|
|
366
|
+
const [result] = await state.collector.finalize(baseScoringContext(state));
|
|
367
|
+
expect(result.passed).toBe(true);
|
|
368
|
+
});
|
|
369
|
+
});
|