world-model-optimizer 0.2.0__py3-none-any.whl

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 (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
wmo/harness/pi_e2b.py ADDED
@@ -0,0 +1,1710 @@
1
+ """The pi agent inside E2B sandboxes: a stdio frame channel + the runtime that drives it.
2
+
3
+ For pi-node harnesses the agent *process* itself must run for real (its multi-file TypeScript
4
+ source — context forking, dropping, summarizing — is the thing under search), while the worker LLM
5
+ and the tool routing stay host-side. The ENVIRONMENT is whatever `AgentEnvironment` the eval
6
+ binds; in `wmo optimize harness` / `wmo eval` that is the world-model simulation. The sandbox is
7
+ purely the compute substrate for the harness process, and its filesystem is never an environment.
8
+
9
+ `E2BStdioChannel` carries the existing RunnerLink frame protocol over an E2B background command's
10
+ stdin/stdout — one base64(JSON) frame per line, because the sandbox command channel is a text
11
+ stream — and `E2BPiRuntime` composes it: acquire a sandbox from the runtime's own pool (create +
12
+ bootstrap on demand: upload `pi_entry/runner_stdio.ts`, install node 22 + the vendored pi's npm
13
+ deps unless a prebaked template supplies them), start the runner, await its `hello`, then delegate
14
+ the whole episode to `wmo.harness.runner_link.RunnerLink` — zero duplication of episode logic, and
15
+ the creds-stay-host-side invariant RunnerLink was built for holds (only frames enter the sandbox).
16
+
17
+ Sandboxes are pooled per runtime instance and reused across sequential episodes (bootstrap is
18
+ paid once per sandbox, not per rollout); concurrent episodes each acquire their own sandbox, so
19
+ rollouts parallelize naturally — no process-wide `_ACTIVE_CHANNEL` singleton, no max_concurrent:1
20
+ limit. The runner's stderr never carries frames; it is collected in a bounded deque and surfaced
21
+ in every transport error so a crashed node process diagnoses itself.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import contextlib
28
+ import json
29
+ import math
30
+ import os
31
+ import queue
32
+ import re
33
+ import shlex
34
+ import threading
35
+ import time
36
+ import uuid
37
+ from collections import deque
38
+ from collections.abc import Callable
39
+ from concurrent.futures import ThreadPoolExecutor
40
+ from typing import Literal, cast, overload
41
+
42
+ from wmo.core.types import JsonObject
43
+ from wmo.harness.e2b_sandbox import (
44
+ DEFAULT_SANDBOX_TIMEOUT_S,
45
+ E2B_TEMPLATE_ENV,
46
+ CommandHandle,
47
+ SandboxCleanupError,
48
+ SandboxFactory,
49
+ SandboxHandle,
50
+ SandboxUsage,
51
+ create_sandbox,
52
+ default_sandbox_factory,
53
+ kill_sandbox,
54
+ resolve_e2b_template,
55
+ )
56
+ from wmo.harness.environment import AgentEnvironment
57
+ from wmo.harness.runner_link import RunnerLink, WorkerFn
58
+ from wmo.harness.runtime import (
59
+ DEFAULT_EVAL_EPISODE_TIMEOUT_S,
60
+ DEFAULT_MAX_OUTPUT_TOKENS,
61
+ DEFAULT_MAX_TURNS,
62
+ RunResult,
63
+ RuntimeCancelled,
64
+ StopReason,
65
+ validate_episode_timeout_s,
66
+ )
67
+ from wmo.harness.skills import SkillLibrary
68
+ from wmo.harness.tools import ToolSpec
69
+ from wmo.providers.base import ToolCallingProvider
70
+
71
+ RUNNER_WORKDIR = "/home/user/pi-run"
72
+
73
+ # The npm packages the vendored pi source imports (verified against
74
+ # vendor/pi-agent/package.json [dependencies] and the import statements under vendor/pi-agent/src;
75
+ # same list the 65520da E2B backend installed, now version-pinned). Everything else is node:*.
76
+ PI_NPM_PACKAGES = (
77
+ "@earendil-works/pi-ai@0.80.3",
78
+ "ignore@7.0.5",
79
+ "typebox@1.1.38",
80
+ "yaml@2.9.0",
81
+ )
82
+
83
+ # The base image ships node 20.9; pi needs >= 22.6 for --experimental-strip-types (65520da
84
+ # precedent for the `n`-based upgrade). Installs are slow, hence the generous cap.
85
+ NODE_INSTALL_CMD = "npm install -g n && n 22"
86
+ INSTALL_TIMEOUT_S = 600.0
87
+ START_CMD = f"cd {RUNNER_WORKDIR} && node --experimental-strip-types runner_stdio.ts"
88
+ # The live-session runner (interactive multi-turn) vs. the episode runner (one-shot eval).
89
+ LIVE_START_CMD = f"cd {RUNNER_WORKDIR} && node --experimental-strip-types runner_live.ts"
90
+ _LIVE_RUNNER_FILES = ("runner_live.ts",)
91
+ # Default working directory a live session's real tools operate in (the agent's "cwd").
92
+ LIVE_WORKSPACE = "/home/user/workspace"
93
+ HELLO_TIMEOUT_S = 60.0
94
+
95
+ # ESM so node treats the runner's .ts files as modules regardless of syntax detection.
96
+ _PACKAGE_JSON = '{"name": "pi-run", "private": true, "type": "module"}\n'
97
+
98
+ _PI_ENTRY_DIR = os.path.join(os.path.dirname(__file__), "pi_entry")
99
+ # runner_stdio.ts is the entrypoint; runner_frames.ts rides along so the two runner transports
100
+ # stay deployed together (spec §4) even though the stdio runner is self-contained.
101
+ # runner_termination.ts is a hard dependency of runner_stdio.ts (the classify + nudge policy).
102
+ _RUNNER_FILES = ("runner_stdio.ts", "runner_frames.ts", "runner_termination.ts")
103
+
104
+ _STDERR_LINES = 50 # bounded diagnostics buffer: enough for a stack trace, never unbounded
105
+ _IDLE_RECONNECT_DELAYS_S = (0.0, 0.25, 1.0)
106
+
107
+ # Durable live sessions mirror every runner -> host semantic frame into a sequenced filesystem
108
+ # outbox. Stdout remains the low-latency path; these deliberately short unary RPC bounds keep a
109
+ # 500ms LiveSession pump (and cancellation) from inheriting the E2B SDK's much longer default
110
+ # request timeout when it polls the outbox.
111
+ _DURABLE_FILE_REQUEST_TIMEOUT_S = 0.25
112
+ _DURABLE_FRAME_REQUEST_TIMEOUT_S = 5.0
113
+ _DURABLE_POLL_INTERVAL_S = 0.25
114
+ _DURABLE_STDOUT_FAST_WAIT_S = 0.025
115
+ _DURABLE_STREAM_DEATH_GRACE_S = 0.5
116
+ _DURABLE_FRAME_READ_GRACE_S = 5.0
117
+ _DURABLE_PID_PROBE_INTERVAL_S = 5.0
118
+ _DURABLE_SEND_REQUEST_TIMEOUT_S = 2.0
119
+ _DURABLE_SEND_ACK_TIMEOUT_S = 5.0
120
+ _DURABLE_CLOSE_REQUEST_TIMEOUT_S = 0.25
121
+ _DURABLE_STDOUT_SILENCE_GRACE_S = 45.0
122
+
123
+ # A runner-originated heartbeat keeps the background command stream active while the host is
124
+ # synchronously waiting on a slow provider call. Pooled evaluation channels also use the same
125
+ # heartbeat to renew the sandbox lease; live-session callers own their own idle/suspend lifecycle
126
+ # and therefore leave renewal disabled.
127
+ TRANSPORT_KEEPALIVE_TYPE = "transport_keepalive"
128
+ _SANDBOX_TIMEOUT_REFRESH_S = 300.0
129
+ # Renewal must not turn mutated agent code into an unbounded cost leak. The default hard lifetime
130
+ # is one hour; a longer episode budget adds one default lease for in-flight work and cleanup.
131
+ MAX_EVAL_EPISODE_LIFETIME_S = 3_600.0
132
+ _MAX_RETIRE_WORKERS = 16
133
+
134
+ _HTTPCORE_REMOTE_STREAM_RESET = re.compile(
135
+ r"<StreamReset stream_id:\d+, error_code:\d+, remote_reset:True>"
136
+ )
137
+
138
+
139
+ def _episode_lease_lifetime_s(episode_timeout_s: float) -> float:
140
+ """Keep the legacy default, or cover one synchronous call plus cleanup after the watchdog."""
141
+ if episode_timeout_s <= DEFAULT_EVAL_EPISODE_TIMEOUT_S:
142
+ return DEFAULT_SANDBOX_TIMEOUT_S
143
+ return episode_timeout_s + DEFAULT_SANDBOX_TIMEOUT_S
144
+
145
+
146
+ class _Eof:
147
+ """Reader-thread sentinel: the runner process's output stream ended."""
148
+
149
+
150
+ _EOF = _Eof()
151
+
152
+
153
+ class _E2BChannelSendError(RuntimeError):
154
+ """A durable host frame could not be acknowledged by the E2B runner."""
155
+
156
+
157
+ class E2BStdioChannel:
158
+ """A `runner_link.Channel` over an E2B background command's stdin/stdout.
159
+
160
+ send: base64(JSON) + newline into the process's stdin (`commands.send_stdin`). recv: a blocking
161
+ queue fed by a daemon reader thread that iterates the command handle's stream events,
162
+ reassembles partial stdout lines, and decodes each complete line as one frame. stderr is never
163
+ parsed as frames — it goes to a bounded deque surfaced in error messages. Once the process
164
+ exits, recv raises a `RuntimeError` carrying that stderr tail (unless `close()` initiated the
165
+ shutdown, in which case recv reports a clean end-of-channel `None`).
166
+ """
167
+
168
+ def __init__(
169
+ self,
170
+ sandbox: SandboxHandle,
171
+ handle: CommandHandle,
172
+ *,
173
+ stderr_lines: int = _STDERR_LINES,
174
+ sandbox_timeout_s: int | None = None,
175
+ timeout_refresh_interval_s: float = _SANDBOX_TIMEOUT_REFRESH_S,
176
+ max_episode_lifetime_s: float = MAX_EVAL_EPISODE_LIFETIME_S,
177
+ reconnect_while_idle: bool = False,
178
+ ) -> None:
179
+ self._sandbox = sandbox
180
+ self._handle = handle
181
+ self._pid = handle.pid
182
+ self._frames: queue.Queue[JsonObject | _Eof] = queue.Queue()
183
+ self._stderr: deque[str] = deque(maxlen=stderr_lines)
184
+ self._sandbox_timeout_s = sandbox_timeout_s
185
+ self._timeout_refresh_interval_s = timeout_refresh_interval_s
186
+ self._max_episode_lifetime_s = max_episode_lifetime_s
187
+ self._next_timeout_refresh_at = 0.0
188
+ self._episode_renewal_deadline_at: float | None = None
189
+ self._reconnect_while_idle = reconnect_while_idle
190
+ # Guards the definitely-idle proof and the same-PID reconnect. A user message clears the
191
+ # proof while holding this lock *before* stdin delivery, so the reader cannot reconnect
192
+ # across a turn-start race where a semantic runner frame might be lost.
193
+ self._transport_lock = threading.Lock()
194
+ self._session_idle = False
195
+ self._closed = False
196
+ self._reader = threading.Thread(
197
+ target=self._read_events, name="e2b-stdio-reader", daemon=True
198
+ )
199
+ self._reader.start()
200
+
201
+ def send(self, frame: JsonObject) -> None:
202
+ kind = frame.get("type")
203
+ if kind == "episode_start" and self._sandbox_timeout_s is not None:
204
+ now = time.monotonic()
205
+ self._episode_renewal_deadline_at = now + self._max_episode_lifetime_s
206
+ # The pool reset the lease immediately before this episode. Wait until the normal
207
+ # refresh cadence instead of bursting one set_timeout call per sandbox at 30 seconds.
208
+ self._next_timeout_refresh_at = now + self._timeout_refresh_interval_s
209
+ line = base64.b64encode(json.dumps(frame).encode("utf-8")).decode("ascii") + "\n"
210
+ with self._transport_lock:
211
+ if kind in {"session_start", "user_message"}:
212
+ self._session_idle = False
213
+ try:
214
+ self._sandbox.commands.send_stdin(self._pid, line)
215
+ except OSError as exc:
216
+ raise _E2BChannelSendError("failed to send a frame to the E2B runner") from exc
217
+ except Exception as exc: # noqa: BLE001 - classify one optional-SDK transport shape
218
+ if _is_httpcore_remote_stream_reset(exc):
219
+ raise _E2BChannelSendError(
220
+ "failed to send a frame to the E2B runner after an HTTP/2 stream reset"
221
+ ) from exc
222
+ raise
223
+
224
+ def recv(self, timeout: float | None = None) -> JsonObject | None:
225
+ """The next frame from the runner; blocks (up to `timeout` seconds when given).
226
+
227
+ Timing out raises `TimeoutError`; a dead runner process raises `RuntimeError`; both
228
+ messages include the recent stderr. RunnerLink uses bounded receives for evaluation wall
229
+ budgets and cooperative cancellation; the hello handshake uses the same contract.
230
+ """
231
+ try:
232
+ item = self._frames.get(timeout=timeout)
233
+ except queue.Empty:
234
+ raise TimeoutError(
235
+ f"no frame from the pi runner within {timeout}s{self._stderr_suffix()}"
236
+ ) from None
237
+ if isinstance(item, _Eof):
238
+ self._frames.put(item) # keep EOF sticky so every later recv sees it too
239
+ if self._closed:
240
+ return None # we asked it to shut down; a clean end-of-channel
241
+ raise RuntimeError(f"pi runner process exited mid-episode{self._stderr_suffix()}")
242
+ return item
243
+
244
+ def close(self) -> None:
245
+ """Ask the runner to exit (best-effort); marks the stream end as clean for recv."""
246
+ with self._transport_lock:
247
+ if self._closed:
248
+ return
249
+ self._closed = True
250
+ self._session_idle = False
251
+ self._episode_renewal_deadline_at = None
252
+ try:
253
+ self.send({"type": "shutdown"})
254
+ except Exception: # noqa: BLE001 - the process/sandbox may already be gone; close is best-effort
255
+ pass
256
+
257
+ def stderr_tail(self) -> str:
258
+ """The recent runner stderr (diagnostics; never part of the frame stream)."""
259
+ return "\n".join(self._stderr)
260
+
261
+ def _stderr_suffix(self) -> str:
262
+ tail = self.stderr_tail()
263
+ return f"; recent runner stderr:\n{tail}" if tail else ""
264
+
265
+ def _read_events(self) -> None:
266
+ pending = ""
267
+ handle = self._handle
268
+ while True:
269
+ try:
270
+ for stdout, stderr, _pty in handle:
271
+ if stderr:
272
+ for line in stderr.splitlines():
273
+ if line.strip():
274
+ self._stderr.append(line)
275
+ if not stdout:
276
+ continue
277
+ pending += stdout
278
+ while "\n" in pending:
279
+ line, pending = pending.split("\n", 1)
280
+ self._decode_line(line)
281
+ except Exception as exc: # noqa: BLE001 - classified by definitely-idle reconnect
282
+ reconnected = self._reconnect_idle_stream()
283
+ if reconnected is not None:
284
+ # A partially delivered line is not replay-safe. The runner writes one frame
285
+ # per line; dropping the fragment lets the next complete frame resynchronize.
286
+ pending = ""
287
+ handle = reconnected
288
+ continue
289
+ self._stderr.append(f"[channel] output stream failed: {exc}")
290
+ else:
291
+ # E2B's stream generator may also end without an exception when the HTTP stream
292
+ # disappears before a process-end event. Same-PID reconnect remains safe only at
293
+ # the exact idle boundary; a real process exit simply makes connect fail.
294
+ reconnected = self._reconnect_idle_stream()
295
+ if reconnected is not None:
296
+ pending = ""
297
+ handle = reconnected
298
+ continue
299
+ self._frames.put(_EOF)
300
+ return
301
+
302
+ def _reconnect_idle_stream(self) -> CommandHandle | None:
303
+ """Reattach to the same live runner only while it is provably between turns.
304
+
305
+ E2B command streams can suffer a transient HTTP/2 disconnect while their process and
306
+ sandbox remain healthy. Reconnecting mid-turn is not safe because an LLM/tool/state frame
307
+ may have been lost. Once ``state:idle`` was decoded, however, the runner emits only filtered
308
+ transport heartbeats until the next host ``user_message``; same-PID reconnect is lossless.
309
+ """
310
+ if not self._reconnect_while_idle:
311
+ return None
312
+ for delay in _IDLE_RECONNECT_DELAYS_S:
313
+ if delay:
314
+ time.sleep(delay)
315
+ # Hold the proof across connect. `send(user_message)` takes this same lock and clears
316
+ # idle before stdin delivery, so it either happens entirely before or after reattach.
317
+ with self._transport_lock:
318
+ if self._closed or not self._session_idle:
319
+ return None
320
+ try:
321
+ handle = self._sandbox.commands.connect(self._pid, timeout=0)
322
+ except Exception: # noqa: BLE001 - the next bounded attempt uses a fresh stream
323
+ continue
324
+ self._handle = handle
325
+ return handle
326
+ return None
327
+
328
+ def _decode_line(self, line: str) -> None:
329
+ text = line.strip()
330
+ if not text:
331
+ return
332
+ try:
333
+ frame = json.loads(base64.b64decode(text, validate=True))
334
+ except ValueError: # binascii.Error and JSONDecodeError are both ValueErrors
335
+ self._stderr.append(f"[stdout] {text}") # not a frame; keep it as a diagnostic
336
+ return
337
+ if isinstance(frame, dict):
338
+ if frame.get("type") == TRANSPORT_KEEPALIVE_TYPE:
339
+ self._refresh_sandbox_timeout()
340
+ return
341
+ if frame.get("type") == "state":
342
+ status = frame.get("status")
343
+ with self._transport_lock:
344
+ # Only an exact idle acknowledgement is a replay-safety proof. Any new or
345
+ # malformed state value conservatively disables transparent reconnect.
346
+ self._session_idle = status == "idle"
347
+ if frame.get("type") in {"done", "episode_error"}:
348
+ self._episode_renewal_deadline_at = None
349
+ self._frames.put(cast("JsonObject", frame))
350
+ else:
351
+ self._stderr.append(f"[stdout] {text}")
352
+
353
+ def _refresh_sandbox_timeout(self) -> None:
354
+ """Renew a pooled eval sandbox lease without surfacing the heartbeat as a protocol frame.
355
+
356
+ A provider call can keep one episode active longer than E2B's 900-second sandbox timeout.
357
+ The runner remains responsive during that host-side wait and emits transport heartbeats;
358
+ refreshing here prevents E2B from killing an otherwise healthy in-flight episode. A
359
+ transient refresh failure stays diagnostic-only and the next heartbeat retries it.
360
+ """
361
+ timeout = self._sandbox_timeout_s
362
+ deadline = self._episode_renewal_deadline_at
363
+ if timeout is None or deadline is None:
364
+ return
365
+ now = time.monotonic()
366
+ if now < self._next_timeout_refresh_at or now >= deadline:
367
+ return
368
+ # Shorten the final lease so the sandbox still dies at the absolute episode deadline.
369
+ refreshed_timeout = min(timeout, max(1, math.ceil(deadline - now)))
370
+ try:
371
+ self._sandbox.set_timeout(refreshed_timeout)
372
+ except Exception as exc: # noqa: BLE001 - retry on the next heartbeat; I/O remains authoritative
373
+ self._stderr.append(f"[channel] sandbox timeout refresh failed: {exc}")
374
+ return
375
+ self._next_timeout_refresh_at = now + self._timeout_refresh_interval_s
376
+
377
+
378
+ class E2BDurableChannel:
379
+ """A lossless live-runner channel backed by a sequenced E2B filesystem outbox.
380
+
381
+ Host -> runner traffic uses the same unary ``commands.send_stdin`` operation as
382
+ :class:`E2BStdioChannel`, wrapped in a monotonically sequenced envelope. The runner dispatches
383
+ each inbound sequence once and emits a durable acknowledgement, so an RPC that delivers bytes
384
+ and then times out can safely reuse the same sequence instead of replaying the whole agent
385
+ turn. In the other direction, the durable runner publishes each semantic frame as
386
+ ``{transport_seq, frame}`` to an exact per-sequence file, advances ``head``, and only then
387
+ writes the same envelope to stdout. The stdout stream is therefore a fast notification path,
388
+ not the source of truth: one expected-sequence cursor deduplicates both sources, fills gaps
389
+ from exact files, and keeps polling ``head`` even when stdout is merely silent.
390
+
391
+ A command-stream EOF or HTTP failure is not a runner failure. The channel continues over unary
392
+ filesystem reads and only classifies process death after a grace period and an explicit
393
+ ``commands.list`` result that omits the runner PID. This is what lets an ordinary
394
+ ``LiveSession`` survive a dropped E2B output stream without changing its frame protocol.
395
+ """
396
+
397
+ def __init__(
398
+ self,
399
+ sandbox: SandboxHandle,
400
+ handle: CommandHandle,
401
+ *,
402
+ outbox_root: str,
403
+ stderr_path: str,
404
+ stderr_lines: int = _STDERR_LINES,
405
+ file_request_timeout_s: float = _DURABLE_FILE_REQUEST_TIMEOUT_S,
406
+ frame_request_timeout_s: float = _DURABLE_FRAME_REQUEST_TIMEOUT_S,
407
+ poll_interval_s: float = _DURABLE_POLL_INTERVAL_S,
408
+ stdout_fast_wait_s: float = _DURABLE_STDOUT_FAST_WAIT_S,
409
+ stream_death_grace_s: float = _DURABLE_STREAM_DEATH_GRACE_S,
410
+ frame_read_grace_s: float = _DURABLE_FRAME_READ_GRACE_S,
411
+ pid_probe_interval_s: float = _DURABLE_PID_PROBE_INTERVAL_S,
412
+ send_request_timeout_s: float = _DURABLE_SEND_REQUEST_TIMEOUT_S,
413
+ send_ack_timeout_s: float = _DURABLE_SEND_ACK_TIMEOUT_S,
414
+ close_request_timeout_s: float = _DURABLE_CLOSE_REQUEST_TIMEOUT_S,
415
+ stdout_silence_grace_s: float = _DURABLE_STDOUT_SILENCE_GRACE_S,
416
+ ) -> None:
417
+ self._sandbox = sandbox
418
+ self._handle = handle
419
+ self._pid = handle.pid
420
+ self._outbox_root = outbox_root.rstrip("/")
421
+ self._stderr_path = stderr_path
422
+ self._stderr_lines = stderr_lines
423
+ self._stderr: deque[str] = deque(maxlen=stderr_lines)
424
+ self._durable_stderr = ""
425
+ self._frames: queue.Queue[JsonObject | _Eof] = queue.Queue()
426
+ self._state_lock = threading.Lock()
427
+ self._resume_condition = threading.Condition(self._state_lock)
428
+ self._send_lock = threading.Lock()
429
+ self._close_lock = threading.Lock()
430
+ self._ack_condition = threading.Condition()
431
+ self._closed = threading.Event()
432
+ self._cleanup_done = threading.Event()
433
+ self._next_inbound_seq = 0
434
+ self._acked_inbound_seq = 0
435
+ self._last_seq = 0
436
+ self._known_head = 0
437
+ self._pending: dict[int, JsonObject] = {}
438
+ self._missing_since: dict[int, float] = {}
439
+ self._fatal_error: str | None = None
440
+ self._eof_queued = False
441
+ self._stream_dead_at: float | None = None
442
+ self._stream_death_probe_done = False
443
+ self._stream_error: str | None = None
444
+ self._last_stdout_at = time.monotonic()
445
+ self._head_failure_since: float | None = None
446
+ self._last_head_failure_at: float | None = None
447
+ self._next_pid_check_at = time.monotonic() + pid_probe_interval_s
448
+ self._pid_dead_at: float | None = None
449
+ self._last_outbox_error: str | None = None
450
+ self._stream_generation = 0
451
+ self._resuming = False
452
+ self._file_request_timeout_s = file_request_timeout_s
453
+ self._frame_request_timeout_s = frame_request_timeout_s
454
+ self._poll_interval_s = poll_interval_s
455
+ self._stdout_fast_wait_s = stdout_fast_wait_s
456
+ self._stream_death_grace_s = stream_death_grace_s
457
+ self._frame_read_grace_s = frame_read_grace_s
458
+ self._pid_probe_interval_s = pid_probe_interval_s
459
+ self._send_request_timeout_s = send_request_timeout_s
460
+ self._send_ack_timeout_s = send_ack_timeout_s
461
+ self._close_request_timeout_s = close_request_timeout_s
462
+ self._stdout_silence_grace_s = stdout_silence_grace_s
463
+ self._reader = self._new_reader(handle, generation=self._stream_generation)
464
+ self._reader.start()
465
+
466
+ def resume(self, sandbox: SandboxHandle, *, timeout: float | None = 0) -> None:
467
+ """Reconnect this channel to its preserved runner after an E2B sandbox resume.
468
+
469
+ E2B keeps the runner process and filesystem outbox in a memory-preserving pause, but the
470
+ old command output stream is gone. Reusing this channel preserves both transport sequence
471
+ cursors while replacing only that notification stream. The durable outbox remains the
472
+ source of truth for frames produced while the stream was detached.
473
+
474
+ Args:
475
+ sandbox: The reconnected handle for the same memory-preserved sandbox.
476
+ timeout: E2B command-stream timeout. Zero keeps the live stream attached indefinitely.
477
+ """
478
+ with self._send_lock, self._close_lock:
479
+ if self._closed.is_set():
480
+ raise RuntimeError("a closed durable runner channel cannot be resumed")
481
+ with self._resume_condition:
482
+ if self._fatal_error is not None:
483
+ raise RuntimeError(
484
+ f"a failed durable runner channel cannot be resumed: {self._fatal_error}"
485
+ )
486
+ self._resuming = True
487
+ self._stream_generation += 1
488
+ generation = self._stream_generation
489
+ previous_handle = self._handle
490
+ try:
491
+ handle = sandbox.commands.connect(self._pid, timeout=timeout)
492
+ except BaseException:
493
+ with self._resume_condition:
494
+ self._resuming = False
495
+ self._resume_condition.notify_all()
496
+ raise
497
+ now = time.monotonic()
498
+ fatal_during_connect: str | None = None
499
+ with self._resume_condition:
500
+ fatal_during_connect = self._fatal_error
501
+ if fatal_during_connect is None:
502
+ self._sandbox = sandbox
503
+ self._handle = handle
504
+ self._stream_dead_at = None
505
+ self._stream_death_probe_done = False
506
+ self._stream_error = None
507
+ self._last_stdout_at = now
508
+ self._head_failure_since = None
509
+ self._last_head_failure_at = None
510
+ self._next_pid_check_at = now + self._pid_probe_interval_s
511
+ self._pid_dead_at = None
512
+ self._last_outbox_error = None
513
+ self._missing_since.clear()
514
+ self._resuming = False
515
+ self._resume_condition.notify_all()
516
+ if fatal_during_connect is not None:
517
+ disconnect = getattr(handle, "disconnect", None)
518
+ if callable(disconnect):
519
+ with contextlib.suppress(Exception):
520
+ disconnect()
521
+ raise RuntimeError(
522
+ f"durable runner channel failed while resuming: {fatal_during_connect}"
523
+ )
524
+ reader = self._new_reader(handle, generation=generation)
525
+ self._reader = reader
526
+
527
+ disconnect = getattr(previous_handle, "disconnect", None)
528
+ if callable(disconnect):
529
+ with contextlib.suppress(Exception):
530
+ disconnect()
531
+ reader.start()
532
+
533
+ def send(self, frame: JsonObject) -> None:
534
+ """Deliver one host frame once, retrying the same sequence until its durable ack."""
535
+ with self._send_lock:
536
+ if self._closed.is_set():
537
+ raise _E2BChannelSendError("durable runner channel is closed")
538
+ self._next_inbound_seq += 1
539
+ inbound_seq = self._next_inbound_seq
540
+ line = self._encode_inbound(inbound_seq, frame)
541
+ deadline = time.monotonic() + self._send_ack_timeout_s
542
+ last_error: Exception | None = None
543
+ attempts = 0
544
+
545
+ while True:
546
+ if self._inbound_acknowledged(inbound_seq):
547
+ return
548
+ if self._closed.is_set():
549
+ raise _E2BChannelSendError(
550
+ f"durable runner closed before acknowledging inbound frame {inbound_seq}"
551
+ )
552
+ remaining = deadline - time.monotonic()
553
+ if remaining <= 0:
554
+ error = _E2BChannelSendError(
555
+ "durable runner did not acknowledge inbound frame "
556
+ f"{inbound_seq} within {self._send_ack_timeout_s:g}s"
557
+ )
558
+ if last_error is not None:
559
+ raise error from last_error
560
+ raise error
561
+
562
+ # At most two physical writes, always with the exact same sequence. If the first
563
+ # write was accepted but its HTTP response or ack notification was lost, the
564
+ # runner deduplicates the second and republishes the ack without dispatching.
565
+ if attempts < 2:
566
+ attempts += 1
567
+ try:
568
+ self._sandbox.commands.send_stdin(
569
+ self._pid,
570
+ line,
571
+ request_timeout=min(self._send_request_timeout_s, remaining),
572
+ )
573
+ except Exception as exc: # noqa: BLE001 - delivery may still have succeeded
574
+ last_error = exc
575
+
576
+ remaining = deadline - time.monotonic()
577
+ if self._wait_for_inbound_ack(
578
+ inbound_seq,
579
+ timeout=min(self._stdout_fast_wait_s, max(0.0, remaining)),
580
+ ):
581
+ return
582
+ remaining = deadline - time.monotonic()
583
+ if remaining <= 0:
584
+ continue
585
+ self._poll_outbox(
586
+ request_timeout=min(self._file_request_timeout_s, remaining),
587
+ frame_request_timeout=min(self._frame_request_timeout_s, remaining),
588
+ )
589
+ if self._inbound_acknowledged(inbound_seq):
590
+ return
591
+ if self._fatal_error is not None:
592
+ raise _E2BChannelSendError(self._fatal_error)
593
+ remaining = deadline - time.monotonic()
594
+ self._wait_for_inbound_ack(
595
+ inbound_seq,
596
+ timeout=min(self._poll_interval_s, max(0.0, remaining)),
597
+ )
598
+
599
+ def recv(self, timeout: float | None = None) -> JsonObject | None:
600
+ """Return the next semantic frame in transport-sequence order.
601
+
602
+ Queue waits are capped by the outbox poll cadence, including during the hello handshake.
603
+ This covers a silently hung stdout iterator as well as an explicit stream error. Every E2B
604
+ head/liveness read gets a request timeout no larger than the caller's remaining deadline;
605
+ exact committed frame reads get their own bounded budget because they can contain a full
606
+ model context and are gzip-compressed by the E2B transport.
607
+ """
608
+ if self._closed.is_set():
609
+ return None
610
+ deadline = None if timeout is None else time.monotonic() + timeout
611
+ while True:
612
+ if not self._wait_for_resume(deadline):
613
+ raise TimeoutError(
614
+ f"no frame from the pi runner within {timeout}s"
615
+ f"{self._stderr_suffix(include_durable=False)}"
616
+ )
617
+ item = self._get_queued_nowait()
618
+ if item is not None:
619
+ return self._unwrap_item(item)
620
+
621
+ remaining = None if deadline is None else deadline - time.monotonic()
622
+ if remaining is not None and remaining <= 0:
623
+ raise TimeoutError(
624
+ f"no frame from the pi runner within {timeout}s"
625
+ f"{self._stderr_suffix(include_durable=False)}"
626
+ )
627
+
628
+ # Stdout is the common, low-latency path. Give its reader one short scheduling window
629
+ # before issuing an E2B filesystem RPC; after an explicit stream failure, skip the
630
+ # window and fall through to the durable source immediately.
631
+ if self._stream_dead_at is None:
632
+ fast_wait = self._stdout_fast_wait_s
633
+ if remaining is not None:
634
+ fast_wait = min(fast_wait, remaining)
635
+ if fast_wait > 0:
636
+ try:
637
+ item = self._frames.get(timeout=fast_wait)
638
+ except queue.Empty:
639
+ pass
640
+ else:
641
+ return self._unwrap_item(item)
642
+
643
+ request_timeout = self._file_request_timeout_s
644
+ if remaining is not None:
645
+ request_timeout = min(request_timeout, max(0.001, remaining))
646
+ self._poll_outbox(request_timeout=request_timeout)
647
+
648
+ item = self._get_queued_nowait()
649
+ if item is not None:
650
+ return self._unwrap_item(item)
651
+
652
+ remaining = None if deadline is None else deadline - time.monotonic()
653
+ if remaining is None or remaining > 0:
654
+ liveness_timeout = self._file_request_timeout_s
655
+ if remaining is not None:
656
+ liveness_timeout = min(liveness_timeout, max(0.001, remaining))
657
+ self._classify_runner_liveness(request_timeout=liveness_timeout)
658
+
659
+ item = self._get_queued_nowait()
660
+ if item is not None:
661
+ return self._unwrap_item(item)
662
+
663
+ remaining = None if deadline is None else deadline - time.monotonic()
664
+ if remaining is not None and remaining <= 0:
665
+ raise TimeoutError(
666
+ f"no frame from the pi runner within {timeout}s"
667
+ f"{self._stderr_suffix(include_durable=False)}"
668
+ )
669
+ wait = (
670
+ self._poll_interval_s
671
+ if remaining is None
672
+ else min(self._poll_interval_s, remaining)
673
+ )
674
+ try:
675
+ item = self._frames.get(timeout=max(0.001, wait))
676
+ except queue.Empty:
677
+ continue
678
+ return self._unwrap_item(item)
679
+
680
+ def _wait_for_resume(self, deadline: float | None) -> bool:
681
+ """Block unary reads while a new sandbox attachment is being established."""
682
+ with self._resume_condition:
683
+ while self._resuming and not self._closed.is_set():
684
+ if deadline is None:
685
+ self._resume_condition.wait()
686
+ continue
687
+ remaining = deadline - time.monotonic()
688
+ if remaining <= 0:
689
+ return False
690
+ self._resume_condition.wait(timeout=remaining)
691
+ return not self._resuming
692
+
693
+ def close(self) -> None:
694
+ """Logically close immediately and retire the runner in bounded background cleanup."""
695
+ with self._close_lock:
696
+ if self._closed.is_set():
697
+ return
698
+ # Never wait for a filesystem replay that currently owns `_state_lock`: cancellation
699
+ # must be able to retire the process even if a large exact-frame read is in flight.
700
+ self._closed.set()
701
+ if not self._eof_queued:
702
+ self._eof_queued = True
703
+ self._frames.put(_EOF)
704
+ threading.Thread(
705
+ target=self._cleanup_runner,
706
+ name="e2b-durable-cleanup",
707
+ daemon=True,
708
+ ).start()
709
+
710
+ def _cleanup_runner(self) -> None:
711
+ """Best-effort remote teardown; isolated because the SDK may add a 5s health probe."""
712
+ # Preserve a graceful shutdown when no logical send is in flight, but never wait behind
713
+ # one: PID termination below is the authoritative bounded cleanup path.
714
+ try:
715
+ if self._send_lock.acquire(blocking=False):
716
+ try:
717
+ self._next_inbound_seq += 1
718
+ line = self._encode_inbound(
719
+ self._next_inbound_seq,
720
+ {"type": "shutdown"},
721
+ )
722
+ try:
723
+ self._sandbox.commands.send_stdin(
724
+ self._pid,
725
+ line,
726
+ request_timeout=self._close_request_timeout_s,
727
+ )
728
+ except Exception: # noqa: BLE001 - transport may already be dead
729
+ pass
730
+ finally:
731
+ self._send_lock.release()
732
+ try:
733
+ self._sandbox.commands.kill(
734
+ self._pid,
735
+ request_timeout=self._close_request_timeout_s,
736
+ )
737
+ except Exception: # noqa: BLE001 - the process may already have exited
738
+ pass
739
+ disconnect = getattr(self._handle, "disconnect", None)
740
+ if callable(disconnect):
741
+ with contextlib.suppress(Exception):
742
+ disconnect()
743
+ if threading.current_thread() is not self._reader:
744
+ self._reader.join(timeout=self._close_request_timeout_s)
745
+ finally:
746
+ self._cleanup_done.set()
747
+
748
+ def stderr_tail(self) -> str:
749
+ """Recent stream diagnostics plus the runner's durable stderr file."""
750
+ self._refresh_durable_stderr(self._file_request_timeout_s)
751
+ lines = [*self._stderr, *self._durable_stderr.splitlines()]
752
+ return "\n".join(lines[-self._stderr_lines :])
753
+
754
+ def _stderr_suffix(self, *, include_durable: bool = True) -> str:
755
+ if include_durable:
756
+ tail = self.stderr_tail()
757
+ else:
758
+ tail = "\n".join(self._stderr)
759
+ return f"; recent runner stderr:\n{tail}" if tail else ""
760
+
761
+ def _get_queued_nowait(self) -> JsonObject | _Eof | None:
762
+ try:
763
+ return self._frames.get_nowait()
764
+ except queue.Empty:
765
+ return None
766
+
767
+ def _unwrap_item(self, item: JsonObject | _Eof) -> JsonObject | None:
768
+ if self._closed.is_set():
769
+ return None # cancellation discards every semantic frame queued before teardown
770
+ if not isinstance(item, _Eof):
771
+ return item
772
+ self._frames.put(item) # sticky for every later recv
773
+ if self._closed.is_set():
774
+ return None
775
+ message = self._fatal_error or "pi live runner process exited"
776
+ raise RuntimeError(f"{message}{self._stderr_suffix()}")
777
+
778
+ def _new_reader(self, handle: CommandHandle, *, generation: int) -> threading.Thread:
779
+ return threading.Thread(
780
+ target=self._read_events,
781
+ args=(handle, generation),
782
+ name="e2b-durable-reader",
783
+ daemon=True,
784
+ )
785
+
786
+ def _read_events(self, handle: CommandHandle, generation: int) -> None:
787
+ pending = ""
788
+ try:
789
+ for stdout, stderr, _pty in handle:
790
+ if self._closed.is_set():
791
+ return
792
+ with self._state_lock:
793
+ if generation != self._stream_generation:
794
+ return
795
+ if stderr:
796
+ for stderr_line in stderr.splitlines():
797
+ if stderr_line.strip():
798
+ self._stderr.append(stderr_line)
799
+ if stdout:
800
+ self._last_stdout_at = time.monotonic()
801
+ if not stdout:
802
+ continue
803
+ pending += stdout
804
+ while "\n" in pending:
805
+ line, pending = pending.split("\n", 1)
806
+ self._decode_stdout_line(line, generation=generation)
807
+ except Exception as exc: # noqa: BLE001 - stdout is only a fallible notification path
808
+ with self._state_lock:
809
+ if generation == self._stream_generation and not self._closed.is_set():
810
+ self._stream_error = f"[channel] output stream failed: {exc}"
811
+ self._stderr.append(self._stream_error)
812
+ else:
813
+ with self._state_lock:
814
+ if generation == self._stream_generation and not self._closed.is_set():
815
+ self._stream_error = "[channel] output stream ended"
816
+ finally:
817
+ with self._state_lock:
818
+ if generation == self._stream_generation and not self._closed.is_set():
819
+ self._stream_dead_at = time.monotonic()
820
+
821
+ def _decode_stdout_line(self, line: str, *, generation: int) -> None:
822
+ text = line.strip()
823
+ if not text:
824
+ return
825
+ try:
826
+ value = json.loads(base64.b64decode(text, validate=True))
827
+ except ValueError:
828
+ with self._state_lock:
829
+ if generation == self._stream_generation:
830
+ self._stderr.append(f"[stdout] {text}")
831
+ return
832
+ if isinstance(value, dict) and value.get("type") == TRANSPORT_KEEPALIVE_TYPE:
833
+ return # deliberately unsequenced and never persisted by the durable runner
834
+ envelope = self._parse_envelope(value)
835
+ if envelope is None:
836
+ if isinstance(value, dict):
837
+ with self._state_lock:
838
+ if generation != self._stream_generation:
839
+ return
840
+ self._mark_fatal_locked(
841
+ "durable runner emitted an unsequenced or malformed semantic frame"
842
+ )
843
+ return
844
+ with self._state_lock:
845
+ if generation == self._stream_generation:
846
+ self._stderr.append(f"[stdout] invalid durable envelope: {text}")
847
+ return
848
+ seq, frame = envelope
849
+ with self._state_lock:
850
+ if generation != self._stream_generation:
851
+ return
852
+ if seq <= self._last_seq:
853
+ return
854
+ self._pending.setdefault(seq, frame)
855
+ # The runner publishes the exact frame + head before stdout, so the notification is
856
+ # proof that disk has committed through this sequence even if `head` is briefly stale.
857
+ self._known_head = max(self._known_head, seq)
858
+ # Never make the notification thread replay an unbounded disk gap while holding the
859
+ # ordering lock. ``recv`` continues one exact file per bounded poll.
860
+ self._drain_committed_locked(
861
+ request_timeout=self._frame_request_timeout_s,
862
+ max_file_reads=1,
863
+ )
864
+
865
+ def _poll_outbox(
866
+ self,
867
+ *,
868
+ request_timeout: float,
869
+ frame_request_timeout: float | None = None,
870
+ ) -> None:
871
+ if self._closed.is_set():
872
+ return
873
+ with self._state_lock:
874
+ if self._resuming:
875
+ return
876
+ generation = self._stream_generation
877
+ sandbox = self._sandbox
878
+ # Head is tiny and latency-sensitive. Exact frames use a separate, gzip-enabled budget:
879
+ # an LLM request can carry the agent's entire context and must not be misclassified as
880
+ # unavailable merely because it cannot fit inside this short polling interval.
881
+ unary_timeout = max(0.001, request_timeout)
882
+ try:
883
+ raw_head = sandbox.files.read(
884
+ f"{self._outbox_root}/head", request_timeout=unary_timeout
885
+ )
886
+ head = int(raw_head.strip())
887
+ if head < 0:
888
+ raise ValueError("negative sequence")
889
+ except Exception as exc: # noqa: BLE001 - startup/momentary unary read misses are normal
890
+ with self._state_lock:
891
+ if generation != self._stream_generation or self._resuming:
892
+ return
893
+ self._last_outbox_error = f"head read failed: {exc}"
894
+ now = time.monotonic()
895
+ # Treat only uninterrupted failures as one outage. AgentProject does not pump this
896
+ # channel while it is idle between proposal iterations, so a long idle gap must not
897
+ # make the next isolated read failure look ancient.
898
+ if self._last_head_failure_at is None or now - self._last_head_failure_at > max(
899
+ 1.0, self._poll_interval_s * 4
900
+ ):
901
+ self._head_failure_since = now
902
+ self._last_head_failure_at = now
903
+ head_failure_since = self._head_failure_since or now
904
+ output_unavailable = self._stream_dead_at is not None or (
905
+ now - self._last_stdout_at >= self._stdout_silence_grace_s
906
+ )
907
+ if output_unavailable and now - head_failure_since >= self._frame_read_grace_s:
908
+ self._mark_fatal_locked(
909
+ "durable outbox head unavailable after "
910
+ f"{self._frame_read_grace_s:g}s: {self._last_outbox_error}"
911
+ )
912
+ return
913
+ with self._state_lock:
914
+ if generation != self._stream_generation or self._resuming:
915
+ return
916
+ self._head_failure_since = None
917
+ self._last_head_failure_at = None
918
+ self._known_head = max(self._known_head, head)
919
+ self._drain_committed_locked(
920
+ request_timeout=(
921
+ self._frame_request_timeout_s
922
+ if frame_request_timeout is None
923
+ else frame_request_timeout
924
+ ),
925
+ max_file_reads=1,
926
+ )
927
+
928
+ def _drain_committed_locked(
929
+ self, *, request_timeout: float, max_file_reads: int | None = None
930
+ ) -> None:
931
+ """Advance the one delivery cursor; caller holds ``_state_lock``."""
932
+ file_reads = 0
933
+ while not self._closed.is_set() and self._fatal_error is None:
934
+ expected = self._last_seq + 1
935
+ frame = self._pending.pop(expected, None)
936
+ if frame is None:
937
+ if expected > self._known_head:
938
+ return
939
+ if max_file_reads is not None and file_reads >= max_file_reads:
940
+ return
941
+ file_reads += 1
942
+ envelope = self._read_frame_file(expected, request_timeout=request_timeout)
943
+ if self._closed.is_set():
944
+ return
945
+ if envelope is None:
946
+ started = self._missing_since.setdefault(expected, time.monotonic())
947
+ if time.monotonic() - started >= self._frame_read_grace_s:
948
+ detail = f": {self._last_outbox_error}" if self._last_outbox_error else ""
949
+ self._mark_fatal_locked(
950
+ f"durable outbox frame {expected} unavailable after "
951
+ f"{self._frame_read_grace_s:g}s{detail}"
952
+ )
953
+ return
954
+ _seq, frame = envelope
955
+ self._missing_since.pop(expected, None)
956
+ self._last_seq = expected
957
+ if frame.get("type") != TRANSPORT_KEEPALIVE_TYPE:
958
+ self._deliver_frame_locked(frame)
959
+
960
+ def _deliver_frame_locked(self, frame: JsonObject) -> None:
961
+ """Filter transport control frames; caller holds the outbound ordering lock."""
962
+ kind = frame.get("type")
963
+ if kind == "transport_ack":
964
+ inbound_seq = frame.get("transport_in_seq")
965
+ if (
966
+ isinstance(inbound_seq, bool)
967
+ or not isinstance(inbound_seq, int)
968
+ or inbound_seq <= 0
969
+ or inbound_seq > self._next_inbound_seq
970
+ ):
971
+ self._mark_fatal_locked("durable runner emitted an invalid inbound acknowledgement")
972
+ return
973
+ with self._ack_condition:
974
+ self._acked_inbound_seq = max(self._acked_inbound_seq, inbound_seq)
975
+ self._ack_condition.notify_all()
976
+ return
977
+ if kind == "transport_nack":
978
+ expected = frame.get("expected_transport_in_seq")
979
+ received = frame.get("transport_in_seq")
980
+ self._mark_fatal_locked(
981
+ f"durable runner rejected inbound sequence {received!r}; expected {expected!r}"
982
+ )
983
+ return
984
+ self._frames.put(frame)
985
+
986
+ @staticmethod
987
+ def _encode_inbound(inbound_seq: int, frame: JsonObject) -> str:
988
+ envelope: JsonObject = {
989
+ "transport_in_seq": inbound_seq,
990
+ "frame": frame,
991
+ }
992
+ return base64.b64encode(json.dumps(envelope).encode("utf-8")).decode("ascii") + "\n"
993
+
994
+ def _inbound_acknowledged(self, inbound_seq: int) -> bool:
995
+ with self._ack_condition:
996
+ return self._acked_inbound_seq >= inbound_seq
997
+
998
+ def _wait_for_inbound_ack(self, inbound_seq: int, *, timeout: float) -> bool:
999
+ with self._ack_condition:
1000
+ if self._acked_inbound_seq >= inbound_seq:
1001
+ return True
1002
+ if timeout > 0:
1003
+ self._ack_condition.wait(timeout=timeout)
1004
+ return self._acked_inbound_seq >= inbound_seq
1005
+
1006
+ def _read_frame_file(
1007
+ self, seq: int, *, request_timeout: float
1008
+ ) -> tuple[int, JsonObject] | None:
1009
+ path = f"{self._outbox_root}/frames/{seq:020d}.json"
1010
+ try:
1011
+ raw = self._sandbox.files.read(
1012
+ path,
1013
+ request_timeout=request_timeout,
1014
+ gzip=True,
1015
+ )
1016
+ value = json.loads(raw)
1017
+ envelope = self._parse_envelope(value)
1018
+ if envelope is None or envelope[0] != seq:
1019
+ raise ValueError(f"expected transport_seq {seq}")
1020
+ return envelope
1021
+ except Exception as exc: # noqa: BLE001 - retried by subsequent bounded recv polls
1022
+ self._last_outbox_error = f"{path} read failed: {exc}"
1023
+ return None
1024
+
1025
+ def _classify_runner_liveness(self, *, request_timeout: float) -> None:
1026
+ with self._state_lock:
1027
+ if self._closed.is_set() or self._resuming:
1028
+ return
1029
+ generation = self._stream_generation
1030
+ sandbox = self._sandbox
1031
+ dead_at = self._stream_dead_at
1032
+ now = time.monotonic()
1033
+ stream_failure_is_due = (
1034
+ dead_at is not None
1035
+ and not self._stream_death_probe_done
1036
+ and now - dead_at >= self._stream_death_grace_s
1037
+ )
1038
+ if not stream_failure_is_due and now < self._next_pid_check_at:
1039
+ return
1040
+ if stream_failure_is_due:
1041
+ self._stream_death_probe_done = True
1042
+ self._next_pid_check_at = now + self._pid_probe_interval_s
1043
+ try:
1044
+ processes = sandbox.commands.list(request_timeout=request_timeout)
1045
+ except Exception as exc: # noqa: BLE001 - a failed probe is not proof of process death
1046
+ message = f"[channel] runner liveness probe failed: {exc}"
1047
+ with self._state_lock:
1048
+ if generation != self._stream_generation or self._resuming:
1049
+ return
1050
+ if not self._stderr or self._stderr[-1] != message:
1051
+ self._stderr.append(message)
1052
+ return
1053
+ with self._state_lock:
1054
+ if generation != self._stream_generation or self._resuming:
1055
+ return
1056
+ if any(process.pid == self._pid for process in processes):
1057
+ self._pid_dead_at = None
1058
+ return
1059
+ # `recv` polls disk before every liveness probe. Keep doing so for a bounded grace after
1060
+ # the PID disappears, allowing a final frame and head update to become visible before
1061
+ # EOF.
1062
+ if self._pid_dead_at is None:
1063
+ self._pid_dead_at = now
1064
+ return
1065
+ if now - self._pid_dead_at < self._frame_read_grace_s:
1066
+ return
1067
+ self._refresh_durable_stderr(
1068
+ request_timeout,
1069
+ generation=generation,
1070
+ sandbox=sandbox,
1071
+ )
1072
+ with self._state_lock:
1073
+ if generation != self._stream_generation or self._resuming:
1074
+ return
1075
+ detail = f" after {self._stream_error}" if self._stream_error else ""
1076
+ self._mark_fatal_locked(f"pi live runner process exited{detail}")
1077
+
1078
+ def _refresh_durable_stderr(
1079
+ self,
1080
+ request_timeout: float,
1081
+ *,
1082
+ generation: int | None = None,
1083
+ sandbox: SandboxHandle | None = None,
1084
+ ) -> None:
1085
+ if generation is None or sandbox is None:
1086
+ with self._state_lock:
1087
+ if self._resuming:
1088
+ return
1089
+ generation = self._stream_generation
1090
+ sandbox = self._sandbox
1091
+ try:
1092
+ stderr = sandbox.files.read(self._stderr_path, request_timeout=request_timeout)
1093
+ except Exception: # noqa: BLE001 - diagnostics must never mask the transport error
1094
+ return
1095
+ with self._state_lock:
1096
+ if generation == self._stream_generation and not self._resuming:
1097
+ self._durable_stderr = "\n".join(stderr.splitlines()[-self._stderr_lines :])
1098
+
1099
+ @staticmethod
1100
+ def _parse_envelope(value: object) -> tuple[int, JsonObject] | None:
1101
+ if not isinstance(value, dict):
1102
+ return None
1103
+ seq = value.get("transport_seq")
1104
+ frame = value.get("frame")
1105
+ if isinstance(seq, bool) or not isinstance(seq, int) or seq <= 0:
1106
+ return None
1107
+ if not isinstance(frame, dict):
1108
+ return None
1109
+ return seq, cast("JsonObject", frame)
1110
+
1111
+ def _mark_fatal_locked(self, message: str) -> None:
1112
+ if self._fatal_error is None:
1113
+ self._fatal_error = message
1114
+ self._queue_eof_locked()
1115
+
1116
+ def _queue_eof_locked(self) -> None:
1117
+ if not self._eof_queued:
1118
+ self._eof_queued = True
1119
+ self._frames.put(_EOF)
1120
+
1121
+
1122
+ class E2BSandboxPool:
1123
+ """Bootstrapped sandboxes with a running pi runner, reused within a proposal batch.
1124
+
1125
+ The bootstrap (runner files + node 22 + pi's npm deps unless a template prebakes them) is
1126
+ doc-independent — a mutated harness's code surfaces travel per-episode in
1127
+ `episode_start.files` — so one pool serves a whole search. `create_harness` retires the idle
1128
+ pool at each iteration boundary before the proposer runs, preventing long
1129
+ proposer/evaluation gaps from leaving stale E2B command streams alive; score waves for
1130
+ sibling proposals in that iteration
1131
+ still reuse warm sandboxes instead of re-paying installs. Concurrent episodes acquire distinct
1132
+ sandboxes; a sandbox whose episode raised is discarded (the runner process is in an unknown
1133
+ state — reuse could cross frames between episodes). `close()` kills everything; the pool lock
1134
+ guards only the free lists, so parallel bootstraps never serialize behind one sandbox's
1135
+ multi-minute npm install.
1136
+ """
1137
+
1138
+ def __init__(
1139
+ self,
1140
+ *,
1141
+ template: str | None = None,
1142
+ api_key: str | None = None,
1143
+ metadata: dict[str, str] | None = None,
1144
+ sandbox_factory: SandboxFactory | None = None,
1145
+ hello_timeout: float = HELLO_TIMEOUT_S,
1146
+ episode_timeout_s: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
1147
+ ) -> None:
1148
+ self._episode_timeout_s = validate_episode_timeout_s(episode_timeout_s)
1149
+ lease_lifetime_s = _episode_lease_lifetime_s(self._episode_timeout_s)
1150
+ self._sandbox_timeout_s = max(
1151
+ math.ceil(DEFAULT_SANDBOX_TIMEOUT_S),
1152
+ math.ceil(lease_lifetime_s),
1153
+ )
1154
+ # The hard 3600s cap alone would kill a legitimately long episode; the cap must always
1155
+ # cover the lease sized for this pool's episode budget.
1156
+ self._max_episode_lifetime_s = max(
1157
+ MAX_EVAL_EPISODE_LIFETIME_S,
1158
+ lease_lifetime_s,
1159
+ )
1160
+ self._template = resolve_e2b_template(template)
1161
+ self._factory = sandbox_factory or default_sandbox_factory(
1162
+ api_key=api_key,
1163
+ template=self._template if self._template is not None else "",
1164
+ metadata=metadata,
1165
+ timeout=self._sandbox_timeout_s,
1166
+ )
1167
+ self._hello_timeout = hello_timeout
1168
+ self._lock = threading.Lock()
1169
+ self._idle: list[tuple[SandboxHandle, E2BStdioChannel]] = []
1170
+ self._all: list[SandboxHandle] = []
1171
+ self._closed = False
1172
+ # Usage meter: lifetime seconds accumulate into _retired_seconds when a sandbox dies;
1173
+ # live sandboxes are counted from their _started stamp. Keyed by id() — SandboxHandle is
1174
+ # a protocol, not hashable-by-contract.
1175
+ self._started: dict[int, float] = {}
1176
+ # An exact lease can be reached concurrently by pool.close() and an in-flight episode's
1177
+ # release(). The event makes one caller own each kill attempt while every other caller
1178
+ # waits for its proof before deciding whether a retry is still needed.
1179
+ self._retiring: dict[int, threading.Event] = {}
1180
+ self._created = 0
1181
+ self._retired_seconds = 0.0
1182
+
1183
+ def acquire(self) -> tuple[SandboxHandle, E2BStdioChannel]:
1184
+ """An idle (bootstrapped, hello-verified) sandbox+channel; creates one when none is free.
1185
+
1186
+ Reused sandboxes get their lifetime EXTENDED first (`set_timeout` restarts E2B's
1187
+ countdown): sandboxes created at a search's first wave must survive every later wave,
1188
+ and a long search otherwise outlives the fixed creation-time cap — the runner stream
1189
+ drops mid-episode ("Server disconnected"). A reused sandbox whose extension fails is
1190
+ already dead (idle past its cap); it is retired and the next one is tried.
1191
+ """
1192
+ while True:
1193
+ with self._lock:
1194
+ if self._closed:
1195
+ raise RuntimeError("E2BSandboxPool is closed")
1196
+ if not self._idle:
1197
+ break
1198
+ sandbox, channel = self._idle.pop()
1199
+ try:
1200
+ sandbox.set_timeout(self._sandbox_timeout_s)
1201
+ except Exception: # noqa: BLE001 - a dead idle sandbox is expected after long gaps
1202
+ self._retire(sandbox)
1203
+ continue
1204
+ return sandbox, channel
1205
+ sandbox = create_sandbox(self._factory)
1206
+ with self._lock:
1207
+ self._created += 1
1208
+ self._started[id(sandbox)] = time.monotonic()
1209
+ if not self._closed:
1210
+ # Register before bootstrap so cancellation can kill cold-start sandboxes that
1211
+ # are still installing dependencies or waiting for the runner hello.
1212
+ self._all.append(sandbox)
1213
+ registered = True
1214
+ else:
1215
+ registered = False
1216
+ if not registered:
1217
+ self._retire(sandbox)
1218
+ raise RuntimeError("E2BSandboxPool is closed")
1219
+ try:
1220
+ self._bootstrap(sandbox)
1221
+ channel = self._start_runner(sandbox)
1222
+ except BaseException:
1223
+ self._retire(sandbox)
1224
+ raise
1225
+ with self._lock:
1226
+ if not self._closed:
1227
+ return sandbox, channel
1228
+ self._retire(sandbox) # closed while we were bootstrapping: don't leak the sandbox
1229
+ raise RuntimeError("E2BSandboxPool is closed")
1230
+
1231
+ def assert_episode_timeout(self, episode_timeout_s: float) -> None:
1232
+ """Reject a runtime budget that this shared pool cannot keep alive."""
1233
+ requested = validate_episode_timeout_s(episode_timeout_s)
1234
+ required_lifetime = _episode_lease_lifetime_s(requested)
1235
+ required_lease = max(math.ceil(DEFAULT_SANDBOX_TIMEOUT_S), math.ceil(required_lifetime))
1236
+ if (
1237
+ self._sandbox_timeout_s < required_lease
1238
+ or self._max_episode_lifetime_s < required_lifetime
1239
+ ):
1240
+ raise ValueError(
1241
+ "shared E2BSandboxPool episode timeout is too small; construct the pool "
1242
+ f"with episode_timeout_s >= {requested:g}"
1243
+ )
1244
+
1245
+ def release(self, sandbox: SandboxHandle, channel: E2BStdioChannel, *, healthy: bool) -> None:
1246
+ """Return a sandbox for reuse, or discard it after a failed episode."""
1247
+ if healthy:
1248
+ with self._lock:
1249
+ if not self._closed:
1250
+ self._idle.append((sandbox, channel))
1251
+ return
1252
+ self._retire(sandbox)
1253
+
1254
+ def retire_idle(self) -> int:
1255
+ """Retire every sandbox currently idle, preserving any episode still in flight.
1256
+
1257
+ The idle set is detached atomically so a concurrent acquire cannot reclaim a stream that
1258
+ this call is about to kill. Teardown happens outside the pool lock and in a bounded worker
1259
+ set: a normal E2B evaluation wave can leave dozens of idle sandboxes, and serial network
1260
+ teardown would otherwise add material latency before every proposer call. Usage remains
1261
+ cumulative because each sandbox still passes through ``_retire`` exactly once.
1262
+
1263
+ Returns the number retired, primarily for diagnostics and tests.
1264
+ """
1265
+ with self._lock:
1266
+ idle, self._idle = self._idle, []
1267
+ sandboxes = [sandbox for sandbox, _channel in idle]
1268
+ self._retire_many(sandboxes)
1269
+ return len(sandboxes)
1270
+
1271
+ def close(self) -> None:
1272
+ """Kill every pooled sandbox; safe to call more than once."""
1273
+ with self._lock:
1274
+ self._closed = True
1275
+ sandboxes = list(self._all)
1276
+ self._idle = []
1277
+ self._retire_many(sandboxes)
1278
+
1279
+ def usage(self) -> SandboxUsage:
1280
+ """The pool's spend meter so far: sandbox count and total lifetime seconds."""
1281
+ now = time.monotonic()
1282
+ with self._lock:
1283
+ live = sum(now - started for started in self._started.values())
1284
+ return SandboxUsage(count=self._created, seconds=self._retired_seconds + live)
1285
+
1286
+ def _retire(self, sandbox: SandboxHandle) -> None:
1287
+ """Kill one lease, finalizing its meter only after teardown is proved."""
1288
+ sandbox_id = id(sandbox)
1289
+ while True:
1290
+ with self._lock:
1291
+ if sandbox_id not in self._started:
1292
+ return # another close/release path proved this exact lease gone
1293
+ owner_done = self._retiring.get(sandbox_id)
1294
+ if owner_done is None:
1295
+ owner_done = threading.Event()
1296
+ self._retiring[sandbox_id] = owner_done
1297
+ # Never make a lease available again once retirement begins. Keep it in
1298
+ # _all until kill succeeds so a later close() can retry a failed cleanup.
1299
+ self._idle = [item for item in self._idle if item[0] is not sandbox]
1300
+ break
1301
+ owner_done.wait()
1302
+
1303
+ try:
1304
+ kill_sandbox(sandbox)
1305
+ retired_at = time.monotonic()
1306
+ with self._lock:
1307
+ started = self._started.pop(sandbox_id)
1308
+ self._all = [item for item in self._all if item is not sandbox]
1309
+ self._retired_seconds += retired_at - started
1310
+ finally:
1311
+ with self._lock:
1312
+ self._retiring.pop(sandbox_id, None)
1313
+ owner_done.set()
1314
+
1315
+ def _retire_many(self, sandboxes: list[SandboxHandle]) -> None:
1316
+ """Retire every requested lease and report all unproven cleanup as one failure."""
1317
+ if not sandboxes:
1318
+ return
1319
+
1320
+ def retire(sandbox: SandboxHandle) -> SandboxCleanupError | None:
1321
+ try:
1322
+ self._retire(sandbox)
1323
+ except SandboxCleanupError as error:
1324
+ return error
1325
+ return None
1326
+
1327
+ if len(sandboxes) == 1:
1328
+ failures = [retire(sandboxes[0])]
1329
+ else:
1330
+ # Cancellation commonly lands during a full-concurrency eval wave. Retiring every
1331
+ # active lease in parallel keeps teardown latency bounded by one E2B retry sequence.
1332
+ with ThreadPoolExecutor(max_workers=min(_MAX_RETIRE_WORKERS, len(sandboxes))) as pool:
1333
+ failures = list(pool.map(retire, sandboxes))
1334
+ errors = [error for error in failures if error is not None]
1335
+ if errors:
1336
+ raise SandboxCleanupError(
1337
+ f"failed to prove cleanup for {len(errors)} of {len(sandboxes)} E2B sandboxes",
1338
+ resource="evaluator_sandbox_pool",
1339
+ sandbox_usage=self.usage(),
1340
+ ) from errors[0]
1341
+
1342
+ def __enter__(self) -> E2BSandboxPool:
1343
+ return self
1344
+
1345
+ def __exit__(self, *exc_info: object) -> None:
1346
+ self.close()
1347
+
1348
+ def _bootstrap(self, sandbox: SandboxHandle) -> None:
1349
+ """Upload the runner files; on template-less sandboxes also install node 22 + pi's deps."""
1350
+ for name in _RUNNER_FILES:
1351
+ sandbox.files.write(f"{RUNNER_WORKDIR}/{name}", _read_entry(name))
1352
+ if self._template:
1353
+ return # the template prebakes node 22 + node_modules; only the runner files refresh
1354
+ sandbox.files.write(f"{RUNNER_WORKDIR}/package.json", _PACKAGE_JSON)
1355
+ sandbox.commands.run(NODE_INSTALL_CMD, timeout=INSTALL_TIMEOUT_S)
1356
+ sandbox.commands.run(
1357
+ f"cd {RUNNER_WORKDIR} && npm install {' '.join(PI_NPM_PACKAGES)}",
1358
+ timeout=INSTALL_TIMEOUT_S,
1359
+ )
1360
+
1361
+ def _start_runner(self, sandbox: SandboxHandle) -> E2BStdioChannel:
1362
+ # Template-less bootstrap can consume most of the creation lease. Reset immediately before
1363
+ # starting the runner so startup + hello get the configured budget, then reset again after
1364
+ # hello so the first episode gets that full budget too. Subsequent in-flight heartbeats
1365
+ # keep extending the lease while an episode is active.
1366
+ timeout = self._sandbox_timeout_s
1367
+ sandbox.set_timeout(timeout)
1368
+ # timeout=0 = no command-connection limit (SDK-documented): the runner must outlive every
1369
+ # episode on this sandbox; the sandbox's own lifetime is the real bound.
1370
+ handle = sandbox.commands.run(START_CMD, background=True, stdin=True, timeout=0)
1371
+ # background=True always yields a handle; the union return type is the protocol's.
1372
+ channel = E2BStdioChannel(
1373
+ sandbox,
1374
+ cast("CommandHandle", handle),
1375
+ sandbox_timeout_s=timeout,
1376
+ max_episode_lifetime_s=self._max_episode_lifetime_s,
1377
+ )
1378
+ self._await_hello(channel)
1379
+ sandbox.set_timeout(timeout)
1380
+ return channel
1381
+
1382
+ def _await_hello(self, channel: E2BStdioChannel) -> None:
1383
+ """Block until the runner's `hello` frame (unknown frames are skipped, RunnerLink-style)."""
1384
+ deadline = time.monotonic() + self._hello_timeout
1385
+ while True:
1386
+ remaining = deadline - time.monotonic()
1387
+ if remaining <= 0:
1388
+ raise RuntimeError(_no_hello(self._hello_timeout, channel))
1389
+ try:
1390
+ frame = channel.recv(timeout=remaining)
1391
+ except TimeoutError as exc:
1392
+ raise RuntimeError(_no_hello(self._hello_timeout, channel)) from exc
1393
+ if frame is None:
1394
+ raise RuntimeError(_no_hello(self._hello_timeout, channel))
1395
+ if frame.get("type") == "hello":
1396
+ return
1397
+
1398
+
1399
+ class E2BPiRuntime:
1400
+ """The `Runtime` for pi-node harnesses on the e2b backend: pi runs inside a pooled sandbox.
1401
+
1402
+ `run` accepts ANY `AgentEnvironment` — tool calls are answered host-side (the world-model
1403
+ simulation in closed-loop eval), while the harness process executes in an E2B sandbox drawn
1404
+ from the pool. Each episode acquires a sandbox, delegates wholly to `RunnerLink` (frame
1405
+ broker, worker-LLM answering, tool budget, transcript recording), and returns the sandbox for
1406
+ reuse. Pass a shared `pool` to amortize bootstrap across many runtimes (a whole harness
1407
+ search); without one, the runtime creates a private pool from `template` and owns its
1408
+ lifetime — call `close()` (or use as a context manager) when the eval finishes.
1409
+ """
1410
+
1411
+ def __init__(
1412
+ self,
1413
+ *,
1414
+ provider: ToolCallingProvider,
1415
+ files: dict[str, str],
1416
+ tools: list[ToolSpec],
1417
+ system_prompt: str,
1418
+ template: str | None = None,
1419
+ api_key: str | None = None,
1420
+ pool: E2BSandboxPool | None = None,
1421
+ worker_fn: WorkerFn | None = None,
1422
+ hello_timeout: float = HELLO_TIMEOUT_S,
1423
+ max_turns: int = DEFAULT_MAX_TURNS,
1424
+ max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
1425
+ temperature: float = 0.7,
1426
+ skills: SkillLibrary | None = None,
1427
+ episode_timeout_s: float = DEFAULT_EVAL_EPISODE_TIMEOUT_S,
1428
+ context_window: int | None = None,
1429
+ transport_retries: int = 1,
1430
+ should_cancel: Callable[[], bool] | None = None,
1431
+ ) -> None:
1432
+ if max_turns < 1:
1433
+ raise ValueError("max_turns must be >= 1")
1434
+ if max_output_tokens < 1:
1435
+ raise ValueError("max_output_tokens must be >= 1")
1436
+ if not 0.0 <= temperature <= 2.0:
1437
+ raise ValueError("temperature must be in [0, 2]")
1438
+ episode_timeout_s = validate_episode_timeout_s(episode_timeout_s)
1439
+ if (
1440
+ isinstance(transport_retries, bool)
1441
+ or not isinstance(transport_retries, int)
1442
+ or transport_retries < 0
1443
+ ):
1444
+ raise ValueError("transport_retries must be a nonnegative integer")
1445
+ self._provider = provider
1446
+ self._files = dict(files)
1447
+ self._tools = list(tools)
1448
+ self._system_prompt = system_prompt
1449
+ self._worker_fn = worker_fn # test seam, exactly like RunnerLink's
1450
+ self._max_turns = max_turns
1451
+ self._max_output_tokens = max_output_tokens
1452
+ self._temperature = temperature
1453
+ self._skills = skills if skills is not None else SkillLibrary()
1454
+ self._episode_timeout_s = episode_timeout_s
1455
+ self._context_window = context_window
1456
+ self._transport_retries = transport_retries
1457
+ self._should_cancel = should_cancel
1458
+ self._aborted = threading.Event()
1459
+ self._owns_pool = pool is None
1460
+ if pool is None:
1461
+ self._pool = E2BSandboxPool(
1462
+ template=template,
1463
+ api_key=api_key,
1464
+ hello_timeout=hello_timeout,
1465
+ episode_timeout_s=episode_timeout_s,
1466
+ )
1467
+ else:
1468
+ pool.assert_episode_timeout(episode_timeout_s)
1469
+ self._pool = pool
1470
+
1471
+ def run(self, task_id: str, instruction: str, environment: AgentEnvironment) -> RunResult:
1472
+ if self._cancel_requested():
1473
+ raise RuntimeCancelled("runtime episode cancelled")
1474
+ transport_failures = 0
1475
+ while True:
1476
+ try:
1477
+ return self._run_episode(task_id, instruction, environment)
1478
+ except RuntimeCancelled:
1479
+ # RunnerLink owns the episode's authoritative partial worker meter.
1480
+ # Preserve it instead of replacing the cancellation at this wrapper.
1481
+ raise
1482
+ except Exception as exc:
1483
+ if self._cancel_requested():
1484
+ raise RuntimeCancelled("runtime episode cancelled") from exc
1485
+ if not _is_retryable_transport_error(exc):
1486
+ raise
1487
+ if transport_failures >= self._transport_retries:
1488
+ raise
1489
+ # Transport death (stream drop, sandbox lifetime, dead runner): the failed
1490
+ # attempt's sandbox was already discarded on release. Replay on a fresh
1491
+ # sandbox up to the configured budget: the environment session may replay the
1492
+ # dead attempt's opening steps, which for the world-model sim beats failing a
1493
+ # whole search wave over one dropped connection, while a side-effectful real
1494
+ # environment sets transport_retries=0 to forbid replay. pi-level failures
1495
+ # come back as RunResults (episode_error), never as exceptions, so this
1496
+ # retries infrastructure only.
1497
+ transport_failures += 1
1498
+
1499
+ def _run_episode(
1500
+ self, task_id: str, instruction: str, environment: AgentEnvironment
1501
+ ) -> RunResult:
1502
+ sandbox, channel = self._pool.acquire()
1503
+ healthy = False
1504
+ try:
1505
+ link = RunnerLink(
1506
+ channel,
1507
+ tools=self._tools,
1508
+ provider=self._provider,
1509
+ worker_fn=self._worker_fn,
1510
+ files=self._files,
1511
+ system_prompt=self._system_prompt,
1512
+ max_turns=self._max_turns,
1513
+ max_output_tokens=self._max_output_tokens,
1514
+ temperature=self._temperature,
1515
+ skills=self._skills,
1516
+ episode_timeout_s=self._episode_timeout_s,
1517
+ context_window=self._context_window,
1518
+ should_cancel=self._cancel_requested,
1519
+ )
1520
+ result = link.run(task_id, instruction, environment)
1521
+ # A wall-budget stop may leave the runner mid-turn. Never reuse or retry it: the
1522
+ # partial result is scoreable, but the sandbox's protocol state is not trustworthy.
1523
+ healthy = result.stop_reason is not StopReason.BUDGET
1524
+ return result
1525
+ finally:
1526
+ self._pool.release(sandbox, channel, healthy=healthy)
1527
+
1528
+ def abort(self) -> None:
1529
+ """Stop every sibling episode and close the shared pool before its caller joins them."""
1530
+ self._aborted.set()
1531
+ self._pool.close()
1532
+
1533
+ def _cancel_requested(self) -> bool:
1534
+ return self._aborted.is_set() or (self._should_cancel is not None and self._should_cancel())
1535
+
1536
+ def close(self) -> None:
1537
+ """Close the private pool; a no-op when the pool is shared (its owner closes it)."""
1538
+ if self._owns_pool:
1539
+ self._pool.close()
1540
+
1541
+ def __enter__(self) -> E2BPiRuntime:
1542
+ return self
1543
+
1544
+ def __exit__(self, *exc_info: object) -> None:
1545
+ self.close()
1546
+
1547
+
1548
+ def _is_retryable_transport_error(exc: Exception) -> bool:
1549
+ """Whether an episode exception means its E2B transport is no longer trustworthy."""
1550
+ if isinstance(exc, RuntimeCancelled):
1551
+ return False
1552
+ if isinstance(exc, (RuntimeError, TimeoutError)):
1553
+ return True
1554
+ # Keep the E2B SDK optional at import time. Its TimeoutException is not a built-in
1555
+ # TimeoutError, but any instance means the sandbox/channel state is uncertain.
1556
+ exc_type = type(exc)
1557
+ return exc_type.__module__ == "e2b.exceptions" and exc_type.__name__ == "TimeoutException"
1558
+
1559
+
1560
+ def _is_httpcore_remote_stream_reset(exc: Exception) -> bool:
1561
+ """Recognize the exact HTTP/2 reset emitted by E2B's httpcore control-plane call."""
1562
+ exc_type = type(exc)
1563
+ return (
1564
+ exc_type.__module__ == "httpcore"
1565
+ and exc_type.__name__ == "RemoteProtocolError"
1566
+ and _HTTPCORE_REMOTE_STREAM_RESET.fullmatch(str(exc)) is not None
1567
+ )
1568
+
1569
+
1570
+ def session_entry_files() -> dict[str, str]:
1571
+ """The in-sandbox runner file(s) a live session uploads, as {filename: content}.
1572
+
1573
+ Public accessor for consumers outside wmo (the platform's live-session driver reads these
1574
+ from the installed wmo package and writes them into the sandbox at start).
1575
+ """
1576
+ return {name: _read_entry(name) for name in _LIVE_RUNNER_FILES}
1577
+
1578
+
1579
+ @overload
1580
+ def start_live_runner(
1581
+ sandbox: SandboxHandle,
1582
+ *,
1583
+ template: str | None = None,
1584
+ workspace: str = LIVE_WORKSPACE,
1585
+ hello_timeout: float = HELLO_TIMEOUT_S,
1586
+ reconnect_while_idle: bool = False,
1587
+ durable_outbox: Literal[False] = False,
1588
+ ) -> E2BStdioChannel: ...
1589
+
1590
+
1591
+ @overload
1592
+ def start_live_runner(
1593
+ sandbox: SandboxHandle,
1594
+ *,
1595
+ template: str | None = None,
1596
+ workspace: str = LIVE_WORKSPACE,
1597
+ hello_timeout: float = HELLO_TIMEOUT_S,
1598
+ reconnect_while_idle: bool = False,
1599
+ durable_outbox: Literal[True],
1600
+ ) -> E2BDurableChannel: ...
1601
+
1602
+
1603
+ def start_live_runner(
1604
+ sandbox: SandboxHandle,
1605
+ *,
1606
+ template: str | None = None,
1607
+ workspace: str = LIVE_WORKSPACE,
1608
+ hello_timeout: float = HELLO_TIMEOUT_S,
1609
+ reconnect_while_idle: bool = False,
1610
+ durable_outbox: bool = False,
1611
+ ) -> E2BStdioChannel | E2BDurableChannel:
1612
+ """Bootstrap and start `runner_live.ts` on an already-created sandbox; return its channel.
1613
+
1614
+ A live session (unlike a pooled eval episode) is one dedicated sandbox whose lifecycle the
1615
+ caller owns — the caller creates it (setting its own timeout / metadata for reaping) and holds
1616
+ the handle for keepalive, PTY, and reconciliation. This function does only the runner
1617
+ bootstrap: upload the live runner file(s), install node 22 + pi's npm deps unless a template
1618
+ prebakes them, ensure the workspace dir, start the long-lived runner over a stdin-writable
1619
+ background command, and block until its `hello`. By default this returns the established
1620
+ ``E2BStdioChannel`` unchanged. ``durable_outbox=True`` instead gives the same ordinary runner a
1621
+ unique sequenced outbox below ``RUNNER_WORKDIR`` and returns an ``E2BDurableChannel``; project
1622
+ files and the agent-facing workspace remain separate from transport state.
1623
+ """
1624
+ for name in _LIVE_RUNNER_FILES:
1625
+ sandbox.files.write(f"{RUNNER_WORKDIR}/{name}", _read_entry(name))
1626
+ if not (template or os.environ.get(E2B_TEMPLATE_ENV)):
1627
+ sandbox.files.write(f"{RUNNER_WORKDIR}/package.json", _PACKAGE_JSON)
1628
+ sandbox.commands.run(NODE_INSTALL_CMD, timeout=INSTALL_TIMEOUT_S)
1629
+ sandbox.commands.run(
1630
+ f"cd {RUNNER_WORKDIR} && npm install {' '.join(PI_NPM_PACKAGES)}",
1631
+ timeout=INSTALL_TIMEOUT_S,
1632
+ )
1633
+ # `workspace` is a public parameter; quote it so a caller-supplied path can't
1634
+ # inject extra shell commands into the live sandbox.
1635
+ outbox_root: str | None = None
1636
+ stderr_path: str | None = None
1637
+ start_cmd = LIVE_START_CMD
1638
+ if durable_outbox:
1639
+ outbox_root = f"{RUNNER_WORKDIR}/live-outbox-{uuid.uuid4().hex}"
1640
+ stderr_path = f"{outbox_root}/stderr.log"
1641
+ sandbox.commands.run(
1642
+ f"mkdir -p {shlex.quote(workspace)} {shlex.quote(outbox_root + '/frames')}",
1643
+ timeout=30,
1644
+ )
1645
+ start_cmd = f"{LIVE_START_CMD} 2>> {shlex.quote(stderr_path)}"
1646
+ handle = sandbox.commands.run(
1647
+ start_cmd,
1648
+ background=True,
1649
+ envs={"WMO_LIVE_OUTBOX": outbox_root},
1650
+ stdin=True,
1651
+ timeout=0,
1652
+ )
1653
+ channel: E2BStdioChannel | E2BDurableChannel = E2BDurableChannel(
1654
+ sandbox,
1655
+ cast("CommandHandle", handle),
1656
+ outbox_root=outbox_root,
1657
+ stderr_path=stderr_path,
1658
+ )
1659
+ else:
1660
+ sandbox.commands.run(f"mkdir -p {shlex.quote(workspace)}", timeout=30)
1661
+ handle = sandbox.commands.run(LIVE_START_CMD, background=True, stdin=True, timeout=0)
1662
+ channel = E2BStdioChannel(
1663
+ sandbox,
1664
+ cast("CommandHandle", handle),
1665
+ reconnect_while_idle=reconnect_while_idle,
1666
+ )
1667
+ try:
1668
+ deadline = time.monotonic() + hello_timeout
1669
+ while True:
1670
+ remaining = deadline - time.monotonic()
1671
+ if remaining <= 0:
1672
+ raise RuntimeError(_no_hello_live(hello_timeout, channel, start_cmd=start_cmd))
1673
+ try:
1674
+ frame = channel.recv(timeout=remaining)
1675
+ except TimeoutError as exc:
1676
+ raise RuntimeError(
1677
+ _no_hello_live(hello_timeout, channel, start_cmd=start_cmd)
1678
+ ) from exc
1679
+ if frame is None:
1680
+ raise RuntimeError(_no_hello_live(hello_timeout, channel, start_cmd=start_cmd))
1681
+ if frame.get("type") == "hello":
1682
+ return channel
1683
+ except Exception:
1684
+ # A failed handshake must not orphan the background node runner + its reader
1685
+ # thread in the caller-owned sandbox; close the channel before propagating.
1686
+ with contextlib.suppress(Exception):
1687
+ channel.close()
1688
+ raise
1689
+
1690
+
1691
+ def _no_hello_live(
1692
+ timeout: float,
1693
+ channel: E2BStdioChannel | E2BDurableChannel,
1694
+ *,
1695
+ start_cmd: str = LIVE_START_CMD,
1696
+ ) -> str:
1697
+ tail = channel.stderr_tail()
1698
+ suffix = f"; recent runner stderr:\n{tail}" if tail else ""
1699
+ return f"live runner sent no hello within {timeout:g}s ({start_cmd!r}){suffix}"
1700
+
1701
+
1702
+ def _no_hello(timeout: float, channel: E2BStdioChannel) -> str:
1703
+ tail = channel.stderr_tail()
1704
+ suffix = f"; recent runner stderr:\n{tail}" if tail else ""
1705
+ return f"pi runner sent no hello within {timeout:g}s ({START_CMD!r}){suffix}"
1706
+
1707
+
1708
+ def _read_entry(name: str) -> str:
1709
+ with open(os.path.join(_PI_ENTRY_DIR, name), encoding="utf-8") as fh:
1710
+ return fh.read()