niceeval 0.6.1 → 0.6.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 (296) hide show
  1. package/dist/agents/types.d.ts +67 -5
  2. package/dist/context/types.d.ts +32 -12
  3. package/dist/i18n/en.d.ts +54 -0
  4. package/dist/i18n/zh-CN.d.ts +55 -1
  5. package/dist/o11y/types.d.ts +16 -2
  6. package/dist/report/aggregate.d.ts +5 -3
  7. package/dist/report/aggregate.js +32 -5
  8. package/dist/report/built-ins/experiment-comparison.d.ts +39 -1
  9. package/dist/report/built-ins/experiment-comparison.js +116 -10
  10. package/dist/report/built-ins/index.d.ts +1 -0
  11. package/dist/report/built-ins/index.js +1 -1
  12. package/dist/report/components.d.ts +8 -2
  13. package/dist/report/components.js +3 -3
  14. package/dist/report/compute.d.ts +11 -18
  15. package/dist/report/compute.js +54 -34
  16. package/dist/report/flag.d.ts +16 -1
  17. package/dist/report/flag.js +19 -1
  18. package/dist/report/format.d.ts +16 -8
  19. package/dist/report/format.js +27 -12
  20. package/dist/report/index.d.ts +4 -3
  21. package/dist/report/index.js +5 -4
  22. package/dist/report/locale.d.ts +11 -2
  23. package/dist/report/locale.js +23 -5
  24. package/dist/report/metrics.d.ts +13 -1
  25. package/dist/report/metrics.js +65 -14
  26. package/dist/report/primitives.d.ts +6 -0
  27. package/dist/report/react/AttemptList.d.ts +2 -2
  28. package/dist/report/react/AttemptList.js +5 -6
  29. package/dist/report/react/EvalList.d.ts +1 -1
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +8 -0
  32. package/dist/report/react/ExperimentComparison.js +11 -0
  33. package/dist/report/react/ExperimentList.d.ts +2 -1
  34. package/dist/report/react/ExperimentList.js +8 -10
  35. package/dist/report/react/MetricScatter.js +5 -11
  36. package/dist/report/react/chart-math.d.ts +23 -6
  37. package/dist/report/react/chart-math.js +71 -19
  38. package/dist/report/react/fixtures.d.ts +3 -3
  39. package/dist/report/react/fixtures.js +21 -14
  40. package/dist/report/report.d.ts +5 -1
  41. package/dist/report/report.js +6 -2
  42. package/dist/report/text/faces.d.ts +1 -1
  43. package/dist/report/text/faces.js +42 -41
  44. package/dist/report/text/table.js +36 -5
  45. package/dist/report/types.d.ts +39 -21
  46. package/dist/results/types.d.ts +11 -0
  47. package/dist/runner/feedback/sink.d.ts +110 -0
  48. package/dist/runner/types.d.ts +513 -22
  49. package/dist/sandbox/docker.d.ts +23 -2
  50. package/dist/sandbox/e2b.d.ts +15 -1
  51. package/dist/sandbox/errors.d.ts +30 -3
  52. package/dist/sandbox/io-retry.d.ts +17 -0
  53. package/dist/sandbox/registry.d.ts +2 -0
  54. package/dist/sandbox/resolve.d.ts +18 -5
  55. package/dist/sandbox/retry.d.ts +11 -1
  56. package/dist/sandbox/types.d.ts +39 -5
  57. package/dist/sandbox/vercel.d.ts +7 -1
  58. package/dist/scoring/coverage.d.ts +30 -0
  59. package/dist/scoring/display.d.ts +21 -0
  60. package/dist/scoring/display.js +120 -0
  61. package/dist/scoring/types.d.ts +103 -20
  62. package/dist/shared/aggregate.d.ts +1 -0
  63. package/dist/shared/aggregate.js +3 -3
  64. package/dist/shared/types.d.ts +28 -0
  65. package/dist/tty-line.d.ts +0 -4
  66. package/dist/util.d.ts +23 -0
  67. package/docs-site/zh/concepts/adapter.mdx +22 -4
  68. package/docs-site/zh/concepts/experiment.mdx +1 -1
  69. package/docs-site/zh/concepts/overview.mdx +6 -6
  70. package/docs-site/zh/guides/agent-feedback-loop.mdx +28 -26
  71. package/docs-site/zh/guides/authoring.mdx +33 -0
  72. package/docs-site/zh/guides/ci-integration.mdx +23 -12
  73. package/docs-site/zh/guides/connect-your-agent.mdx +29 -3
  74. package/docs-site/zh/guides/custom-reports.mdx +29 -34
  75. package/docs-site/zh/guides/dataset-fanout.mdx +25 -3
  76. package/docs-site/zh/guides/debug-sandbox.mdx +57 -0
  77. package/docs-site/zh/guides/debugging.mdx +210 -0
  78. package/docs-site/zh/guides/experiments.mdx +10 -3
  79. package/docs-site/zh/guides/official-adapters.mdx +26 -2
  80. package/docs-site/zh/guides/publish-report.mdx +30 -16
  81. package/docs-site/zh/guides/report-components.mdx +42 -30
  82. package/docs-site/zh/guides/reporters.mdx +2 -2
  83. package/docs-site/zh/guides/results-data.mdx +17 -9
  84. package/docs-site/zh/guides/runner.mdx +17 -7
  85. package/docs-site/zh/guides/sandbox-agent.mdx +56 -7
  86. package/docs-site/zh/guides/sandbox-providers.mdx +257 -9
  87. package/docs-site/zh/guides/scoring-guide.mdx +4 -4
  88. package/docs-site/zh/guides/viewing-results.mdx +79 -36
  89. package/docs-site/zh/guides/write-experiment.mdx +5 -3
  90. package/docs-site/zh/guides/write-send.mdx +17 -1
  91. package/docs-site/zh/index.mdx +1 -1
  92. package/docs-site/zh/reference/builtin-agents.mdx +27 -0
  93. package/docs-site/zh/reference/capabilities.mdx +2 -2
  94. package/docs-site/zh/reference/cli.mdx +33 -7
  95. package/docs-site/zh/reference/define-agent.mdx +57 -4
  96. package/docs-site/zh/reference/define-config.mdx +1 -1
  97. package/docs-site/zh/reference/define-eval.mdx +42 -9
  98. package/docs-site/zh/reference/expect.mdx +26 -1
  99. package/package.json +5 -1
  100. package/src/agents/ai-sdk-otel.test.ts +1 -0
  101. package/src/agents/ai-sdk.test.ts +3 -0
  102. package/src/agents/ai-sdk.ts +3 -0
  103. package/src/agents/bub-install-spec.test.ts +34 -0
  104. package/src/agents/bub-install-spec.ts +32 -0
  105. package/src/agents/bub.ts +31 -32
  106. package/src/agents/claude-code.test.ts +130 -9
  107. package/src/agents/claude-code.ts +76 -4
  108. package/src/agents/codex.test.ts +189 -40
  109. package/src/agents/codex.ts +155 -14
  110. package/src/agents/coding-cli-versions.test.ts +15 -0
  111. package/src/agents/coding-cli-versions.ts +3 -0
  112. package/src/agents/index.ts +11 -0
  113. package/src/agents/langgraph.test.ts +204 -0
  114. package/src/agents/langgraph.ts +495 -0
  115. package/src/agents/marketplace.ts +85 -0
  116. package/src/agents/native-config.test.ts +179 -0
  117. package/src/agents/native-config.ts +267 -0
  118. package/src/agents/openai-compat.test.ts +1 -0
  119. package/src/agents/openclaw.test.ts +31 -0
  120. package/src/agents/openclaw.ts +171 -0
  121. package/src/agents/plugin-config.test.ts +1 -0
  122. package/src/agents/sdk-streams.test.ts +79 -0
  123. package/src/agents/sdk-streams.ts +55 -10
  124. package/src/agents/skills.test.ts +1 -0
  125. package/src/agents/streaming.test.ts +3 -9
  126. package/src/agents/types.ts +68 -5
  127. package/src/agents/ui-message-stream.test.ts +3 -0
  128. package/src/cli.ts +411 -108
  129. package/src/context/context.test.ts +51 -12
  130. package/src/context/context.ts +161 -29
  131. package/src/context/session.test.ts +1 -0
  132. package/src/context/session.ts +114 -6
  133. package/src/context/types.ts +30 -12
  134. package/src/define.test.ts +13 -8
  135. package/src/define.ts +25 -4
  136. package/src/expect/index.ts +53 -23
  137. package/src/i18n/en.ts +64 -2
  138. package/src/i18n/zh-CN.ts +65 -3
  139. package/src/o11y/cost.test.ts +1 -0
  140. package/src/o11y/execution-tree.test.ts +1 -20
  141. package/src/o11y/otlp/mappers/claude-code.test.ts +1 -0
  142. package/src/o11y/otlp/parse.test.ts +1 -0
  143. package/src/o11y/otlp/turn-otel.test.ts +1 -0
  144. package/src/o11y/parsers/bub.test.ts +1 -0
  145. package/src/o11y/parsers/claude-code.test.ts +1 -34
  146. package/src/o11y/parsers/openclaw.test.ts +154 -0
  147. package/src/o11y/parsers/openclaw.ts +310 -0
  148. package/src/o11y/prices.json +746 -311
  149. package/src/o11y/tool-names.test.ts +1 -0
  150. package/src/o11y/types.ts +16 -2
  151. package/src/report/aggregate.ts +34 -5
  152. package/src/report/built-in-user-parity.test.tsx +110 -153
  153. package/src/report/built-ins/experiment-comparison.tsx +173 -13
  154. package/src/report/built-ins/index.ts +6 -1
  155. package/src/report/components.tsx +9 -3
  156. package/src/report/compute.ts +70 -40
  157. package/src/report/dual-render.test.tsx +194 -67
  158. package/src/report/flag.ts +30 -2
  159. package/src/report/format.ts +35 -11
  160. package/src/report/index.ts +22 -4
  161. package/src/report/locale.ts +25 -5
  162. package/src/report/metrics.ts +67 -14
  163. package/src/report/primitives.tsx +6 -0
  164. package/src/report/react/AttemptList.tsx +6 -31
  165. package/src/report/react/EvalList.tsx +0 -0
  166. package/src/report/react/ExperimentComparison.tsx +68 -0
  167. package/src/report/react/ExperimentList.tsx +15 -9
  168. package/src/report/react/MetricScatter.tsx +12 -14
  169. package/src/report/react/chart-math.test.ts +85 -0
  170. package/src/report/react/chart-math.ts +101 -22
  171. package/src/report/react/enhance.js +33 -1
  172. package/src/report/react/fixtures.ts +24 -17
  173. package/src/report/react/render.test.tsx +9 -64
  174. package/src/report/react/styles.css +73 -2
  175. package/src/report/report.test.ts +306 -98
  176. package/src/report/report.ts +6 -2
  177. package/src/report/text/faces.ts +47 -43
  178. package/src/report/text/table.ts +42 -5
  179. package/src/report/types.ts +41 -21
  180. package/src/results/annotated-source.test.ts +62 -9
  181. package/src/results/annotated-source.ts +64 -6
  182. package/src/results/attempt-evidence.test.ts +9 -7
  183. package/src/results/attempt-evidence.ts +15 -8
  184. package/src/results/attempt-source.ts +6 -3
  185. package/src/results/copy.ts +145 -55
  186. package/src/results/host-equivalence.test.ts +8 -6
  187. package/src/results/index.ts +2 -0
  188. package/src/results/locator.test.ts +1 -22
  189. package/src/results/open.ts +7 -1
  190. package/src/results/publish.ts +149 -0
  191. package/src/results/results.test.ts +85 -51
  192. package/src/results/truncate.ts +90 -0
  193. package/src/results/types.ts +7 -0
  194. package/src/results/writer.ts +31 -13
  195. package/src/runner/attempt.test.ts +138 -7
  196. package/src/runner/attempt.ts +603 -104
  197. package/src/runner/discover.test.ts +47 -0
  198. package/src/runner/discover.ts +36 -2
  199. package/src/runner/eval-source.test.ts +1 -27
  200. package/src/runner/feedback/agent.test.ts +504 -0
  201. package/src/runner/feedback/agent.ts +409 -0
  202. package/src/runner/feedback/ci.test.ts +562 -0
  203. package/src/runner/feedback/ci.ts +401 -0
  204. package/src/runner/feedback/coordinator.test.ts +317 -0
  205. package/src/runner/feedback/coordinator.ts +397 -0
  206. package/src/runner/feedback/failure.ts +40 -0
  207. package/src/runner/feedback/human.test.ts +616 -0
  208. package/src/runner/feedback/human.ts +535 -0
  209. package/src/runner/feedback/index.ts +66 -0
  210. package/src/runner/feedback/io.ts +78 -0
  211. package/src/runner/feedback/profile.test.ts +50 -0
  212. package/src/runner/feedback/profile.ts +58 -0
  213. package/src/runner/feedback/reducer.test.ts +395 -0
  214. package/src/runner/feedback/reducer.ts +260 -0
  215. package/src/runner/feedback/renderer.ts +82 -0
  216. package/src/runner/feedback/sink.ts +203 -0
  217. package/src/runner/feedback/testing.ts +106 -0
  218. package/src/runner/ledger.test.ts +230 -0
  219. package/src/runner/ledger.ts +329 -0
  220. package/src/runner/report.test.ts +128 -3
  221. package/src/runner/report.ts +33 -9
  222. package/src/runner/reporters/artifacts.ts +8 -2
  223. package/src/runner/reporters/braintrust.test.ts +8 -7
  224. package/src/runner/reporters/braintrust.ts +9 -2
  225. package/src/runner/reporters/index.ts +2 -2
  226. package/src/runner/reporters/json.test.ts +162 -0
  227. package/src/runner/reporters/json.ts +35 -8
  228. package/src/runner/reporters/shared.ts +1 -5
  229. package/src/runner/run.test.ts +760 -3
  230. package/src/runner/run.ts +242 -36
  231. package/src/runner/sandbox-prep.ts +3 -42
  232. package/src/runner/timing.ts +158 -0
  233. package/src/runner/types.ts +518 -22
  234. package/src/sandbox/checkpoint.test.ts +55 -0
  235. package/src/sandbox/checkpoint.ts +29 -8
  236. package/src/sandbox/cli-commands.ts +407 -0
  237. package/src/sandbox/docker.ts +115 -16
  238. package/src/sandbox/e2b-agent-template.test.ts +56 -0
  239. package/src/sandbox/e2b-agent-template.ts +94 -0
  240. package/src/sandbox/e2b.ts +74 -9
  241. package/src/sandbox/errors.ts +111 -4
  242. package/src/sandbox/index.ts +2 -0
  243. package/src/sandbox/io-retry.test.ts +58 -0
  244. package/src/sandbox/io-retry.ts +45 -0
  245. package/src/sandbox/keep-registry.test.ts +86 -0
  246. package/src/sandbox/keep-registry.ts +142 -0
  247. package/src/sandbox/keep.ts +178 -0
  248. package/src/sandbox/paths.test.ts +1 -0
  249. package/src/sandbox/paths.ts +19 -8
  250. package/src/sandbox/registry.ts +20 -3
  251. package/src/sandbox/resolve.ts +76 -11
  252. package/src/sandbox/retry.test.ts +70 -0
  253. package/src/sandbox/retry.ts +46 -4
  254. package/src/sandbox/types.ts +44 -6
  255. package/src/sandbox/vercel.ts +43 -20
  256. package/src/scoring/collector.ts +60 -17
  257. package/src/scoring/coverage.ts +95 -0
  258. package/src/scoring/diff.ts +81 -0
  259. package/src/scoring/display.test.ts +121 -0
  260. package/src/scoring/display.ts +133 -0
  261. package/src/scoring/evidence.test.ts +189 -0
  262. package/src/scoring/judge.test.ts +142 -0
  263. package/src/scoring/judge.ts +15 -18
  264. package/src/scoring/scoped.ts +217 -50
  265. package/src/scoring/types.ts +117 -20
  266. package/src/scoring/verdict.ts +16 -4
  267. package/src/shared/aggregate.ts +3 -2
  268. package/src/shared/types.ts +31 -0
  269. package/src/show/compose.ts +2 -2
  270. package/src/show/index.ts +21 -1
  271. package/src/show/render.ts +619 -104
  272. package/src/show/show.test.ts +235 -19
  273. package/src/tty-line.ts +8 -26
  274. package/src/util.test.ts +1 -0
  275. package/src/util.ts +41 -0
  276. package/src/view/app/components/AttemptModal.tsx +153 -2
  277. package/src/view/app/components/CodeView.tsx +32 -11
  278. package/src/view/app/components/CopyControls.tsx +2 -2
  279. package/src/view/app/i18n.ts +6 -0
  280. package/src/view/app/lib/attempt-route.test.ts +1 -0
  281. package/src/view/app/lib/verdict.ts +7 -9
  282. package/src/view/artifact-serving.test.ts +2 -1
  283. package/src/view/client-dist/app.css +1 -1
  284. package/src/view/client-dist/app.js +17 -17
  285. package/src/view/data.test.ts +1 -0
  286. package/src/view/data.ts +11 -1
  287. package/src/view/index.ts +11 -0
  288. package/src/view/server.ts +2 -0
  289. package/src/view/styles.css +3 -0
  290. package/src/view/view-report.test.ts +6 -5
  291. package/src/runner/reporters/console.ts +0 -70
  292. package/src/runner/reporters/live.test.ts +0 -56
  293. package/src/runner/reporters/live.ts +0 -247
  294. package/src/runner/reporters/quiet.test.ts +0 -66
  295. package/src/runner/reporters/quiet.ts +0 -49
  296. package/src/runner/reporters/table.ts +0 -277
@@ -24,8 +24,8 @@ 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";
27
+ import { reportActivity } from "../runner/feedback/sink.ts";
28
+ import { classifyProvisionErrorFallback, type SandboxProvisionErrorKind } from "./errors.ts";
29
29
 
30
30
  /**
31
31
  * dockerode 对镜像拉取限流没有专门的错误类型;Docker Hub 429 体现在错误 message 里
@@ -33,7 +33,31 @@ import type { SandboxProvisionErrorKind } from "./errors.ts";
33
33
  */
34
34
  export function classifyProvisionError(e: unknown): SandboxProvisionErrorKind {
35
35
  const msg = e instanceof Error ? e.message : String(e);
36
- return /toomanyrequests|rate limit exceeded|429/i.test(msg) ? "rate_limit" : "unknown";
36
+ // provider 原生限流形态先归拒绝类;没认出的过与文件 IO 共用的保守瞬时兜底分类器。
37
+ if (/toomanyrequests|rate limit exceeded|429/i.test(msg)) return "rate_limit";
38
+ return classifyProvisionErrorFallback(e);
39
+ }
40
+
41
+ /**
42
+ * Provisioning 重试前的对账:按 provision token 查询本地 daemon,查到的实例先销毁再重建
43
+ * (不做断线收养,重建比重连语义干净)。docker create 是对本地 daemon 的调用,歧义窗口极小,
44
+ * 这条主要兜 daemon 代理 / 远程 DOCKER_HOST 的场景。查询或销毁失败必须抛出——对账是重试的
45
+ * 硬前置,静默放行等于盲重试(见 docs/feature/sandbox/architecture.md);唯一的例外:
46
+ * 容器已不存在(404),视作对账完成。
47
+ */
48
+ export async function reconcileProvision(token: string): Promise<void> {
49
+ const docker = new Docker();
50
+ const containers = await docker.listContainers({
51
+ all: true,
52
+ filters: { label: [`niceeval.provision-token=${token}`] },
53
+ });
54
+ for (const info of containers) {
55
+ try {
56
+ await docker.getContainer(info.Id).remove({ force: true });
57
+ } catch (e) {
58
+ if ((e as { statusCode?: number }).statusCode !== 404) throw e;
59
+ }
60
+ }
37
61
  }
38
62
 
39
63
  // 行首哨兵:源码文件几乎不可能出现这一串,用来切分单次 shell 输出里的多份文件。
@@ -84,6 +108,10 @@ export interface DockerSandboxOptions {
84
108
  runtime?: "node20" | "node24";
85
109
  /** 覆盖默认镜像(默认按 runtime 选 `node:*-slim`)。预制模板:烘焙好 agent CLI 的镜像名。 */
86
110
  image?: string;
111
+ /** runner 绑定到 `sandbox.create` 的反馈句柄(镜像拉取进度走它);省略退回全局 sink。 */
112
+ feedback?: import("../types.ts").ScopedFeedback;
113
+ /** 一次性 provision token:写进容器 label,歧义类失败重试前按它对账(见 errors.ts 的两维分类)。 */
114
+ provisionToken?: string;
87
115
  }
88
116
 
89
117
  /**
@@ -99,18 +127,30 @@ export class DockerSandbox implements Sandbox {
99
127
  private timeout: number;
100
128
  private runtime: string;
101
129
  private image?: string;
130
+ private feedback?: import("../types.ts").ScopedFeedback;
131
+ private provisionToken?: string;
102
132
 
103
133
  constructor(options: DockerSandboxOptions = {}) {
104
134
  this.docker = new Docker();
105
135
  this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
106
136
  this.runtime = options.runtime ?? "node24";
107
137
  this.image = options.image;
138
+ this.feedback = options.feedback;
139
+ this.provisionToken = options.provisionToken;
108
140
  }
109
141
 
110
142
  /** 创建并启动一个 Docker 沙箱。 */
111
143
  static async create(options: DockerSandboxOptions = {}): Promise<DockerSandbox> {
112
144
  const sandbox = new DockerSandbox(options);
113
- await sandbox.initialize();
145
+ try {
146
+ await sandbox.initialize();
147
+ } catch (e) {
148
+ // kill-on-failure:容器创建之后的初始化(start、基础工具安装、工作区属主)一旦失败,
149
+ // 先尽力销毁容器再抛出原始错误——不给重试层留一台无主容器
150
+ // (见 docs/feature/sandbox/architecture.md「Provisioning 失败与重试」)。
151
+ await sandbox.container?.remove({ force: true }).catch(() => {});
152
+ throw e;
153
+ }
114
154
  return sandbox;
115
155
  }
116
156
 
@@ -140,9 +180,18 @@ export class DockerSandbox implements Sandbox {
140
180
  `touch ${CONTAINER_LOG}; chmod 666 ${CONTAINER_LOG}; exec timeout ${ttlSec} tail -n +1 -F ${CONTAINER_LOG}`,
141
181
  ],
142
182
  WorkingDir: CONTAINER_WORKDIR,
183
+ // provision token:歧义类失败的对账通道(按 label 查询本地容器);
184
+ // keep-candidate:留存候选标记(异常硬退时核对未完成提交的候选)。
185
+ Labels: {
186
+ "niceeval.keep-candidate": "true",
187
+ ...(this.provisionToken ? { "niceeval.provision-token": this.provisionToken } : {}),
188
+ },
143
189
  Tty: true,
144
190
  HostConfig: {
145
- AutoRemove: true, // 停止即清理
191
+ // 不带 AutoRemove:留存意图必须在创建期传入(--keep-sandbox suspend = docker stop,
192
+ // 停驻容器的文件系统落盘持久)。默认路径的销毁由 stop() 显式 stop + remove,行为等价;
193
+ // 宿主异常硬退留下的孤儿由 TTL dead-man switch 停驻后按 keep-candidate 标签事后核对。
194
+ AutoRemove: false,
146
195
  // 容器经 host.docker.internal 回连宿主上的 OTLP 接收器(tracing agent 用)。
147
196
  // Docker Desktop 自带这个名字;Linux 需显式映到 host-gateway,这里统一加上。
148
197
  ExtraHosts: ["host.docker.internal:host-gateway"],
@@ -177,12 +226,14 @@ export class DockerSandbox implements Sandbox {
177
226
  const image = this.docker.getImage(imageName);
178
227
  await image.inspect();
179
228
  } catch {
180
- // 镜像不存在,拉取。
181
- beforeExternalTerminalWrite();
182
- console.log(t("docker.imagePullStart", { image: imageName }));
229
+ // 镜像不存在,拉取。「progress」而非「diagnostic」—— 这是正常进度,不是需要去重/永久
230
+ // 留痕的 warning。走 create 绑定的 ScopedFeedback(runner 归因到 sandbox.create);
231
+ // 直调( runner)时退回全局 sink。
232
+ const progress = (message: string) =>
233
+ this.feedback ? this.feedback.progress({ message }) : reportActivity(message);
234
+ progress(t("docker.imagePullStart", { image: imageName }).trimEnd());
183
235
  await this.pullImage(imageName);
184
- beforeExternalTerminalWrite();
185
- console.log(t("docker.imagePullDone", { image: imageName }));
236
+ progress(t("docker.imagePullDone", { image: imageName }).trimEnd());
186
237
  }
187
238
  }
188
239
 
@@ -229,7 +280,14 @@ export class DockerSandbox implements Sandbox {
229
280
  // 实现:把 cmd+args 安全拼成 shell 串,经 runShell 走 tee(只 tee stdout,保留 stderr 分离 + 退出码)。
230
281
  if (opts.stream) {
231
282
  const joined = [cmd, ...args].map(shellQuote).join(" ");
232
- return this.runShell(joined, { env: opts.env, cwd: opts.cwd, stream: true, root: opts.root });
283
+ return this.runShell(joined, {
284
+ env: opts.env,
285
+ cwd: opts.cwd,
286
+ stream: true,
287
+ root: opts.root,
288
+ onStdout: opts.onStdout,
289
+ onStderr: opts.onStderr,
290
+ });
233
291
  }
234
292
 
235
293
  // 保证 npm 全局 bin 在 PATH 里;固定 HOME/USER,让 codex(~/.codex)、npm 全局、
@@ -250,6 +308,8 @@ export class DockerSandbox implements Sandbox {
250
308
  env,
251
309
  cwd: resolveSandboxPath(this.workdir, opts.cwd),
252
310
  user: isRoot ? ROOT_USER : SANDBOX_USER,
311
+ onStdout: opts.onStdout,
312
+ onStderr: opts.onStderr,
253
313
  });
254
314
  }
255
315
 
@@ -275,7 +335,13 @@ export class DockerSandbox implements Sandbox {
275
335
  private async execCommand(
276
336
  cmd: string,
277
337
  args: string[] = [],
278
- opts: { env?: Record<string, string>; cwd?: string; user?: string } = {},
338
+ opts: {
339
+ env?: Record<string, string>;
340
+ cwd?: string;
341
+ user?: string;
342
+ onStdout?: (chunk: string) => void | Promise<void>;
343
+ onStderr?: (chunk: string) => void | Promise<void>;
344
+ } = {},
279
345
  ): Promise<CommandResult> {
280
346
  if (!this.container) {
281
347
  throw new Error(t("docker.containerNotInitialized"));
@@ -301,8 +367,18 @@ export class DockerSandbox implements Sandbox {
301
367
  // Docker 把 stdout/stderr 复用在同一条流里(8 字节头 + 载荷),需手动 demux;
302
368
  // 跨 chunk 的帧累积逻辑见 docker-stream.ts 的 createExecDemuxer。
303
369
  const demuxer = createExecDemuxer();
304
-
305
- stream.on("data", (chunk: Buffer) => demuxer.push(chunk));
370
+ // Docker 的 demuxer 先保证帧边界完整,再把每帧即时送给调用方。回调串行化,避免
371
+ // async consumer(如 JSONL 行缓冲)因后一个 chunk 先完成而乱序。
372
+ let callbackChain = Promise.resolve();
373
+ stream.on("data", (chunk: Buffer) => {
374
+ const beforeStdout = demuxer.stdout().length;
375
+ const beforeStderr = demuxer.stderr().length;
376
+ demuxer.push(chunk);
377
+ const stdout = demuxer.stdout().slice(beforeStdout);
378
+ const stderr = demuxer.stderr().slice(beforeStderr);
379
+ if (stdout && opts.onStdout) callbackChain = callbackChain.then(() => opts.onStdout!(stdout));
380
+ if (stderr && opts.onStderr) callbackChain = callbackChain.then(() => opts.onStderr!(stderr));
381
+ });
306
382
 
307
383
  // 超时:杀流并 reject。
308
384
  const timeoutId = setTimeout(() => {
@@ -316,6 +392,7 @@ export class DockerSandbox implements Sandbox {
316
392
  const stderr = demuxer.stderr();
317
393
 
318
394
  try {
395
+ await callbackChain;
319
396
  const inspection = await exec.inspect();
320
397
  resolve({
321
398
  stdout,
@@ -339,7 +416,13 @@ export class DockerSandbox implements Sandbox {
339
416
  if (opts.stream) {
340
417
  // 只 tee stdout 到容器主日志:保留 stderr 分离(解析器要)+ pipefail 保留命令退出码。
341
418
  const wrapped = `set -o pipefail; { ${script} ; } | tee -a ${CONTAINER_LOG}`;
342
- return this.runCommand("bash", ["-c", wrapped], { env: opts.env, cwd: opts.cwd, root: opts.root });
419
+ return this.runCommand("bash", ["-c", wrapped], {
420
+ env: opts.env,
421
+ cwd: opts.cwd,
422
+ root: opts.root,
423
+ onStdout: opts.onStdout,
424
+ onStderr: opts.onStderr,
425
+ });
343
426
  }
344
427
  return this.runCommand("bash", ["-c", script], opts);
345
428
  }
@@ -460,7 +543,7 @@ export class DockerSandbox implements Sandbox {
460
543
  await (this.container as Docker.Container).putArchive(pack, { path: dirname(absPath) });
461
544
  }
462
545
 
463
- /** 停止并清理容器(AutoRemove 负责销毁)。 */
546
+ /** 销毁容器:显式 stop + remove(创建时不带 AutoRemove,见 createContainer 的注释)。 */
464
547
  async stop(): Promise<void> {
465
548
  if (this.container) {
466
549
  try {
@@ -468,7 +551,23 @@ export class DockerSandbox implements Sandbox {
468
551
  } catch {
469
552
  // 容器可能已停止或被移除,忽略。
470
553
  }
554
+ try {
555
+ await this.container.remove({ force: true });
556
+ } catch {
557
+ // 已被移除,忽略。
558
+ }
471
559
  this.container = null;
472
560
  }
473
561
  }
562
+
563
+ /**
564
+ * 留存休眠(suspend):`docker stop`——文件系统落盘持久、不占内存、跨 daemon 重启存活。
565
+ * 不用 `docker pause`(内存驻留,daemon 重启即失)也不用 `docker commit`(引入第二种资源面)。
566
+ * 不属于中性 Sandbox 接口——「留下」是 runner 的调度决定,不是沙箱的能力;由 sandbox/keep.ts
567
+ * 在 sandbox/ 域内路由到这里。
568
+ */
569
+ async suspend(): Promise<void> {
570
+ if (!this.container) throw new Error("container already released");
571
+ await this.container.stop({ t: 5 });
572
+ }
474
573
  }
@@ -0,0 +1,56 @@
1
+ // cases: docs/engineering/unit-tests/sandbox/cases.md
2
+ import { Template } from "e2b";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ BUB_INSTALL_MARKER,
6
+ DEFAULT_BUB_OTEL_PLUGIN,
7
+ DEFAULT_BUB_OVERRIDE,
8
+ } from "../agents/bub-install-spec.ts";
9
+ import {
10
+ E2B_OFFICIAL_AGENT_TEMPLATES,
11
+ NICEEVAL_BUB_E2B_TEMPLATE,
12
+ NICEEVAL_CLAUDE_CODE_E2B_TEMPLATE,
13
+ NICEEVAL_CODEX_E2B_TEMPLATE,
14
+ e2bCodingAgentTemplate,
15
+ } from "./e2b-agent-template.ts";
16
+
17
+ const publicTemplates = {
18
+ "claude-code": NICEEVAL_CLAUDE_CODE_E2B_TEMPLATE,
19
+ codex: NICEEVAL_CODEX_E2B_TEMPLATE,
20
+ bub: NICEEVAL_BUB_E2B_TEMPLATE,
21
+ } as const;
22
+
23
+ describe("e2bCodingAgentTemplate", () => {
24
+ it("exports complete public refs pinned to one verified release", () => {
25
+ const releases = Object.values(publicTemplates).map((template) => template.split(":").at(-1));
26
+
27
+ expect(new Set(releases).size).toBe(1);
28
+ for (const template of Object.values(publicTemplates)) {
29
+ expect(template).toMatch(/^correctroads-default-team\/.+:v\d+\.\d+\.\d+$/);
30
+ }
31
+ });
32
+
33
+ it.each([
34
+ ["claude-code", "claude"],
35
+ ["codex", "codex"],
36
+ ] as const)("extends the E2B official %s template", async (agent, base) => {
37
+ const json = JSON.parse(await Template.toJSON(e2bCodingAgentTemplate(agent)));
38
+ expect(json.fromTemplate).toBe(base);
39
+ expect(E2B_OFFICIAL_AGENT_TEMPLATES[agent]).toBe(base);
40
+ expect(publicTemplates[agent]).toContain(`/niceeval-${agent}`);
41
+ expect(JSON.stringify(json)).toContain(
42
+ agent === "claude-code" ? "claude.ai/install.sh" : "npm install -g",
43
+ );
44
+ });
45
+
46
+ it("builds Bub from the pinned NiceEval recipe and writes its marker", async () => {
47
+ const json = await Template.toJSON(e2bCodingAgentTemplate("bub", {
48
+ bubPythonPackages: ["bub-plugin-memory==1.3.0"],
49
+ }));
50
+ // pin 的单源在 bub-install-spec.ts;这里只证明 spec → 模板 recipe 的传播,不复刻 pin 值。
51
+ expect(json).toContain(DEFAULT_BUB_OVERRIDE.split("@").at(-1)!);
52
+ expect(json).toContain(DEFAULT_BUB_OTEL_PLUGIN.split("@").at(-1)!.split("#")[0]!);
53
+ expect(json).toContain(BUB_INSTALL_MARKER.split("/").at(-1)!);
54
+ expect(json).toContain("bub-plugin-memory==1.3.0");
55
+ });
56
+ });
@@ -0,0 +1,94 @@
1
+ import { Template, type TemplateBuilder } from "e2b";
2
+ import {
3
+ BUB_INSTALL_MARKER,
4
+ DEFAULT_BUB_OTEL_PLUGIN,
5
+ DEFAULT_BUB_OVERRIDE,
6
+ bubInstallHash,
7
+ normalizeBubPackages,
8
+ } from "../agents/bub-install-spec.ts";
9
+ import {
10
+ DEFAULT_CLAUDE_CODE_CLI_VERSION,
11
+ DEFAULT_CODEX_CLI_VERSION,
12
+ } from "../agents/coding-cli-versions.ts";
13
+
14
+ export type E2BCodingAgent = "claude-code" | "codex" | "bub";
15
+
16
+ export interface E2BCodingAgentTemplateOptions {
17
+ /** Extra packages installed in Bub's uv tool environment and included in its compatibility marker. */
18
+ bubPythonPackages?: readonly string[];
19
+ }
20
+
21
+ /** Provider-owned template aliases. Bub is built from NiceEval's pinned recipe. */
22
+ export const E2B_OFFICIAL_AGENT_TEMPLATES = {
23
+ "claude-code": "claude",
24
+ codex: "codex",
25
+ } as const;
26
+
27
+ /**
28
+ * NiceEval 当前已发布并完成启动校验的公共 E2B template release。
29
+ *
30
+ * 这是公共模板 registry 的版本,不从源码 checkout 中可能滞后的 package.json 推导。
31
+ * 发布一组新的 Claude Code / Codex / Bub template 并验证后,由 NiceEval 在这里统一 bump;
32
+ * 下游不应再复制这条 release 知识。
33
+ */
34
+ const NICEEVAL_E2B_TEMPLATE_RELEASE = "v0.6.1";
35
+
36
+ /**
37
+ * NiceEval 官方公共 E2B baseline:每个值已经是完整、release-pinned、跨 Team template ref。
38
+ * 直接交给 `e2bSandbox({ template })`,或交给 E2B `Template().fromTemplate(...)` 继续派生。
39
+ */
40
+ export const NICEEVAL_CLAUDE_CODE_E2B_TEMPLATE =
41
+ `correctroads-default-team/niceeval-claude-code:${NICEEVAL_E2B_TEMPLATE_RELEASE}`;
42
+ export const NICEEVAL_CODEX_E2B_TEMPLATE =
43
+ `correctroads-default-team/niceeval-codex:${NICEEVAL_E2B_TEMPLATE_RELEASE}`;
44
+ export const NICEEVAL_BUB_E2B_TEMPLATE =
45
+ `correctroads-default-team/niceeval-bub:${NICEEVAL_E2B_TEMPLATE_RELEASE}`;
46
+
47
+ function shellQuote(value: string): string {
48
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
49
+ }
50
+
51
+ /**
52
+ * Start an extensible E2B template for a coding agent.
53
+ *
54
+ * Claude Code and Codex extend E2B's official templates. Bub uses NiceEval's
55
+ * immutable install recipe because E2B does not currently publish a Bub base.
56
+ * Callers can chain normal E2B TemplateBuilder operations before building.
57
+ */
58
+ export function e2bCodingAgentTemplate(
59
+ agent: E2BCodingAgent,
60
+ options: E2BCodingAgentTemplateOptions = {},
61
+ ): TemplateBuilder {
62
+ if (agent === "claude-code" || agent === "codex") {
63
+ if (options.bubPythonPackages?.length) {
64
+ throw new Error("bubPythonPackages can only be used with the Bub E2B template");
65
+ }
66
+ const template = Template().fromTemplate(E2B_OFFICIAL_AGENT_TEMPLATES[agent]);
67
+ if (agent === "claude-code") {
68
+ // E2B's official template puts a native Claude binary first in the user PATH; installing
69
+ // npm as root would leave that older binary shadowing /usr/local/bin/claude.
70
+ return template.runCmd(
71
+ `curl -fsSL https://claude.ai/install.sh | bash -s ${DEFAULT_CLAUDE_CODE_CLI_VERSION}`,
72
+ { user: "user" },
73
+ );
74
+ }
75
+ return template.runCmd(`npm install -g @openai/codex@${DEFAULT_CODEX_CLI_VERSION}`, { user: "root" });
76
+ }
77
+
78
+ const packages = normalizeBubPackages(options.bubPythonPackages ?? []);
79
+ const installHash = bubInstallHash(packages);
80
+ const withPackages = packages.map((value) => ` --with ${shellQuote(value)}`).join("");
81
+ const marker = `/home/user/${BUB_INSTALL_MARKER}`;
82
+ const overrideFile = "/tmp/bub-override.txt";
83
+ return Template()
84
+ .fromBaseImage()
85
+ .runCmd("curl -LsSf https://astral.sh/uv/install.sh | sh", { user: "user" })
86
+ .runCmd(
87
+ [
88
+ `printf '%s\\n' ${shellQuote(DEFAULT_BUB_OVERRIDE)} > ${overrideFile}`,
89
+ `$HOME/.local/bin/uv tool install --python 3.12 --prerelease allow bub --overrides ${overrideFile} --with ${shellQuote(DEFAULT_BUB_OTEL_PLUGIN)}${withPackages}`,
90
+ `mkdir -p $(dirname ${marker}) && printf '%s' ${shellQuote(installHash)} > ${marker}`,
91
+ ],
92
+ { user: "user" },
93
+ );
94
+ }
@@ -5,7 +5,7 @@
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, RateLimitError } from "e2b";
8
+ import { Sandbox as E2BSdkSandbox, CommandExitError, NotFoundError, RateLimitError } from "e2b";
9
9
  import type {
10
10
  Sandbox,
11
11
  CommandResult,
@@ -14,7 +14,8 @@ import type {
14
14
  SourceFiles,
15
15
  ReadSourceFilesOptions,
16
16
  } from "../types.ts";
17
- import type { SandboxProvisionErrorKind } from "./errors.ts";
17
+ import { classifyProvisionErrorFallback, type SandboxProvisionErrorKind } from "./errors.ts";
18
+ import { classifySandboxIoError } from "./errors.ts";
18
19
  import { readSourceFilesByList } from "./source-files.ts";
19
20
  import { collectLocalFiles } from "./local-files.ts";
20
21
  import { shellQuote } from "./shell.ts";
@@ -29,8 +30,36 @@ const DEFAULT_COMMAND_TIMEOUT_MS = 600_000;
29
30
  const SESSION_TIMEOUT_MS = 1_800_000;
30
31
 
31
32
  /** e2b 的限流错误是 SDK 原生的 RateLimitError(HTTP 429 映射而来);见 resolve.ts 的 withProvisionRetry。 */
33
+ /**
34
+ * Provisioning 重试前的对账:按 metadata 里的 provision token 检索远端实例,查到即 kill。
35
+ * 检索或销毁失败必须抛出——对账是重试的硬前置,静默放行等于盲重试,会复制计费实例
36
+ * (见 docs/feature/sandbox/architecture.md「Provisioning 失败与重试」)。
37
+ * 唯一的例外:实例已不存在(NotFound),视作对账完成。
38
+ */
39
+ export async function reconcileProvision(token: string): Promise<void> {
40
+ const apiKey = process.env.E2B_API_KEY;
41
+ const list = (E2BSdkSandbox as unknown as {
42
+ list?: (opts?: Record<string, unknown>) => Promise<Array<{ sandboxId: string; metadata?: Record<string, string> }>>;
43
+ }).list;
44
+ if (typeof list !== "function") throw new Error("this e2b SDK version has no Sandbox.list; cannot reconcile provision token");
45
+ const sandboxes = await list({ apiKey });
46
+ for (const info of sandboxes) {
47
+ if (info.metadata?.["niceeval-provision-token"] !== token) continue;
48
+ const kill = (E2BSdkSandbox as unknown as { kill?: (id: string, opts?: Record<string, unknown>) => Promise<unknown> }).kill;
49
+ if (typeof kill !== "function") throw new Error("this e2b SDK version has no Sandbox.kill; cannot reconcile provision token");
50
+ try {
51
+ await kill(info.sandboxId, { apiKey });
52
+ } catch (e) {
53
+ if (!(e instanceof NotFoundError)) throw e;
54
+ }
55
+ }
56
+ }
57
+
32
58
  export function classifyProvisionError(e: unknown): SandboxProvisionErrorKind {
33
- return e instanceof RateLimitError ? "rate_limit" : "unknown";
59
+ // SDK 原生限流先归拒绝类;没认出的过与文件 IO 共用的保守瞬时兜底分类器
60
+ // (真实跑分里出现过 create 阶段 `fetch failed · other side closed`,属歧义类)。
61
+ if (e instanceof RateLimitError) return "rate_limit";
62
+ return classifyProvisionErrorFallback(e);
34
63
  }
35
64
 
36
65
  export class E2BSandbox implements Sandbox {
@@ -47,19 +76,33 @@ export class E2BSandbox implements Sandbox {
47
76
  }
48
77
 
49
78
  static async create(
50
- opts: { timeout?: number; runtime?: "node20" | "node24"; template?: string } = {},
79
+ opts: { timeout?: number; runtime?: "node20" | "node24"; template?: string; provisionToken?: string } = {},
51
80
  ): Promise<E2BSandbox> {
52
81
  const commandTimeoutMs = opts.timeout ?? DEFAULT_COMMAND_TIMEOUT_MS;
53
82
  // e2b 的 node 版本由模板决定,runtime 仅作记录(不在创建时选)。
54
83
  const apiKey = process.env.E2B_API_KEY;
55
- const sdkOpts = { apiKey, timeoutMs: SESSION_TIMEOUT_MS } as const;
84
+ // provision token metadata 打进实例:歧义类失败(fetch failed · other side closed)
85
+ // 重试前按它检索远端、销毁可能已创建的实例(见 reconcileProvision)。
86
+ const sdkOpts = {
87
+ apiKey,
88
+ timeoutMs: SESSION_TIMEOUT_MS,
89
+ ...(opts.provisionToken ? { metadata: { "niceeval-provision-token": opts.provisionToken } } : {}),
90
+ } as const;
56
91
  // 有 template 就从模板起,否则用 e2b 默认 "base"。
57
92
  const sbx = opts.template
58
93
  ? await E2BSdkSandbox.create(opts.template, sdkOpts)
59
94
  : await E2BSdkSandbox.create(sdkOpts);
60
- // 备好工作区目录(模板默认 cwd home,workspace 子目录可能不存在)
61
- await sbx.commands.run(`mkdir -p ${E2B_WORKDIR}`);
62
- return new E2BSandbox(sbx, sbx.sandboxId, commandTimeoutMs);
95
+ // kill-on-failure:实例句柄已到手,创建之后的初始化请求(如下面的 mkdir 429)一旦失败,
96
+ // 先尽力销毁实例再抛出原始错误——否则重试层按「拒绝类=远端没有实例」盲重试,就会复制一台
97
+ // 计费实例( docs/feature/sandbox/architecture.md「Provisioning 失败与重试」)
98
+ try {
99
+ // 备好工作区目录(模板默认 cwd 是 home,workspace 子目录可能不存在)。
100
+ await sbx.commands.run(`mkdir -p ${E2B_WORKDIR}`);
101
+ return new E2BSandbox(sbx, sbx.sandboxId, commandTimeoutMs);
102
+ } catch (e) {
103
+ await sbx.kill().catch(() => {});
104
+ throw e;
105
+ }
63
106
  }
64
107
 
65
108
  async runCommand(cmd: string, args: string[] = [], opts: CommandOptions = {}): Promise<CommandResult> {
@@ -76,6 +119,8 @@ export class E2BSandbox implements Sandbox {
76
119
  envs: opts.env,
77
120
  user: opts.root ? "root" : undefined,
78
121
  timeoutMs: this.commandTimeoutMs,
122
+ onStdout: opts.onStdout,
123
+ onStderr: opts.onStderr,
79
124
  });
80
125
  return { stdout: res.stdout, stderr: res.stderr, exitCode: res.exitCode };
81
126
  } catch (e) {
@@ -100,7 +145,9 @@ export class E2BSandbox implements Sandbox {
100
145
  try {
101
146
  await this.sbx.files.read(this.abs(path), { format: "bytes" });
102
147
  return true;
103
- } catch {
148
+ } catch (error) {
149
+ // 不把瞬时网络/服务错误伪装成“不存在”,交给统一 IO 层重试。
150
+ if (classifySandboxIoError(error) !== "unknown") throw error;
104
151
  return false;
105
152
  }
106
153
  }
@@ -142,6 +189,24 @@ export class E2BSandbox implements Sandbox {
142
189
  await this.sbx.kill();
143
190
  }
144
191
 
192
+ /**
193
+ * 留存休眠(suspend):e2b `pause`——文件系统与内存整体持久化,暂停期间停止计费,
194
+ * 现场无限期保留、可 resume 找回(没有自然过期时刻,注册表不写 expiresAt)。
195
+ * SDK 版本差异按能力探测(betaPause 是旧名),都没有则如实抛错(现场保持 alive)。
196
+ */
197
+ async suspend(): Promise<void> {
198
+ const sbx = this.sbx as unknown as { pause?: () => Promise<unknown>; betaPause?: () => Promise<unknown> };
199
+ if (typeof sbx.pause === "function") {
200
+ await sbx.pause();
201
+ return;
202
+ }
203
+ if (typeof sbx.betaPause === "function") {
204
+ await sbx.betaPause();
205
+ return;
206
+ }
207
+ throw new Error("this e2b SDK version has no pause capability; sandbox left running");
208
+ }
209
+
145
210
  async downloadFile(path: string): Promise<Buffer> {
146
211
  const bytes = await this.sbx.files.read(this.abs(path), { format: "bytes" });
147
212
  return Buffer.from(bytes);
@@ -4,10 +4,117 @@
4
4
  // 做退避重试,不需要认识任何 provider 专属的错误类型——分类逻辑留在各 provider 自己的
5
5
  // 文件里(见 e2b.ts / vercel.ts / docker.ts 的 classifyProvisionError)。
6
6
 
7
- /** 目前只区分"限流,值得退避重试"和"其它,原样抛出"。 */
8
- export type SandboxProvisionErrorKind = "rate_limit" | "unknown";
7
+ /**
8
+ * Provisioning 失败的两维分类(见 docs/feature/sandbox/architecture.md「Provisioning 失败与重试」):
9
+ * **性质**(瞬时 / 确定性)决定要不要重试,**后果**(远端是否可能已创建实例)决定能不能直接重试。
10
+ * - `rate_limit` / `rejected`:拒绝类——请求确定没被受理(限流、DNS/连接被拒/TLS 握手失败),
11
+ * 直接指数退避重试。
12
+ * - `ambiguous`:歧义类——请求可能已被受理、只是响应丢了(响应中途重置、请求超时、5xx),
13
+ * 远端可能有一台正在计费的实例;重试前必须对账(provider 提供检索通道时),否则第一次抛出。
14
+ * - `unknown`:确定性失败(模板不存在、凭据缺失、权限不足),第一次就抛,重试没有意义。
15
+ * 分类器偏向宽认瞬时:误判成确定性会白白判死可自愈的 attempt;反向只多花封顶的退避时间。
16
+ */
17
+ export type SandboxProvisionErrorKind = "rate_limit" | "rejected" | "ambiguous" | "unknown";
9
18
 
10
- /** kind 判断是否该重试;模板不存在、凭据缺失等归入 unknown,第一次就抛,重试没有意义。 */
19
+ /** 拒绝类(含限流):请求确定没被受理,直接退避重试。 */
20
+ export function isRejectedProvisionError(kind: SandboxProvisionErrorKind): boolean {
21
+ return kind === "rate_limit" || kind === "rejected";
22
+ }
23
+
24
+ /** 按 kind 判断是否可能重试(歧义类还需对账通道,见 retry.ts);unknown 第一次就抛。 */
11
25
  export function isRetryableProvisionError(kind: SandboxProvisionErrorKind): boolean {
12
- return kind === "rate_limit";
26
+ return kind !== "unknown";
27
+ }
28
+
29
+ /**
30
+ * 与文件 IO 重试共用的保守瞬时分类兜底:provider 没认出的错误按形态落进拒绝类或歧义类。
31
+ * 连接建立失败(DNS / refused / TLS)= 拒绝类;响应中途重置 / 超时 / 5xx = 歧义类。
32
+ */
33
+ export function classifyProvisionErrorFallback(error: unknown): SandboxProvisionErrorKind {
34
+ let current: unknown = error;
35
+ for (let depth = 0; depth < 5 && current != null; depth += 1) {
36
+ const record = typeof current === "object" ? (current as Record<string, unknown>) : undefined;
37
+ const message = current instanceof Error ? current.message : String(current);
38
+ const status = provisionStatus(record);
39
+ if (status === 429) return "rate_limit";
40
+ if (status !== undefined && status >= 500 && status <= 599) return "ambiguous";
41
+ const code = record && typeof record.code === "string" ? record.code : "";
42
+ // 连接根本没建立:请求确定没被受理。
43
+ if (/^(ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|CERT_|ERR_TLS)/i.test(code)) return "rejected";
44
+ // 连接中途断掉 / 超时:请求可能已被受理。
45
+ if (/^(ECONNRESET|EPIPE|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_SOCKET)$/i.test(code)) return "ambiguous";
46
+ if (/getaddrinfo|connection refused|certificate|tls handshake/i.test(message)) return "rejected";
47
+ if (/fetch failed|other side closed|socket hang up|connection (?:reset|closed)|timed? ?out|service unavailable|bad gateway|gateway timeout|\b50[0234]\b/i.test(message)) {
48
+ return "ambiguous";
49
+ }
50
+ if (/too many requests|rate.?limit|\b429\b/i.test(message)) return "rate_limit";
51
+ current = record?.cause;
52
+ }
53
+ return "unknown";
54
+ }
55
+
56
+ function provisionStatus(record: Record<string, unknown> | undefined): number | undefined {
57
+ if (!record) return undefined;
58
+ if (typeof record.status === "number") return record.status;
59
+ if (typeof record.statusCode === "number") return record.statusCode;
60
+ const response = record.response;
61
+ if (response && typeof response === "object") {
62
+ const status = (response as Record<string, unknown>).status;
63
+ if (typeof status === "number") return status;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * 已创建 Sandbox 上单次文件 IO 的中性错误分类。这里描述的是传输层瞬时故障,
70
+ * 不是文件不存在、权限不足、路径错误等确定性结果。
71
+ */
72
+ export type SandboxIoErrorKind = "rate_limit" | "network" | "service_unavailable" | "unknown";
73
+
74
+ export function isRetryableSandboxIoError(kind: SandboxIoErrorKind): boolean {
75
+ return kind !== "unknown";
76
+ }
77
+
78
+ /**
79
+ * 内置 provider 与自定义 provider 共用的保守分类器。SDK 常把底层网络错误包在
80
+ * `cause` 中,因此最多沿 cause 链向下检查几层;Abort/沙箱终止明确不重试。
81
+ */
82
+ export function classifySandboxIoError(error: unknown): SandboxIoErrorKind {
83
+ let current: unknown = error;
84
+ for (let depth = 0; depth < 5 && current != null; depth += 1) {
85
+ const record = typeof current === "object" ? current as Record<string, unknown> : undefined;
86
+ const name = record && typeof record.name === "string" ? record.name : "";
87
+ const message = current instanceof Error ? current.message : String(current);
88
+
89
+ if (/abort|cancel|terminated|killed|sandbox.*(closed|stopped)/i.test(`${name} ${message}`)) return "unknown";
90
+
91
+ const status = numericStatus(record);
92
+ if (status === 429) return "rate_limit";
93
+ if (status !== undefined && status >= 500 && status <= 599) return "service_unavailable";
94
+
95
+ const code = record && typeof record.code === "string" ? record.code : "";
96
+ if (/^(ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|UND_ERR_CONNECT_TIMEOUT)$/i.test(code)) {
97
+ return "network";
98
+ }
99
+ if (/fetch failed|socket hang up|network error|connection (?:reset|closed)|temporary failure|timed out while (?:fetching|uploading|downloading)/i.test(message)) {
100
+ return "network";
101
+ }
102
+ if (/too many requests|rate.?limit|\b429\b/i.test(message)) return "rate_limit";
103
+ if (/service unavailable|bad gateway|gateway timeout|\b50[0234]\b/i.test(message)) return "service_unavailable";
104
+
105
+ current = record?.cause;
106
+ }
107
+ return "unknown";
108
+ }
109
+
110
+ function numericStatus(record: Record<string, unknown> | undefined): number | undefined {
111
+ if (!record) return undefined;
112
+ if (typeof record.status === "number") return record.status;
113
+ if (typeof record.statusCode === "number") return record.statusCode;
114
+ const response = record.response;
115
+ if (response && typeof response === "object") {
116
+ const status = (response as Record<string, unknown>).status;
117
+ if (typeof status === "number") return status;
118
+ }
119
+ return undefined;
13
120
  }
@@ -16,6 +16,8 @@ export type {
16
16
  SandboxOption,
17
17
  SandboxSpec,
18
18
  SandboxRuntime,
19
+ SandboxHook,
20
+ SandboxHookContext,
19
21
  DockerSandboxSpec,
20
22
  VercelSandboxSpec,
21
23
  E2BSandboxSpec,