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/agents/project.py ADDED
@@ -0,0 +1,928 @@
1
+ """Persistent E2B filesystem projects driven by the shared pi session runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import shlex
8
+ import time
9
+ from collections.abc import Callable, Collection
10
+ from dataclasses import dataclass
11
+ from pathlib import PurePosixPath
12
+ from typing import Protocol
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from wmo.core.types import JsonObject
17
+ from wmo.harness.doc import HarnessDoc
18
+ from wmo.harness.e2b_sandbox import (
19
+ SandboxCleanupError,
20
+ SandboxFactory,
21
+ SandboxHandle,
22
+ SandboxUsage,
23
+ create_sandbox,
24
+ default_sandbox_factory,
25
+ kill_sandbox,
26
+ )
27
+ from wmo.harness.live_session import (
28
+ DEFAULT_ACTIONS_PER_TURN,
29
+ LiveSession,
30
+ SessionEvent,
31
+ ToolOutcome,
32
+ )
33
+ from wmo.harness.pi_e2b import start_live_runner
34
+ from wmo.harness.runner_link import Channel, TokenUsage
35
+ from wmo.harness.runtime import HarnessSearchCancelled
36
+ from wmo.harness.source_tree import MAX_SOURCE_PATH_BYTES, HarnessSourceFile, HarnessSourceTree
37
+ from wmo.harness.tools import resolve_tools
38
+ from wmo.providers.base import ToolCallingProvider
39
+
40
+ PROJECT_WORKSPACE = "/home/user/project"
41
+ DEFAULT_PROJECT_TIMEOUT_S = 21_600
42
+ DEFAULT_SOURCE_TREE_MAX_FILES = 1_024
43
+ DEFAULT_SOURCE_TREE_MAX_BYTES = 8 * 1024 * 1024
44
+ _OUTPUT_CAP = 16_000
45
+ # Combined stdout+stderr budget for one bash command (head+tail truncated per stream).
46
+ _BASH_OUTPUT_CAP = 32 * 1024
47
+ _BASH_TIMEOUT_S = 60.0
48
+ _PROJECT_TOOLS = frozenset({"bash", "read_file", "write_file", "submit"})
49
+ _RECOVERABLE_SESSION_MARKERS = (
50
+ "server disconnected",
51
+ "connection reset",
52
+ "connection closed",
53
+ "broken pipe",
54
+ "remoteprotocolerror",
55
+ "readerror",
56
+ "pi runner process exited",
57
+ "pi live runner process exited",
58
+ "durable outbox",
59
+ "durable runner",
60
+ "failed to send a frame to the e2b runner",
61
+ "session ended before completing its turn",
62
+ "live session runner did not become ready",
63
+ "channel send failed",
64
+ )
65
+
66
+ # One trusted in-sandbox walk turning a project directory into bounded {path: base64} JSON.
67
+ # Only regular files are captured; every non-regular entry (symlink, socket, pipe) is COUNTED
68
+ # in the payload's `skipped` list so the host can reject a candidate that would otherwise be
69
+ # silently thinner than what the agent built. Path and byte bounds are enforced in-sandbox so
70
+ # the host never downloads more than the declared budget.
71
+ _SNAPSHOT_SOURCE_TREE_SCRIPT = r"""
72
+ import base64
73
+ import json
74
+ import os
75
+ import stat
76
+ import sys
77
+
78
+
79
+ def fail(message):
80
+ sys.stderr.write(message + "\n")
81
+ raise SystemExit(2)
82
+
83
+
84
+ root = sys.argv[1]
85
+ max_files = int(sys.argv[2])
86
+ max_bytes = int(sys.argv[3])
87
+ max_path_bytes = int(sys.argv[4])
88
+ if not os.path.isdir(root):
89
+ fail("source directory does not exist: " + root)
90
+
91
+ files = []
92
+ skipped = []
93
+ total_bytes = 0
94
+ for current, directories, names in os.walk(root, topdown=True, followlinks=False):
95
+ directories.sort()
96
+ names.sort()
97
+ for name in directories:
98
+ path = os.path.join(current, name)
99
+ if not stat.S_ISDIR(os.lstat(path).st_mode):
100
+ skipped.append(os.path.relpath(path, root).replace(os.sep, "/"))
101
+ for name in names:
102
+ path = os.path.join(current, name)
103
+ relative = os.path.relpath(path, root).replace(os.sep, "/")
104
+ if not stat.S_ISREG(os.lstat(path).st_mode):
105
+ skipped.append(relative)
106
+ continue
107
+ if len(relative.encode("utf-8")) > max_path_bytes:
108
+ fail("file path exceeds " + str(max_path_bytes) + " bytes: " + relative)
109
+ if len(files) >= max_files:
110
+ fail("directory exceeds the " + str(max_files) + " file bound")
111
+ with open(path, "rb") as source:
112
+ content = source.read(max_bytes - total_bytes + 1)
113
+ total_bytes += len(content)
114
+ if total_bytes > max_bytes:
115
+ fail("directory exceeds the " + str(max_bytes) + " byte bound")
116
+ files.append(
117
+ {
118
+ "path": relative,
119
+ "content_base64": base64.b64encode(content).decode("ascii"),
120
+ }
121
+ )
122
+
123
+ json.dump({"files": files, "skipped": sorted(skipped)}, sys.stdout, separators=(",", ":"))
124
+ """
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class ProjectBashResult:
129
+ """One finished project bash command: bounded streams plus its exit code."""
130
+
131
+ stdout: str
132
+ stderr: str
133
+ exit_code: int
134
+
135
+
136
+ class _EncodedSourceFile(BaseModel):
137
+ """One regular snapshot file encoded for bounded JSON transport."""
138
+
139
+ path: str
140
+ content_base64: str
141
+
142
+
143
+ class _EncodedSourceSnapshot(BaseModel):
144
+ """The trusted sandbox script's wire representation."""
145
+
146
+ files: tuple[_EncodedSourceFile, ...]
147
+ # Non-regular entries (symlinks, sockets, pipes) the walk refused to capture. A candidate
148
+ # containing them is invalid, not silently thinner, so the host raises on a nonzero count.
149
+ skipped: tuple[str, ...] = ()
150
+
151
+
152
+ class ChannelFactory(Protocol):
153
+ """Start one fresh runner channel in a project's sandbox."""
154
+
155
+ def __call__(self, sandbox: SandboxHandle, workspace: str) -> Channel: ...
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class AgentProjectRun:
160
+ """Result of one agent turn inside a project."""
161
+
162
+ answer: str
163
+ events: tuple[SessionEvent, ...]
164
+ worker_usage: TokenUsage
165
+
166
+
167
+ class _ProjectAgentTurnError(RuntimeError):
168
+ """A worker/provider error reported by a live agent turn, not its transport."""
169
+
170
+
171
+ class AgentProject:
172
+ """A persistent filesystem that can run project-scoped pi agents.
173
+
174
+ The project owns environment state, while :class:`LiveSession` owns ordinary agent execution.
175
+ Repeated ``run`` calls for the same agent and provider reuse one live session and runner, while
176
+ each outer project task gets a fresh model transcript. The project filesystem is the durable
177
+ memory shared across those tasks.
178
+ Changing the agent harness or provider starts a new session against the same filesystem.
179
+ """
180
+
181
+ def __init__(
182
+ self,
183
+ sandbox: SandboxHandle,
184
+ *,
185
+ workspace: str = PROJECT_WORKSPACE,
186
+ channel_factory: ChannelFactory | None = None,
187
+ sandbox_factory: SandboxFactory | None = None,
188
+ owns_sandbox: bool = True,
189
+ ) -> None:
190
+ self._sandbox = sandbox
191
+ self.workspace = workspace.rstrip("/")
192
+ self._channel_factory = channel_factory or _start_channel
193
+ # Replacing a caller-owned sandbox would exceed this object's authority. Injected test or
194
+ # application sandboxes still get the bounded fresh-session retry in the same filesystem.
195
+ self._sandbox_factory = sandbox_factory if owns_sandbox else None
196
+ self._owns_sandbox = owns_sandbox
197
+ self._active_sandbox_started_at = time.monotonic()
198
+ self._retired_sandbox_seconds = 0.0
199
+ self._sandbox_count = 1
200
+ # A lease remains live until E2B confirms its kill. Replacement failures retain both
201
+ # handles here so usage keeps accruing and close() can retry every unproven teardown.
202
+ self._live_sandboxes: dict[int, tuple[SandboxHandle, float]] = {
203
+ id(sandbox): (sandbox, self._active_sandbox_started_at)
204
+ }
205
+ self._closing = False
206
+ self._finished_at: float | None = None
207
+ # Keep an in-process mirror of mediated writes so a dead E2B transport can be replaced
208
+ # without discarding the prior proposals that make this a persistent meta-agent project.
209
+ self._file_contents: dict[str, str] = {}
210
+ self._channel: Channel | None = None
211
+ self._session: LiveSession | None = None
212
+ self._session_agent_hash: str | None = None
213
+ self._session_provider: ToolCallingProvider | None = None
214
+ self._network_locked_sandbox_id: int | None = None
215
+ self._active_event_sink: Callable[[SessionEvent], None] | None = None
216
+ # ``None`` preserves the historical unrestricted project-tool behavior. A concrete set is
217
+ # one logical run's exact, project-relative write grant; it is cleared even when the turn
218
+ # fails so a reused live session cannot inherit the preceding turn's authority.
219
+ self._active_writable_files: frozenset[str] | None = None
220
+ self._retired_worker_usage = TokenUsage()
221
+ try:
222
+ self._initialize_sandbox(self._sandbox)
223
+ except Exception as error:
224
+ if self._owns_sandbox:
225
+ try:
226
+ self._retire_sandbox(self._sandbox)
227
+ except SandboxCleanupError as cleanup_error:
228
+ raise cleanup_error from error
229
+ raise
230
+
231
+ @classmethod
232
+ def create(
233
+ cls,
234
+ *,
235
+ timeout: float = DEFAULT_PROJECT_TIMEOUT_S,
236
+ template: str | None = None,
237
+ api_key: str | None = None,
238
+ metadata: dict[str, str] | None = None,
239
+ ) -> AgentProject:
240
+ """Create one owned E2B project sandbox."""
241
+ factory = default_sandbox_factory(
242
+ timeout=timeout,
243
+ template=template,
244
+ api_key=api_key,
245
+ metadata=metadata,
246
+ )
247
+ sandbox = create_sandbox(factory)
248
+ return cls(sandbox, sandbox_factory=factory)
249
+
250
+ def write_text(self, path: str, content: str) -> None:
251
+ """Write one project-relative file without allowing path traversal."""
252
+ if self._closing:
253
+ raise RuntimeError("cannot write to a closed project")
254
+ absolute = self._absolute_path(path)
255
+ try:
256
+ self._write_sandbox_file(self._sandbox, absolute, content)
257
+ except Exception as error:
258
+ # Proposer context is written before ``run()``, so its recovery loop cannot own an
259
+ # exhausted control-plane retry. Replace an owned, transport-poisoned sandbox once,
260
+ # replay the established mirror, and then apply this idempotent overwrite there.
261
+ if self._sandbox_factory is None or not _is_recoverable_transport_error(error):
262
+ raise
263
+ try:
264
+ self._replace_sandbox()
265
+ self._write_sandbox_file(self._sandbox, absolute, content)
266
+ except Exception as recovery_error:
267
+ raise RuntimeError(
268
+ f"{error}; fresh project sandbox recovery failed: {recovery_error}"
269
+ ) from recovery_error
270
+ self._file_contents[self._relative_path(absolute)] = content
271
+
272
+ def read_text(self, path: str) -> str:
273
+ """Read one project-relative file."""
274
+ if self._closing:
275
+ raise RuntimeError("cannot read from a closed project")
276
+ absolute = self._absolute_path(path)
277
+ relative = self._relative_path(absolute)
278
+ try:
279
+ content = self._sandbox.files.read(absolute)
280
+ except Exception:
281
+ if relative in self._file_contents:
282
+ return self._file_contents[relative]
283
+ raise
284
+ self._file_contents[relative] = content
285
+ return content
286
+
287
+ def run_bash(self, command: str, *, timeout_s: float = _BASH_TIMEOUT_S) -> ProjectBashResult:
288
+ """Run one bounded shell command in the workspace as the default sandbox user.
289
+
290
+ The command runs under ``timeout --kill-after`` inside a profile-free bash with the
291
+ project workspace as its working directory. A nonzero exit is a RESULT, not an
292
+ exception: E2B raises on nonzero exits with the streams attached to the exception, so
293
+ that shape is unwrapped here. Only a transport failure (no exit code) propagates.
294
+ Streams are head+tail truncated to a fixed combined budget.
295
+ """
296
+ if self._closing:
297
+ raise RuntimeError("cannot run bash in a closed project")
298
+ budget_s = max(1, int(timeout_s))
299
+ script = (
300
+ f"cd {shlex.quote(self.workspace)} && "
301
+ f"timeout --kill-after=10s {budget_s}s "
302
+ f"bash --noprofile --norc -c {shlex.quote(command)}"
303
+ )
304
+ result: object
305
+ try:
306
+ result = self._sandbox.commands.run(script, timeout=budget_s + 30)
307
+ except Exception as error: # noqa: BLE001 - E2B raises finished nonzero exits
308
+ exit_code = getattr(error, "exit_code", None)
309
+ if isinstance(exit_code, bool) or not isinstance(exit_code, int):
310
+ raise # a transport failure, not a finished command
311
+ result = error
312
+ return ProjectBashResult(
313
+ stdout=_head_tail(str(getattr(result, "stdout", "") or ""), _BASH_OUTPUT_CAP // 2),
314
+ stderr=_head_tail(str(getattr(result, "stderr", "") or ""), _BASH_OUTPUT_CAP // 2),
315
+ exit_code=int(getattr(result, "exit_code", 0) or 0),
316
+ )
317
+
318
+ def stage_source_tree(
319
+ self,
320
+ tree: HarnessSourceTree,
321
+ dest: str,
322
+ *,
323
+ max_files: int = DEFAULT_SOURCE_TREE_MAX_FILES,
324
+ max_bytes: int = DEFAULT_SOURCE_TREE_MAX_BYTES,
325
+ ) -> None:
326
+ """Write one bounded, validated source tree under a project-relative directory."""
327
+ tree.validate_bounds(max_files=max_files, max_bytes=max_bytes)
328
+ self._absolute_path(dest) # containment: reject absolute or traversing destinations
329
+ for item in tree.files:
330
+ self.write_text(f"{dest}/{item.path}", item.content)
331
+
332
+ def snapshot_source_tree(
333
+ self,
334
+ directory: str,
335
+ *,
336
+ max_files: int = DEFAULT_SOURCE_TREE_MAX_FILES,
337
+ max_bytes: int = DEFAULT_SOURCE_TREE_MAX_BYTES,
338
+ ) -> HarnessSourceTree:
339
+ """Capture one project directory as a bounded, validated host-side source tree.
340
+
341
+ A single in-sandbox python walk emits every regular file as base64 JSON on stdout;
342
+ the host decodes and validates it into a :class:`HarnessSourceTree`. Bound breaches,
343
+ non-UTF-8 content, invalid paths, and non-regular entries (symlinks, sockets, pipes)
344
+ all raise with the offending path in the message: a candidate is captured exactly or
345
+ rejected, never silently thinner.
346
+ """
347
+ if self._closing:
348
+ raise RuntimeError("cannot snapshot a source tree in a closed project")
349
+ if isinstance(max_files, bool) or not isinstance(max_files, int) or max_files < 1:
350
+ raise ValueError("max_files must be a positive integer")
351
+ if isinstance(max_bytes, bool) or not isinstance(max_bytes, int) or max_bytes < 1:
352
+ raise ValueError("max_bytes must be a positive integer")
353
+ absolute = self._absolute_path(directory)
354
+ command = (
355
+ f"python3 -c {shlex.quote(_SNAPSHOT_SOURCE_TREE_SCRIPT)} {shlex.quote(absolute)} "
356
+ f"{max_files} {max_bytes} {MAX_SOURCE_PATH_BYTES}"
357
+ )
358
+ try:
359
+ result = self._sandbox.commands.run(command, timeout=120)
360
+ except Exception as error: # noqa: BLE001 - E2B raises command failures
361
+ detail = str(getattr(error, "stderr", "") or error)
362
+ raise RuntimeError(
363
+ f"project source snapshot failed: {_head_tail(detail, 4_096)}"
364
+ ) from error
365
+ exit_code = int(getattr(result, "exit_code", 0) or 0)
366
+ if exit_code != 0:
367
+ stderr = str(getattr(result, "stderr", "") or "snapshot command failed")
368
+ raise RuntimeError(f"project source snapshot failed: {_head_tail(stderr, 4_096)}")
369
+ try:
370
+ tree = _decode_source_tree_snapshot(str(getattr(result, "stdout", "")))
371
+ except ValueError as error:
372
+ raise RuntimeError(f"project source snapshot is invalid: {error}") from error
373
+ tree.validate_bounds(max_files=max_files, max_bytes=max_bytes)
374
+ return tree
375
+
376
+ def run(
377
+ self,
378
+ agent: HarnessDoc,
379
+ provider: ToolCallingProvider,
380
+ instruction: str,
381
+ *,
382
+ timeout: float = DEFAULT_PROJECT_TIMEOUT_S,
383
+ on_event: Callable[[SessionEvent], None] | None = None,
384
+ should_cancel: Callable[[], bool] | None = None,
385
+ writable_files: Collection[str] | None = None,
386
+ retry_recoverable: bool = True,
387
+ ) -> AgentProjectRun:
388
+ """Run one turn of an ordinary agent against this persistent project.
389
+
390
+ A transient runner-channel disconnect retries the turn once. Owned E2B
391
+ projects replace a transport-poisoned sandbox and replay their mirrored
392
+ filesystem first; injected test projects keep the sandbox and replace
393
+ only the ordinary live session. ``writable_files`` optionally grants the
394
+ agent's ``write_file`` tool access to exact project-relative files for
395
+ this logical run. Omitting it preserves unrestricted project writes;
396
+ an empty collection denies every agent write. Host ``write_text`` calls
397
+ are not constrained by an agent turn's grant. ``retry_recoverable=False``
398
+ makes the run exactly one agent attempt: a transport failure closes the
399
+ session and propagates instead of transparently replaying paid work.
400
+ A bash-capable agent REQUIRES it: shell writes bypass the replayable
401
+ host mirror, so a replayed turn would land on a replacement sandbox
402
+ missing those edits, and that mismatch is rejected here, not silently
403
+ downgraded.
404
+ """
405
+ if self._closing:
406
+ raise RuntimeError("cannot run an agent in a closed project")
407
+ _check_cancelled(should_cancel)
408
+ if self._active_event_sink is not None:
409
+ raise RuntimeError("a project agent turn is already running")
410
+ unsupported = set(agent.tools()) - _PROJECT_TOOLS
411
+ if unsupported:
412
+ names = ", ".join(sorted(unsupported))
413
+ raise ValueError(f"project agents cannot use uncontained tools: {names}")
414
+ if retry_recoverable and "bash" in agent.tools():
415
+ raise ValueError(
416
+ "bash-capable project agents must run with retry_recoverable=False: shell "
417
+ "writes bypass the replayable host file mirror, so a transparently retried "
418
+ "turn could replay onto a replacement sandbox missing those edits"
419
+ )
420
+ write_grant = self._normalize_writable_files(writable_files)
421
+ usage_before = self._total_worker_usage()
422
+ self._active_writable_files = write_grant
423
+ max_attempts = 2 if retry_recoverable else 1
424
+ try:
425
+ for attempt in range(max_attempts):
426
+ try:
427
+ result = self._run_turn(
428
+ agent,
429
+ provider,
430
+ instruction,
431
+ timeout=timeout,
432
+ on_event=on_event,
433
+ should_cancel=should_cancel,
434
+ )
435
+ usage_after = self._total_worker_usage()
436
+ return AgentProjectRun(
437
+ answer=result.answer,
438
+ events=result.events,
439
+ worker_usage=_usage_delta(usage_after, usage_before),
440
+ )
441
+ except HarnessSearchCancelled:
442
+ raise
443
+ except Exception as error:
444
+ if not _is_recoverable_session_error(error):
445
+ raise
446
+ if attempt + 1 >= max_attempts:
447
+ # A transport-poisoned session is never reused by a later logical run,
448
+ # even when the caller deliberately owns recovery at a higher level.
449
+ self._close_agent_session()
450
+ raise
451
+ if self._sandbox_factory is None:
452
+ self._close_agent_session()
453
+ continue
454
+ try:
455
+ self._replace_sandbox()
456
+ except Exception as recovery_error:
457
+ raise RuntimeError(
458
+ f"{error}; fresh project sandbox recovery failed: {recovery_error}"
459
+ ) from recovery_error
460
+ raise AssertionError("unreachable")
461
+ finally:
462
+ self._active_writable_files = None
463
+
464
+ def _run_turn(
465
+ self,
466
+ agent: HarnessDoc,
467
+ provider: ToolCallingProvider,
468
+ instruction: str,
469
+ *,
470
+ timeout: float,
471
+ on_event: Callable[[SessionEvent], None] | None,
472
+ should_cancel: Callable[[], bool] | None,
473
+ ) -> AgentProjectRun:
474
+ """Execute one attempt using the compatible ordinary live session."""
475
+ session = self._ensure_session(agent, provider)
476
+ events: list[SessionEvent] = []
477
+ answer = ""
478
+ turn_started = False
479
+ turn_running = False
480
+ turn_finished = False
481
+ turn_terminal_reason: str | None = None
482
+ turn_error: str | None = None
483
+
484
+ def sink(event: SessionEvent) -> None:
485
+ nonlocal answer, turn_error, turn_finished, turn_running, turn_terminal_reason
486
+ events.append(event)
487
+ if event.kind == "submit":
488
+ submitted = event.payload.get("answer")
489
+ answer = submitted if isinstance(submitted, str) else ""
490
+ elif event.kind == "error" and turn_error is None:
491
+ message = event.payload.get("message")
492
+ turn_error = message if isinstance(message, str) else "project agent session error"
493
+ elif turn_started and event.kind == "state":
494
+ status = event.payload.get("status")
495
+ if status == "running":
496
+ turn_running = True
497
+ elif status == "idle" and turn_running:
498
+ turn_finished = True
499
+ reason = event.payload.get("reason")
500
+ turn_terminal_reason = reason if isinstance(reason, str) else None
501
+ if on_event is not None:
502
+ on_event(event)
503
+
504
+ self._active_event_sink = sink
505
+ try:
506
+ session.send_user_message(instruction)
507
+ turn_started = True
508
+ deadline = time.monotonic() + timeout
509
+ while not turn_finished:
510
+ self._cancel_turn_if_requested(session, should_cancel)
511
+ remaining = deadline - time.monotonic()
512
+ if remaining <= 0:
513
+ session.interrupt("project_run_timeout")
514
+ session.flush_pending_intents()
515
+ # An abort acknowledgement can arrive after this deadline. Retiring the
516
+ # session prevents that stale idle boundary from completing the next turn.
517
+ self._close_agent_session()
518
+ raise TimeoutError(f"project agent did not finish within {timeout:g}s")
519
+ running = session.pump(timeout=min(0.5, remaining))
520
+ # A pump can synchronously run one provider completion. Observe cancellation as
521
+ # soon as it returns, before consuming a second model or tool request.
522
+ self._cancel_turn_if_requested(session, should_cancel)
523
+ if not running and not turn_finished:
524
+ if session.failure_message is not None:
525
+ raise RuntimeError(
526
+ f"project agent session failed: {session.failure_message}"
527
+ )
528
+ raise RuntimeError("project agent session ended before completing its turn")
529
+ if turn_error is not None:
530
+ raise _ProjectAgentTurnError(f"project agent session failed: {turn_error}")
531
+ if turn_terminal_reason in {"aborted", "turn_limit"}:
532
+ raise _ProjectAgentTurnError(
533
+ f"project agent turn ended with reason: {turn_terminal_reason}"
534
+ )
535
+ finally:
536
+ self._active_event_sink = None
537
+ return AgentProjectRun(answer=answer, events=tuple(events), worker_usage=TokenUsage())
538
+
539
+ def _cancel_turn_if_requested(
540
+ self,
541
+ session: LiveSession,
542
+ should_cancel: Callable[[], bool] | None,
543
+ ) -> None:
544
+ """Abort and retire the active session at one cooperative cancellation boundary."""
545
+ if should_cancel is None or not should_cancel():
546
+ return
547
+ session.interrupt("harness_search_cancelled")
548
+ with contextlib.suppress(Exception):
549
+ session.flush_pending_intents()
550
+ self._close_agent_session()
551
+ raise HarnessSearchCancelled("harness search cancelled")
552
+
553
+ def _ensure_session(self, agent: HarnessDoc, provider: ToolCallingProvider) -> LiveSession:
554
+ """Return the compatible live session, starting one when the harness changed."""
555
+ if (
556
+ self._session is not None
557
+ and not self._session.closed
558
+ and self._session_agent_hash == agent.doc_hash
559
+ and self._session_provider is provider
560
+ ):
561
+ return self._session
562
+ self._close_agent_session()
563
+ channel = self._channel_factory(self._sandbox, self.workspace)
564
+ try:
565
+ # Runner bootstrap has completed in channel_factory, but no agent-controlled source
566
+ # has been imported yet. Remove egress before session_start materializes that code.
567
+ self._lock_project_network()
568
+ skills = agent.skills()
569
+ session = LiveSession(
570
+ channel,
571
+ tools=resolve_tools(agent.tools()),
572
+ execute_tool=self._execute_tool,
573
+ on_event=self._emit_session_event,
574
+ files={
575
+ surface.path: surface.content for surface in agent.code_files() if surface.path
576
+ },
577
+ system_prompt=agent.assembled_prompt(),
578
+ skill_bodies={skill.name: skill.body for skill in skills},
579
+ provider=provider,
580
+ # Project agents explore a durable filesystem and can legitimately need one
581
+ # project action per model turn. Never let LiveSession's generic 40-action default
582
+ # silently undercut a harness that explicitly raises its turn budget.
583
+ actions_per_turn=max(DEFAULT_ACTIONS_PER_TURN, agent.max_turns()),
584
+ turn_cap=agent.max_turns(),
585
+ max_output_tokens=agent.max_output_tokens(),
586
+ temperature=agent.temperature(),
587
+ # Project files are durable memory. Replaying every prior project task in the
588
+ # model transcript only duplicates that state and eventually collapses pi's
589
+ # available output budget as context fills.
590
+ conversation_scope="turn",
591
+ )
592
+ session.start()
593
+ except Exception:
594
+ close = getattr(channel, "close", None)
595
+ if callable(close):
596
+ with contextlib.suppress(Exception):
597
+ close()
598
+ raise
599
+ self._channel = channel
600
+ self._session = session
601
+ self._session_agent_hash = agent.doc_hash
602
+ self._session_provider = provider
603
+ return session
604
+
605
+ def _lock_project_network(self) -> None:
606
+ """Remove internet egress before untrusted project evidence can drive tools."""
607
+ if not self._owns_sandbox or self._network_locked_sandbox_id == id(self._sandbox):
608
+ return
609
+ update_network = getattr(self._sandbox, "update_network", None)
610
+ if not callable(update_network):
611
+ raise RuntimeError("owned project sandbox cannot disable internet access")
612
+ update_network({"allow_internet_access": False})
613
+ self._network_locked_sandbox_id = id(self._sandbox)
614
+
615
+ def _emit_session_event(self, event: SessionEvent) -> None:
616
+ """Route session events to the currently active project turn."""
617
+ if self._active_event_sink is not None:
618
+ self._active_event_sink(event)
619
+
620
+ def _close_agent_session(self) -> None:
621
+ """Close the current agent session without touching the project filesystem."""
622
+ session = self._session
623
+ channel = self._channel
624
+ self._session = None
625
+ self._channel = None
626
+ self._session_agent_hash = None
627
+ self._session_provider = None
628
+ if session is not None:
629
+ self._retired_worker_usage.input_tokens += session.worker_usage.input_tokens
630
+ self._retired_worker_usage.output_tokens += session.worker_usage.output_tokens
631
+ self._retired_worker_usage.calls += session.worker_usage.calls
632
+ close = getattr(channel, "close", None)
633
+ if callable(close):
634
+ with contextlib.suppress(Exception):
635
+ close()
636
+ elif session is not None and not session.closed:
637
+ # Test/local channels without an owned close hook still get the protocol-level end.
638
+ # Real project channels close the runner directly above so cancellation never waits
639
+ # for two durable abort/shutdown acknowledgements from an unreachable process.
640
+ with contextlib.suppress(Exception):
641
+ session.end()
642
+ session.pump(timeout=0)
643
+
644
+ def usage(self) -> SandboxUsage:
645
+ """Return this project's sandbox lifetime meter."""
646
+ now = time.monotonic()
647
+ active_seconds = sum(
648
+ max(0.0, now - started_at) for _sandbox, started_at in self._live_sandboxes.values()
649
+ )
650
+ return SandboxUsage(
651
+ count=self._sandbox_count,
652
+ seconds=self._retired_sandbox_seconds + active_seconds,
653
+ )
654
+
655
+ def _total_worker_usage(self) -> TokenUsage:
656
+ """Return worker usage across retired and currently attached live sessions."""
657
+ current = self._session.worker_usage if self._session is not None else TokenUsage()
658
+ return TokenUsage(
659
+ input_tokens=self._retired_worker_usage.input_tokens + current.input_tokens,
660
+ output_tokens=self._retired_worker_usage.output_tokens + current.output_tokens,
661
+ calls=self._retired_worker_usage.calls + current.calls,
662
+ )
663
+
664
+ def close(self) -> None:
665
+ """Release every owned lease, retaining unproven kills for a later retry."""
666
+ if self._finished_at is not None:
667
+ return
668
+ self._closing = True
669
+ self._close_agent_session()
670
+ if not self._owns_sandbox:
671
+ finished_at = time.monotonic()
672
+ for _sandbox, started_at in self._live_sandboxes.values():
673
+ self._retired_sandbox_seconds += max(0.0, finished_at - started_at)
674
+ self._live_sandboxes.clear()
675
+ self._finished_at = finished_at
676
+ return
677
+
678
+ leases = list(self._live_sandboxes.values())
679
+ failures: list[SandboxCleanupError] = []
680
+ for sandbox, _started_at in leases:
681
+ try:
682
+ self._retire_sandbox(sandbox)
683
+ except SandboxCleanupError as error:
684
+ failures.append(error)
685
+ if failures:
686
+ raise SandboxCleanupError(
687
+ "failed to prove cleanup for "
688
+ f"{len(failures)} of {len(leases)} "
689
+ "meta-project E2B sandboxes",
690
+ resource="meta_project_sandbox",
691
+ sandbox_usage=self.usage(),
692
+ ) from failures[0]
693
+ self._finished_at = time.monotonic()
694
+
695
+ def __enter__(self) -> AgentProject:
696
+ return self
697
+
698
+ def __exit__(self, *exc_info: object) -> None:
699
+ self.close()
700
+
701
+ def _absolute_path(self, path: str) -> str:
702
+ candidate = PurePosixPath(path)
703
+ if candidate.is_absolute() or not candidate.parts or ".." in candidate.parts:
704
+ raise ValueError(f"expected a relative project path, got {path!r}")
705
+ return f"{self.workspace}/{candidate.as_posix()}"
706
+
707
+ def _relative_path(self, absolute: str) -> str:
708
+ """Return one already-contained absolute path relative to the project root."""
709
+ return PurePosixPath(absolute).relative_to(PurePosixPath(self.workspace)).as_posix()
710
+
711
+ def _initialize_sandbox(self, sandbox: SandboxHandle) -> None:
712
+ """Create the workspace and replay the authoritative project-file mirror."""
713
+ sandbox.commands.run(f"mkdir -p {shlex.quote(self.workspace)}", timeout=30)
714
+ for relative, content in self._file_contents.items():
715
+ absolute = f"{self.workspace}/{relative}"
716
+ self._write_sandbox_file(sandbox, absolute, content)
717
+
718
+ @staticmethod
719
+ def _write_sandbox_file(sandbox: SandboxHandle, absolute: str, content: str) -> None:
720
+ directory = str(PurePosixPath(absolute).parent)
721
+ for attempt in range(2):
722
+ try:
723
+ sandbox.commands.run(f"mkdir -p {shlex.quote(directory)}", timeout=30)
724
+ sandbox.files.write(absolute, content)
725
+ return
726
+ except Exception as error: # noqa: BLE001 - classify the E2B transport boundary
727
+ # Both operations are idempotent: replaying ``mkdir -p`` and the same overwrite is
728
+ # safe even when the first request reached E2B but its response was disconnected.
729
+ # Keep the live project sandbox/session intact for a one-off control-plane drop.
730
+ if attempt > 0 or not _is_recoverable_transport_error(error):
731
+ raise
732
+
733
+ def _replace_sandbox(self) -> None:
734
+ """Replace a transport-poisoned sandbox while retaining every project file."""
735
+ factory = self._sandbox_factory
736
+ if factory is None:
737
+ raise RuntimeError("project sandbox replacement is unavailable")
738
+ # Required durable files are synchronously mirrored by write_text/write_file. Bash is
739
+ # explicitly scratch-only, so recovery never scans or replays an unbounded agent-created
740
+ # tree before honoring cancellation or replacing a poisoned transport.
741
+ replacement = create_sandbox(factory)
742
+ replacement_started_at = time.monotonic()
743
+ self._sandbox_count += 1
744
+ self._live_sandboxes[id(replacement)] = (replacement, replacement_started_at)
745
+ try:
746
+ self._initialize_sandbox(replacement)
747
+ except Exception as error:
748
+ try:
749
+ self._retire_sandbox(replacement)
750
+ except SandboxCleanupError as cleanup_error:
751
+ raise cleanup_error from error
752
+ raise
753
+
754
+ previous = self._sandbox
755
+ self._close_agent_session()
756
+ self._active_sandbox_started_at = replacement_started_at
757
+ self._sandbox = replacement
758
+ self._network_locked_sandbox_id = None
759
+ if self._owns_sandbox:
760
+ self._retire_sandbox(previous)
761
+
762
+ def _retire_sandbox(self, sandbox: SandboxHandle) -> None:
763
+ """Finalize one lease only after E2B confirms that it is gone."""
764
+ lease = self._live_sandboxes.get(id(sandbox))
765
+ if lease is None:
766
+ return
767
+ kill_sandbox(sandbox)
768
+ retired_at = time.monotonic()
769
+ _handle, started_at = self._live_sandboxes.pop(id(sandbox))
770
+ self._retired_sandbox_seconds += max(0.0, retired_at - started_at)
771
+
772
+ def _execute_tool(
773
+ self,
774
+ name: str,
775
+ arguments: JsonObject,
776
+ emit: Callable[[str, str], None],
777
+ ) -> ToolOutcome:
778
+ del emit # Project tools return one bounded observation; they do not stream output.
779
+ try:
780
+ if name == "bash":
781
+ # Agent bash writes bypass the host-side file mirror, so a sandbox replacement
782
+ # would silently lose them. That is sound only because run() ENFORCES that a
783
+ # bash-capable agent runs with retry_recoverable=False: a dead transport ends
784
+ # the turn instead of replaying it onto a replacement sandbox.
785
+ result = self.run_bash(str(arguments.get("command", "")))
786
+ body = result.stdout
787
+ if result.stderr:
788
+ body = f"{body}\n[stderr]\n{result.stderr}" if body else result.stderr
789
+ if result.exit_code != 0:
790
+ marker = f"[exit {result.exit_code}]"
791
+ body = f"{body}\n{marker}" if body else marker
792
+ return ToolOutcome(content=body, is_error=result.exit_code != 0)
793
+ if name == "read_file":
794
+ path = self._tool_path(str(arguments.get("path", "")))
795
+ relative = self._relative_path(path)
796
+ try:
797
+ content = self._sandbox.files.read(path)
798
+ except Exception:
799
+ if relative not in self._file_contents:
800
+ raise
801
+ content = self._file_contents[relative]
802
+ else:
803
+ self._file_contents[relative] = content
804
+ return _capped(content)
805
+ if name == "write_file":
806
+ path = self._tool_path(str(arguments.get("path", "")))
807
+ relative = self._relative_path(path)
808
+ if (
809
+ self._active_writable_files is not None
810
+ and relative not in self._active_writable_files
811
+ ):
812
+ raise PermissionError(
813
+ f"path is not writable in this project turn: {relative!r}"
814
+ )
815
+ content = str(arguments.get("content", ""))
816
+ self._write_sandbox_file(self._sandbox, path, content)
817
+ self._file_contents[relative] = content
818
+ return ToolOutcome(content=f"wrote {path}")
819
+ except Exception as error: # noqa: BLE001 - tool errors are agent observations
820
+ return ToolOutcome(content=f"{name} failed: {error}", is_error=True)
821
+ return ToolOutcome(content=f"tool {name!r} not available", is_error=True)
822
+
823
+ def _tool_path(self, path: str) -> str:
824
+ """Resolve an agent-supplied path while containing it to the project."""
825
+ candidate = PurePosixPath(path)
826
+ workspace = PurePosixPath(self.workspace)
827
+ if candidate.is_absolute():
828
+ try:
829
+ candidate = candidate.relative_to(workspace)
830
+ except ValueError as error:
831
+ raise ValueError(f"path escapes project workspace: {path!r}") from error
832
+ if not candidate.parts or ".." in candidate.parts:
833
+ raise ValueError(f"path escapes project workspace: {path!r}")
834
+ return str(workspace / candidate)
835
+
836
+ def _normalize_writable_files(
837
+ self, writable_files: Collection[str] | None
838
+ ) -> frozenset[str] | None:
839
+ """Normalize one optional exact-file grant to project-relative paths."""
840
+ if writable_files is None:
841
+ return None
842
+ return frozenset(self._relative_path(self._absolute_path(path)) for path in writable_files)
843
+
844
+
845
+ def _start_channel(sandbox: SandboxHandle, workspace: str) -> Channel:
846
+ # Project turns can be separated by long evaluation waves. Their ordinary live runner writes
847
+ # every semantic output frame to a sequenced E2B outbox before stdout, so the shared
848
+ # LiveSession can replay a dropped command stream without replacing the agent, transcript, or
849
+ # project sandbox. Platform live sessions keep start_live_runner's established stdio default.
850
+ return start_live_runner(sandbox, workspace=workspace, durable_outbox=True)
851
+
852
+
853
+ def _is_recoverable_session_error(error: Exception) -> bool:
854
+ """Return whether one fresh live session may recover this transport failure."""
855
+ if isinstance(error, _ProjectAgentTurnError):
856
+ return False
857
+ return _is_recoverable_transport_error(error)
858
+
859
+
860
+ def _is_recoverable_transport_error(error: Exception) -> bool:
861
+ """Return whether one idempotent E2B transport operation may be retried once."""
862
+ error_type = type(error)
863
+ if error_type.__module__ == "e2b.exceptions" and error_type.__name__ == "TimeoutException":
864
+ return True
865
+ text = str(error).lower()
866
+ # httpcore can race an E2B HTTP/2 GOAWAY with request body delivery. h2 then surfaces a raw
867
+ # ProtocolError instead of httpx's usual transport wrapper. The pool will not reassign that
868
+ # unavailable closed connection, so the next idempotent control-plane request opens a fresh
869
+ # one. Match the state-machine shape rather than every h2 ProtocolError: malformed responses
870
+ # remain fatal.
871
+ if "invalid input connectioninputs." in text and "connectionstate.closed" in text:
872
+ return True
873
+ return any(marker in text for marker in _RECOVERABLE_SESSION_MARKERS)
874
+
875
+
876
+ def _check_cancelled(should_cancel: Callable[[], bool] | None) -> None:
877
+ """Fail before creating or retrying a project turn when search cancellation is already set."""
878
+ if should_cancel is not None and should_cancel():
879
+ raise HarnessSearchCancelled("harness search cancelled")
880
+
881
+
882
+ def _usage_delta(after: TokenUsage, before: TokenUsage) -> TokenUsage:
883
+ """Subtract cumulative usage snapshots for one logical project run."""
884
+ return TokenUsage(
885
+ input_tokens=after.input_tokens - before.input_tokens,
886
+ output_tokens=after.output_tokens - before.output_tokens,
887
+ calls=after.calls - before.calls,
888
+ )
889
+
890
+
891
+ def _capped(content: str, *, is_error: bool = False) -> ToolOutcome:
892
+ if len(content) <= _OUTPUT_CAP:
893
+ return ToolOutcome(content=content, is_error=is_error)
894
+ half = _OUTPUT_CAP // 2
895
+ marker = f"\n... {len(content) - _OUTPUT_CAP} characters truncated ...\n"
896
+ return ToolOutcome(
897
+ content=f"{content[:half]}{marker}{content[-half:]}",
898
+ is_error=is_error,
899
+ truncated=True,
900
+ )
901
+
902
+
903
+ def _head_tail(content: str, cap: int) -> str:
904
+ """Keep both ends of one stream within `cap` characters, with an omission marker."""
905
+ if len(content) <= cap:
906
+ return content
907
+ half = cap // 2
908
+ omitted = len(content) - cap
909
+ return f"{content[:half]}\n... {omitted} characters truncated ...\n{content[-half:]}"
910
+
911
+
912
+ def _decode_source_tree_snapshot(payload: str) -> HarnessSourceTree:
913
+ """Decode bounded base64 JSON from the trusted snapshot script."""
914
+ encoded = _EncodedSourceSnapshot.model_validate_json(payload)
915
+ if encoded.skipped:
916
+ names = ", ".join(encoded.skipped)
917
+ raise ValueError(
918
+ f"directory contains {len(encoded.skipped)} non-regular entr(y/ies) that cannot "
919
+ f"be part of a portable source tree: {names}; replace them with regular files"
920
+ )
921
+ files: list[HarnessSourceFile] = []
922
+ for item in encoded.files:
923
+ try:
924
+ content = base64.b64decode(item.content_base64, validate=True).decode("utf-8")
925
+ except (ValueError, UnicodeDecodeError) as error:
926
+ raise ValueError(f"file {item.path!r} is not valid UTF-8 text") from error
927
+ files.append(HarnessSourceFile(path=item.path, content=content))
928
+ return HarnessSourceTree(files=tuple(files))