niceeval 0.5.3 → 0.6.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 (344) hide show
  1. package/INDEX.md +55 -0
  2. package/dist/agents/types.d.ts +317 -0
  3. package/dist/context/types.d.ts +335 -0
  4. package/dist/i18n/core.d.ts +11 -0
  5. package/dist/i18n/en.d.ts +181 -0
  6. package/dist/i18n/index.d.ts +5 -0
  7. package/dist/i18n/zh-CN.d.ts +183 -0
  8. package/dist/o11y/execution-tree.d.ts +103 -0
  9. package/dist/o11y/otlp/parse.d.ts +3 -0
  10. package/dist/o11y/otlp/receiver.d.ts +21 -0
  11. package/dist/o11y/otlp/select.d.ts +22 -0
  12. package/dist/o11y/otlp/turn-otel.d.ts +43 -0
  13. package/dist/o11y/types.d.ts +190 -0
  14. package/dist/report/aggregate.d.ts +57 -0
  15. package/dist/report/aggregate.js +212 -0
  16. package/dist/report/built-ins/cost-pass-rate-comparison.d.ts +1 -0
  17. package/dist/report/built-ins/cost-pass-rate-comparison.js +17 -0
  18. package/dist/report/built-ins/index.d.ts +1 -0
  19. package/dist/report/built-ins/index.js +2 -0
  20. package/dist/report/components.d.ts +178 -0
  21. package/dist/report/components.js +132 -0
  22. package/dist/report/compute.d.ts +106 -0
  23. package/dist/report/compute.js +618 -0
  24. package/dist/report/flag.d.ts +6 -0
  25. package/dist/report/flag.js +8 -0
  26. package/dist/report/format.d.ts +32 -0
  27. package/dist/report/format.js +126 -0
  28. package/dist/report/index.d.ts +19 -0
  29. package/dist/report/index.js +30 -0
  30. package/dist/report/load.d.ts +12 -0
  31. package/dist/report/load.js +56 -0
  32. package/dist/report/locale.d.ts +83 -0
  33. package/dist/report/locale.js +160 -0
  34. package/dist/report/metrics.d.ts +21 -0
  35. package/dist/report/metrics.js +115 -0
  36. package/dist/report/primitives.d.ts +64 -0
  37. package/dist/report/primitives.js +113 -0
  38. package/dist/report/react/AttemptList.d.ts +23 -0
  39. package/dist/report/react/AttemptList.js +20 -0
  40. package/dist/report/react/DeltaTable.d.ts +8 -0
  41. package/dist/report/react/DeltaTable.js +24 -0
  42. package/dist/report/react/EvalList.d.ts +10 -0
  43. package/dist/report/react/EvalList.js +0 -0
  44. package/dist/report/react/ExperimentList.d.ts +10 -0
  45. package/dist/report/react/ExperimentList.js +15 -0
  46. package/dist/report/react/GroupSummary.d.ts +8 -0
  47. package/dist/report/react/GroupSummary.js +8 -0
  48. package/dist/report/react/MetricBars.d.ts +10 -0
  49. package/dist/report/react/MetricBars.js +48 -0
  50. package/dist/report/react/MetricLine.d.ts +9 -0
  51. package/dist/report/react/MetricLine.js +68 -0
  52. package/dist/report/react/MetricMatrix.d.ts +10 -0
  53. package/dist/report/react/MetricMatrix.js +23 -0
  54. package/dist/report/react/MetricScatter.d.ts +9 -0
  55. package/dist/report/react/MetricScatter.js +100 -0
  56. package/dist/report/react/MetricTable.d.ts +11 -0
  57. package/dist/report/react/MetricTable.js +26 -0
  58. package/dist/report/react/RunOverview.d.ts +8 -0
  59. package/dist/report/react/RunOverview.js +12 -0
  60. package/dist/report/react/Scoreboard.d.ts +8 -0
  61. package/dist/report/react/Scoreboard.js +26 -0
  62. package/dist/report/react/cell.d.ts +9 -0
  63. package/dist/report/react/cell.js +9 -0
  64. package/dist/report/react/chart-math.d.ts +14 -0
  65. package/dist/report/react/chart-math.js +68 -0
  66. package/dist/report/react/colors.d.ts +14 -0
  67. package/dist/report/react/colors.js +45 -0
  68. package/dist/report/react/fixtures.d.ts +19 -0
  69. package/dist/report/react/fixtures.js +393 -0
  70. package/dist/report/react/format.d.ts +3 -0
  71. package/dist/report/react/format.js +7 -0
  72. package/dist/report/react/index.d.ts +6 -0
  73. package/dist/report/react/index.js +14 -0
  74. package/dist/report/report.d.ts +24 -0
  75. package/dist/report/report.js +49 -0
  76. package/dist/report/text/faces.d.ts +29 -0
  77. package/dist/report/text/faces.js +503 -0
  78. package/dist/report/text/layout.d.ts +75 -0
  79. package/dist/report/text/layout.js +173 -0
  80. package/dist/report/text/plot.d.ts +37 -0
  81. package/dist/report/text/plot.js +81 -0
  82. package/dist/report/text/table.d.ts +8 -0
  83. package/dist/report/text/table.js +86 -0
  84. package/dist/report/tree.d.ts +99 -0
  85. package/dist/report/tree.js +228 -0
  86. package/dist/report/types.d.ts +420 -0
  87. package/dist/report/types.js +4 -0
  88. package/dist/report/web.d.ts +15 -0
  89. package/dist/report/web.js +37 -0
  90. package/dist/results/annotated-source.d.ts +61 -0
  91. package/dist/results/attempt-evidence.d.ts +69 -0
  92. package/dist/results/attempt-source.d.ts +15 -0
  93. package/dist/results/locator.d.ts +75 -0
  94. package/dist/results/locator.js +0 -0
  95. package/dist/results/select.d.ts +45 -0
  96. package/dist/results/select.js +230 -0
  97. package/dist/results/source-hash.d.ts +9 -0
  98. package/dist/results/types.d.ts +204 -0
  99. package/dist/runner/eval-source.d.ts +22 -0
  100. package/dist/runner/fingerprint.d.ts +23 -0
  101. package/dist/runner/types.d.ts +332 -0
  102. package/dist/sandbox/docker-stream.d.ts +24 -0
  103. package/dist/sandbox/docker.d.ts +84 -0
  104. package/dist/sandbox/e2b.d.ts +31 -0
  105. package/dist/sandbox/errors.d.ts +4 -0
  106. package/dist/sandbox/local-files.d.ts +6 -0
  107. package/dist/sandbox/paths.d.ts +4 -0
  108. package/dist/sandbox/registry.d.ts +13 -0
  109. package/dist/sandbox/resolve.d.ts +40 -0
  110. package/dist/sandbox/retry.d.ts +10 -0
  111. package/dist/sandbox/shell.d.ts +11 -0
  112. package/dist/sandbox/source-files.d.ts +13 -0
  113. package/dist/sandbox/types.d.ts +203 -0
  114. package/dist/sandbox/vercel.d.ts +37 -0
  115. package/dist/scoring/types.d.ts +70 -0
  116. package/dist/shared/aggregate.d.ts +29 -0
  117. package/dist/shared/aggregate.js +55 -0
  118. package/dist/shared/types.d.ts +27 -0
  119. package/dist/shared/verdict.d.ts +27 -0
  120. package/dist/shared/verdict.js +34 -0
  121. package/dist/tty-line.d.ts +6 -0
  122. package/dist/types.d.ts +7 -0
  123. package/dist/util.d.ts +22 -0
  124. package/docs-site/zh/concepts/adapter.mdx +9 -4
  125. package/docs-site/zh/concepts/assert.mdx +8 -8
  126. package/docs-site/zh/concepts/evals.mdx +5 -5
  127. package/docs-site/zh/concepts/experiment.mdx +3 -1
  128. package/docs-site/zh/concepts/judge.mdx +9 -9
  129. package/docs-site/zh/concepts/overview.mdx +2 -2
  130. package/docs-site/zh/concepts/tier.mdx +6 -6
  131. package/docs-site/zh/example/claude-code-codex-plugin.mdx +9 -5
  132. package/docs-site/zh/example/claude-code-codex-skill.mdx +12 -7
  133. package/docs-site/zh/guides/agent-feedback-loop.mdx +235 -78
  134. package/docs-site/zh/guides/connect-otel.mdx +3 -3
  135. package/docs-site/zh/guides/connect-your-agent.mdx +2 -2
  136. package/docs-site/zh/guides/custom-reports.mdx +196 -36
  137. package/docs-site/zh/guides/experiments.mdx +7 -0
  138. package/docs-site/zh/guides/fixtures.mdx +1 -1
  139. package/docs-site/zh/guides/official-adapters.mdx +31 -9
  140. package/docs-site/zh/guides/publish-report.mdx +4 -4
  141. package/docs-site/zh/guides/report-components.mdx +194 -55
  142. package/docs-site/zh/guides/reporters.mdx +2 -2
  143. package/docs-site/zh/guides/results-data.mdx +35 -34
  144. package/docs-site/zh/guides/runner.mdx +2 -2
  145. package/docs-site/zh/guides/sandbox-agent.mdx +10 -5
  146. package/docs-site/zh/guides/sandbox-providers.mdx +102 -0
  147. package/docs-site/zh/guides/viewing-results.mdx +199 -79
  148. package/docs-site/zh/guides/write-experiment.mdx +23 -1
  149. package/docs-site/zh/guides/write-send.mdx +2 -2
  150. package/docs-site/zh/index.mdx +4 -3
  151. package/docs-site/zh/introduction.mdx +2 -1
  152. package/docs-site/zh/quickstart.mdx +5 -3
  153. package/docs-site/zh/reference/builtin-agents.mdx +38 -13
  154. package/docs-site/zh/reference/capabilities.mdx +1 -1
  155. package/docs-site/zh/reference/cli.mdx +36 -13
  156. package/docs-site/zh/reference/define-agent.mdx +24 -12
  157. package/docs-site/zh/reference/define-config.mdx +1 -1
  158. package/docs-site/zh/reference/define-eval.mdx +3 -3
  159. package/docs-site/zh/reference/events.mdx +7 -1
  160. package/docs-site/zh/reference/expect.mdx +3 -3
  161. package/package.json +10 -6
  162. package/src/agents/bub.ts +134 -43
  163. package/src/agents/claude-code.test.ts +152 -0
  164. package/src/agents/claude-code.ts +127 -13
  165. package/src/agents/codex.test.ts +192 -0
  166. package/src/agents/codex.ts +151 -12
  167. package/src/agents/index.ts +11 -3
  168. package/src/agents/manifest.ts +41 -0
  169. package/src/agents/plugin-config.test.ts +94 -0
  170. package/src/agents/shared.ts +33 -2
  171. package/src/agents/skills.test.ts +214 -0
  172. package/src/agents/skills.ts +304 -0
  173. package/src/agents/streaming.ts +5 -5
  174. package/src/agents/types.ts +62 -2
  175. package/src/cli.ts +74 -47
  176. package/src/context/context.test.ts +20 -2
  177. package/src/context/context.ts +3 -0
  178. package/src/context/control-flow.ts +1 -1
  179. package/src/context/session.test.ts +92 -1
  180. package/src/context/session.ts +26 -1
  181. package/src/context/types.ts +4 -4
  182. package/src/define.test.ts +82 -0
  183. package/src/define.ts +72 -11
  184. package/src/expect/index.ts +5 -5
  185. package/src/i18n/en.ts +36 -15
  186. package/src/i18n/zh-CN.ts +39 -21
  187. package/src/index.ts +1 -1
  188. package/src/o11y/execution-tree.test.ts +452 -0
  189. package/src/o11y/execution-tree.ts +367 -0
  190. package/src/o11y/otlp/receiver.ts +1 -1
  191. package/src/o11y/otlp/select.ts +7 -3
  192. package/src/o11y/parsers/claude-code.test.ts +175 -0
  193. package/src/o11y/parsers/claude-code.ts +32 -0
  194. package/src/o11y/types.ts +6 -1
  195. package/src/report/aggregate.ts +26 -20
  196. package/src/report/built-in-user-parity.test.tsx +643 -0
  197. package/src/report/built-ins/cost-pass-rate-comparison.tsx +23 -0
  198. package/src/report/built-ins/index.ts +2 -0
  199. package/src/report/components.tsx +223 -50
  200. package/src/report/compute.ts +358 -53
  201. package/src/report/dual-render.test.tsx +1125 -0
  202. package/src/report/flag.ts +1 -1
  203. package/src/report/format.ts +50 -2
  204. package/src/report/index.ts +48 -14
  205. package/src/report/load.ts +1 -1
  206. package/src/report/locale.ts +206 -0
  207. package/src/report/metrics.ts +39 -15
  208. package/src/report/primitives.tsx +100 -2
  209. package/src/report/react/AttemptList.tsx +115 -0
  210. package/src/report/react/DeltaTable.tsx +9 -6
  211. package/src/report/react/EvalList.tsx +0 -0
  212. package/src/report/react/ExperimentList.tsx +108 -0
  213. package/src/report/react/GroupSummary.tsx +66 -0
  214. package/src/report/react/MetricBars.tsx +24 -13
  215. package/src/report/react/MetricLine.tsx +21 -21
  216. package/src/report/react/MetricMatrix.tsx +8 -4
  217. package/src/report/react/MetricScatter.tsx +136 -96
  218. package/src/report/react/MetricTable.tsx +115 -15
  219. package/src/report/react/RunOverview.tsx +48 -26
  220. package/src/report/react/Scoreboard.tsx +15 -7
  221. package/src/report/react/cell.tsx +18 -9
  222. package/src/{view/app/lib/chart.ts → report/react/chart-math.ts} +2 -6
  223. package/src/report/react/colors.ts +24 -15
  224. package/src/report/react/enhance.js +129 -0
  225. package/src/report/react/fixtures.ts +163 -34
  226. package/src/report/react/format.ts +1 -1
  227. package/src/report/react/index.tsx +22 -6
  228. package/src/report/react/render.test.tsx +164 -31
  229. package/src/report/react/styles.css +405 -213
  230. package/src/report/report.test.ts +403 -94
  231. package/src/report/report.ts +29 -11
  232. package/src/report/text/faces.ts +303 -95
  233. package/src/report/text/layout.ts +76 -11
  234. package/src/report/text/table.ts +98 -0
  235. package/src/report/tree.ts +103 -20
  236. package/src/report/types.ts +196 -25
  237. package/src/report/web.ts +46 -19
  238. package/src/results/annotated-source.test.ts +146 -0
  239. package/src/results/annotated-source.ts +128 -0
  240. package/src/results/attempt-evidence.test.ts +158 -0
  241. package/src/results/attempt-evidence.ts +158 -0
  242. package/src/results/attempt-source.ts +48 -0
  243. package/src/results/copy.ts +154 -139
  244. package/src/results/format.ts +54 -47
  245. package/src/results/host-equivalence.test.ts +527 -0
  246. package/src/results/index.ts +56 -14
  247. package/src/results/locator.test.ts +198 -0
  248. package/src/results/locator.ts +0 -0
  249. package/src/results/open.ts +392 -175
  250. package/src/results/results.test.ts +943 -494
  251. package/src/results/select.ts +129 -23
  252. package/src/results/skipped-notice.ts +0 -0
  253. package/src/results/source-hash.ts +28 -0
  254. package/src/results/types.ts +98 -62
  255. package/src/results/writer.ts +304 -176
  256. package/src/runner/attempt.test.ts +170 -0
  257. package/src/runner/attempt.ts +97 -15
  258. package/src/runner/discover.test.ts +65 -0
  259. package/src/runner/discover.ts +6 -2
  260. package/src/runner/eval-source.test.ts +121 -0
  261. package/src/runner/eval-source.ts +45 -0
  262. package/src/runner/fingerprint.ts +54 -1
  263. package/src/runner/remote-sandbox.ts +1 -1
  264. package/src/runner/report.test.ts +6 -8
  265. package/src/runner/report.ts +5 -4
  266. package/src/runner/reporters/artifacts.ts +27 -41
  267. package/src/runner/reporters/braintrust.test.ts +3 -3
  268. package/src/runner/reporters/braintrust.ts +1 -1
  269. package/src/runner/reporters/console.ts +6 -6
  270. package/src/runner/reporters/json.ts +4 -4
  271. package/src/runner/reporters/live.test.ts +56 -0
  272. package/src/runner/reporters/live.ts +36 -10
  273. package/src/runner/reporters/quiet.test.ts +66 -0
  274. package/src/runner/reporters/quiet.ts +49 -0
  275. package/src/runner/reporters/shared.ts +5 -5
  276. package/src/runner/reporters/table.ts +17 -17
  277. package/src/runner/run.ts +61 -102
  278. package/src/runner/types.ts +43 -32
  279. package/src/sandbox/checkpoint.ts +1 -1
  280. package/src/sandbox/docker.ts +15 -2
  281. package/src/sandbox/e2b.ts +11 -5
  282. package/src/sandbox/errors.ts +13 -0
  283. package/src/sandbox/index.ts +3 -3
  284. package/src/sandbox/local-files.ts +1 -1
  285. package/src/sandbox/registry.ts +6 -5
  286. package/src/sandbox/resolve.ts +38 -23
  287. package/src/sandbox/retry.test.ts +68 -0
  288. package/src/sandbox/retry.ts +44 -0
  289. package/src/sandbox/types.ts +69 -28
  290. package/src/sandbox/vercel.ts +17 -3
  291. package/src/scoring/collector.ts +3 -3
  292. package/src/scoring/judge.ts +2 -2
  293. package/src/scoring/scoped.ts +13 -1
  294. package/src/scoring/types.ts +7 -7
  295. package/src/scoring/verdict.ts +4 -4
  296. package/src/shared/aggregate.ts +23 -3
  297. package/src/shared/types.ts +2 -2
  298. package/src/shared/{outcome.ts → verdict.ts} +15 -15
  299. package/src/show/compose.ts +12 -132
  300. package/src/show/index.ts +125 -94
  301. package/src/show/render.ts +660 -160
  302. package/src/show/show.test.ts +472 -253
  303. package/src/source-loc.ts +1 -1
  304. package/src/tty-line.ts +32 -0
  305. package/src/types.ts +2 -2
  306. package/src/util.test.ts +21 -1
  307. package/src/util.ts +5 -1
  308. package/src/view/app/App.tsx +33 -198
  309. package/src/view/app/components/AttemptModal.tsx +6 -6
  310. package/src/view/app/components/CodeView.tsx +2 -2
  311. package/src/view/app/components/CopyControls.tsx +32 -36
  312. package/src/view/app/components/SkippedRunsBanner.tsx +140 -0
  313. package/src/view/app/components/ui/badge.tsx +1 -1
  314. package/src/view/app/components/ui/dialog.tsx +2 -2
  315. package/src/view/app/i18n.ts +40 -31
  316. package/src/view/app/lib/artifact-url.ts +1 -1
  317. package/src/view/app/lib/attempt-route.test.ts +37 -47
  318. package/src/view/app/lib/attempt-route.ts +26 -26
  319. package/src/view/app/lib/rows.ts +9 -112
  320. package/src/view/app/lib/verdict.ts +25 -0
  321. package/src/view/app/main.tsx +9 -12
  322. package/src/view/app/pages/RunsPage.tsx +4 -4
  323. package/src/view/app/pages/TracesPage.tsx +3 -3
  324. package/src/view/app/shared.ts +0 -1
  325. package/src/view/app/types.ts +6 -51
  326. package/src/view/artifact-serving.test.ts +136 -0
  327. package/src/view/client-dist/app.css +1 -1
  328. package/src/view/client-dist/app.js +21 -21
  329. package/src/view/data.test.ts +214 -143
  330. package/src/view/data.ts +166 -120
  331. package/src/view/index.ts +23 -11
  332. package/src/view/server.ts +52 -17
  333. package/src/view/shared/types.ts +22 -31
  334. package/src/view/styles.css +117 -327
  335. package/src/view/view-report.test.ts +146 -98
  336. package/docs-site/zh/guides/sandbox-backends.mdx +0 -75
  337. package/src/report/default-report.tsx +0 -222
  338. package/src/report/dual-face.test.tsx +0 -527
  339. package/src/report/react/CaseList.tsx +0 -70
  340. package/src/view/app/components/CostScoreChart.tsx +0 -147
  341. package/src/view/app/components/ExperimentTable.tsx +0 -284
  342. package/src/view/app/components/GroupSelector.tsx +0 -61
  343. package/src/view/app/components/primitives.tsx +0 -43
  344. package/src/view/app/lib/outcome.ts +0 -82
@@ -3,10 +3,11 @@
3
3
 
4
4
  import type { Cleanup, LocalizedText, SourceArtifact } from "../shared/types.ts";
5
5
  import type { O11ySummary, StreamEvent, TraceSpan, Usage } from "../o11y/types.ts";
6
- import type { Agent } from "../agents/types.ts";
6
+ import type { Agent, AgentSetupManifest } from "../agents/types.ts";
7
7
  import type { Sandbox, SandboxOption } from "../sandbox/types.ts";
8
- import type { AssertionResult, DiffData, JudgeConfig, ResultOutcome } from "../scoring/types.ts";
8
+ import type { AssertionResult, DiffData, JudgeConfig, Verdict } from "../scoring/types.ts";
9
9
  import type { TestContext } from "../context/types.ts";
10
+ import type { CapturedEvalSource } from "./eval-source.ts";
10
11
 
11
12
  // ───────────────────────── 结果 / 报告 ─────────────────────────
12
13
 
@@ -27,11 +28,19 @@ export interface EvalResult {
27
28
  experiment?: ExperimentRunInfo;
28
29
  agent: string;
29
30
  model?: string;
30
- outcome: ResultOutcome;
31
+ verdict: Verdict;
31
32
  fingerprint?: string;
32
33
  attempt: number;
33
34
  /** 本 attempt 开始的墙钟时刻(ISO);view 按 eval 粒度展示「何时跑的」。 */
34
35
  startedAt?: string;
36
+ /**
37
+ * 不透明的 Attempt 定位符(`@` 前缀短确定性编码,见 `src/results/locator.ts` 的 AttemptLocator),
38
+ * 由 {experimentId, 快照 startedAt, evalId, attempt} 身份元组派生。非携带条目由 writer 在落盘时
39
+ * 算出;携带条目(`--resume` 合入)原样复制上一轮的值,从不重算——原快照的 startedAt 已经不在
40
+ * 当前快照里,重算会算出不同的字符串,详见 writer.ts 对携带分支的说明。省略时读取面按当前
41
+ * 已知身份兜底算出(第三方 harness 未实现 locator 时的降级,不保证跨 --resume 稳定)。
42
+ */
43
+ locator?: string;
35
44
  durationMs: number;
36
45
  assertions: AssertionResult[];
37
46
  usage?: Usage;
@@ -44,33 +53,29 @@ export interface EvalResult {
44
53
  o11y?: O11ySummary;
45
54
  /** agent 经 OpenTelemetry 导出的运行追踪(有 tracing 能力且收到 span 时)。 */
46
55
  trace?: TraceSpan[];
56
+ /** 本 attempt 的 agent setup 实际装了什么(Skill / native plugin / MCP / Python plugin);不参与评分。 */
57
+ agentSetup?: AgentSetupManifest;
47
58
  diff?: DiffData;
48
59
  rawTranscript?: string;
49
- // ── 拆分工件的引用(Artifacts 报告器写 summary.json 时填;view 按需懒加载)──
50
- /** 本 attempt 工件目录(相对 run 根),下有 events/trace/o11y/diff.json。 */
51
- artifactsDir?: string;
52
- /** 工件目录(相对结果根,供前端 fetch 与懒加载回退);view 的 loader 注入,--resume 携带条目落盘时保留。 */
60
+ /** 携带条目(--resume 合入)专用:artifact 目录(相对结果根目录),指向原快照里的落盘。 */
53
61
  artifactBase?: string;
54
- /** 工件目录的绝对路径;历史字段,当前读取面不再注入。 */
55
- artifactAbsBase?: string;
56
62
  hasTrace?: boolean;
57
63
  hasEvents?: boolean;
58
64
  hasSources?: boolean;
59
65
  }
60
66
 
61
- /** `summary.json` 的格式标记;把 niceeval 报告和其它工具的同名文件区分开。 */
67
+ /** `snapshot.json` 的格式标记;把 niceeval 报告和其它工具的同名文件区分开。 */
62
68
  export const RESULTS_FORMAT = "niceeval.results";
63
- /** 结果格式版本,只在破坏兼容读取时递增;读取器只认相同版本,缺失按 1。见 docs/results-format.md。 */
64
- export const RESULTS_SCHEMA_VERSION = 3;
69
+ /**
70
+ * 结果格式版本,只在破坏兼容读取时递增;读取器只认相同版本。见 docs/feature/results/architecture.md。
71
+ * `5`(见 memory 的 attempt-locator-and-source-dedup 条目)= result.json 新增 `locator` 字段;
72
+ * `sources.json` 从逐 attempt 内联全量内容改为「attempt 级引用 + 快照级 `sources/<sha256>.json`
73
+ * 去重仓库」,`AttemptHandle.sources()` 的公开返回形状不变(仍是 `SourceArtifact[] | null`)。
74
+ */
75
+ export const RESULTS_SCHEMA_VERSION = 5;
65
76
 
77
+ /** 一次运行的纯运行时内存聚合(reporter 契约用);落盘格式契约在 niceeval/results 的 SnapshotMeta / AttemptRecord,见 docs/feature/results/architecture.md。 */
66
78
  export interface RunSummary {
67
- /** 恒为 "niceeval.results";和 schemaVersion、producer 一起构成持久化契约,永不移动或改名。 */
68
- format?: typeof RESULTS_FORMAT;
69
- /** 结果格式版本;与读取器不同即视为不兼容,提示用 producer.version 对应的 niceeval 查看。 */
70
- schemaVersion?: number;
71
- /** 写这份报告的工具:niceeval 自己,或经 niceeval/results 写入面转换的第三方 harness。
72
- * name === "niceeval" 时 version 用于拼 `npx niceeval@<version> view` 提示;其它 name 如实报出。 */
73
- producer?: { name: string; version?: string; commit?: string };
74
79
  /** 项目名(来自 config.name),透传给 `niceeval view` 顶部 hero 显示。 */
75
80
  name?: LocalizedText;
76
81
  agent: string;
@@ -87,12 +92,6 @@ export interface RunSummary {
87
92
  usage?: Usage;
88
93
  estimatedCostUSD?: number;
89
94
  results: EvalResult[];
90
- outputDir?: string;
91
- /** 快照级元数据,按 experiment 键(缺 experimentId 时为 "<agent>/<model>" 合成键)存放:
92
- * startedAt 只在与顶层 startedAt 不同(一个 run 装多份不同时刻的快照)时需要;
93
- * knownEvalIds 是写入时刻该实验已知的 eval 并集(copySnapshots 自动补记,writer.snapshot 可声明)。
94
- * 可选新增字段,不递增 schemaVersion(docs/results-format.md 版本规则)。 */
95
- snapshots?: Record<string, { startedAt?: string; knownEvalIds?: string[] }>;
96
95
  }
97
96
 
98
97
  /** onRunStart 的运行规模:去重后 eval 数 × 配置(agent×model×flags)数 → 总运行(attempt)数。 */
@@ -144,7 +143,7 @@ export interface EvalDef {
144
143
  /**
145
144
  * eval 级预置:拿到沙箱(已上传 workspace + git 基线 + 装好依赖前)。
146
145
  * 默认命令以非 root 跑(agent 的自然环境);装系统依赖时给 `runCommand` 传 `{ root: true }`
147
- * (如 `runCommand("apt-get", ["install", …], { root: true })`),跨后端语义一致。
146
+ * (如 `runCommand("apt-get", ["install", …], { root: true })`),跨 provider 语义一致。
148
147
  */
149
148
  setup?: (sandbox: Sandbox) => Promise<void | Cleanup> | void | Cleanup;
150
149
  /** eval 主体:拿到 TestContext,驱动对话 / 沙箱操作并就地断言。 */
@@ -158,6 +157,11 @@ export interface DiscoveredEval extends EvalDef {
158
157
  baseDir: string;
159
158
  /** 定义文件绝对路径,用于内容指纹缓存。 */
160
159
  sourcePath: string;
160
+ /**
161
+ * discovery 时捕获的规范化源码(归一化文本 + 项目相对路径 + SHA-256),见 `eval-source.ts`。
162
+ * 同一文件里多个 eval(数组默认导出)共享同一份引用——哈希与内容天然相同,不重复读盘。
163
+ */
164
+ source: CapturedEvalSource;
161
165
  }
162
166
 
163
167
  export interface ExperimentDef {
@@ -196,7 +200,7 @@ export interface ExperimentDef {
196
200
  * 本实验自己的并发上限:调度器只对这个实验的 attempt 限流,同批其它实验不受影响,
197
201
  * 仍按全局并发(CLI / env / config / 沙箱默认)跑。用于串行化有共享状态的实验
198
202
  * (如跨 eval 累积记忆:`maxConcurrency: 1` 保证 attempt 按 eval 顺序一个个跑),
199
- * 或给撞后端限额的实验单独降速。
203
+ * 或给撞 provider 限额的实验单独降速。
200
204
  */
201
205
  maxConcurrency?: number;
202
206
  }
@@ -212,7 +216,7 @@ export interface Config {
212
216
  * 可传字符串,或按 locale 提供多语言(如 `{ en: "...", "zh-CN": "..." }`),随 view 语言切换。
213
217
  */
214
218
  name?: LocalizedText;
215
- /** 项目级默认沙箱后端(docker / vercel / e2b / custom);experiment / CLI flag 可覆盖。 */
219
+ /** 项目级默认沙箱 provider(docker / vercel / e2b / custom);experiment 可覆盖。 */
216
220
  sandbox?: SandboxOption;
217
221
  /** 上传进沙箱的工作区根目录,省略则用项目根;eval 的 sandbox 视图从这里起步。 */
218
222
  workspace?: string;
@@ -262,9 +266,9 @@ export interface PriceOverride {
262
266
  * 能区分同 agent 同 model 的实验变体,如 xxx 与 xxx--agents-md;与汇总表口径一致);
263
267
  * 无 experiment 时退回 agent/model。live display 以它作行聚合 key,两处必须同源。
264
268
  */
265
- export function runWho(run: Pick<AgentRun, "agent" | "model" | "experimentId">): string {
269
+ export function runWho(run: { agentName: string; model?: string; experimentId?: string }): string {
266
270
  if (run.experimentId) return run.experimentId.split("/").pop()!;
267
- return run.model ? `${run.agent.name}/${run.model}` : run.agent.name;
271
+ return run.model ? `${run.agentName}/${run.model}` : run.agentName;
268
272
  }
269
273
 
270
274
  /** 一个 (agent, model, flags) 的运行配置 —— 由 CLI / 实验展开。 */
@@ -295,8 +299,15 @@ export interface RunOptions {
295
299
  signal?: AbortSignal;
296
300
  /** TTY live display 的进度回调;设置后 attempt 的 log 消息路由到它而不是 stderr。 */
297
301
  onProgress?: (evalId: string, who: string, msg: string) => void;
298
- /** 上次运行的结果。outcome === "passed" 的 (experimentId, evalId) 组合跳过重跑,结果直接合入本次汇总。 */
302
+ /** 上次运行的结果。verdict passed/failed 的 (experimentId, evalId) 组合跳过重跑,结果直接合入本次汇总。 */
299
303
  priorResults?: EvalResult[];
304
+ /**
305
+ * 预算好的携入计划(见 fingerprint.ts 的 planCarry)。cli.ts 为了让 live 表格提前知道
306
+ * 哪些行会被携入,必须在构建 liveRows 之前算一次;传进来后 runEvals 直接复用,不重算
307
+ * 一遍(否则两处各自算一次,不仅重复 I/O,还留下"两边判断可能不一致"的隐患)。
308
+ * 省略时 runEvals 自己算(测试直调等场景)。
309
+ */
310
+ carryPlan?: import("./fingerprint.ts").CarryPlan;
300
311
  /**
301
312
  * 非沙箱 tracing agent 的 run 级共享 OTLP 接收池(runEvals 创建并回收;
302
313
  * 每个 agent 一个 receiver,attempt 之间共享 —— 被测应用是长驻进程,端点不能随 attempt 换)。
@@ -309,7 +320,7 @@ export interface Attempt {
309
320
  evalDef: DiscoveredEval;
310
321
  run: AgentRun;
311
322
  attempt: number;
312
- /** agent+model+evalId,用于早停。 */
323
+ /** agent+model+evalId,用于首过即停。 */
313
324
  key: string;
314
325
  fingerprint: string;
315
326
  }
@@ -8,7 +8,7 @@
8
8
  // restore: uploadFile → /tmp/__fe_rs_<uuid>.tar.gz → tar xzf -C /
9
9
  //
10
10
  // tar / binary file I/O 在所有 Linux sandbox(Docker、Vercel、e2b、Modal…)里都支持,
11
- // 这段代码对任意 backend 的 Sandbox 实现无改动即可使用。
11
+ // 这段代码对任意 provider 的 Sandbox 实现无改动即可使用。
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
14
  import type { Sandbox } from "../types.ts";
@@ -1,4 +1,4 @@
1
- // Docker 沙箱后端:用 dockerode 把容器当隔离工作区跑 eval。
1
+ // Docker 沙箱 provider:用 dockerode 把容器当隔离工作区跑 eval。
2
2
  // 改编自 agent-eval 的 docker-sandbox.ts,签名对齐 ../types.ts 的 Sandbox 契约
3
3
  //(runShell/runCommand 的 opts 一律是选项对象,不再用位置参数)。
4
4
 
@@ -24,6 +24,17 @@ import { buildFindScript, shellQuote } from "./shell.ts";
24
24
  import { createExecDemuxer, extractFileFromTar, packFilesToTar, readableToBuffer } from "./docker-stream.ts";
25
25
  import { resolveSandboxPath } from "./paths.ts";
26
26
  import { t } from "../i18n/index.ts";
27
+ import { beforeExternalTerminalWrite } from "../tty-line.ts";
28
+ import type { SandboxProvisionErrorKind } from "./errors.ts";
29
+
30
+ /**
31
+ * dockerode 对镜像拉取限流没有专门的错误类型;Docker Hub 429 体现在错误 message 里
32
+ * (如 "toomanyrequests: You have reached your pull rate limit")。
33
+ */
34
+ export function classifyProvisionError(e: unknown): SandboxProvisionErrorKind {
35
+ const msg = e instanceof Error ? e.message : String(e);
36
+ return /toomanyrequests|rate limit exceeded|429/i.test(msg) ? "rate_limit" : "unknown";
37
+ }
27
38
 
28
39
  // 行首哨兵:源码文件几乎不可能出现这一串,用来切分单次 shell 输出里的多份文件。
29
40
  const SOURCE_FILE_MARKER = "::FE-SRC-7b3f9c::";
@@ -167,8 +178,10 @@ export class DockerSandbox implements Sandbox {
167
178
  await image.inspect();
168
179
  } catch {
169
180
  // 镜像不存在,拉取。
181
+ beforeExternalTerminalWrite();
170
182
  console.log(t("docker.imagePullStart", { image: imageName }));
171
183
  await this.pullImage(imageName);
184
+ beforeExternalTerminalWrite();
172
185
  console.log(t("docker.imagePullDone", { image: imageName }));
173
186
  }
174
187
  }
@@ -240,7 +253,7 @@ export class DockerSandbox implements Sandbox {
240
253
  });
241
254
  }
242
255
 
243
- /** 以 root 跑命令(后端内部用:容器初始化、属主收敛)。 */
256
+ /** 以 root 跑命令(provider 内部用:容器初始化、属主收敛)。 */
244
257
  private async runCommandAsRoot(
245
258
  cmd: string,
246
259
  args: string[] = [],
@@ -1,11 +1,11 @@
1
- // E2B 沙箱后端:用 e2b SDK 把 E2B microVM 当隔离工作区跑 eval。
1
+ // E2B 沙箱 provider:用 e2b SDK 把 E2B microVM 当隔离工作区跑 eval。
2
2
  // 契约对齐 ../types.ts 的 Sandbox 接口,与 DockerSandbox / VercelSandbox 可互换。
3
3
  //
4
4
  // 鉴权:E2B_API_KEY(team 级,e2b CLI `e2b auth login` 后也写在 ~/.e2b)。
5
5
  // 模板:opts.template 选 e2b 模板名/ID;省略用 e2b 默认 "base"。预制模板(烘焙好
6
6
  // codex/claude-code/bub 的 "niceeval-agents")见 sandbox/e2b/。
7
7
 
8
- import { Sandbox as E2BSdkSandbox, CommandExitError } from "e2b";
8
+ import { Sandbox as E2BSdkSandbox, CommandExitError, RateLimitError } from "e2b";
9
9
  import type {
10
10
  Sandbox,
11
11
  CommandResult,
@@ -14,6 +14,7 @@ import type {
14
14
  SourceFiles,
15
15
  ReadSourceFilesOptions,
16
16
  } from "../types.ts";
17
+ import type { SandboxProvisionErrorKind } from "./errors.ts";
17
18
  import { readSourceFilesByList } from "./source-files.ts";
18
19
  import { collectLocalFiles } from "./local-files.ts";
19
20
  import { shellQuote } from "./shell.ts";
@@ -27,6 +28,11 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
27
28
  // 沙箱存活上限(到点 e2b 自动回收)。给足空间跑完 setup + agent + 测试脚本。
28
29
  const SESSION_TIMEOUT_MS = 1_800_000;
29
30
 
31
+ /** e2b 的限流错误是 SDK 原生的 RateLimitError(HTTP 429 映射而来);见 resolve.ts 的 withProvisionRetry。 */
32
+ export function classifyProvisionError(e: unknown): SandboxProvisionErrorKind {
33
+ return e instanceof RateLimitError ? "rate_limit" : "unknown";
34
+ }
35
+
30
36
  export class E2BSandbox implements Sandbox {
31
37
  readonly workdir = E2B_WORKDIR;
32
38
  readonly otlpHost = null;
@@ -63,7 +69,7 @@ export class E2BSandbox implements Sandbox {
63
69
 
64
70
  async runShell(script: string, opts: CommandOptions = {}): Promise<CommandResult> {
65
71
  // e2b commands.run 经 bash 执行 → 支持 && / 管道 / $()。root 用户映射到 { user: "root" },
66
- // 否则用模板默认(非 root)用户 —— 跨后端语义一致(见 types.ts 的 CommandOptions.root)。
72
+ // 否则用模板默认(非 root)用户 —— 跨 provider 语义一致(见 types.ts 的 CommandOptions.root)。
67
73
  try {
68
74
  const res = await this.sbx.commands.run(script, {
69
75
  cwd: resolveSandboxPath(this.workdir, opts.cwd),
@@ -100,7 +106,7 @@ export class E2BSandbox implements Sandbox {
100
106
  }
101
107
 
102
108
  async readSourceFiles(opts: ReadSourceFilesOptions = {}): Promise<SourceFiles> {
103
- // find 列路径 + 逐文件 files.read —— 与 vercel 后端共用同一两阶段模板。
109
+ // find 列路径 + 逐文件 files.read —— 与 vercel provider 共用同一两阶段模板。
104
110
  return readSourceFilesByList({
105
111
  options: opts,
106
112
  runShell: (script) => this.runShell(script),
@@ -109,7 +115,7 @@ export class E2BSandbox implements Sandbox {
109
115
  }
110
116
 
111
117
  // targetDir 已由 paths.ts 的 normalizeSandboxPaths 解析成绝对路径;这里再解析一次
112
- // 只是对直接使用后端实例(未包 normalize)的幂等防御,提到 map 外只算一次。
118
+ // 只是对直接使用 provider 实例(未包 normalize)的幂等防御,提到 map 外只算一次。
113
119
  async writeFiles(files: Record<string, string>, targetDir?: string): Promise<void> {
114
120
  const base = resolveSandboxPath(this.workdir, targetDir);
115
121
  const entries = Object.entries(files).map(([p, data]) => ({ path: resolveSandboxPath(base, p), data }));
@@ -0,0 +1,13 @@
1
+ // 沙箱 provisioning 错误的中性分类:各 provider SDK 的限流错误形状互不相同(e2b 抛
2
+ // RateLimitError,vercel 抛 APIError{ response.status: 429 },docker 是 dockerode 的
3
+ // 普通 Error,message 里带 "toomanyrequests")。resolve.ts 的 createProvider() 据此统一
4
+ // 做退避重试,不需要认识任何 provider 专属的错误类型——分类逻辑留在各 provider 自己的
5
+ // 文件里(见 e2b.ts / vercel.ts / docker.ts 的 classifyProvisionError)。
6
+
7
+ /** 目前只区分"限流,值得退避重试"和"其它,原样抛出"。 */
8
+ export type SandboxProvisionErrorKind = "rate_limit" | "unknown";
9
+
10
+ /** 按 kind 判断是否该重试;模板不存在、凭据缺失等归入 unknown,第一次就抛,重试没有意义。 */
11
+ export function isRetryableProvisionError(kind: SandboxProvisionErrorKind): boolean {
12
+ return kind === "rate_limit";
13
+ }
@@ -1,6 +1,6 @@
1
1
  // niceeval/sandbox 公开导出:「在哪里跑」相关的类型 + 工厂 + 扩展点。
2
- // 具体后端实现类(DockerSandbox / VercelSandbox / E2BSandbox)是内部实现细节,不在此导出——
3
- // 需要自定义后端时用 defineSandbox(),不需要绕开 resolve.ts 直接 new 内置类。
2
+ // 具体 provider 实现类(DockerSandbox / VercelSandbox / E2BSandbox)是内部实现细节,不在此导出——
3
+ // 需要自定义 provider 时用 defineSandbox(),不需要绕开 resolve.ts 直接 new 内置类。
4
4
 
5
5
  export { dockerSandbox, vercelSandbox, e2bSandbox, defineSandbox } from "../define.ts";
6
6
  export { createCheckpoint, restoreCheckpoint } from "./checkpoint.ts";
@@ -12,7 +12,7 @@ export type {
12
12
  SourceFile,
13
13
  SourceFiles,
14
14
  ReadSourceFilesOptions,
15
- SandboxBackend,
15
+ SandboxProvider,
16
16
  SandboxOption,
17
17
  SandboxSpec,
18
18
  SandboxRuntime,
@@ -1,5 +1,5 @@
1
1
  // 本地目录文件采集(uploadDirectory 用)与 readSourceFiles 的默认过滤常量,
2
- // docker / vercel / e2b 三后端共用一份。
2
+ // docker / vercel / e2b 三个 provider 共用一份。
3
3
 
4
4
  import { readdir, readFile, stat } from "node:fs/promises";
5
5
  import { join, relative, sep } from "node:path";
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 中断(Ctrl+C)时,正常路径靠 Effect 的 Scope finalizer 跑 sb.stop() 停容器。但 finalizer
4
4
  // 这条路不是 100% 可靠:vsb.stop() 这类远端调用可能慢/挂,用户等不及再按一次 Ctrl+C,进程
5
- // 就被硬退了 —— 沙箱成了孤儿(只能等后端 session/TTL 过期)。
5
+ // 就被硬退了 —— 沙箱成了孤儿(只能等 provider session/TTL 过期)。
6
6
  //
7
7
  // 这里维护一份独立于 Effect 的登记表,让 cli 在「二次中断 / graceful 清理超时 / 正常返回后」
8
8
  // 都能直接、带超时地强停所有还活着的沙箱。stop 不再静默吞异常(原 `.catch(() => {})`),失败打到
@@ -10,11 +10,12 @@
10
10
 
11
11
  import type { Sandbox } from "../types.ts";
12
12
  import { t } from "../i18n/index.ts";
13
+ import { writeStderrLine } from "../tty-line.ts";
13
14
 
14
15
  const live = new Set<Sandbox>();
15
16
 
16
17
  // 单个 stop 的默认超时:vsb.stop() 偶发慢/挂,清理不能无限等。到点就放弃、记一笔,
17
- // 让流程继续走到退出 —— 没停掉的沙箱靠后端 session/TTL 兜底过期。
18
+ // 让流程继续走到退出 —— 没停掉的沙箱靠 provider session/TTL 兜底过期。
18
19
  const DEFAULT_STOP_TIMEOUT_MS = 8_000;
19
20
 
20
21
  export function registerSandbox(sb: Sandbox): void {
@@ -26,7 +27,7 @@ export function liveSandboxCount(): number {
26
27
  }
27
28
 
28
29
  /**
29
- * 带超时地停单个沙箱。成功 / 失败 / 超时都从登记表移除(失败的靠后端过期兜底),
30
+ * 带超时地停单个沙箱。成功 / 失败 / 超时都从登记表移除(失败的靠 provider 过期兜底),
30
31
  * 异常打到 stderr 不再静默吞。供 Scope finalizer 与兜底强清共用,避免重复实现 stop 语义。
31
32
  */
32
33
  export async function stopSandbox(sb: Sandbox, timeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<void> {
@@ -40,7 +41,7 @@ export async function stopSandbox(sb: Sandbox, timeoutMs = DEFAULT_STOP_TIMEOUT_
40
41
  ]);
41
42
  } catch (e) {
42
43
  const msg = e instanceof Error ? e.message : String(e);
43
- process.stderr.write(t("sandbox.stopFailed", { id: sb.sandboxId, message: msg }));
44
+ writeStderrLine(t("sandbox.stopFailed", { id: sb.sandboxId, message: msg }));
44
45
  } finally {
45
46
  if (timer) clearTimeout(timer);
46
47
  live.delete(sb);
@@ -54,7 +55,7 @@ export async function stopSandbox(sb: Sandbox, timeoutMs = DEFAULT_STOP_TIMEOUT_
54
55
  export async function stopAllSandboxes(timeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<number> {
55
56
  const all = [...live];
56
57
  if (all.length === 0) return 0;
57
- process.stderr.write(t("sandbox.forceCleanup", { count: all.length }));
58
+ writeStderrLine(t("sandbox.forceCleanup", { count: all.length }));
58
59
  await Promise.allSettled(all.map((sb) => stopSandbox(sb, timeoutMs)));
59
60
  return all.length;
60
61
  }
@@ -1,18 +1,19 @@
1
- // 沙箱后端解析:把 config.sandbox / experiment.sandbox(工厂函数产出的 spec 数据结构)
2
- // 折叠成一个具体后端 + 参数,并按需创建实例。`sandbox` 没有默认值也没有按名字选的入口——
1
+ // 沙箱 provider 解析:把 config.sandbox / experiment.sandbox(工厂函数产出的 spec 数据结构)
2
+ // 折叠成一个具体 provider + 参数,并按需创建实例。`sandbox` 没有默认值也没有按名字选的入口——
3
3
  // 沙箱型 agent 必须显式给 dockerSandbox() / vercelSandbox() / e2bSandbox() / defineSandbox(),
4
4
  // 省略时 resolveSandbox() 直接抛错,不猜环境、不兜底。
5
- // 后端名的行为分支只允许出现在 sandbox/ 内(见 docs/architecture.md)。
5
+ // provider 名的行为分支只允许出现在 sandbox/ 内(见 docs/architecture.md)。
6
6
 
7
7
  import { Effect } from "effect";
8
8
  import type { CustomSandboxSpec, Sandbox, SandboxOption, SandboxRuntime } from "../types.ts";
9
9
  import { registerSandbox, stopSandbox } from "./registry.ts";
10
10
  import { normalizeSandboxPaths } from "./paths.ts";
11
11
  import { t } from "../i18n/index.ts";
12
+ import { withProvisionRetry, type ProvisionSlot } from "./retry.ts";
12
13
 
13
- /** 归一化后的沙箱描述:确定的后端 + 各后端参数(只有对应后端用得上的会有值)。 */
14
+ /** 归一化后的沙箱描述:确定的 provider + 各 provider 参数(只有对应 provider 用得上的会有值)。 */
14
15
  export interface ResolvedSandbox {
15
- backend: string;
16
+ provider: string;
16
17
  runtime?: SandboxRuntime;
17
18
  /** docker */
18
19
  image?: string;
@@ -20,19 +21,19 @@ export interface ResolvedSandbox {
20
21
  snapshotId?: string;
21
22
  /** e2b */
22
23
  template?: string;
23
- /** 自定义后端(defineSandbox):有它就直接调用,跳过下面的内置 backend switch。 */
24
+ /** 自定义 provider(defineSandbox):有它就直接调用,跳过下面的内置 provider switch。 */
24
25
  create?: CustomSandboxSpec["create"];
25
26
  recommendedConcurrency?: number;
26
27
  }
27
28
 
28
- /** 把 spec 数据结构归一化成 ResolvedSandbox;省略(undefined)直接报错——没有默认后端。 */
29
+ /** 把 spec 数据结构归一化成 ResolvedSandbox;省略(undefined)直接报错——没有默认 provider。 */
29
30
  export function resolveSandbox(opt: SandboxOption | undefined, runtimeDefault?: SandboxRuntime): ResolvedSandbox {
30
31
  if (!opt) throw new Error(t("sandbox.missingSpec"));
31
32
  return { ...opt, runtime: opt.runtime ?? runtimeDefault };
32
33
  }
33
34
 
34
35
  /**
35
- * 各后端的推荐默认并发数。反映的是后端侧约束(daemon 容量、API quota、session 池大小),
36
+ * 各 provider 的推荐默认并发数。反映的是 provider 侧约束(daemon 容量、API quota、session 池大小),
36
37
  * 不是用户侧的 agent API 限速——后者由用户通过 --max-concurrency 或 config.maxConcurrency 设置。
37
38
  * docker:本地 daemon 创建容器有开销,10 是经验上稳健的上限。
38
39
  * e2b:云服务,20 是默认账户并发配额的保守估计。
@@ -41,7 +42,7 @@ export function resolveSandbox(opt: SandboxOption | undefined, runtimeDefault?:
41
42
  export function sandboxRecommendedConcurrency(opt: SandboxOption | undefined): number {
42
43
  if (!opt) return 10;
43
44
  const r = resolveSandbox(opt);
44
- switch (r.backend) {
45
+ switch (r.provider) {
45
46
  case "docker": return 10;
46
47
  case "e2b": return 20;
47
48
  case "vercel": return 1;
@@ -49,28 +50,30 @@ export function sandboxRecommendedConcurrency(opt: SandboxOption | undefined): n
49
50
  }
50
51
  }
51
52
 
52
- /** 报告 / 日志用的简短标签:后端名,带上区分性的参数(镜像 / 快照 / 模板)。 */
53
+ /** 报告 / 日志用的简短标签:provider 名,带上区分性的参数(镜像 / 快照 / 模板)。 */
53
54
  export function sandboxLabel(opt: SandboxOption | undefined): string {
54
55
  const r = resolveSandbox(opt);
55
56
  const detail = r.image ?? r.snapshotId ?? r.template;
56
- return detail ? `${r.backend}:${detail}` : r.backend;
57
+ return detail ? `${r.provider}:${detail}` : r.provider;
57
58
  }
58
59
 
59
60
  /**
60
- * 按解析出的后端 + 参数创建沙箱,并把 stop() 注册为 Scope 回收动作。
61
+ * 按解析出的 provider + 参数创建沙箱,并把 stop() 注册为 Scope 回收动作。
61
62
  * 在 Effect.scoped / Effect.gen 里 yield* 即可;成功/失败/中断都保证 stop。
62
63
  */
63
64
  export function createSandbox(opts: {
64
65
  sandbox?: SandboxOption;
65
66
  timeout?: number;
66
67
  runtime?: SandboxRuntime;
68
+ /** 调用方并发槽位的临时归还/收回,传给 withProvisionRetry 在退避睡眠期间释放(见 retry.ts)。 */
69
+ provisionSlot?: ProvisionSlot;
67
70
  }) {
68
71
  const r = resolveSandbox(opts.sandbox, opts.runtime);
69
72
  return Effect.acquireRelease(
70
73
  Effect.promise<Sandbox>(async () => {
71
74
  // 起好就登记:让 cli 的兜底强清(二次 Ctrl+C / 看门狗超时)能直接停到它,不只靠下面的
72
75
  // release。即便本 fiber 创建后立刻被中断、release 还没来得及跑,登记表也已认得这个沙箱。
73
- const sb = normalizeSandboxPaths(await createBackend(r, opts.timeout));
76
+ const sb = normalizeSandboxPaths(await createProvider(r, opts.timeout, opts.provisionSlot));
74
77
  registerSandbox(sb);
75
78
  return sb;
76
79
  }),
@@ -79,29 +82,41 @@ export function createSandbox(opts: {
79
82
  );
80
83
  }
81
84
 
82
- async function createBackend(r: ResolvedSandbox, timeout?: number): Promise<Sandbox> {
83
- // 自定义后端(defineSandbox):不认后端名,直接调用用户给的 create()。
85
+ async function createProvider(r: ResolvedSandbox, timeout?: number, provisionSlot?: ProvisionSlot): Promise<Sandbox> {
86
+ // 自定义 provider(defineSandbox):不认 provider 名,直接调用用户给的 create()。
84
87
  if (r.create) return r.create({ timeout, runtime: r.runtime });
85
- switch (r.backend) {
88
+ switch (r.provider) {
86
89
  case "docker": {
87
- const { DockerSandbox } = await import("./docker.ts").catch(() => {
90
+ const { DockerSandbox, classifyProvisionError } = await import("./docker.ts").catch(() => {
88
91
  throw new Error(t("sandbox.dependencyMissing.docker"));
89
92
  });
90
- return DockerSandbox.create({ timeout, runtime: r.runtime, image: r.image });
93
+ return withProvisionRetry(
94
+ () => DockerSandbox.create({ timeout, runtime: r.runtime, image: r.image }),
95
+ classifyProvisionError,
96
+ provisionSlot,
97
+ );
91
98
  }
92
99
  case "vercel": {
93
- const { VercelSandbox } = await import("./vercel.ts").catch(() => {
100
+ const { VercelSandbox, classifyProvisionError } = await import("./vercel.ts").catch(() => {
94
101
  throw new Error(t("sandbox.dependencyMissing.vercel"));
95
102
  });
96
- return VercelSandbox.create({ timeout, runtime: r.runtime, snapshotId: r.snapshotId });
103
+ return withProvisionRetry(
104
+ () => VercelSandbox.create({ timeout, runtime: r.runtime, snapshotId: r.snapshotId }),
105
+ classifyProvisionError,
106
+ provisionSlot,
107
+ );
97
108
  }
98
109
  case "e2b": {
99
- const { E2BSandbox } = await import("./e2b.ts").catch(() => {
110
+ const { E2BSandbox, classifyProvisionError } = await import("./e2b.ts").catch(() => {
100
111
  throw new Error(t("sandbox.dependencyMissing.e2b"));
101
112
  });
102
- return E2BSandbox.create({ timeout, runtime: r.runtime, template: r.template });
113
+ return withProvisionRetry(
114
+ () => E2BSandbox.create({ timeout, runtime: r.runtime, template: r.template }),
115
+ classifyProvisionError,
116
+ provisionSlot,
117
+ );
103
118
  }
104
119
  default:
105
- throw new Error(t("sandbox.backendNotImplemented", { backend: r.backend }));
120
+ throw new Error(t("sandbox.providerNotImplemented", { provider: r.provider }));
106
121
  }
107
122
  }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { withProvisionRetry, type ProvisionSlot } from "./retry.ts";
3
+
4
+ function fakeSlot() {
5
+ const calls: string[] = [];
6
+ const slot: ProvisionSlot = {
7
+ release: async () => {
8
+ calls.push("release");
9
+ },
10
+ reacquire: async () => {
11
+ calls.push("reacquire");
12
+ },
13
+ };
14
+ return { slot, calls };
15
+ }
16
+
17
+ describe("withProvisionRetry", () => {
18
+ it("succeeds without touching the slot when create() succeeds first try", async () => {
19
+ const { slot, calls } = fakeSlot();
20
+ const result = await withProvisionRetry(
21
+ async () => "sandbox",
22
+ () => "unknown",
23
+ slot,
24
+ );
25
+ expect(result).toBe("sandbox");
26
+ expect(calls).toEqual([]);
27
+ });
28
+
29
+ it("throws immediately on a non-retryable error without touching the slot", async () => {
30
+ const { slot, calls } = fakeSlot();
31
+ const err = new Error("bad template");
32
+ await expect(
33
+ withProvisionRetry(
34
+ async () => {
35
+ throw err;
36
+ },
37
+ () => "unknown",
38
+ slot,
39
+ ),
40
+ ).rejects.toBe(err);
41
+ expect(calls).toEqual([]);
42
+ });
43
+
44
+ it("releases the slot before backing off and reacquires it before retrying", async () => {
45
+ vi.useFakeTimers();
46
+ try {
47
+ const { slot, calls } = fakeSlot();
48
+ let attempts = 0;
49
+ const promise = withProvisionRetry(
50
+ async () => {
51
+ attempts += 1;
52
+ if (attempts === 1) throw new Error("rate limited");
53
+ return "sandbox";
54
+ },
55
+ () => "rate_limit",
56
+ slot,
57
+ );
58
+ // 第一次失败后应该先 release,再进入退避睡眠 —— 此时还没到 reacquire。
59
+ await vi.waitFor(() => expect(calls).toEqual(["release"]));
60
+ await vi.runAllTimersAsync();
61
+ await expect(promise).resolves.toBe("sandbox");
62
+ expect(calls).toEqual(["release", "reacquire"]);
63
+ expect(attempts).toBe(2);
64
+ } finally {
65
+ vi.useRealTimers();
66
+ }
67
+ });
68
+ });
@@ -0,0 +1,44 @@
1
+ // provider 无关的 provisioning 退避重试:createProvider()(resolve.ts)对每个内置
2
+ // provider 的 create() 套这一层。只有各 provider 自己的 classifyProvisionError 判为
3
+ // 可重试(目前仅 rate_limit)的错误才会退避重试;其它错误第一次就抛出。
4
+
5
+ import { isRetryableProvisionError, type SandboxProvisionErrorKind } from "./errors.ts";
6
+
7
+ const MAX_ATTEMPTS = 4;
8
+ const BASE_DELAY_MS = 1000;
9
+
10
+ /** 指数退避 + 全抖动(0.5x~1.5x),避免同一批被限流的 create() 同时醒来再次撞限流。 */
11
+ function delayFor(attempt: number): number {
12
+ return BASE_DELAY_MS * 2 ** attempt * (0.5 + Math.random());
13
+ }
14
+
15
+ /**
16
+ * 调用方(resolve.ts)持有的并发槽位的临时归还/收回。不认调用方用的是不是 Effect —— 只要求
17
+ * 两个 async 方法,保持这层 provider 无关。
18
+ */
19
+ export interface ProvisionSlot {
20
+ release(): Promise<void>;
21
+ reacquire(): Promise<void>;
22
+ }
23
+
24
+ export async function withProvisionRetry<T>(
25
+ create: () => Promise<T>,
26
+ classify: (e: unknown) => SandboxProvisionErrorKind,
27
+ slot?: ProvisionSlot,
28
+ ): Promise<T> {
29
+ for (let attempt = 0; ; attempt++) {
30
+ try {
31
+ return await create();
32
+ } catch (e) {
33
+ if (!isRetryableProvisionError(classify(e)) || attempt >= MAX_ATTEMPTS - 1) throw e;
34
+ // 退避期间只是在睡觉,不是在真的创建沙箱:攥着并发槽位陪跑 setTimeout 会让被限流的
35
+ // provider 把整批并发名额拖垮成"看起来卡在个位数并发"——先还名额,睡醒了再排队要回来。
36
+ if (slot) await slot.release();
37
+ try {
38
+ await new Promise((resolve) => setTimeout(resolve, delayFor(attempt)));
39
+ } finally {
40
+ if (slot) await slot.reacquire();
41
+ }
42
+ }
43
+ }
44
+ }