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,140 @@
1
+ # Architecture
2
+
3
+ niceeval 把一个评测过程拆成四段职责:**发现**要跑什么、**驱动**被测对象产生结果、**评分**得出判决、**报告**落盘与回传。核心拥有这四段里对所有被测对象都一样的部分;被测对象的差异被收进 `Agent`(契约)/ `Adapter`(你写的实现)/ `Sandbox` 三层。
4
+
5
+ 这条边界是有意为之的,理由见 [Vision](vision.md)。本篇给出模块分层、数据流,以及一次运行的端到端时序。
6
+
7
+ ## 系统总览
8
+
9
+ ```text
10
+ ┌──────────────────────────── core ────────────────────────────┐
11
+ evals/ │ │
12
+ └─ *.eval.ts ──────────►│ Discovery ──► Runner ──► Scoring ──► Outcome ──► Reporters │
13
+ │ (发现) (调度) (评分) (判决) (报告) │
14
+ │ │ 驱动 │ │
15
+ └──────────────────┼──────────────────────────────────┼──────────┘
16
+ │ ▼
17
+ ▼ 对接口分发,不按名字分支 .niceeval/<run>/
18
+ ┌──────────── Agent(自实现 Adapter)────────────┐
19
+ │ 远程 adapter 沙箱 adapter │
20
+ │ (你的服务) ┌─────┴─────┐ │
21
+ │ │ claude-code│ │
22
+ │ │ codex / bub│ │
23
+ │ └─────┬──────┘ │
24
+ │ ▼ │
25
+ │ ┌── Sandbox ──┐ │
26
+ │ │ docker │ │
27
+ │ │ vercel / 三方│ │
28
+ │ └─────────────┘ │
29
+ └────────────────────────────────────────────────┘
30
+ ```
31
+
32
+ 四段职责是**单向数据流**:发现产出一批 `Eval`,运行器逐个对 Agent `send` 得到 `Turn`,评分器把 `Turn` 折叠成 `Assertion[]`,再把全部断言折叠成一个互斥的 `Outcome`;报告器消费 `Outcome` + 工件。没有反向耦合 —— 评分器不知道 agent 的 transport 是 HTTP 还是沙箱 CLI,它只看 `Turn`。
33
+
34
+ ## 模块分层
35
+
36
+ 设想中的源码树(实现时以此为骨架):
37
+
38
+ ```
39
+ src/
40
+ ├─ index.ts # 公开导出:defineEval / defineConfig
41
+ ├─ define-eval.ts # eval 定义 + 校验(会话型/沙箱型由 agent 能力决定,不是两个函数)
42
+ ├─ define-config.ts # 项目配置
43
+ ├─ types.ts # 核心类型(Eval / Agent / Turn / Outcome …)
44
+
45
+ ├─ context/ # `t` 上下文的构建
46
+ │ ├─ session.ts # 会话驱动(send / respond / 多会话)
47
+ │ ├─ assertions.ts # 作用域断言收集器(t 级 / turn 级共享同一套)
48
+ │ └─ sandbox-context.ts # 沙箱型 `t`(diff / transcript / sandbox 句柄)
49
+
50
+ ├─ expect/ # 值级断言库(includes / equals / matches / similarity …)
51
+
52
+ ├─ scoring/
53
+ │ ├─ scoped.ts # 作用域断言(succeeded / calledTool / toolOrder …)
54
+ │ ├─ judge.ts # LLM-as-judge
55
+ │ └─ verdict.ts # 判决计算 → Outcome
56
+
57
+ ├─ agents/ # —— 连到 AI 的全部特殊性都在这里(自实现 Adapter)——
58
+ │ ├─ types.ts # Agent / Adapter 接口(kind: "sandbox" | "remote",无能力位字段)
59
+ │ ├─ define-agent.ts # defineAgent / defineSandboxAgent
60
+ │ ├─ shared.ts # 沙箱型共享:diff 采集 / transcript 注入
61
+ │ ├─ claude-code.ts # 内置沙箱 adapter
62
+ │ ├─ codex.ts
63
+ │ └─ bub.ts
64
+
65
+ ├─ sandbox/ # —— 沙箱型 agent 在哪里跑的全部特殊性都在这里 ——
66
+ │ ├─ types.ts # Sandbox 接口
67
+ │ ├─ resolve.ts # 后端选择(auto / docker / vercel / …)
68
+ │ ├─ docker.ts
69
+ │ └─ vercel.ts
70
+
71
+ ├─ o11y/ # transcript 归一化 + o11y 派生
72
+ │ ├─ types.ts
73
+ │ └─ parsers/ # 各 agent 的 transcript 解析器
74
+
75
+ ├─ runner/
76
+ │ ├─ run.ts # 主调度(有界并发 / 重试 / 早停)
77
+ │ ├─ discover.ts # eval 发现
78
+ │ ├─ fingerprint.ts # 指纹缓存
79
+ │ └─ reporters/ # console / junit / json / 第三方
80
+
81
+ └─ cli.ts # CLI 入口
82
+ ```
83
+
84
+ 边界规则一句话:**`agents/` 和 `sandbox/` 之外的任何文件,都不应出现 agent 名字或 sandbox 名字的行为分支。** 核心拿到的是接口,不是名字。
85
+
86
+ ## 一个授权面,能力决定形状
87
+
88
+ niceeval 只有一个写 eval 的入口——`defineEval`。会话型和沙箱型不是两个函数,而是**同一个入口,`t` 的形状随引用的 Agent 能力变化**:
89
+
90
+ | | 会话型 | 沙箱型 |
91
+ |---|---|---|
92
+ | 典型 Agent | 远程 agent | 沙箱 agent(coding agent + Sandbox) |
93
+ | Task 形态 | `t.send(...)` 序列 | 同左——沙箱型的任务照样写在 `t.send(...)` 里,没有另一种任务格式 |
94
+ | `t` 暴露什么 | `send`/`reply`/`calledTool`/`judge` | 上述 + `t.sandbox`(文件 IO / 命令执行 / 结果断言 / diff) |
95
+ | 评分手段 | expect + 作用域断言 + judge | 上述 + 手工在沙箱里跑命令,再用 `t.check(result, commandSucceeded())` 判定 |
96
+ | 共享 | **Scorer、Outcome、Runner、Reporter、Config、工件格式全部共享** | 同 → |
97
+
98
+ 这张表是整个架构的中心论点:**两种范式只在"Agent 的构造证据(`kind` 与 `send` 实际做到了什么)"上不同,在"如何判分、如何调度、如何记录"上完全一致。** 所以它们能住在同一个入口、同一个库里,而不是两个入口或两个库。
99
+
100
+ ## `t` 上下文:构造证据决定形状
101
+
102
+ `test(t)` 收到的 `t` 对每个 Agent 都暴露同一套宽接口(`TestContext`),但每个方法**实际能不能读到数据**由 Agent 的构造证据决定,不是声明式的能力位——这是唯一的运行时守卫例外:
103
+
104
+ - 任何 Agent → `t.check` / `t.require`(值级断言)、`t.log`、`t.skip`、`t.signal`、`t.judge`,以及 `t.send` / `t.reply` / `t.newSession`(能不能多轮取决于 `send` 有没有接上 `ctx.session` 的续接存取器,不取决于声明)。
105
+ - `send` 吐出 `action.*` 事件 → `t.calledTool` / `t.toolOrder` / `t.usedNoTools` 有数据可断;没吐,正断言自然不命中、负断言按事件来源的完整性证明判断可信度(见[适配器契约](adapters/contract.md#能力从哪来构造证明不是问卷))。
106
+ - `defineSandboxAgent` 构造(`kind: "sandbox"`)→ `t.sandbox`:文件 IO(`writeFiles` / `readFile`)、命令执行(`runCommand` / `runShell`)和结果断言 / diff(`fileChanged` / `fileDeleted` / `diff` / `file`)都收在这一个命名空间下。评 sandbox 产物用 `t.judge.autoevals.closedQA` 配 `{ on: t.sandbox.diff.get(path) }`。非沙箱型 agent 调用这组方法会立即得到清晰报错(`capabilityGuard`)——这是唯一仍需要运行时拦截的能力,因为没有沙箱就没有文件系统可读。
107
+
108
+ ## 一次运行,端到端
109
+
110
+ 以一个沙箱型 agent eval 为例(会话型是它的子集:第 6 步没有 `Sandbox.create` / git 基线,直接跑 `test(t)`;跳过第 7 步,没有沙箱 diff 可采):
111
+
112
+ 1. **加载配置。** CLI 合并 标志 → 环境变量 → `niceeval.config.ts` → 默认值。
113
+ 2. **发现。** 扫 `evals/`,收集 `*.eval.ts`;据路径推导 id,排序;按过滤器(id 前缀 / `--tag`)筛。
114
+ 3. **指纹与缓存。** 对每个 eval 算 `(eval 代码 + 配置)` 指纹;已通过且指纹未变的,标记跳过(除非 `--force`)。
115
+ 4. **建尝试列表。** 每个 eval × `runs` 次 → 一批 attempt。为每个 eval 建一个 `AbortController`(供早停)。
116
+ 5. **有界并发调度。** 维持至多 `maxConcurrency` 个 attempt 在飞;池满则 `Promise.race` 等任一完成再补位。可疑的"秒挂"(< 5s 且非超时)按指数退避重试。
117
+ 6. **准备环境,交给 `test(t)`。** 沙箱型:`Sandbox.create` → 打一次空 git 基线 → 跑 agent 自己的 `SandboxAgent.setup`(装 CLI 等)。之后全部交给这条 eval 自己的 `test(t)`:作者按自己的顺序调 `t.sandbox.writeFiles`/`uploadFiles`(手工写入起始文件)、`t.send()`(驱动 agent——adapter 在沙箱里跑 CLI、抓 transcript、解析成标准事件流、注入 `__niceeval__/results.json`)、`t.sandbox.runCommand(..., { cwd })`(手工跑校验命令)——顺序、次数、要不要对 agent 隐藏某些文件,全部是 `test(t)` 里的普通代码决定,核心不插手,也不预设"先上传什么、后上传什么"这种固定编排。
118
+ 7. **采集生成文件。** `test(t)` 跑完后 `git diff HEAD` 得到生成 / 删除的文件,供 `t.sandbox.diff` / `t.sandbox.fileChanged` 读取。
119
+ 8. **评分。** `test(t)` 里记录的作用域断言、值级断言、judge,连同手工校验命令的结果断言,全部折叠成 `Assertion[]`。
120
+ 9. **判决。** 断言 + 执行错误 + 跳过原因直接折叠成一个互斥的 `Outcome`(`passed`/`failed`/`errored`/`skipped`,没有中间态)。
121
+ 10. **早停。** 若该 attempt 通过且开了 `earlyExit`,`abort()` 掉同一 eval 的其余 attempt。
122
+ 11. **报告。** 每个 eval 完成即在串行报告队列上回调 `onEvalComplete`(不阻塞执行池);全部完成后回调 `onRunComplete`,落盘工件到 `.niceeval/<run>/`。
123
+ 12. **退出码。** 有 `outcome=failed`(含 `--strict` 下 soft 未达标而改判的)或 `outcome=errored` → 非零退出;报告里两者分开列,供 CI 判红和诊断。
124
+
125
+ ## 错误隔离
126
+
127
+ 三类错误被分开处理,避免一个 case 拖垮整批:
128
+
129
+ - **断言失败** —— 正常路径,折叠进判决,不抛,对应 `outcome=failed`。
130
+ - **执行器异常**(超时、网络、沙箱起不来)—— 在单 eval 边界被 catch,该 eval 记为 `outcome=errored` 并附错误,其余 eval 照跑。
131
+ - **作者错误**(`test` 里抛了非断言异常)—— 同样被 catch,记为 `outcome=errored`,不污染别人。
132
+
133
+ 沙箱型还可选 AI 失败分类(model / infra / timeout),把"是模型不行还是基础设施抖了"自动归类,详见 [Observability](observability.md)。
134
+
135
+ ## 相关阅读
136
+
137
+ - [Vision](vision.md) —— 为什么核心不许按名字分支。
138
+ - [Runner](runner.md) —— 调度、并发、重试、早停、缓存的细节。
139
+ - [Agents 与 Adapters](adapters/README.md)、[Sandbox](sandbox.md) —— 三层的契约。
140
+ - [Scoring](scoring.md) —— 评分与判决。
@@ -0,0 +1,388 @@
1
+ # Assertions —— 断言参考(作用域 + 来源)
2
+
3
+ 这一篇是断言的速查参考:每条 API 做什么、看哪一轮、属于哪一类。怎么把它们组织进 eval,见 [Eval Authoring](eval-authoring.md);判决规则与 judge 细节见 [Scoring](scoring.md)。
4
+
5
+ > **来源一句话:** 会话 / 作用域断言 DX 借自 **eve.dev evals**;sandbox / diff 的工程形状借自 **Vercel agent-eval**;`--budget` 等护栏借鉴 **crabbox**;`closedQA` / `factuality` / `summarizes` 直接用 **autoevals(Braintrust)**。
6
+
7
+ 断言是 eval 给 `test(t)` 的产出打分的方式。每条记录一个结果、返回可链式 handle;runner 收齐**所有**记录再算判决,所以一次运行会报告每一条失败断言,而不是遇到第一个就停。
8
+
9
+ ## t、Session 和 Turn
10
+
11
+ 写 eval 时只有三个接收者需要分清:`t`、`session` 和 `turn`。`Attempt` 是 runner/result 里的执行单位,不是一个 author-facing API 对象。
12
+
13
+ | 接收者 | 含义 | 断言作用域 |
14
+ |---|---|---|
15
+ | **`t`** | 当前 eval run 的主上下文,同时驱动主 session | `t.succeeded()` / `t.calledTool()` 等看本次运行的全部 session 和全部 turn |
16
+ | **Session** | `t.newSession()` 返回的一条独立会话线 | `session.succeeded()` / `session.calledTool()` 等看这个 session 当时已经发生的事件 |
17
+ | **Turn** | 一次 `t.send()` 返回的一轮交互结果 | `turn.succeeded()` / `turn.calledTool()` 等只看这一轮自己的事件 |
18
+
19
+ 在**作用域断言**这一层,`t` / `session` / `turn` 就是同一组函数,只是 scope binding 不同。规则是:**作用域由你调用在哪个对象上决定,不由断言名字决定。** `t.newSession()` 返回的是独立 session,但仍属于同一次 eval run;这些 session 的事件会一起进入 `t.succeeded()` / `t.calledTool()` / `t.eventsSatisfy()` 等 `t.*` 断言。
20
+
21
+ 其它子函数按对象职责分开:驱动 API 只在 `t` / `session` 上,结果读取字段按 `t` / `session` / `turn` 各自的数据形状给,`t.check` / `t.sandbox` 这类 run 级能力不下放到 `session` / `turn`。
22
+
23
+ ## API 分组速查
24
+
25
+ ### 会话驱动与控制 API
26
+
27
+ `t` 与 `session` 共享同一套会话驱动接口:`send` / `sendFile` / `requireInputRequest` / `respond` / `respondAll`。区别是 `t` 驱动主 session,`session` 驱动一条独立 session;只有 `t` 能 `newSession()`。
28
+
29
+ | API | 作用 | 备注 |
30
+ |---|---|---|
31
+ | `await t.send(input)` | 给 agent 发一轮输入并等待稳定 | `input` 可以是字符串或结构化消息;返回 `turn` |
32
+ | `await t.sendFile(path, text?)` | 给 agent 发带本地文件的一轮输入 | 文件按 `path`(相对项目根)读取,作为 data URL 附加;`text` 是可选的配文文字;MIME 类型按扩展名推断,暂不支持显式覆盖 |
33
+ | `t.requireInputRequest(filter?)` | 断言恰好有一个待处理输入请求,并返回它 | gate;filter 可匹配工具名、action input、prompt、display、option ids |
34
+ | `await t.respond(...responses)` | 回答待处理输入请求 | 每个 response 是字符串(option id 或自由文本);多个用换行拼接,作为下一轮发送 |
35
+ | `await t.respondAll(optionId)` | 用同一 option 回答所有待处理输入请求 | 响应会作为下一轮发送 |
36
+ | `t.newSession()` | 开一条独立会话线 | 返回 `session`;事件仍汇入 `t.*` run 级断言 |
37
+ | `session.send(input)` | 给独立 session 发一轮输入 | 返回 `turn`;不影响主 session 的 resume 状态 |
38
+ | `session.sendFile(path, text?)` | 给独立 session 发带本地文件的一轮输入 | 与 `t.sendFile` 同形,但归属这个 session |
39
+ | `session.requireInputRequest(filter?)` | 在这个 session 里断言恰好有一个待处理输入请求 | gate;避免多 session 时误匹配其它 session |
40
+ | `await session.respond(...responses)` | 回答这个 session 的指定待处理输入请求 | 与 `t.respond` 同形 |
41
+ | `await session.respondAll(optionId)` | 用同一 option 回答这个 session 的所有待处理输入请求 | 与 `t.respondAll` 同形 |
42
+
43
+ `turn` 是一次 `send` 的不可变结果,不负责继续驱动会话。下一轮仍然从 `t` 或对应 `session` 调 `send` / `respond`。
44
+
45
+ ```typescript
46
+ import { defineEval } from "niceeval";
47
+
48
+ export default defineEval({
49
+ description: "主 session 与独立 session 分开驱动",
50
+ async test(t) {
51
+ const mainTurn = await t.send("查一下布鲁克林天气。");
52
+
53
+ const other = t.newSession();
54
+ const otherTurn = await other.send("查一下旧金山天气。");
55
+
56
+ mainTurn.messageIncludes("Brooklyn");
57
+ otherTurn.messageIncludes("San Francisco");
58
+ },
59
+ });
60
+ ```
61
+
62
+ ### 结果读取字段
63
+
64
+ | API | 作用 | 备注 |
65
+ |---|---|---|
66
+ | `t.reply` | 主 session 最后一条 assistant 消息 | 常用于值级 matcher;不是 `t.judge` 的默认材料 |
67
+ | `t.sessionId` | 当前主会话 id | adapter 返回时填入;用于 resume / 调试 |
68
+ | `t.events` | 主 session 目前已捕获的强类型事件流 | 即时读取主 session;`t.*` 最终断言会聚合全部 session |
69
+ | `session.reply` | 这个 session 的最后一条 assistant 消息 | 不读主 session |
70
+ | `session.events` | 这个 session 已捕获的事件流 | `session.*` 作用域断言读同一份材料 |
71
+ | `session.sessionId` | 这个 session 的 id | adapter 返回时填入;用于 resume / 调试 |
72
+ | `turn.message` | 这一轮 assistant 消息 | 多轮 judge 时建议自己收集这些值 |
73
+ | `turn.data` | 这一轮结构化输出 | 配 `turn.outputEquals` / `turn.outputMatches` |
74
+ | `turn.status` | 这一轮状态 | `completed` / `failed` / `waiting` |
75
+ | `turn.events` | 这一轮标准事件流 | 只含这一轮,不含之前轮次 |
76
+ | `turn.usage` | 这一轮 token 用量 | 可选,取决于 adapter 能否带回 |
77
+
78
+ ```typescript
79
+ import { defineEval } from "niceeval";
80
+ import { includes } from "niceeval/expect";
81
+
82
+ export default defineEval({
83
+ description: "读取主 session、独立 session 与单轮结果",
84
+ async test(t) {
85
+ const mainTurn = await t.send("查一下布鲁克林天气。");
86
+
87
+ const other = t.newSession();
88
+ const otherTurn = await other.send("查一下旧金山天气。");
89
+
90
+ t.check(t.reply, includes("Brooklyn")); // 主 session 最后一条回复
91
+ t.check(other.reply, includes("San Francisco")); // 独立 session 最后一条回复
92
+ t.check(mainTurn.message, includes("Brooklyn")); // 第一轮自己的回复
93
+ t.check(otherTurn.message, includes("San Francisco"));
94
+ },
95
+ });
96
+ ```
97
+
98
+ ### 作用域断言共享词汇
99
+
100
+ 下表这些 API 在 `t.*` / `session.*` / `turn.*` 上同名存在。它们应该是同一组函数 / 同一套实现,只更换接收者绑定的数据:
101
+
102
+ - `t.*`:本次 attempt 的全部 session 和全部 turn,在 `test` 结束后聚合评估。
103
+ - `session.*`:这条 session 在断言记录时已经发生的事件。
104
+ - `turn.*`:这一轮自己的事件和用量。
105
+
106
+ ```typescript
107
+ import { defineEval } from "niceeval";
108
+
109
+ export default defineEval({
110
+ description: "同名断言在 t / session / turn 上只换作用域",
111
+ async test(t) {
112
+ const firstTurn = await t.send("查一下布鲁克林天气。");
113
+
114
+ // turn.*:只看第一轮自己的事件和消息
115
+ firstTurn.succeeded();
116
+ firstTurn.calledTool("get_weather", { input: { city: "Brooklyn" } });
117
+
118
+ const followup = t.newSession();
119
+ await followup.send("查一下旧金山天气。");
120
+
121
+ // session.*:只看 followup 这条独立 session
122
+ followup.succeeded();
123
+ followup.calledTool("get_weather", { input: { city: "San Francisco" } });
124
+
125
+ // t.*:test 结束后聚合主 session + followup session 的全部轮次
126
+ t.succeeded();
127
+ t.calledTool("get_weather", { count: 2 });
128
+ },
129
+ });
130
+ ```
131
+
132
+ | API 后缀 | 作用 | 来源 |
133
+ |---|---|---|
134
+ | `succeeded()` | 当前作用域没失败、且没卡在未回答的 HITL | eve.dev |
135
+ | `parked()` | 当前作用域干净停在 HITL 输入上 | eve.dev |
136
+ | `messageIncludes(token)` | 当前作用域 assistant 文本拼接后含 token | eve.dev |
137
+ | `calledTool(name, match?)` | 当前作用域有匹配 name / input / status / count 的工具调用 | eve.dev |
138
+ | `notCalledTool(name, match?)` | 当前作用域没有匹配的工具调用 | eve.dev |
139
+ | `toolOrder(names)` | 当前作用域工具调用按给定子序出现 | eve.dev |
140
+ | `usedNoTools()` | 当前作用域完全没调工具 | eve.dev |
141
+ | `maxToolCalls(max)` | 当前作用域工具调用数不超过 max | eve.dev |
142
+ | `loadedSkill(skill)` | `calledTool("load_skill", { input: { skill } })` 的糖 | eve.dev |
143
+ | `calledSubagent(name, match?)` | 当前作用域有匹配子 agent 委派 | eve.dev |
144
+ | `noFailedActions()` | 当前作用域没有 failed 的工具 / 子 agent 动作 | eve.dev |
145
+ | `event(type, opts?)` | 当前作用域出现某类型事件,可指定 count | eve.dev |
146
+ | `notEvent(type)` | 当前作用域没出现某类型事件 | eve.dev |
147
+ | `eventOrder(types)` | 当前作用域事件类型按给定子序出现 | eve.dev |
148
+ | `eventsSatisfy(label, predicate)` | 自定义谓词直接查当前作用域事件流 | eve.dev |
149
+ | `maxTokens(max)` | 当前作用域 input + output token 不超过 max | niceeval |
150
+ | `maxCost(usd)` | 当前作用域估算成本不超过 usd | niceeval |
151
+
152
+ ### 接收者专属 API
153
+
154
+ 这些 API 不应该为了表面一致合并,因为它们表达的是不同对象的职责:
155
+
156
+ | 接收者 | API | 为什么专属 |
157
+ |---|---|---|
158
+ | `t` | `check` / `require` / `skip` / `log` | 记录 eval run 级断言或控制流,不属于某个 session / turn |
159
+ | `t` | `newSession()` | 只有 run 主上下文负责创建额外 session |
160
+ | `t` | `sandbox.*` | 沙箱是 attempt / run 资源,不是某条会话或某一轮的资源 |
161
+ | `turn` | `expectOk()` | 只对一次 `send` 的返回状态有意义 |
162
+ | `turn` | `outputEquals(value)` / `outputMatches(schema)` | 只对这一轮的 `turn.data` 有意义 |
163
+
164
+ ### 值级断言
165
+
166
+ `check` 和 `require` 都记录值级断言,但它们不是等价 API。`check` 是"记录并继续":同步返回一个断言句柄,不阻塞后续代码,适合尽量收集多条失败信号。`require` 是"前置条件":立即等待 matcher 结果,通过后返回原 value,不通过就抛出 eval 控制流异常并中止依赖它的后续代码。只有后续逻辑确实依赖这个值或条件时才用 `require`。
167
+
168
+ **记录 API:**
169
+
170
+ | API | 作用 | 备注 |
171
+ |---|---|---|
172
+ | `t.check(value, matcher)` | 同步记录一条值级断言 | 返回 `AssertionHandle`;不等待结果;失败不阻止后续代码 |
173
+ | `await t.require(value, matcher)` | 立即等待并要求值通过 matcher | 返回原 `value`;不通过就抛,按 gate 中止后续 |
174
+
175
+ **Matcher 函数:**
176
+
177
+ | API | 作用 | 备注 |
178
+ |---|---|---|
179
+ | `includes(needle, opts?)` | 含子串 / 命中正则 | 默认 gate |
180
+ | `excludes(needle, opts?)` | 不含子串 / 不命中正则 | 默认 gate |
181
+ | `equals(expected)` | 深度相等 | 默认 gate |
182
+ | `matches(schema)` | Standard Schema / zod 校验 | 默认 gate |
183
+ | `similarity(expected)` | 归一化相似度 `[0,1]` | 默认 soft |
184
+ | `satisfies(predicate, label?)` | 自定义谓词 | 默认 gate |
185
+ | `isDefined(label?)` | `value != null` | 默认 gate |
186
+ | `isTrue(label?)` | 严格等于 `true` | 默认 gate |
187
+ | `isFalse(label?)` | 严格等于 `false` | 默认 gate |
188
+ | `commandSucceeded()` | 命令退出码为 0 | 用来替代 `scriptPassed` |
189
+ | `makeAssertion(spec)` | 自定义 matcher | 复杂评分逃生舱 |
190
+
191
+ ```typescript
192
+ import { defineEval } from "niceeval";
193
+ import { includes, isDefined } from "niceeval/expect";
194
+
195
+ export default defineEval({
196
+ description: "check 记录断言,require 作为前置条件",
197
+ async test(t) {
198
+ await t.send("查一下布鲁克林天气。");
199
+
200
+ const reply = await t.require(t.reply, isDefined("reply")); // 不满足就中止后续
201
+ t.check(reply, includes("Brooklyn")); // 记录一条断言,继续收集其它断言
202
+ t.check(reply, includes("weather"));
203
+ },
204
+ });
205
+ ```
206
+
207
+ ### Sandbox:文件 IO
208
+
209
+ | API | 作用 | 路径 / 目标 |
210
+ |---|---|---|
211
+ | `t.sandbox.writeFiles(files, targetDir?)` | 写入文本文件清单 | `targetDir` 省略 → workdir;key 是相对 `targetDir` 的路径 |
212
+ | `t.sandbox.uploadFiles(files, targetDir?)` | 写入文本 / 二进制文件清单 | `targetDir` 省略 → workdir;每个文件的 `path` 是相对 `targetDir` 的路径 |
213
+ | `t.sandbox.uploadDirectory(localDir, targetDir?, opts?)` | 递归上传宿主机目录 | `localDir` 相对路径解析到 eval 文件所在目录;`targetDir` 省略 → workdir |
214
+ | `t.sandbox.readFile(path)` | 读取 sandbox 文件 | 相对路径解析到 workdir |
215
+ | `t.sandbox.fileExists(path)` | 判断 sandbox 文件是否存在 | 相对路径解析到 workdir |
216
+ | `t.sandbox.readSourceFiles(root?)` | 批量读取源码文件 | `root` 省略 → workdir |
217
+
218
+ 文件 IO 不限制只能写某个目录:只要后端允许、权限允许,`targetDir` 可以是 sandbox 内任何可写目录。但常规写法是**省略 `targetDir`**——它默认落到 workdir(agent 的工作目录,也是 git 基线和 diff 采集的锚点),而 workdir 的绝对值随后端不同(见 [Sandbox · 路径与 workdir](sandbox.md#路径与-workdir一个坐标系)),hardcode 任何一个后端的绝对路径都会让 eval 换后端就坏。`writeFiles` / `uploadFiles` 的文件 key 不写绝对路径;目标目录用 `targetDir` 表达,文件 key 只表达该目标目录下的相对路径。必须要绝对路径时(比如拼进 prompt)用 `t.sandbox.workdir`。
219
+
220
+ ### Sandbox:命令执行
221
+
222
+ | API | 作用 | 备注 |
223
+ |---|---|---|
224
+ | `t.sandbox.runCommand(cmd, args?, opts?)` | 执行命令并返回结果 | `opts.cwd` 控制工作目录;省略 → workdir;不自动评分 |
225
+ | `t.sandbox.runShell(script, opts?)` | 执行 shell 脚本并返回结果 | `opts.cwd` 控制工作目录;省略 → workdir;不自动评分 |
226
+
227
+ `Sandbox.stop()` 是运行器生命周期职责,不暴露给 eval 作者。eval 只描述“测什么、怎么判分”,不负责销毁沙箱。
228
+
229
+ ### Sandbox:结果断言与 diff
230
+
231
+ **Sandbox 结果断言:**
232
+
233
+ | API | 作用 | 备注 |
234
+ |---|---|---|
235
+ | `t.sandbox.fileChanged(path)` | 文件出现在生成 diff 里 | 延迟断言 |
236
+ | `t.sandbox.fileDeleted(path)` | 文件被删除 | 延迟断言 |
237
+ | `t.sandbox.notInDiff(re)` | diff 不含某模式 | 延迟断言 |
238
+
239
+ **Sandbox 结果材料:**
240
+
241
+ | API | 作用 | 备注 |
242
+ |---|---|---|
243
+ | `t.sandbox.diff.get(path)` | 读取某文件 diff 内容 | `path` 写 workdir 相对的项目路径(git diff 产出的就是它) |
244
+ | `t.sandbox.diff.isEmpty()` | diff 是否为空 | 值级断言材料 |
245
+ | `t.sandbox.diff.matches(re)` | diff 是否命中正则 | 值级断言材料 |
246
+ | `t.sandbox.file(path)` | 延迟读取 sandbox 文件 | `path` 是 sandbox 内路径;配 `t.check` 使用 |
247
+
248
+ 同一个 `t.sandbox` 下同时有“放文件”和“断言文件变化”,但文档按类别区分:
249
+
250
+ - 文件 IO 和命令执行是**立即动作**;
251
+ - diff / fileChanged / file 是**结果视图和延迟断言**;
252
+ - 沙箱创建、清理、停止是**runner 生命周期**,不暴露给 eval 作者。
253
+
254
+ ### Judge
255
+
256
+ `t.judge` / `session.judge` / `turn.judge` 共享同一套 judge 函数,只换默认材料:
257
+
258
+ - `t.judge`:默认评主 session 对话。
259
+ - `session.judge`:默认评这个独立 session 对话。
260
+ - `turn.judge`:默认评这一轮的 `turn.message`。
261
+ - `{ on }`:显式覆盖被评材料,用于 sandbox diff、文件内容或其它自定义值。
262
+
263
+ | API 后缀 | 作用 | 默认材料 |
264
+ |---|---|---|
265
+ | `judge.autoevals.closedQA(criteria, opts?)` | 闭合式判断 | 接收者默认材料,或 `opts.on` |
266
+ | `judge.autoevals.factuality(expected, opts?)` | 事实一致性 | 接收者默认材料,或 `opts.on` |
267
+ | `judge.autoevals.summarizes(source, opts?)` | 是否忠实摘要 | 接收者默认材料,或 `opts.on` |
268
+
269
+ judge 是评分器,默认材料也由接收者决定:`t.judge` / `session.judge` 是 session 级,默认评对应 session 的对话文本;`turn.judge` 是 turn 级,默认只评 `turn.message`。评 sandbox 产物或其它自定义值时,显式传 `t.sandbox.diff.get(path)`、`await t.sandbox.readFile(path)` 或其它 `{ on }` 材料。
270
+
271
+ ```typescript
272
+ import { defineEval } from "niceeval";
273
+
274
+ export default defineEval({
275
+ description: "judge 默认材料按接收者决定",
276
+ async test(t) {
277
+ const firstTurn = await t.send("解释今天布鲁克林天气,给出穿衣建议。");
278
+
279
+ const other = t.newSession();
280
+ await other.send("解释今天旧金山天气,给出穿衣建议。");
281
+
282
+ t.judge.autoevals.closedQA("主 session 的建议是否具体?").atLeast(0.7);
283
+ other.judge.autoevals.closedQA("独立 session 的建议是否具体?").atLeast(0.7);
284
+ firstTurn.judge.autoevals.closedQA("这一轮是否提到了穿衣建议?").gate();
285
+
286
+ // 沙箱型 eval 里,也可以用 { on } 显式评 diff / 文件内容。
287
+ const diff = t.sandbox.diff.get("src/weather.ts");
288
+ t.judge.autoevals.closedQA("diff 是否只改了天气逻辑?", { on: diff }).atLeast(0.7);
289
+ },
290
+ });
291
+ ```
292
+
293
+ ## 作用域规则
294
+
295
+ 作用域由你调用在哪个对象上决定,不由断言名字决定。
296
+
297
+ | 层 | 谁 | 作用域 |
298
+ |---|---|---|
299
+ | **值级** | `t.check(value, matcher)` / `await t.require(value, matcher)`、judge 的 `{ on }` | 只评你传进去的值;`require` 额外承担前置条件控制流 |
300
+ | **run 级聚合** | `t.succeeded()`、`t.calledTool()`、`t.event()` 等 | `test` 跑完后,看本次运行的全部 session 和全部 turn |
301
+ | **session 级会话** | `session.succeeded()`、`session.calledTool()`、`session.event()` 等 | 只看这个 session 在断言记录时已有的事件 |
302
+ | **turn 级单轮** | `turn.succeeded()`、`turn.calledTool()`、`turn.event()` 等 | 只看这一轮自己的事件 |
303
+ | **sandbox 结果级** | `t.sandbox.fileChanged()`、`t.sandbox.diff` 等 | 只看本次 eval run 最终 sandbox diff,不按轮次切分 |
304
+
305
+ judge 默认材料按接收者分层:`t.judge` / `session.judge` 默认评对应 session 对话;`turn.judge` 默认评当前 turn。要评 sandbox 产物或其它非对话材料,显式传 `{ on }`:
306
+
307
+ ```typescript
308
+ const turns = [
309
+ await t.send("这张图里有什么?"),
310
+ await t.send("背景是什么颜色?"),
311
+ await t.send("中间那个形状是什么颜色的?"),
312
+ ];
313
+
314
+ t.judge.autoevals.closedQA("助手是否始终基于第一轮的图片作答?").atLeast(0.7);
315
+ ```
316
+
317
+ ## 命令结果怎么评分
318
+
319
+ 命令执行和评分分开。`runCommand` / `runShell` 只负责执行并返回结果;是否通过,用普通 matcher 表达。
320
+
321
+ ```typescript
322
+ import { commandSucceeded, excludes } from "niceeval/expect";
323
+
324
+ const test = await t.sandbox.runCommand("npm", ["test"]);
325
+
326
+ t.check(test, commandSucceeded());
327
+ t.check(test.stderr, excludes("TypeError"));
328
+ ```
329
+
330
+ 不保留 `scriptPassed()` / `testsPassed()` 作为目标 DX。它们来自 Vercel agent-eval 的固定 fixture 流程:`PROMPT.md` + `EVAL.ts` + `scripts[]` 由 runner 自动调度。niceeval 的目标形状是“用户在 `test(t)` 里手工写入文件、手工运行验证命令、手工断言命令结果”,所以不再需要一个同时暗示“执行脚本”和“注册断言”的 API。
331
+
332
+ ## 沙箱能力错误
333
+
334
+ eval 不需要额外声明 `requires`。如果在不支持 sandbox 的 agent 上调用 `t.sandbox`,运行时直接抛出清晰错误:
335
+
336
+ ```text
337
+ This eval used t.sandbox.fileChanged(), but agent "web-agent" does not provide a sandbox.
338
+ Use a sandbox agent, or remove sandbox calls from this eval.
339
+ ```
340
+
341
+ 这样 `defineEval` 保持轻;错误出现在实际用错的 API 上,比额外维护一份 capability 声明更直接。
342
+
343
+ ## 严重级:gate vs soft
344
+
345
+ - **gate** —— 硬要求,不过即 `failed`,任何时候都生效。`includes` / `equals` 等默认 gate。
346
+ - **soft** —— 质量分,非 `--strict` 下不让 eval failed;`--strict` 下低于阈值才 failed。`similarity` 和 judge 默认 soft。
347
+
348
+ 链式改写:
349
+
350
+ | API | 作用 |
351
+ |---|---|
352
+ | `.atLeast(x)` | soft 阈值;非 `--strict` 只记分,`--strict` 下低于 x 才 failed |
353
+ | `.gate()` | 转成硬要求;低于默认通过线即 failed |
354
+ | `.gate(x)` | 转成硬阈值;低于 x 任何时候都 failed |
355
+
356
+ 示例:
357
+
358
+ ```typescript
359
+ t.check(t.reply, includes("晴")); // 默认 gate
360
+ t.check(t.reply, similarity(expected).atLeast(0.8)); // soft 阈值
361
+ t.check(t.reply, similarity(expected).gate(0.8)); // 硬阈值
362
+ t.judge.autoevals.closedQA("语气是否礼貌"); // soft 纯记分
363
+ t.judge.autoevals.closedQA("语气是否礼貌").atLeast(0.7); // soft 阈值
364
+ ```
365
+
366
+ ## 来源一览 & 哪些是 niceeval 自创
367
+
368
+ | 来源 | 给了 niceeval 什么 | 出处 |
369
+ |---|---|---|
370
+ | **eve.dev evals** | 声明式 DX、路径即身份、gate/soft 分层、t / session / turn 接收者模型、`t.check` / `t.require`、匹配器、LLM-judge 接口 | `docs/architecture.md`、`docs/README.md` |
371
+ | **Vercel agent-eval** | Adapter / Sandbox 工程形状、sandbox diff、transcript 归一化与可观测、experiment 层、本地 `niceeval view` | `docs/vision.md`、`docs/experiments.md` |
372
+ | **crabbox** | capability 分发纪律、`--budget` / `maxCost` 的 spend cap、source-map 文档观 | `docs/vision.md`、`docs/runner.md` |
373
+ | **autoevals(Braintrust)** | `closedQA` / `factuality` / `summarizes` 评判器 | `src/scoring/judge.ts` |
374
+
375
+ **niceeval 自创(不在以上任何来源里):**
376
+
377
+ - **成本聚合** —— 用量 → 成本价格表估算 + `t.maxCost()`。
378
+ - **匹配器扩展** —— `excludes` / `isDefined` / `isTrue` / `isFalse` / `commandSucceeded`。
379
+ - **judge 接收者默认材料** —— `t.judge` / `session.judge` 评 session;`turn.judge` 评单轮,避免为了单轮 judge 手写 `{ on: turn.message }`。
380
+ - **sandbox author API 分层** —— 文件 IO / 命令执行 / 结果断言都在 `t.sandbox`,但生命周期动作如 `stop()` 不暴露给 eval 作者。
381
+ - **本地结果查看器** —— 读 `.niceeval/<run>/` 结构化工件出图。
382
+
383
+ ## 接下来读什么
384
+
385
+ - [Eval Authoring](eval-authoring.md) —— 怎么把这些 API 组织进单轮 / 多轮 / 数据集 / 沙箱型 eval。
386
+ - [Scoring](scoring.md) —— 判决规则、judge 细节、效率 / 成本断言。
387
+ - [Adapter 契约](adapters/contract.md) —— 断言读的标准事件流从哪来,以及每条断言对 adapter 的数据义务。
388
+ - [Observability](observability.md) —— transcript / usage / cost 的数据来源。
@@ -0,0 +1,48 @@
1
+ # Capabilities by Construction —— 能力由构造证明,不再有声明式布尔位
2
+
3
+ > **状态:已实现。** 本文最初是一份设计提案(「为什么能力是布尔变量,而不是实现了某个函数就算有?」),写作时 `AgentCapabilities` 布尔位系统还在。实现落地时走得比提案本身更彻底:提案设想的是"能证明的位由推断决定,证明不了的位保留声明、默认翻转为 false";实际做法是**整个 `capabilities` 字段连同它的类型 `AgentCapabilities` 一起从 `Agent` 接口删除**,包括提案里"仍然保留声明"的 `conversation`。本文记录这次实现和最初提案的差异,以及哪些部分至今仍未落地。
4
+
5
+ ## 现状:`Agent` 接口上没有能力位
6
+
7
+ `src/agents/types.ts` 的 `Agent` 接口只有 `name` / `kind` / `setup` / `tracing` / `spanMapper` / `send` / `teardown`,没有 `capabilities` 字段。`t` 上解锁什么完全按下表由构造证据决定:
8
+
9
+ | 能力 | 证据 | 用户要写什么 |
10
+ |---|---|---|
11
+ | `t.sandbox`、`t.fileChanged()` 等文件系统断言 | `defineSandboxAgent` 构造(`kind: "sandbox"`) | 无——`defineAgent` 构造的 agent(`kind: "remote"`)调用这些方法会立即得到清晰报错,这是唯一仍需要运行时守卫的能力(`src/context/context.ts` 的 `capabilityGuard`) |
12
+ | 多次 `t.send()`、`t.reply`、`t.newSession()` | `send` 里接了 `ctx.session` 的续接存取器(`history<TMsg>()` 或 `id` + `capture(id)`) | 无——没接就每轮各是新对话,不报错,只是断言看不到跨轮历史 |
13
+ | `t.calledTool()` 等正断言 | `send` 返回的 events 里有 `action.*` | 无——有事件就能断,没有就是断言 fail(响,不静默) |
14
+ | `t.notCalledTool()` / `usedNoTools()` 等负断言的**可信度** | 事件来源是否有完整性契约(SDK 原生事件流透传、`fromAiSdk` 的 `result.steps`、Responses 的 `output`) | 无——但手写映射时如果知道自己漏了工具层,应在 eval README 里如实说明这条 eval 的负断言不可信,而不是假装它和正断言一样可靠 |
15
+ | `t.respond()` / `t.parked()` 等 HITL | `send` 返回过 `status: "waiting"` + `input.requested` 事件 | 无——做到了就是有,提案写作时这条已经是行为证明,现状延续 |
16
+ | `EvalResult.trace`、`niceeval view` 瀑布图 | 配置了 OTel 接入(agent 的 `tracing` 块,或 remote agent + `defineConfig({ telemetry })`) | 无——块/配置本来就要写,不需要额外声明 |
17
+
18
+ 对照最初提案的分类表(`sandbox`/`workspace` 构造即证明、`tracing` 块即证明、HITL 行为即证明、`conversation`"任何一层都证明不了、保留声明"),唯一的方向性差异在 `conversation`:提案认为对端是否真续接谁都证明不了,所以要保留一个显式声明;实现干脆**连这个声明也删了**——用户不写任何东西,`t.send()` 能不能带上历史,完全取决于 `send` 有没有实际使用 `ctx.session.history()` 或 `ctx.session.id`/`capture()`。这不是"证明了对端续接"(确实证明不了),而是"不再需要证明"——没有能力位意味着没有"承诺"这个概念,只有"这段代码写了什么就是什么"。
19
+
20
+ 最小接入现在是:
21
+
22
+ ```ts
23
+ // 不声明 = 不承诺,零心智负担;t.send() 只有一轮、t.calledTool() 只有事件里有才断得到
24
+ export default defineAgent({
25
+ name: "my-bot",
26
+ async send(input, ctx) { /* ... */ },
27
+ });
28
+ ```
29
+
30
+ ## 和最初提案不同、至今仍未实现的部分
31
+
32
+ 提案第二层设想了一套"认证来源"机制:`fromAiSdk` 这类官方转换器的返回值携带一个不可枚举的证明标记(`certify({ events, usage, status }, { toolObservability: true })`),runner 按 attempt 聚合"是否全部事件都来自带证明的来源",从而让负断言的可信度变成一个运行时可判定的状态,证明不够时打 warning。**这套 `certify()` 机制没有实现**——源码里搜不到 `certify` 或任何等价的证明标记传递逻辑,`t.notCalledTool()` 在 `src/scoring/scoped.ts` 里就是纯粹的计数判断,不检查事件来源。
33
+
34
+ 现状是:**负断言的可信度完全是文档层面的判断,不是运行时机制。** 官方转换器(`fromAiSdk`、`fromClaudeSdkMessages` 等)透传的是 SDK/协议本身承诺完整的事件流,所以文档上说这些来源"负断言可信";手写映射没有这份契约,文档上说"负断言提示不可信"——但这只是写在文档里提醒 eval 作者自行判断,niceeval 不会在运行时警告或标记。这是提案与实现之间最大的落差,以后如果要补,`certify()` 一节的设计仍然是现成的起点。
35
+
36
+ `compactionObservability` 同理:没有单独的能力位,`send` 吐了 `compaction` 事件,`t.event("compaction")` 就能断到,吐了多少算多少,不存在"完整性证明"这层。
37
+
38
+ ## 边界
39
+
40
+ - **默认值问题已经不存在。** 提案担心的"`defineAgent` 默认给 `conversation: true, toolObservability: true`,教程第一步要写一行反悔"——这个问题连同整个 `capabilities` 字段一起消失了,不需要保留"opt-out 默认值"这个概念。
41
+ - **`workspace` 对远程 agent 依然不放开。** 文件系统断言(`t.sandbox`、`t.fileChanged()`)只在 `kind: "sandbox"` 上解锁,远程 agent 即便自己改了文件,也没有 sandbox 提供的 diff 基线,这条边界和提案设想的一致。
42
+ - **`t.newSession()` 在没有接会话续接存取器时依然可以调用。** 新会话线的第一轮就是存取器的自然空态,不需要判断"这个 agent 支不支持多轮"。
43
+
44
+ ## 相关阅读
45
+
46
+ - [adapters/contract.md](adapters/contract.md) —— 逐能力的构造证据、逐 API 的适配义务、负断言完整性规则。
47
+ - [Observability · OTLP traces](observability.md#otlp-traces--统一瀑布图) —— OTel 现在只喂 trace 瀑布图,和这里讨论的事件类能力完全独立。
48
+ - docs-site [Adapter 概念](../docs-site/zh/concepts/adapter.mdx) —— 面向用户的「能力从哪来」讲法,与本文现状一节一致。