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,637 @@
1
+ """Snapshot a deployed env's live service state into a new EnvironmentUniverseArtifact."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import base64
6
+ import json
7
+ import logging
8
+ import mimetypes
9
+ import os
10
+ import tempfile
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import ClassVar, Optional
15
+ from urllib.parse import urlparse
16
+
17
+ import httpx
18
+
19
+ from agentenv_protocol import FilePart
20
+
21
+ from agent_env.env.gateway.constants import EXT_TRAJECTORY_URI
22
+ from agent_env.store import get_config
23
+ from agent_env.store.object_store import S3ObjectStore
24
+ from agent_env.store.routing import in_local_run
25
+ from agent_env.task_step.context import TaskStepContext
26
+ from agent_env.entity_refs import EntityRef, RefRole
27
+ from agent_env.task_step.task_step import TaskStep, TaskStepDependency
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ DEFAULT_EXPORT_TIMEOUT_SECONDS = 600
32
+ DEFAULT_EXPORT_CONCURRENCY = 3
33
+ _CONNECT_TIMEOUT_SECONDS = 30
34
+
35
+ _TRAJECTORY_CAPTURE_BUDGET_SECONDS = 300
36
+
37
+ # Must match the S3 credentials extension URI the server-side credentials mixin advertises.
38
+ _S3_CREDENTIALS_EXTENSION_URI = "urn:agentenv:add-s3-credentials/v1"
39
+
40
+
41
+ async def _push_s3_credentials(base_url: str, card: dict, timeout_seconds: float) -> None:
42
+ """Best-effort: push the object store's shared AWS creds + bucket to a service
43
+ advertising add_s3_credentials, if the store shares any. Never raises (get_data falls back).
44
+ An @local run's snapshots land in the local stores, so it hands a service no credentials to
45
+ upload one to S3 with; the service returns it instead."""
46
+ from agentenv_protocol import client as protocol_v1
47
+
48
+ if protocol_v1.find_extension(card, _S3_CREDENTIALS_EXTENSION_URI) is None or in_local_run():
49
+ return
50
+ try:
51
+ store = get_config().get_object_store()
52
+ if not isinstance(store, S3ObjectStore):
53
+ return
54
+ env = await asyncio.to_thread(store.shared_credentials_env)
55
+ if "AWS_ACCESS_KEY_ID" not in env:
56
+ return
57
+ params = {
58
+ "aws_access_key_id": env["AWS_ACCESS_KEY_ID"],
59
+ "aws_secret_access_key": env["AWS_SECRET_ACCESS_KEY"],
60
+ "aws_session_token": env.get("AWS_SESSION_TOKEN"),
61
+ "region_name": env.get("AWS_DEFAULT_REGION"),
62
+ "bucket": store.bucket,
63
+ }
64
+ await protocol_v1.invoke_extension(
65
+ base_url,
66
+ card,
67
+ _S3_CREDENTIALS_EXTENSION_URI,
68
+ params,
69
+ timeout=int(timeout_seconds),
70
+ )
71
+ logger.info("snapshot_env: pushed S3 creds to %s", base_url)
72
+ except Exception:
73
+ logger.warning(
74
+ "snapshot_env: failed to push S3 creds to %s (get_data will fall back)",
75
+ base_url,
76
+ exc_info=True,
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class EnvSnapshotResult:
82
+ environment_universe_artifact_id: str
83
+ environment_universe_artifact_version: int
84
+ environments_snapshotted: list[str]
85
+ total: int
86
+
87
+
88
+ class SnapshotEnvTaskStep(TaskStep):
89
+ """Snapshot a deployed env's current service state into a new
90
+ ``EnvironmentUniverseArtifact``.
91
+
92
+ Picks the env from ``context.deployed_envs`` by ``env_instance_id``, else
93
+ ``env_id``, else the context's single env; ``env_step_id`` narrows that to
94
+ one ``deploy_env`` step's deployment when an env is deployed more than once.
95
+ ``gateway_url``, ``snapshot_id`` and ``original_universe_artifact_id`` are
96
+ optional overrides; universe metadata carries over from that id, else the
97
+ loaded universe. The generated snapshot id is stable across
98
+ retries of a run, so retries bump versions instead of minting
99
+ duplicate universes.
100
+
101
+ Output: ``context.metadata["env_snapshotted_universes"][self.id]`` =
102
+ ``{"id", "version"}``. Fails the step if any service fails to export.
103
+ """
104
+
105
+ type: ClassVar[str] = "snapshot_env"
106
+ entity_refs = (
107
+ EntityRef.env("env_id"),
108
+ EntityRef.artifact("snapshot_id", role=RefRole.OUTPUT, artifact_type="environment_universe"),
109
+ EntityRef.artifact("original_universe_artifact_id", artifact_type="environment_universe"),
110
+ )
111
+ DEFAULT_EXPORT_TIMEOUT_SECONDS: ClassVar[int] = DEFAULT_EXPORT_TIMEOUT_SECONDS
112
+
113
+ def __init__(
114
+ self,
115
+ id: str,
116
+ version: Optional[int],
117
+ env_id: Optional[str] = None,
118
+ env_instance_id: Optional[str] = None,
119
+ env_step_id: Optional[str] = None,
120
+ gateway_url: Optional[str] = None,
121
+ snapshot_id: Optional[str] = None,
122
+ original_universe_artifact_id: Optional[str] = None,
123
+ export_timeout_seconds: int = DEFAULT_EXPORT_TIMEOUT_SECONDS,
124
+ include_env_trajectory: bool = False,
125
+ depends_on: Optional[list[TaskStepDependency]] = None,
126
+ fail_task_on_error: bool = True,
127
+ ):
128
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
129
+ self.env_id = env_id
130
+ self.env_instance_id = env_instance_id
131
+ self.env_step_id = env_step_id
132
+ self.gateway_url = gateway_url
133
+ self.snapshot_id = snapshot_id
134
+ self.original_universe_artifact_id = original_universe_artifact_id
135
+ self.export_timeout_seconds = export_timeout_seconds
136
+ self.include_env_trajectory = include_env_trajectory
137
+
138
+ def to_dict(self) -> dict:
139
+ base = super().to_dict()
140
+ base["env_id"] = self.env_id
141
+ base["env_instance_id"] = self.env_instance_id
142
+ base["env_step_id"] = self.env_step_id
143
+ base["gateway_url"] = self.gateway_url
144
+ base["snapshot_id"] = self.snapshot_id
145
+ base["original_universe_artifact_id"] = self.original_universe_artifact_id
146
+ base["export_timeout_seconds"] = self.export_timeout_seconds
147
+ base["include_env_trajectory"] = self.include_env_trajectory
148
+ return base
149
+
150
+ @classmethod
151
+ def from_dict(cls, data: dict) -> SnapshotEnvTaskStep:
152
+ return cls(
153
+ **cls._base_from_dict(data),
154
+ env_id=data.get("env_id"),
155
+ env_instance_id=data.get("env_instance_id"),
156
+ env_step_id=data.get("env_step_id"),
157
+ gateway_url=data.get("gateway_url"),
158
+ snapshot_id=data.get("snapshot_id"),
159
+ original_universe_artifact_id=data.get("original_universe_artifact_id"),
160
+ export_timeout_seconds=data.get(
161
+ "export_timeout_seconds", cls.DEFAULT_EXPORT_TIMEOUT_SECONDS
162
+ ),
163
+ include_env_trajectory=data.get("include_env_trajectory", False),
164
+ )
165
+
166
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
167
+ from agent_env.env.env import Env, gateway_url_of
168
+
169
+ deployed = self._resolve_deployed_env(context)
170
+ env_id = self.env_id or (deployed.env_id if deployed else None)
171
+ if not env_id:
172
+ raise RuntimeError("snapshot_env could not resolve an env_id")
173
+ gateway_url = self.gateway_url or gateway_url_of(deployed)
174
+ if not gateway_url:
175
+ raise RuntimeError(
176
+ f"Deployed env '{env_id}' has no gateway_url and no override was provided"
177
+ )
178
+
179
+ if self.include_env_trajectory:
180
+ entry: dict = {"captured_at_utc": datetime.now(timezone.utc).isoformat(), "capture_step_id": self.id}
181
+ try:
182
+ await asyncio.wait_for(self._capture_env_trajectory(entry, context, env_id, deployed), timeout=_TRAJECTORY_CAPTURE_BUDGET_SECONDS)
183
+ except Exception as e:
184
+ detail = f"{type(e).__name__}: {str(e)[:200]}"
185
+ logger.warning(f"snapshot_env: env trajectory capture aborted (continuing): {detail}")
186
+ entry["error"] = detail
187
+ context.metadata.setdefault("env_trajectory", {})[env_id] = entry
188
+
189
+ # Pin the env version the sandbox actually runs, so service enumeration
190
+ # can't drift if the env was re-registered since this sandbox deployed.
191
+ env_version = deployed.env_version if deployed else None
192
+ env = await asyncio.to_thread(Env.get, env_id, env_version)
193
+
194
+ snapshot_id = self._derive_snapshot_id(context, env_id)
195
+ result = await self.snapshot_env_state(
196
+ env=env,
197
+ gateway_url=gateway_url,
198
+ snapshot_id=snapshot_id,
199
+ deployed=deployed,
200
+ original_universe_artifact_id=self.original_universe_artifact_id,
201
+ export_timeout_seconds=self.export_timeout_seconds,
202
+ )
203
+
204
+ logger.info(
205
+ f"snapshot_env: {env_id} -> {result.environment_universe_artifact_id} "
206
+ f"v{result.environment_universe_artifact_version} "
207
+ f"({len(result.environments_snapshotted)}/{result.total} services)"
208
+ )
209
+ # Identifiers only — the context is heartbeated into task_instances.
210
+ context.metadata.setdefault("env_snapshotted_universes", {})[self.id] = {
211
+ "id": result.environment_universe_artifact_id,
212
+ "version": result.environment_universe_artifact_version,
213
+ }
214
+ return context
215
+
216
+ # ── internals ────────────────────────────────────────────────────────────
217
+
218
+ @staticmethod
219
+ def _deploy_step_of(deployed_env) -> Optional[str]:
220
+ return (deployed_env.metadata or {}).get("deploy_step_id")
221
+
222
+ def _select_by_step_id(self, candidates: list) -> list:
223
+ """The candidates ``env_step_id`` names; all of them when it isn't set."""
224
+ if self.env_step_id is None:
225
+ return candidates
226
+ # A filter, not a hint: an unmatched id is an error, never a substitute.
227
+ # Empty candidates are left to the callers' own not-deployed handling.
228
+ named = [d for d in candidates if self._deploy_step_of(d) == self.env_step_id]
229
+ if candidates and not named:
230
+ raise RuntimeError(
231
+ f"snapshot_env '{self.id}': env_step_id '{self.env_step_id}' names no deployment of "
232
+ f"{self._env_label} (deployed by: {[self._deploy_step_of(d) for d in candidates]})"
233
+ )
234
+ return named
235
+
236
+ @property
237
+ def _env_label(self) -> str:
238
+ return f"env '{self.env_id}'" if self.env_id else "any deployed env"
239
+
240
+ def _resolve_deployed_env(self, context: TaskStepContext):
241
+ """The DeployedEnv this step targets, or None in pure-override mode."""
242
+ if self.env_instance_id:
243
+ deployed = next(
244
+ (d for d in context.deployed_envs if d.instance_id == self.env_instance_id),
245
+ None,
246
+ )
247
+ if deployed is None:
248
+ raise RuntimeError(
249
+ f"No deployed env with instance_id '{self.env_instance_id}' in context "
250
+ f"(have: {[d.instance_id for d in context.deployed_envs]})"
251
+ )
252
+ return deployed
253
+ if self.env_id:
254
+ matches = self._select_by_step_id(
255
+ [d for d in context.deployed_envs if d.env_id == self.env_id]
256
+ )
257
+ if len(matches) > 1:
258
+ raise RuntimeError(
259
+ f"snapshot_env '{self.id}': {len(matches)} deployments of env '{self.env_id}' "
260
+ f"(deployed by: {[self._deploy_step_of(d) for d in matches]}). Set env_step_id "
261
+ "to the deploy_env step this snapshot should target."
262
+ )
263
+ deployed = matches[0] if matches else None
264
+ if deployed is None and self.gateway_url:
265
+ return None # pure-override mode: env_id + gateway_url suffice
266
+ if deployed is None:
267
+ raise RuntimeError(
268
+ f"No deployed env with env_id '{self.env_id}' in context "
269
+ f"(have: {[d.env_id for d in context.deployed_envs]}) and no "
270
+ "gateway_url override was provided"
271
+ )
272
+ return deployed
273
+ # `env_step_id` alone also names one of k, so it answers this ambiguity.
274
+ candidates = self._select_by_step_id(context.deployed_envs)
275
+ if len(candidates) == 1:
276
+ return candidates[0]
277
+ raise RuntimeError(
278
+ "snapshot_env needs env_id, env_instance_id or env_step_id when the context has "
279
+ f"{len(context.deployed_envs)} deployed envs "
280
+ f"(have: {[d.env_id for d in context.deployed_envs]})"
281
+ )
282
+
283
+ async def _capture_env_trajectory(self, entry: dict, context: TaskStepContext, env_id: str, deployed) -> None:
284
+ """Stream the trajectory to a temp file, upload it verbatim (no envelope), and fill ``entry``."""
285
+ method, url = _trajectory_route(deployed, self.gateway_url)
286
+ store = get_config().get_object_store()
287
+ event_count = 0
288
+ timeout = httpx.Timeout(_TRAJECTORY_CAPTURE_BUDGET_SECONDS, connect=_CONNECT_TIMEOUT_SECONDS)
289
+ tmp = tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False)
290
+ try:
291
+ async with httpx.AsyncClient(timeout=timeout) as client:
292
+ async with client.stream(method, url) as response:
293
+ response.raise_for_status()
294
+ async for chunk in response.aiter_bytes():
295
+ tmp.write(chunk)
296
+ event_count += chunk.count(b"\n")
297
+ tmp.close()
298
+ instance_key = context.instance_id or f"adhoc-{uuid.uuid4().hex[:12]}"
299
+ key = (
300
+ f"{get_config().get_artifact_key_prefix()}env_trajectory/"
301
+ f"instance_id={instance_key}/{env_id}-{uuid.uuid4().hex[:8]}.jsonl"
302
+ )
303
+ object_url = await asyncio.to_thread(store.put_file, key, tmp.name, "application/jsonl")
304
+ finally:
305
+ tmp.close()
306
+ os.unlink(tmp.name)
307
+ entry["object_url"] = object_url
308
+ entry["event_count"] = event_count
309
+
310
+ def _derive_snapshot_id(self, context: TaskStepContext, env_id: str) -> str:
311
+ if self.snapshot_id:
312
+ return self.snapshot_id
313
+ instance_id = context.instance_id or context.metadata.get("instance_id")
314
+ if instance_id:
315
+ # Stable per run instance: activity retries re-put the same ids.
316
+ return f"snapshot-{env_id}-{instance_id.rsplit('-', 1)[-1][:16]}"
317
+ generated = f"snapshot-{env_id}-{uuid.uuid4().hex[:8]}"
318
+ logger.warning(
319
+ f"snapshot_env: no instance_id in context; using random snapshot_id "
320
+ f"{generated} (retries will not be idempotent)"
321
+ )
322
+ return generated
323
+
324
+ @staticmethod
325
+ def _resolve_loaded_universe_ref(deployed) -> Optional[tuple[str, Optional[int]]]:
326
+ """(id, version) of the universe this instance recorded loading, or None (best-effort)."""
327
+ instance_id = getattr(deployed, "instance_id", None) if deployed else None
328
+ if not instance_id:
329
+ return None
330
+ try:
331
+ from agent_env.env.store import get_env_instance_store
332
+
333
+ su = get_env_instance_store().get_environment_universe(instance_id)
334
+ except Exception:
335
+ logger.warning("snapshot_env: could not read loaded universe for %s", instance_id, exc_info=True)
336
+ return None
337
+ return (su["id"], su.get("version")) if su and su.get("id") else None
338
+
339
+ @staticmethod
340
+ def _enumerate_environments(env) -> list[str]:
341
+ """environment names for a multi env or a single MCP server env."""
342
+ if getattr(env, "mcp_server_envs", None):
343
+ return [e.environment_name for e in env.mcp_server_envs]
344
+ if getattr(env, "environment_name", None):
345
+ return [env.environment_name]
346
+ raise RuntimeError(
347
+ f"Env '{env.id}' v{env.version} has no MCP services to snapshot "
348
+ "(expected mcp_server_envs or environment_name)"
349
+ )
350
+
351
+ @staticmethod
352
+ async def _export_environment_to_file(
353
+ gateway_url: str, environment_name: str, tmp_path: str, timeout_seconds: float, deployed=None
354
+ ) -> str:
355
+ """Export one service; return the suffix written (".json"/".zip") to
356
+ ``tmp_path``, or the object url a FilePart names when the service uploaded the
357
+ bundle itself (nothing written to ``tmp_path`` then — export_one registers it).
358
+
359
+ v1 ``get_data``: ``DataPart`` → json; ``FilePart`` → object url (no
360
+ download) / base64 ``bytes`` / streamed relative-or-http ``uri`` → ".zip";
361
+ else legacy ``GET /export-state`` streamed to disk with a first-byte JSON
362
+ guard. Errors are caught by the caller (fails just this service)."""
363
+ from agent_env.env import legacy_protocol
364
+ from agentenv_protocol import client as protocol_v1
365
+
366
+ base_url = await legacy_protocol.v1_base_url(deployed, gateway_url, environment_name, mcp=True)
367
+ if base_url is not None:
368
+ # Push S3 creds (no-op unless the service advertises the extension).
369
+ card_base, card = await legacy_protocol.child_env_card(deployed, gateway_url, environment_name)
370
+ await _push_s3_credentials(card_base, card or {}, timeout_seconds)
371
+ resp = await protocol_v1.get_data(base_url, timeout=int(timeout_seconds))
372
+ part = resp.parts[0] if resp.parts else None
373
+ if isinstance(part, FilePart):
374
+ raw = getattr(part.file, "bytes", None)
375
+ if raw is not None:
376
+ with open(tmp_path, "wb") as f:
377
+ f.write(base64.b64decode(raw))
378
+ else:
379
+ uri = getattr(part.file, "uri", None)
380
+ if not uri:
381
+ # ValueError, not TypeError, so export_one catches it and
382
+ # fails only this service — not the whole snapshot.
383
+ raise ValueError(
384
+ f"FilePart for '{environment_name}' has neither bytes nor uri"
385
+ )
386
+ # Any url but http(s) is an object the service uploaded itself — hand
387
+ # it back as-is; export_one registers it directly (no download). Its
388
+ # last segment carries the shape (e.g. gdrive.zip).
389
+ scheme = urlparse(uri).scheme
390
+ if scheme and scheme not in ("http", "https"):
391
+ return uri
392
+ # A relative uri (e.g. "export-snapshot") means "stream it from
393
+ # my service endpoint" — resolve against the service base_url so
394
+ # multi-GB bundles never ride inline in the JSON-RPC response.
395
+ if not scheme:
396
+ uri = f"{base_url.rstrip('/')}/{uri.lstrip('/')}"
397
+ async with httpx.AsyncClient() as client:
398
+ async with client.stream(
399
+ "GET",
400
+ uri,
401
+ timeout=httpx.Timeout(
402
+ timeout_seconds, connect=_CONNECT_TIMEOUT_SECONDS
403
+ ),
404
+ ) as resp_file:
405
+ resp_file.raise_for_status()
406
+ with open(tmp_path, "wb") as f:
407
+ async for chunk in resp_file.aiter_bytes():
408
+ f.write(chunk)
409
+ return os.path.splitext(getattr(part.file, "name", "") or "")[1] or ".zip"
410
+ state = part.data if part is not None else {}
411
+ with open(tmp_path, "w") as f:
412
+ json.dump(state, f, default=str)
413
+ return ".json"
414
+
415
+ legacy_url = f"{legacy_protocol.environment_base_url(gateway_url, environment_name, mcp=True)}/export-state"
416
+ async with httpx.AsyncClient() as client:
417
+ async with client.stream(
418
+ "GET",
419
+ legacy_url,
420
+ timeout=httpx.Timeout(timeout_seconds, connect=_CONNECT_TIMEOUT_SECONDS),
421
+ ) as resp:
422
+ resp.raise_for_status()
423
+ seen_first_bytes = False
424
+ with open(tmp_path, "wb") as f:
425
+ async for chunk in resp.aiter_bytes():
426
+ if not seen_first_bytes and chunk:
427
+ lead = chunk.lstrip()[:1]
428
+ if lead not in (b"{", b"["):
429
+ raise ValueError(
430
+ f"export-state returned non-JSON body (starts with {lead!r})"
431
+ )
432
+ seen_first_bytes = True
433
+ f.write(chunk)
434
+ return ".json"
435
+
436
+ @classmethod
437
+ async def snapshot_env_state(
438
+ cls,
439
+ *,
440
+ env,
441
+ gateway_url: str,
442
+ snapshot_id: str,
443
+ deployed=None,
444
+ original_universe_artifact_id: Optional[str] = None,
445
+ export_timeout_seconds: int = DEFAULT_EXPORT_TIMEOUT_SECONDS,
446
+ concurrency: int = DEFAULT_EXPORT_CONCURRENCY,
447
+ ) -> EnvSnapshotResult:
448
+ """Export all services via the gateway into a new EnvironmentUniverseArtifact
449
+ ``snapshot_id``. All-or-nothing; the raised error carries public
450
+ per-service summaries (verbose details go to the log).
451
+
452
+ A classmethod so callers that have no step instance can reuse it."""
453
+ from agent_env.artifact import FileArtifact, EnvironmentArtifact, EnvironmentUniverseArtifact
454
+ from agent_env.env.env import gateway_url_of
455
+
456
+ services = cls._enumerate_environments(env)
457
+ gateway = gateway_url.rstrip("/")
458
+ # `gateway` identifies which deployment was exported: with k deployments of
459
+ # one env_id, env= and snapshot_id= are identical across branches.
460
+ logger.info(
461
+ f"snapshot_env: env={env.id} v{env.version} snapshot_id={snapshot_id} "
462
+ f"gateway={gateway} services={services}"
463
+ )
464
+
465
+ # Carry the source universe's metadata into the snapshot: explicit
466
+ # original id (fail-fast), else the loaded universe (best-effort).
467
+ snapshot_metadata = None
468
+ source_id = original_universe_artifact_id
469
+ explicit = source_id is not None
470
+ source_version: Optional[int] = None
471
+ if not explicit:
472
+ source_id, source_version = cls._resolve_loaded_universe_ref(deployed) or (None, None)
473
+ if source_id:
474
+ try:
475
+ source = await asyncio.to_thread(
476
+ EnvironmentUniverseArtifact.get, source_id, version=source_version
477
+ )
478
+ snapshot_metadata = await asyncio.to_thread(source.get_metadata) or None
479
+ if not snapshot_metadata:
480
+ logger.warning("snapshot_env: source universe %s has no metadata to carry", source_id)
481
+ except Exception:
482
+ if explicit:
483
+ raise # caller named this id — surface a bad one
484
+ logger.warning("snapshot_env: failed to carry metadata from %s", source_id, exc_info=True)
485
+
486
+ # The stored card describes the deployment's own gateway; an override pointing elsewhere keeps the live probe.
487
+ card_record = deployed if deployed is not None and (gateway_url_of(deployed) or "").rstrip("/") == gateway else None
488
+ sem = asyncio.Semaphore(concurrency)
489
+
490
+ async def export_one(environment_name: str):
491
+ """Returns EnvironmentArtifact on success, (public_err, log_err) on failure."""
492
+ async with sem:
493
+ # Suffix (.json/.zip) is reported by _export_environment_to_file.
494
+ fd, tmp_path = tempfile.mkstemp(prefix=f"{snapshot_id}-{environment_name}-")
495
+ os.close(fd)
496
+ cleanup_paths = [tmp_path]
497
+ artifact_path = tmp_path
498
+ try:
499
+ try:
500
+ result = await cls._export_environment_to_file(
501
+ gateway, environment_name, tmp_path, export_timeout_seconds,
502
+ deployed=card_record,
503
+ )
504
+ except httpx.HTTPStatusError as e:
505
+ body_snippet = ""
506
+ try:
507
+ body_snippet = (await e.response.aread())[:500].decode(errors="replace")
508
+ except Exception:
509
+ pass
510
+ public = f"export-state returned HTTP {e.response.status_code}"
511
+ return public, f"{public} from {environment_name}: {body_snippet!r}"
512
+ except httpx.RequestError as e:
513
+ public = f"export-state request failed ({type(e).__name__})"
514
+ return public, f"{public}: {e}"
515
+ except ValueError as e:
516
+ return str(e), str(e)
517
+
518
+ # Service uploaded the bundle itself — register a FileArtifact
519
+ # pointing at it directly (no download). Derive filename/content_type
520
+ # from the url's last segment (the extension drives load-time parsing),
521
+ # like FileArtifact.put.
522
+ if urlparse(result).scheme:
523
+ from agent_env.artifact.store import get_artifact_store
524
+
525
+ object_url = result
526
+ filename = object_url.rsplit("/", 1)[-1] or f"{environment_name}.zip"
527
+ content_type = mimetypes.guess_type(filename)[0] or "application/zip"
528
+
529
+ def _register_uploaded_bundle():
530
+ if get_config().get_object_store().get_object_metadata_at(object_url) is None:
531
+ raise FileNotFoundError(f"snapshot bundle not found at {object_url}")
532
+ store = get_artifact_store()
533
+ fa_id = f"{snapshot_id}-{environment_name}-file"
534
+ get_config().check_object_url(fa_id, object_url)
535
+ fa = FileArtifact(
536
+ id=fa_id,
537
+ version=store.next_version(fa_id),
538
+ description=f"State snapshot of {environment_name} from env {env.id}",
539
+ filename=filename,
540
+ content_type=content_type,
541
+ s3_url=object_url,
542
+ )
543
+ fa = store.put_document(fa)
544
+ return EnvironmentArtifact.put(
545
+ id=f"{snapshot_id}-{environment_name}",
546
+ environment_name=environment_name,
547
+ file_artifact=fa,
548
+ )
549
+
550
+ try:
551
+ return await asyncio.to_thread(_register_uploaded_bundle)
552
+ except Exception as e:
553
+ public = f"failed to register the uploaded bundle ({type(e).__name__})"
554
+ return public, f"{public} for {environment_name}: {e}"
555
+
556
+ # Otherwise `result` is the written suffix (.json/.zip).
557
+ # FileArtifact.filename = basename(path); a .zip name routes the
558
+ # load side to reset_data() (lossless).
559
+ if result and not tmp_path.endswith(result):
560
+ artifact_path = tmp_path + result
561
+ os.rename(tmp_path, artifact_path)
562
+ cleanup_paths.append(artifact_path)
563
+
564
+ try:
565
+ file_artifact = await asyncio.to_thread(
566
+ FileArtifact.put,
567
+ id=f"{snapshot_id}-{environment_name}-file",
568
+ description=f"State snapshot of {environment_name} from env {env.id}",
569
+ file_path=artifact_path,
570
+ )
571
+ return await asyncio.to_thread(
572
+ EnvironmentArtifact.put,
573
+ id=f"{snapshot_id}-{environment_name}",
574
+ environment_name=environment_name,
575
+ file_artifact=file_artifact,
576
+ )
577
+ except Exception as e:
578
+ public = f"failed to create artifacts ({type(e).__name__})"
579
+ return public, f"{public}: {e}"
580
+ finally:
581
+ for path in cleanup_paths:
582
+ try:
583
+ os.unlink(path)
584
+ except OSError:
585
+ pass
586
+
587
+ results = await asyncio.gather(*(export_one(name) for name in services))
588
+
589
+ environment_artifacts = []
590
+ skipped: dict[str, tuple[str, str]] = {}
591
+ for name, result in zip(services, results):
592
+ if isinstance(result, tuple):
593
+ skipped[name] = result
594
+ else:
595
+ environment_artifacts.append(result)
596
+
597
+ if skipped:
598
+ details = "; ".join(f"{name}: {log}" for name, (_, log) in skipped.items())
599
+ logger.error(
600
+ f"snapshot_env: skipped {len(skipped)}/{len(services)} services for "
601
+ f"{env.id} (snapshot_id={snapshot_id}): {details}"
602
+ )
603
+ public_summary = "; ".join(
604
+ f"{name}: {public}" for name, (public, _) in skipped.items()
605
+ )
606
+ raise RuntimeError(
607
+ f"{len(skipped)}/{len(services)} services were not exported "
608
+ f"(missing={sorted(skipped)}): {public_summary}"
609
+ )
610
+
611
+ universe = await asyncio.to_thread(
612
+ EnvironmentUniverseArtifact.put,
613
+ id=snapshot_id,
614
+ environment_artifacts=environment_artifacts,
615
+ metadata=snapshot_metadata,
616
+ )
617
+ logger.info(
618
+ f"snapshot_env: created {universe.id} v{universe.version} "
619
+ f"({len(environment_artifacts)} services)"
620
+ )
621
+ return EnvSnapshotResult(
622
+ environment_universe_artifact_id=universe.id,
623
+ environment_universe_artifact_version=universe.version,
624
+ environments_snapshotted=[sa.environment_name for sa in environment_artifacts],
625
+ total=len(services),
626
+ )
627
+
628
+
629
+ def _trajectory_route(deployed, override_url: Optional[str]) -> tuple[str, str]:
630
+ """The verb and URL of the env trajectory: an override keeps `{override}/trajectory`, else the stored card names them."""
631
+ if override_url:
632
+ return "GET", f"{override_url.rstrip('/')}/trajectory"
633
+ from agentenv_protocol import client as protocol_v1
634
+
635
+ deployed.require(EXT_TRAJECTORY_URI, "get")
636
+ op = protocol_v1.find_extension_method(deployed.environment_card, EXT_TRAJECTORY_URI, "get")
637
+ return op["method"], f"{deployed.environment_url}{op['endpoint']}"