agentenv-framework 0.9.1254__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 (312) hide show
  1. agent_env/__init__.py +0 -0
  2. agent_env/a2a_agent/__init__.py +23 -0
  3. agent_env/a2a_agent/a2a_agent.py +600 -0
  4. agent_env/a2a_agent/conversation_store.py +356 -0
  5. agent_env/a2a_agent/object_transfer.py +609 -0
  6. agent_env/a2a_agent/protocol.py +238 -0
  7. agent_env/a2a_agent/store.py +201 -0
  8. agent_env/a2a_agent/validator.py +901 -0
  9. agent_env/artifact/__init__.py +48 -0
  10. agent_env/artifact/artifact.py +74 -0
  11. agent_env/artifact/artifacts/__init__.py +0 -0
  12. agent_env/artifact/artifacts/cli.py +83 -0
  13. agent_env/artifact/artifacts/docker_image.py +457 -0
  14. agent_env/artifact/artifacts/environment.py +89 -0
  15. agent_env/artifact/artifacts/environment_universe.py +156 -0
  16. agent_env/artifact/artifacts/file.py +214 -0
  17. agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
  18. agent_env/artifact/artifacts/skill.py +206 -0
  19. agent_env/artifact/artifacts/vm_image.py +95 -0
  20. agent_env/artifact/ref.py +15 -0
  21. agent_env/artifact/registry.py +251 -0
  22. agent_env/artifact/store.py +295 -0
  23. agent_env/artifact/universe.py +38 -0
  24. agent_env/attribution.py +41 -0
  25. agent_env/bundle/__init__.py +4 -0
  26. agent_env/bundle/_fs.py +128 -0
  27. agent_env/bundle/authoring.py +239 -0
  28. agent_env/bundle/installed.py +175 -0
  29. agent_env/bundle/ledger.py +269 -0
  30. agent_env/bundle/materialize.py +204 -0
  31. agent_env/bundle/parse.py +594 -0
  32. agent_env/bundle/plan.py +414 -0
  33. agent_env/bundle/resolve.py +404 -0
  34. agent_env/bundle/run.py +361 -0
  35. agent_env/cli/__init__.py +69 -0
  36. agent_env/cli/__main__.py +10 -0
  37. agent_env/cli/_installers.py +576 -0
  38. agent_env/cli/_plugin_changes.py +947 -0
  39. agent_env/cli/a2a_agent/__init__.py +22 -0
  40. agent_env/cli/a2a_agent/add_skill.py +97 -0
  41. agent_env/cli/a2a_agent/deploy.py +60 -0
  42. agent_env/cli/a2a_agent/get.py +15 -0
  43. agent_env/cli/a2a_agent/get_instance.py +26 -0
  44. agent_env/cli/a2a_agent/put.py +78 -0
  45. agent_env/cli/a2a_agent/validate.py +56 -0
  46. agent_env/cli/artifact/__init__.py +20 -0
  47. agent_env/cli/artifact/cli.py +83 -0
  48. agent_env/cli/artifact/environment.py +44 -0
  49. agent_env/cli/artifact/environment_universe.py +163 -0
  50. agent_env/cli/artifact/file_artifact_universe.py +281 -0
  51. agent_env/cli/artifact/skill.py +128 -0
  52. agent_env/cli/banner.py +25 -0
  53. agent_env/cli/config.py +263 -0
  54. agent_env/cli/env/__init__.py +31 -0
  55. agent_env/cli/env/deploy.py +112 -0
  56. agent_env/cli/env/gateway.py +57 -0
  57. agent_env/cli/env/get_instance.py +40 -0
  58. agent_env/cli/env/mcp_server.py +410 -0
  59. agent_env/cli/env/multi.py +282 -0
  60. agent_env/cli/env/service_db.py +99 -0
  61. agent_env/cli/env/snapshot.py +35 -0
  62. agent_env/cli/env/state/__init__.py +6 -0
  63. agent_env/cli/env/state/init.py +108 -0
  64. agent_env/cli/env/state/teardown.py +44 -0
  65. agent_env/cli/env/website.py +216 -0
  66. agent_env/cli/env/website_browser.py +67 -0
  67. agent_env/cli/eval/__init__.py +16 -0
  68. agent_env/cli/eval/add_tasks.py +63 -0
  69. agent_env/cli/eval/create.py +49 -0
  70. agent_env/cli/eval/run.py +194 -0
  71. agent_env/cli/identity/__init__.py +5 -0
  72. agent_env/cli/identity/client_identity.py +37 -0
  73. agent_env/cli/plugin.py +438 -0
  74. agent_env/cli/run.py +285 -0
  75. agent_env/cli/task/__init__.py +21 -0
  76. agent_env/cli/task/create.py +109 -0
  77. agent_env/cli/task/get.py +17 -0
  78. agent_env/cli/task/get_instance.py +34 -0
  79. agent_env/cli/task/run.py +615 -0
  80. agent_env/cli/task/validate.py +31 -0
  81. agent_env/cli/up.py +123 -0
  82. agent_env/cli/utils.py +183 -0
  83. agent_env/config/__init__.py +50 -0
  84. agent_env/config/describe.py +1067 -0
  85. agent_env/config/errors.py +13 -0
  86. agent_env/config/loader.py +245 -0
  87. agent_env/config/model.py +154 -0
  88. agent_env/config/paths.py +25 -0
  89. agent_env/config/plugin_tables.py +101 -0
  90. agent_env/config/provenance.py +134 -0
  91. agent_env/config/runtime.py +1064 -0
  92. agent_env/config/snapshot.py +81 -0
  93. agent_env/entity_refs.py +186 -0
  94. agent_env/env/__init__.py +49 -0
  95. agent_env/env/env.py +433 -0
  96. agent_env/env/env_artifact_store.py +87 -0
  97. agent_env/env/envs/__init__.py +7 -0
  98. agent_env/env/envs/_deployment.py +281 -0
  99. agent_env/env/envs/gateway_server.py +32 -0
  100. agent_env/env/envs/mcp_server.py +387 -0
  101. agent_env/env/envs/multi_env.py +780 -0
  102. agent_env/env/envs/service_db/Dockerfile +20 -0
  103. agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
  104. agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
  105. agent_env/env/envs/service_db.py +92 -0
  106. agent_env/env/envs/website.py +294 -0
  107. agent_env/env/envs/website_browser/Dockerfile +40 -0
  108. agent_env/env/envs/website_browser/__init__.py +17 -0
  109. agent_env/env/envs/website_browser/entrypoint.sh +13 -0
  110. agent_env/env/gateway/Dockerfile +14 -0
  111. agent_env/env/gateway/__init__.py +37 -0
  112. agent_env/env/gateway/clock.py +184 -0
  113. agent_env/env/gateway/constants.py +190 -0
  114. agent_env/env/gateway/entrypoint.py +156 -0
  115. agent_env/env/gateway/gateway.py +1095 -0
  116. agent_env/env/gateway/get_time.py +60 -0
  117. agent_env/env/gateway/requirements.txt +4 -0
  118. agent_env/env/gateway/triggers.py +1193 -0
  119. agent_env/env/legacy_protocol.py +146 -0
  120. agent_env/env/registry.py +93 -0
  121. agent_env/env/snapshot_store.py +372 -0
  122. agent_env/env/store.py +411 -0
  123. agent_env/eval/__init__.py +14 -0
  124. agent_env/eval/eval.py +68 -0
  125. agent_env/eval/store.py +116 -0
  126. agent_env/examples/hello/README.md +9 -0
  127. agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
  128. agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
  129. agent_env/examples/hello/tasks/hello.json +10 -0
  130. agent_env/explorer/__init__.py +12 -0
  131. agent_env/explorer/app.py +277 -0
  132. agent_env/explorer/openapi_docs.py +242 -0
  133. agent_env/explorer/plugin.py +95 -0
  134. agent_env/explorer/routers/__init__.py +1 -0
  135. agent_env/explorer/routers/common.py +174 -0
  136. agent_env/explorer/routers/conversations.py +25 -0
  137. agent_env/explorer/routers/objects.py +112 -0
  138. agent_env/explorer/routers/runs.py +475 -0
  139. agent_env/plugins/__init__.py +59 -0
  140. agent_env/plugins/_cli.py +244 -0
  141. agent_env/plugins/_discovery.py +97 -0
  142. agent_env/plugins/_inventory.py +262 -0
  143. agent_env/plugins/_registration.py +268 -0
  144. agent_env/plugins/_report.py +47 -0
  145. agent_env/plugins/_requirements.py +55 -0
  146. agent_env/providers/__init__.py +37 -0
  147. agent_env/providers/env_providers/__init__.py +12 -0
  148. agent_env/providers/env_providers/constants.py +13 -0
  149. agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
  150. agent_env/providers/env_providers/env_provider.py +237 -0
  151. agent_env/providers/env_providers/env_server_provider.py +133 -0
  152. agent_env/providers/env_state/__init__.py +49 -0
  153. agent_env/providers/env_state/env_state_provider.py +554 -0
  154. agent_env/providers/env_state/local_postgres.py +390 -0
  155. agent_env/providers/env_state/store.py +112 -0
  156. agent_env/providers/sandbox_providers/__init__.py +29 -0
  157. agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
  158. agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
  159. agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
  160. agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
  161. agent_env/providers/sandbox_providers/e2b/template.py +158 -0
  162. agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
  163. agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
  164. agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
  165. agent_env/providers/sandbox_providers/sandbox.py +387 -0
  166. agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
  167. agent_env/py.typed +0 -0
  168. agent_env/runner/__init__.py +16 -0
  169. agent_env/runner/local_runner.py +190 -0
  170. agent_env/runner/runner.py +127 -0
  171. agent_env/runner/store.py +94 -0
  172. agent_env/store/__init__.py +126 -0
  173. agent_env/store/_google.py +68 -0
  174. agent_env/store/base.py +33 -0
  175. agent_env/store/document_store/__init__.py +60 -0
  176. agent_env/store/document_store/document_store.py +519 -0
  177. agent_env/store/document_store/dynamodb_document_store.py +306 -0
  178. agent_env/store/document_store/evaluation.py +199 -0
  179. agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
  180. agent_env/store/document_store/mongo_document_store.py +259 -0
  181. agent_env/store/document_store/sqlite_document_store.py +314 -0
  182. agent_env/store/ids.py +123 -0
  183. agent_env/store/image_store/__init__.py +34 -0
  184. agent_env/store/image_store/ecr_image_store.py +131 -0
  185. agent_env/store/image_store/google_credentials.py +113 -0
  186. agent_env/store/image_store/image_store.py +113 -0
  187. agent_env/store/image_store/local_registry_image_store.py +106 -0
  188. agent_env/store/image_store/oci_registry_credentials.py +196 -0
  189. agent_env/store/local_state.py +27 -0
  190. agent_env/store/object_store/__init__.py +24 -0
  191. agent_env/store/object_store/gcs_object_store.py +415 -0
  192. agent_env/store/object_store/local_object_store.py +143 -0
  193. agent_env/store/object_store/object_store.py +198 -0
  194. agent_env/store/object_store/s3_object_store.py +354 -0
  195. agent_env/store/query.py +112 -0
  196. agent_env/store/routing.py +609 -0
  197. agent_env/store/secret_store/__init__.py +19 -0
  198. agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
  199. agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
  200. agent_env/store/secret_store/local_secret_store.py +47 -0
  201. agent_env/store/secret_store/secret_store.py +19 -0
  202. agent_env/task/__init__.py +27 -0
  203. agent_env/task/interrupts.py +109 -0
  204. agent_env/task/registry.py +27 -0
  205. agent_env/task/step_journal.py +99 -0
  206. agent_env/task/store.py +940 -0
  207. agent_env/task/task.py +815 -0
  208. agent_env/task/teardown.py +142 -0
  209. agent_env/task_step/__init__.py +47 -0
  210. agent_env/task_step/context.py +195 -0
  211. agent_env/task_step/context_ops.py +224 -0
  212. agent_env/task_step/registry.py +182 -0
  213. agent_env/task_step/review_store.py +118 -0
  214. agent_env/task_step/snapshot_utils/__init__.py +2 -0
  215. agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
  216. agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
  217. agent_env/task_step/store.py +129 -0
  218. agent_env/task_step/task_step.py +173 -0
  219. agent_env/task_step/task_steps/__init__.py +27 -0
  220. agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
  221. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
  222. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
  223. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
  224. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
  225. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
  226. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
  227. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
  228. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
  229. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
  230. agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
  231. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
  232. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
  233. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
  234. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
  235. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
  236. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
  237. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
  238. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
  239. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
  240. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
  241. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
  242. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
  243. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
  244. agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
  245. agent_env/task_step/task_steps/add_skills.py +240 -0
  246. agent_env/task_step/task_steps/apply_server_config.py +288 -0
  247. agent_env/task_step/task_steps/collect_artifacts.py +940 -0
  248. agent_env/task_step/task_steps/deploy_agent.py +626 -0
  249. agent_env/task_step/task_steps/deploy_env.py +197 -0
  250. agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
  251. agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
  252. agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
  253. agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
  254. agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
  255. agent_env/task_step/task_steps/install_agent.py +376 -0
  256. agent_env/task_step/task_steps/load_artifact.py +775 -0
  257. agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
  258. agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
  259. agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
  260. agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
  261. agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
  262. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
  263. agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
  264. agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
  265. agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
  266. agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
  267. agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
  268. agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
  269. agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
  270. agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
  271. agent_env/task_step/task_steps/peer_agents.py +92 -0
  272. agent_env/task_step/task_steps/prompt_agent.py +900 -0
  273. agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
  274. agent_env/task_step/task_steps/register_env_triggers.py +97 -0
  275. agent_env/task_step/task_steps/reset_env.py +71 -0
  276. agent_env/task_step/task_steps/review.py +152 -0
  277. agent_env/task_step/task_steps/run_code.py +461 -0
  278. agent_env/task_step/task_steps/run_code_runner.py +52 -0
  279. agent_env/task_step/task_steps/run_docker_container.py +380 -0
  280. agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
  281. agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
  282. agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
  283. agent_env/task_step/task_steps/snapshot_env.py +637 -0
  284. agent_env/task_step/task_steps/sync_env_clock.py +132 -0
  285. agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
  286. agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
  287. agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
  288. agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
  289. agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
  290. agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
  291. agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
  292. agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
  293. agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
  294. agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
  295. agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
  296. agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
  297. agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
  298. agent_env/task_step/thread_work.py +61 -0
  299. agent_env/utils/__init__.py +0 -0
  300. agent_env/utils/card_naming.py +108 -0
  301. agent_env/utils/deprecation.py +109 -0
  302. agent_env/utils/docker_build.py +29 -0
  303. agent_env/utils/exec_retry.py +77 -0
  304. agent_env/utils/litellm_attribution.py +59 -0
  305. agent_env/utils/paths.py +20 -0
  306. agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
  307. agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
  308. agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
  309. agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
  310. agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
  311. agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
  312. agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
@@ -0,0 +1,780 @@
1
+ """Multi environment combining multiple MCP servers and websites."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ from typing import TYPE_CHECKING, Callable, ClassVar, Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Off by default: a bake builds and pushes a multi-GB servicedb image, which is the right
11
+ # trade once per universe and the wrong one on every deploy. Callers that know they are
12
+ # seeding a reusable universe pass snapshot_after_load=True explicitly; this flag exists
13
+ # so an operator can turn it on for a whole worker without a code change.
14
+ _SNAPSHOT_AFTER_LOAD_ENV_VAR = "AGENT_ENV_SNAPSHOT_AFTER_LOAD"
15
+
16
+ # Escape hatch for the derived per-load concurrency cap: pin the old load-everything-at-once
17
+ # behaviour, or hold it fixed while bisecting a contention problem.
18
+ _LOAD_CONCURRENCY_ENV_VAR = "AGENT_ENV_LOAD_CONCURRENCY"
19
+
20
+ def _snapshot_after_load_default() -> bool:
21
+ return os.environ.get(_SNAPSHOT_AFTER_LOAD_ENV_VAR, "").strip().lower() in ("1", "true", "yes")
22
+
23
+ if TYPE_CHECKING:
24
+ from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact
25
+ from agent_env.providers.env_state import DatabaseStateProvider
26
+
27
+ from agent_env.env.env import Env, gateway_url_of
28
+ from agent_env.env.gateway import GatewayMode
29
+ from agent_env.env.envs._deployment import (
30
+ as_builtin, builtin_provider_for, close_deployed, close_replaced, deploy_refusal, deploy_through_provider, host_staging_refusal,
31
+ plugin_provider_like_a_builtin, provider_or_class,
32
+ )
33
+ from agent_env.env.envs.mcp_server import MCPServerEnv
34
+ from agent_env.env.envs.website import WebsiteEnv
35
+ from agent_env.store.routing import refuse_local_derivation
36
+ from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER
37
+ from agent_env.attribution import Attribution
38
+
39
+
40
+ class MultiEnv(Env):
41
+ type: ClassVar[str] = "multi"
42
+ description = "Multi environment combining multiple MCP servers and websites, deployed through the environment provider its env_provider_type names"
43
+
44
+ def __init__(self, id: str, version: Optional[int], mcp_server_envs: list[MCPServerEnv], website_envs: list[WebsiteEnv] | None = None, metadata: Optional[dict[str, str]] = None, name: Optional[str] = None, env_provider_type: str = "gateway"):
45
+ super().__init__(id, version, metadata=metadata)
46
+ if name is not None and (not name or any(ch.isspace() for ch in name)):
47
+ raise ValueError("name must be non-empty with no whitespace")
48
+ if not env_provider_type:
49
+ raise ValueError("env_provider_type cannot be empty")
50
+ self.name = name
51
+ self.env_provider_type = env_provider_type # the children's own types are ignored: this one deploys them all
52
+ self.mcp_server_envs = mcp_server_envs
53
+ self.website_envs = website_envs or []
54
+ self._sandbox = None
55
+ self._gateway_url = None
56
+ self._gateway_mode = GatewayMode.PERFORMANCE
57
+ self._env_provider = None
58
+ self._mcp_server_name: Optional[str] = None
59
+ self._deployed: Optional[DeployedEnv] = None
60
+ self._replaced: list[tuple] = [] # the provider and sandbox of each deployment a later deploy replaced, for close()
61
+
62
+ def to_dict(self) -> dict:
63
+ base = super().to_dict()
64
+ base["mcp_server_envs"] = [
65
+ {"id": env.id, "type": env.type, "version": env.version}
66
+ for env in self.mcp_server_envs
67
+ ]
68
+ base["website_envs"] = [
69
+ {"id": env.id, "type": env.type, "version": env.version}
70
+ for env in self.website_envs
71
+ ]
72
+ if self.name:
73
+ base["name"] = self.name
74
+ base["env_provider_type"] = self.env_provider_type
75
+ return base
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict) -> "MultiEnv":
79
+ mcp_server_envs = [
80
+ Env.get(ref["id"], version=ref["version"])
81
+ for ref in data.get("mcp_server_envs", [])
82
+ ]
83
+ website_envs = [
84
+ Env.get(ref["id"], version=ref["version"])
85
+ for ref in data.get("website_envs", [])
86
+ ]
87
+ return cls(id=data["id"], version=data.get("version"), mcp_server_envs=mcp_server_envs, website_envs=website_envs, metadata=data.get("metadata", {}),
88
+ name=data.get("name"), env_provider_type=data.get("env_provider_type", "gateway"))
89
+
90
+ async def deploy(self, ttl_seconds: int = 10800, disk_size_gb: float = 10, gateway_mode: GatewayMode = GatewayMode.PERFORMANCE, cpu: float | None = None, memory_mb: int | None = None, sandbox_type: str | None = None, priority: Optional[int] = None, env_state_type: str | None = None, env_state_instance_id: str | None = None, *, attribution: Optional[Attribution] = None) -> DeployedEnv:
91
+ deployed_env = await deploy_through_provider(
92
+ self, environment_name=None, ttl_seconds=ttl_seconds, sandbox_type=sandbox_type,
93
+ disk_size_gb=disk_size_gb, gateway_mode=gateway_mode, cpu=cpu, memory_mb=memory_mb, priority=priority,
94
+ env_state_type=env_state_type, env_state_instance_id=env_state_instance_id, attribution=attribution,
95
+ )
96
+ self._gateway_mode = gateway_mode
97
+ self._mcp_server_name = deployed_env.mcp_server_name
98
+ _hand_provider_to_child_envs(self)
99
+ _hand_record_to_child_envs(self, deployed_env)
100
+ return deployed_env
101
+
102
+ def deploy_refusal(self, **options) -> str | None:
103
+ """Why deploy() would refuse these options before building anything, or None; raises, as deploy() does, for a type this process can't find."""
104
+ return deploy_refusal(self, provider_or_class(self), options)
105
+
106
+ @classmethod
107
+ async def from_deployed_env(cls, deployed: DeployedEnv) -> MultiEnv:
108
+ env = Env.get(deployed.env_id, deployed.env_version)
109
+ if not isinstance(env, MultiEnv):
110
+ raise TypeError(f"Expected MultiEnv, got {type(env).__name__}")
111
+ provider = builtin_provider_for(env.env_provider_type) or plugin_provider_like_a_builtin(env.env_provider_type)
112
+ if builtin := as_builtin(provider):
113
+ env._sandbox = await builtin._reattach(env, deployed)
114
+ # Any other plugin's deployment holds its own sandboxes: its children load through its card, and nothing is reattached.
115
+ env._env_provider = provider
116
+ env._gateway_url = gateway_url_of(deployed)
117
+ env._deployed = deployed
118
+ _hand_provider_to_child_envs(env)
119
+ _hand_record_to_child_envs(env, deployed)
120
+ env._instance_id = deployed.instance_id
121
+ env._mcp_server_name = deployed.mcp_server_name
122
+ return env
123
+
124
+ async def load_environment_artifact(self, environment_artifact: EnvironmentArtifact) -> None:
125
+ # A built-in's deploy, or a caller wiring one up by hand, leaves a gateway; a plugin's leaves only its record.
126
+ if self._gateway_url is None and self._deployed is None:
127
+ raise RuntimeError("Environment not deployed - call deploy() first")
128
+ # MCP servers are checked first; if a website shares the same environment_name, the MCP server wins.
129
+ for env in self.mcp_server_envs:
130
+ if env.environment_name == environment_artifact.environment_name:
131
+ await env.load_environment_artifact(environment_artifact)
132
+ return
133
+ for env in self.website_envs:
134
+ if env.environment_name == environment_artifact.environment_name:
135
+ await env.load_environment_artifact(environment_artifact)
136
+ return
137
+ logger.warning(f"No env with environment_name '{environment_artifact.environment_name}' — skipping")
138
+
139
+ async def load_environment_universe_artifact(
140
+ self,
141
+ environment_universe_artifact: EnvironmentUniverseArtifact,
142
+ snapshot_after_load: bool | None = None,
143
+ ) -> LoadEnvironmentUniverseArtifactResult:
144
+ """Seed this env from a universe artifact, preferring a pre-baked snapshot.
145
+
146
+ A snapshot restore swaps a pre-ingested servicedb image; a miss re-ingests every
147
+ service over HTTP under a fixed 600s per-service cap.
148
+
149
+ Restore is NOT reliably faster. Measured on a 13-service / ~8.7GB universe:
150
+ restore ~4min (an image pull plus recreating every service container) versus
151
+ ~3.5min to re-ingest on 8 vCPU. What restore buys is a bound — no per-service
152
+ timeout to squeeze under — and far less CPU: the same re-ingest FAILS on the
153
+ 1-vCPU sandbox default, where a 409MB service blew its cap while a 3.5GB one
154
+ succeeded. Re-ingest cost tracks record count, not bytes, so the busier the
155
+ universe the better restore looks.
156
+
157
+ ``snapshot_after_load`` bakes a reusable snapshot after a successful re-ingest so
158
+ the next load of the same (images, universe) pair can restore instead. It defaults
159
+ to the ``AGENT_ENV_SNAPSHOT_AFTER_LOAD`` config flag (off), because a bake builds
160
+ and pushes a multi-GB servicedb image (~5min, ~2.3GB stored) — worth it to make a
161
+ universe cheap to re-serve, not worth it on a one-off deploy. The bake is
162
+ best-effort and never fails a load that already succeeded.
163
+ """
164
+ import uuid
165
+ from agent_env.env.env import LoadEnvironmentUniverseArtifactResult
166
+ from agent_env.env.snapshot_store import compute_env_fingerprint, get_env_snapshot_store
167
+
168
+ # Refused before any service is touched, rather than after they all load.
169
+ if (refusal := host_staging_refusal(self, "Staging a universe's metadata files onto the env's host")) \
170
+ and environment_universe_artifact.get_metadata():
171
+ raise RuntimeError(refusal)
172
+
173
+ # Check for a clean snapshot before doing slow REST-based loading.
174
+
175
+ # Shortcutting data-load by using a snapshot is only supported when the env_state_type is local_postgres -
176
+ # a remote-backed deploy has no local servicedb container to swap, so it falls through to
177
+ # the normal load. So does a plugin's deployment, which has no state provider of ours.
178
+ state_provider = _gateway_state_provider(self)
179
+ can_restore = state_provider is not None and state_provider.supports_restore_from_snapshot
180
+ # Keyed on the service images rather than self.id, so re-registering the same
181
+ # servers under a new env id still hits (and a rebuilt server correctly misses).
182
+ env_fingerprint = compute_env_fingerprint(self) if can_restore else None
183
+ snapshot = get_env_snapshot_store().get_clean(
184
+ self.id,
185
+ environment_universe_artifact.id,
186
+ environment_universe_artifact.version,
187
+ env_fingerprint=env_fingerprint,
188
+ ) if can_restore else None
189
+ restored_from_snapshot = False
190
+ snapshot_baked: bool | None = None
191
+ snapshot_bake_error: str | None = None
192
+
193
+ if snapshot:
194
+ logger.info(f"Found clean snapshot for env={self.id} universe={environment_universe_artifact.id}: artifact={snapshot.db_image_artifact_id} v{snapshot.db_image_artifact_version} instance={snapshot.instance_id}")
195
+ await self._load_from_snapshot(snapshot)
196
+ restored_from_snapshot = True
197
+ # A restore swaps the whole database at once, so per-service resume state from
198
+ # any earlier partial re-ingest no longer describes anything real.
199
+ if self._instance_id:
200
+ try:
201
+ from agent_env.env.store import get_env_instance_store
202
+ get_env_instance_store().clear_loaded_environments(self._instance_id)
203
+ except Exception as e: # noqa: BLE001
204
+ logger.warning("Could not clear resume state after a snapshot restore: %s", e)
205
+ else:
206
+ import asyncio
207
+ import time
208
+ environment_artifacts = environment_universe_artifact.get_environment_artifacts()
209
+ total = len(environment_artifacts)
210
+ # WARNING, not info: this is the path that can FAIL. Every service is
211
+ # re-ingested over HTTP under a fixed per-service timeout, and one service
212
+ # exceeding it fails the whole load -- which on an under-provisioned VM is the
213
+ # normal outcome, not the edge case. The fingerprint belongs in the log
214
+ # because "no snapshot was ever baked" and "one exists but the service images
215
+ # changed" look identical from the outside and call for different fixes.
216
+ logger.warning(
217
+ "No clean snapshot for env=%s (fingerprint=%s) universe=%s v%s — "
218
+ "re-ingesting all %d services over HTTP, each under its own timeout.%s",
219
+ self.id, env_fingerprint, environment_universe_artifact.id,
220
+ environment_universe_artifact.version, total,
221
+ " Bake a snapshot to make subsequent loads an image swap instead." if can_restore else "",
222
+ )
223
+
224
+ # Bound how many load at once. Unbounded, every service races every other for
225
+ # the same cores, and since each one is separately capped at
226
+ # DATA_PLANE_LOAD_TIMEOUT_S, contention doesn't just slow the load down -- it
227
+ # fails it. Measured on this universe: 13-way on 1 vCPU gives each service
228
+ # ~1/13th of a core and a 409MB service exceeds its 600s cap, while a 3.5GB one
229
+ # finishes; the cost tracks record count, not bytes. Throttling trades total
230
+ # wall-clock (which nothing caps) for per-service headroom (which is capped).
231
+ # Resume an interrupted load instead of re-running all of it. A step retry
232
+ # re-enters this step from scratch, and because the load is destructive
233
+ # (reset-then-add per service) that meant re-wiping and re-ingesting every
234
+ # service -- three attempts x the full universe is how one slow service became
235
+ # a ~44 minute failure.
236
+ already = await self._resumable_environments(environment_universe_artifact, total)
237
+ if already:
238
+ environment_artifacts = [
239
+ a for a in environment_artifacts if a.environment_name not in already
240
+ ]
241
+ logger.warning(
242
+ "Resuming an interrupted load of %s v%s: skipping %d already-loaded "
243
+ "service(s) %s, loading the remaining %d",
244
+ environment_universe_artifact.id, environment_universe_artifact.version,
245
+ len(already), sorted(already), len(environment_artifacts),
246
+ )
247
+ total = len(environment_artifacts)
248
+ if not environment_artifacts:
249
+ logger.info("Every service was already loaded; nothing to re-ingest")
250
+
251
+ limit = await self._load_concurrency_limit(total) if total else 1
252
+ semaphore = asyncio.Semaphore(limit)
253
+
254
+ async def _load_one(idx: int, artifact):
255
+ name = artifact.environment_name
256
+ async with semaphore:
257
+ # Timed inside the semaphore: the per-service timeout only starts once
258
+ # the work does, so queue time must not be charged against it. A
259
+ # duration here is comparable to DATA_PLANE_LOAD_TIMEOUT_S.
260
+ t0 = time.monotonic()
261
+ logger.info(f"[{idx}/{total}] loading {name} ...")
262
+ print(f"[{idx}/{total}] loading {name} ...", flush=True)
263
+ try:
264
+ await self.load_environment_artifact(artifact)
265
+ except BaseException as e: # noqa: BLE001 — includes CancelledError
266
+ dt = time.monotonic() - t0
267
+ logger.error(f"[{idx}/{total}] FAIL {name} after {dt:.0f}s: {type(e).__name__}: {e}")
268
+ print(f"[{idx}/{total}] ✗ {name} FAILED after {dt:.0f}s: {type(e).__name__}: {e}", flush=True)
269
+ raise
270
+ dt = time.monotonic() - t0
271
+ # Recorded per service, immediately, rather than once at the end: the
272
+ # whole point is that a load which dies partway leaves usable progress.
273
+ self._record_loaded_environment(environment_universe_artifact, name)
274
+ logger.info(f"[{idx}/{total}] OK {name} in {dt:.0f}s")
275
+ print(f"[{idx}/{total}] ✓ {name} loaded in {dt:.0f}s", flush=True)
276
+ return dt
277
+
278
+ # Key failures by position, not environment_name: a duplicate service
279
+ # must not let one success mask another's failure.
280
+ outcomes = await asyncio.gather(
281
+ *(_load_one(i, a) for i, a in enumerate(environment_artifacts, 1)),
282
+ return_exceptions=True,
283
+ )
284
+ failed = [
285
+ (i, environment_artifacts[i - 1].environment_name, outcome)
286
+ for i, outcome in enumerate(outcomes, 1)
287
+ if isinstance(outcome, BaseException)
288
+ ]
289
+ if failed:
290
+ raise RuntimeError(
291
+ f"Failed to load {len(failed)}/{total} services: "
292
+ + ", ".join(
293
+ f"[{i}/{total}] {name} ({type(e).__name__}: {e})"
294
+ for i, name, e in failed
295
+ )
296
+ )
297
+ if self._instance_id:
298
+ from agent_env.env.store import update_env_instance_environment_universe
299
+ update_env_instance_environment_universe(self._instance_id, environment_universe_artifact.id, environment_universe_artifact.version)
300
+
301
+ # Bake the snapshot that makes the NEXT load of this (images, universe) pair an
302
+ # image swap. Deliberately placed here: after the universe stamp, which
303
+ # EnvSnapshot.create reads back off the instance record, and before the metadata
304
+ # download, so the captured PGDATA reflects the data load and nothing else.
305
+ if not restored_from_snapshot and can_restore:
306
+ if snapshot_after_load is None:
307
+ snapshot_after_load = _snapshot_after_load_default()
308
+ if snapshot_after_load:
309
+ snapshot_baked, snapshot_bake_error = await self._bake_snapshot_after_load(
310
+ environment_universe_artifact
311
+ )
312
+
313
+ metadata_filepaths: dict[str, str] = {}
314
+ metadata_artifacts = environment_universe_artifact.get_metadata()
315
+ if metadata_artifacts:
316
+ if self._sandbox is None:
317
+ raise RuntimeError("Environment not deployed - call deploy() first")
318
+ dest_dir = f"/tmp/metadata-{uuid.uuid4().hex[:8]}"
319
+ if self._sandbox.mode != SANDBOX_MODE_CONTAINER:
320
+ await self._sandbox.exec_script(f"mkdir -p {dest_dir}")
321
+ for key, file_artifact in metadata_artifacts.items():
322
+ dest_path = f"{dest_dir}/{file_artifact.filename}"
323
+ logger.info(f"Downloading metadata '{key}' -> {dest_path}")
324
+ if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
325
+ await self._sandbox.write_file_from_s3(file_artifact.object_url, dest_path)
326
+ else:
327
+ await self._sandbox.load_s3_file(file_artifact.object_url, dest_path)
328
+ metadata_filepaths[key] = dest_path
329
+ logger.info(f"Successfully downloaded metadata '{key}'")
330
+ return LoadEnvironmentUniverseArtifactResult(
331
+ metadata_filepaths=metadata_filepaths,
332
+ restored_from_snapshot=restored_from_snapshot,
333
+ snapshot_db_image_artifact_id=snapshot.db_image_artifact_id if snapshot else None,
334
+ snapshot_baked=snapshot_baked,
335
+ snapshot_bake_error=snapshot_bake_error,
336
+ )
337
+
338
+ async def _resumable_environments(self, environment_universe_artifact, total: int) -> set[str]:
339
+ """Services safe to skip because a previous attempt already loaded them.
340
+
341
+ Gated on the changelog being EMPTY, which is what keeps this from quietly breaking
342
+ the documented contract that loading a universe is destructive. Two cases have to be
343
+ told apart, and the changelog is what distinguishes them:
344
+
345
+ * A retry of an interrupted load. Nothing has mutated the data (load triggers are
346
+ installed only after each service's own load), so the changelog is empty and the
347
+ recorded services are still exactly what the artifact says. Skipping is correct
348
+ and saves re-wiping them.
349
+ * A deliberate re-load to reset an env an agent has been working in. The changelog
350
+ is non-empty, so NOTHING is skipped and every service is wiped and re-seeded --
351
+ today's behaviour, which callers depend on.
352
+
353
+ Coarse on purpose: the changelog is global, not per-service, so any mutation
354
+ anywhere disables resume entirely. That errs toward doing redundant work rather than
355
+ toward leaving stale data in place, which is the right way to be wrong here.
356
+ """
357
+ if not self._instance_id or self._sandbox is None:
358
+ return set()
359
+ if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
360
+ return set()
361
+ try:
362
+ from agent_env.env.snapshot_store import _check_changelog_empty
363
+ from agent_env.env.store import get_env_instance_store
364
+
365
+ recorded = set(get_env_instance_store().get_loaded_environments(
366
+ self._instance_id, environment_universe_artifact.id, environment_universe_artifact.version,
367
+ ))
368
+ if not recorded:
369
+ return set()
370
+ if not await _check_changelog_empty(self._sandbox):
371
+ logger.info(
372
+ "Not resuming: the changelog is non-empty, so this env's data has been "
373
+ "modified since it was loaded and every service must be re-seeded"
374
+ )
375
+ return set()
376
+ if len(recorded) >= total:
377
+ # A complete prior load with an untouched changelog. Re-seeding would be a
378
+ # no-op, but skipping everything would silently turn a caller's explicit
379
+ # load into nothing -- so do the work rather than guess at intent.
380
+ logger.info(
381
+ "All %d services are recorded as loaded and the changelog is clean; "
382
+ "re-loading anyway rather than turning an explicit load into a no-op",
383
+ total,
384
+ )
385
+ return set()
386
+ return recorded
387
+ except Exception as e: # noqa: BLE001 -- an optimisation, not a dependency
388
+ logger.warning("Could not determine resumable services (%s); loading all of them", e)
389
+ return set()
390
+
391
+ def _record_loaded_environment(self, environment_universe_artifact, environment_name: str) -> None:
392
+ """Persist one service's completion. Best-effort: losing a record costs redundant
393
+ work on a retry, whereas raising here would fail a service that actually loaded."""
394
+ if not self._instance_id:
395
+ return
396
+ try:
397
+ from agent_env.env.store import get_env_instance_store
398
+
399
+ get_env_instance_store().record_loaded_environment(
400
+ self._instance_id, environment_universe_artifact.id,
401
+ environment_universe_artifact.version, environment_name,
402
+ )
403
+ except Exception as e: # noqa: BLE001
404
+ logger.warning(
405
+ "Could not record %s as loaded (%s); a retry will re-load it",
406
+ environment_name, e,
407
+ )
408
+
409
+ async def _load_concurrency_limit(self, total: int) -> int:
410
+ """How many services may load at once.
411
+
412
+ Derived from the sandbox's actual core count rather than a constant, because the
413
+ failure this prevents is contention-driven: the per-service data-plane timeout is
414
+ fixed, so what matters is how much CPU each concurrent load can get. Two in flight
415
+ per core keeps some I/O overlap while leaving each service a real share.
416
+
417
+ At >= ceil(total/2) cores this is a no-op -- a well-provisioned VM keeps loading
418
+ everything at once, which is what the measured 8 vCPU run did. It only bites on the
419
+ small boxes where unbounded concurrency was turning a slow load into a failed one.
420
+
421
+ ``AGENT_ENV_LOAD_CONCURRENCY`` overrides it outright, for pinning the old behaviour
422
+ or bisecting a contention problem. Falls back to loading everything at once if the
423
+ core count can't be read: that is today's behaviour, so a probe failure must not
424
+ quietly change how a load runs.
425
+ """
426
+ override = os.environ.get(_LOAD_CONCURRENCY_ENV_VAR, "").strip()
427
+ if override:
428
+ try:
429
+ value = int(override)
430
+ if value < 1:
431
+ raise ValueError(f"must be >= 1, got {value}")
432
+ except ValueError as e:
433
+ # Loud, not silent: a typo'd override that fell back to the default would
434
+ # make a contention experiment quietly measure the wrong thing.
435
+ raise ValueError(
436
+ f"{_LOAD_CONCURRENCY_ENV_VAR}={override!r} is not a positive integer: {e}"
437
+ ) from None
438
+ limit = min(value, total)
439
+ logger.info("Load concurrency %d/%d (from %s)", limit, total, _LOAD_CONCURRENCY_ENV_VAR)
440
+ return limit
441
+
442
+ cores = None
443
+ if self._sandbox is not None:
444
+ try:
445
+ cores = int((await self._sandbox.exec_script("nproc")).strip())
446
+ except Exception as e: # noqa: BLE001 — a probe, not a dependency
447
+ logger.warning("Could not read sandbox core count (%s); loading all %d services at once", e, total)
448
+ if not cores or cores < 1:
449
+ return total
450
+
451
+ limit = max(1, min(total, cores * 2))
452
+ logger.info(
453
+ "Load concurrency %d/%d services (sandbox has %d core(s))", limit, total, cores,
454
+ )
455
+ return limit
456
+
457
+ async def _bake_snapshot_after_load(
458
+ self, environment_universe_artifact: EnvironmentUniverseArtifact
459
+ ) -> tuple[bool, str | None]:
460
+ """Capture a reusable clean snapshot of what was just loaded.
461
+
462
+ Best-effort by contract: the data load already succeeded, and the caller wants a
463
+ seeded env more than it wants a cache entry. Any failure here is logged and
464
+ reported in the result, never raised — otherwise adding a cache would turn
465
+ working loads into failures.
466
+
467
+ Returns ``(baked, error)``.
468
+ """
469
+ import asyncio
470
+
471
+ from agent_env.env.snapshot_store import EnvSnapshot
472
+
473
+ if not self._instance_id:
474
+ # create() resolves the env and its loaded universe from the instance record,
475
+ # so an unregistered env (ad-hoc/local deploy) has nothing to key a snapshot on.
476
+ msg = "no instance_id on this env; snapshot capture needs a registered env instance"
477
+ logger.warning("Skipping snapshot bake for env=%s: %s", self.id, msg)
478
+ return False, msg
479
+ try:
480
+ logger.info(
481
+ "Baking clean snapshot for env=%s universe=%s v%s (instance=%s) so the "
482
+ "next load can skip the re-ingest...",
483
+ self.id, environment_universe_artifact.id,
484
+ environment_universe_artifact.version, self._instance_id,
485
+ )
486
+ snapshot = await EnvSnapshot.create(self._instance_id)
487
+ except asyncio.CancelledError:
488
+ # Cancellation is control flow, not a bake failure. Swallowing it here would
489
+ # let a cancelled/timed-out activity carry on and report success.
490
+ raise
491
+ except Exception as e: # noqa: BLE001 — a cache miss must not fail a good load
492
+ logger.warning(
493
+ "Snapshot bake failed for env=%s universe=%s: %s: %s (the load itself "
494
+ "succeeded; the next load will take the slow path)",
495
+ self.id, environment_universe_artifact.id, type(e).__name__, e,
496
+ exc_info=True,
497
+ )
498
+ return False, f"{type(e).__name__}: {e}"
499
+ if not snapshot.is_clean:
500
+ # create() derives is_clean from an empty changelog. Landing here means
501
+ # something mutated the data between load and capture, so the snapshot exists
502
+ # but get_clean will never serve it — say so rather than reporting success.
503
+ msg = (
504
+ "captured snapshot is dirty (changelog non-empty), so it will not be "
505
+ "reused; something wrote to the env between load and capture"
506
+ )
507
+ logger.warning("Snapshot bake for env=%s: %s", self.id, msg)
508
+ return False, msg
509
+ logger.info(
510
+ "Baked clean snapshot for env=%s universe=%s v%s: image=%s v%s",
511
+ self.id, environment_universe_artifact.id, environment_universe_artifact.version,
512
+ snapshot.db_image_artifact_id, snapshot.db_image_artifact_version,
513
+ )
514
+ return True, None
515
+
516
+ async def load_file_artifact_universe(self, file_artifact_universe: "Any", destination_path: Optional[str] = None,
517
+ ) -> "LoadFileArtifactUniverseResult":
518
+ if refusal := host_staging_refusal(self, "Staging files onto the env's host"):
519
+ raise RuntimeError(refusal)
520
+ return await super().load_file_artifact_universe(file_artifact_universe, destination_path)
521
+
522
+ async def load_artifact(self, artifact):
523
+ from agent_env.artifact import EnvironmentArtifact, EnvironmentUniverseArtifact
524
+ if isinstance(artifact, EnvironmentUniverseArtifact):
525
+ return await self.load_environment_universe_artifact(artifact)
526
+ if isinstance(artifact, EnvironmentArtifact):
527
+ return await self.load_environment_artifact(artifact)
528
+ raise ValueError(f"{type(self).__name__} '{self.id}' cannot load artifact '{getattr(artifact, 'id', '?')}' of type '{getattr(artifact, 'type', '?')}' — expected a EnvironmentArtifact or EnvironmentUniverseArtifact")
529
+
530
+ async def _load_from_snapshot(self, snapshot) -> None:
531
+ """Swap the servicedb container with a pre-loaded snapshot image. Only supported when both the current deploy and the snapshot has env_state_type set to local_postgres"""
532
+ import asyncio
533
+ from agent_env.artifact import Artifact, DockerImageArtifact
534
+ from agent_env.env.env import Env
535
+ from agent_env.env.envs.service_db import DB_USER
536
+ from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT
537
+ from agent_env.providers import EnvironmentGatewayProvider, MCPServerConfig, WebsiteConfig
538
+ from agent_env.env.envs.service_db import DATABASE_SERVICE_NAME, DB_MCP_SERVICE_NAME, PGWEB_SERVICE_NAME
539
+ from agent_env.providers.env_providers.constants import DOCKER_COMPOSE_PATH, GATEWAY_APP_DIR, GATEWAY_SERVICE_NAME
540
+ from agent_env.providers.env_state import LocalPostgresStateProvider
541
+ from agent_env.config import get_config
542
+
543
+ if self._sandbox is None:
544
+ raise RuntimeError("Environment not deployed - call deploy() first")
545
+ if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
546
+ raise NotImplementedError("Snapshot loading not supported on container mode")
547
+ db_image = Artifact.get(snapshot.db_image_artifact_id, snapshot.db_image_artifact_version)
548
+ if not isinstance(db_image, DockerImageArtifact):
549
+ raise RuntimeError(f"Snapshot artifact {snapshot.db_image_artifact_id} is not a DockerImageArtifact")
550
+ logger.info(f"Loading snapshot image {db_image.image_name} into sandbox...")
551
+ await self._sandbox.load_docker_images([db_image])
552
+
553
+ # Regenerate docker-compose.yml with the snapshot image (no init-schemas volume needed)
554
+ logger.info("Regenerating docker-compose.yml with snapshot image...")
555
+ config = get_config()
556
+ gateway_env = Env.get(config.default_gateway_env_id)
557
+ service_db = Env.get(config.default_service_db_env_id)
558
+ service_db_config = service_db.to_config()
559
+ service_db_config.db_image = db_image.image_name
560
+
561
+ gw = EnvironmentGatewayProvider()
562
+ gw._sandbox = self._sandbox
563
+ gw._state_provider = LocalPostgresStateProvider(service_db_config=service_db_config)
564
+ gw._state_instance = LocalPostgresStateProvider.default_instance()
565
+ mcp_servers = [MCPServerConfig(image=env.docker_image_artifact.image_name, environment_name=env.environment_name) for env in self.mcp_server_envs]
566
+ website_configs = [
567
+ WebsiteConfig(backend_image=env.backend_docker_image_artifact.image_name, frontend_image=env.frontend_docker_image_artifact.image_name, environment_name=env.environment_name)
568
+ for env in self.website_envs
569
+ ]
570
+ if website_configs:
571
+ website_browser_env = Env.get(config.default_website_browser_env_id)
572
+ mcp_servers = list(mcp_servers) + [MCPServerConfig(image=website_browser_env.docker_image_artifact.image_name, environment_name=website_browser_env.environment_name)]
573
+
574
+ compose_content = gw.create_docker_compose(
575
+ mcp_servers=mcp_servers,
576
+ gateway_image=gateway_env.docker_image_artifact.image_name,
577
+ gateway_port=AGENT_ENV_GATEWAY_MCP_PORT, website_configs=website_configs or None,
578
+ gateway_mode=self._gateway_mode,
579
+ state_provider=gw._state_provider, # local-only, set above
580
+ state_instance=gw._state_instance,
581
+ mcp_server_name=self._mcp_server_name or self.name,
582
+ )
583
+ compose_content = compose_content.replace(
584
+ " volumes:\n - ./init-schemas.sql:/docker-entrypoint-initdb.d/init-schemas.sql\n", "",
585
+ )
586
+ write_script = f'''cat > {DOCKER_COMPOSE_PATH} << 'COMPOSE_EOF'
587
+ {compose_content}
588
+ COMPOSE_EOF'''
589
+ await self._sandbox.exec_script(write_script)
590
+
591
+ # Remove old servicedb container and its anonymous volume, then start fresh from snapshot image
592
+ logger.info("Recreating servicedb container with snapshot image...")
593
+ await self._sandbox.exec_script(
594
+ f"cd {GATEWAY_APP_DIR} && docker compose rm -sf -v {DATABASE_SERVICE_NAME} && docker compose up -d {DATABASE_SERVICE_NAME} 2>&1"
595
+ )
596
+
597
+ # Wait for servicedb to be healthy
598
+ logger.info("Waiting for servicedb to be healthy...")
599
+ for i in range(60):
600
+ try:
601
+ await self._sandbox.exec_script(
602
+ f"docker compose -f {DOCKER_COMPOSE_PATH} exec -T {DATABASE_SERVICE_NAME} pg_isready -U {DB_USER}",
603
+ )
604
+ logger.info(f"servicedb healthy after {i + 1}s")
605
+ break
606
+ except Exception:
607
+ if i % 10 == 0:
608
+ logger.info(f"Still waiting for servicedb... ({i}s)")
609
+ await asyncio.sleep(1)
610
+ else:
611
+ raise RuntimeError("servicedb not healthy after 60s")
612
+
613
+ # Recreate non-servicedb services so they reconnect to the new servicedb.
614
+ # Uses `up -d --force-recreate` which respects depends_on ordering
615
+ # (MCP servers start before gateway), unlike `restart` which is unordered.
616
+ environment_names = list(dict.fromkeys(
617
+ [env.environment_name for env in self.mcp_server_envs] + [env.environment_name for env in self.website_envs]
618
+ ))
619
+ svc_list = " ".join(environment_names + [GATEWAY_SERVICE_NAME, PGWEB_SERVICE_NAME, DB_MCP_SERVICE_NAME])
620
+ logger.info(f"Recreating services: {svc_list}")
621
+ await self._sandbox.exec_script(
622
+ f"cd {GATEWAY_APP_DIR} && docker compose up -d --force-recreate {svc_list}"
623
+ )
624
+ await gw._wait_for_gateway(self._sandbox, AGENT_ENV_GATEWAY_MCP_PORT)
625
+
626
+ # Install changelog triggers for each service
627
+ for name in environment_names:
628
+ await gw.install_changelog_triggers(name)
629
+
630
+ async def validate(self, on_progress: Callable[[str], None] | None = None) -> str:
631
+ """Run the basic env-card validator task and return the task instance ID.
632
+
633
+ Deploys the env, fetches + persists its composed EnvironmentCard, then tears down.
634
+ """
635
+ refuse_local_derivation(self.id, "env", "validating")
636
+ from agent_env.task import Task
637
+ from agent_env.task_step import DeployEnvTaskStep, VerifyEnvironmentCardStep
638
+
639
+ task_id = f"validate-{self.id}-v{self.version}"
640
+ task = Task.put(
641
+ id=task_id,
642
+ steps=[
643
+ DeployEnvTaskStep(id=f"{task_id}-deploy", version=None, env_id=self.id, env_version=self.version),
644
+ VerifyEnvironmentCardStep(id=f"{task_id}-envcard", version=None, env_id=self.id),
645
+ ],
646
+ )
647
+ logger.info(f"Created validation task: {task.id} version={task.version}")
648
+
649
+ log = on_progress or (lambda msg: None)
650
+ on_start = lambda i, total, step, ctx: log(f"Running step [{i+1}/{total}]: {step.type}...")
651
+ on_complete = lambda i, total, step, ctx, dur: log(f"Completed step [{i+1}/{total}]: {step.type} [{dur:.1f}s]")
652
+ context = await task.run(on_step_start=on_start, on_step_complete=on_complete)
653
+
654
+ for deployed_env in context.deployed_envs:
655
+ try:
656
+ await close_deployed(deployed_env, MultiEnv)
657
+ except Exception as e:
658
+ logger.warning(f"Failed to clean up env {deployed_env.env_id}: {e}")
659
+ return context.instance_id
660
+
661
+ async def validate_universe_compatibility(self, universe_artifact_id: str, universe_artifact_version: int | None = None, on_progress: Callable[[str], None] | None = None) -> str:
662
+ """Run universe compatibility validation and return the task instance ID.
663
+
664
+ Two verdicts are produced: the programmatic verifier (``compare_dicts``) and an LLM
665
+ agent-judge that diffs the original/export universes for *semantic* (major) differences the
666
+ programmatic comparison misses. The round-trip step bundles original/export1/export2 into a
667
+ FileArtifactUniverse, which is staged onto a judge agent. The judge's flagged differences are
668
+ merged into the ``UNIVERSE_COMPATIBILITY`` result as per-service critical issues — so they
669
+ surface (and gate ``compatible``) the same way programmatic issues do.
670
+ """
671
+ refuse_local_derivation(self.id, "env", "validating")
672
+ from agent_env.task import Task
673
+ from agent_env.task_step import (
674
+ CombineUniverseVerdictsStep,
675
+ DeployAgentTaskStep,
676
+ DeployEnvTaskStep,
677
+ LoadArtifactTaskStep,
678
+ PromptAgentTaskStep,
679
+ RubricsVerifierTaskStep,
680
+ VerifyUniverseLoadExportRoundtripStep,
681
+ )
682
+ from agent_env.task_step.task_steps.multienv_validator.verify_universe_agent_judge import (
683
+ JUDGE_SYSTEM_PROMPT,
684
+ UNIVERSES_DIR,
685
+ build_criteria,
686
+ build_user_prompt,
687
+ )
688
+ from agent_env.artifact import EnvironmentUniverseArtifact
689
+
690
+ universe = EnvironmentUniverseArtifact.get(universe_artifact_id, universe_artifact_version)
691
+ task_id = f"validate-universe-compat-{self.id}-v{self.version}-{universe.id}-v{universe.version}"
692
+ environment_names = [sa.environment_name for sa in universe.get_environment_artifacts()]
693
+ fau_id = VerifyUniverseLoadExportRoundtripStep.file_artifact_universe_id(self.id, self.version, universe.id, universe.version)
694
+ judge_name = "universe-judge"
695
+ judge_prompt_id = f"{task_id}-judge-prompt"
696
+ judge_verifier_id = f"{task_id}-judge-rubric"
697
+ # Steps run sequentially (depends_on=None ⇒ depends on all prior steps), so the FAU exists
698
+ # before it is loaded, and the judge is prompted before it is graded. The round-trip step
699
+ # computes the programmatic verdict but does NOT persist it (persist_result=False); the final
700
+ # CombineUniverseVerdictsStep is the single authoritative writer, so a crash before the judge
701
+ # merges can't leave an ungated half-written result.
702
+ steps = [
703
+ DeployEnvTaskStep(id=f"{task_id}-deploy", version=None, env_id=self.id, env_version=self.version),
704
+ VerifyUniverseLoadExportRoundtripStep(id=f"{task_id}-roundtrip", version=None, env_id=self.id, universe_artifact_id=universe.id, universe_artifact_version=universe.version, emit_file_artifact_universe=True, persist_result=False),
705
+ DeployAgentTaskStep(id=f"{task_id}-judge-deploy", version=None, agent_name=judge_name, system_prompt=JUDGE_SYSTEM_PROMPT, ttl_seconds=1800),
706
+ LoadArtifactTaskStep(id=f"{task_id}-judge-load", version=None, agent_name=judge_name, artifact_id=fau_id, destination_path=UNIVERSES_DIR),
707
+ PromptAgentTaskStep(id=f"{task_id}-judge-prompt", version=None, agent_name=judge_name, prompt=build_user_prompt(), prompt_id=judge_prompt_id),
708
+ RubricsVerifierTaskStep(id=judge_verifier_id, version=None, criteria=build_criteria(environment_names), prompt_id=judge_prompt_id, agent_name=judge_name, verifier_id=judge_verifier_id),
709
+ CombineUniverseVerdictsStep(id=f"{task_id}-combine", version=None, env_id=self.id, universe_artifact_id=universe.id, universe_artifact_version=universe.version, judge_verifier_id=judge_verifier_id),
710
+ ]
711
+ task = Task.put(id=task_id, steps=steps)
712
+ logger.info(f"Created validation task: {task.id} version={task.version}")
713
+
714
+ log = on_progress or (lambda msg: None)
715
+ on_start = lambda i, total, step, ctx: log(f"Running step [{i+1}/{total}]: {step.type}...")
716
+ on_complete = lambda i, total, step, ctx, dur: log(f"Completed step [{i+1}/{total}]: {step.type} [{dur:.1f}s]")
717
+ context = await task.run(on_step_start=on_start, on_step_complete=on_complete)
718
+
719
+ for deployed_env in context.deployed_envs:
720
+ try:
721
+ await close_deployed(deployed_env, MultiEnv)
722
+ except Exception as e:
723
+ logger.warning(f"Failed to clean up env {deployed_env.env_id}: {e}")
724
+ # Tear down any judge agent (ttl is a backstop if this is skipped).
725
+ for deployed_agent in context.deployed_agents:
726
+ if deployed_agent.sandbox_id:
727
+ try:
728
+ from agent_env.providers import build_sandbox_provider, get_agent_sandbox_provider
729
+ provider = build_sandbox_provider(deployed_agent.sandbox_type) if deployed_agent.sandbox_type else get_agent_sandbox_provider()
730
+ sandbox = await provider.get_sandbox(deployed_agent.sandbox_id)
731
+ await sandbox.terminate()
732
+ except Exception as e:
733
+ logger.warning(f"Failed to clean up agent sandbox {deployed_agent.sandbox_id}: {e}")
734
+
735
+ # Note: the merged UNIVERSE_COMPATIBILITY result is written by CombineUniverseVerdictsStep
736
+ # (the final task step), so it persists wherever the Task runs — not only via this method.
737
+ return context.instance_id
738
+
739
+ async def close(self) -> None:
740
+ await close_replaced(self)
741
+ if self._env_provider is not None:
742
+ try:
743
+ await self._env_provider.close()
744
+ self._env_provider = None
745
+ except Exception as e:
746
+ logger.warning(f"Failed to close env provider, kept for the next close(): {e}")
747
+ if self._sandbox is not None:
748
+ try:
749
+ await self._sandbox.terminate()
750
+ except BaseException as e:
751
+ logger.warning(f"Failed to terminate sandbox {self._sandbox.sandbox_id}: {e}")
752
+ self._sandbox = None
753
+
754
+
755
+ def _hand_provider_to_child_envs(env: MultiEnv) -> None:
756
+ """Point each child env at the MultiEnv's deployment: the provider and URL its loads use, and behind our gateway the sandbox they
757
+ stage into. A plugin's provider leaves them no sandbox, so they load through the record's card."""
758
+ provider = env._env_provider
759
+ builtin = as_builtin(provider)
760
+ for child in env.mcp_server_envs:
761
+ child._sandbox = (builtin.environment_sandbox(child.environment_name) if builtin else None) or env._sandbox
762
+ child._gateway_url = env._gateway_url
763
+ child._env_provider = provider
764
+ for child in env.website_envs:
765
+ child._sandbox = env._sandbox
766
+ child._gateway_url = env._gateway_url
767
+ child._env_provider = provider
768
+
769
+
770
+ def _gateway_state_provider(env: MultiEnv) -> DatabaseStateProvider | None:
771
+ """The state provider behind our gateway, which a snapshot restores into or captures from; None for a deployment without it."""
772
+ from agent_env.providers import EnvironmentGatewayProvider
773
+ return env._env_provider._state_provider if isinstance(env._env_provider, EnvironmentGatewayProvider) else None
774
+
775
+
776
+ def _hand_record_to_child_envs(env: MultiEnv, deployed: DeployedEnv) -> None:
777
+ """Give each child env the record its loads read the card from; a name an MCP server and a website share keeps the live probe, since the card can't tell them apart by name."""
778
+ shared = {c.environment_name for c in env.mcp_server_envs} & {c.environment_name for c in env.website_envs}
779
+ for child in [*env.mcp_server_envs, *env.website_envs]:
780
+ child._deployed = None if child.environment_name in shared else deployed