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,775 @@
1
+ """Load artifact into a deployed environment task step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import posixpath
9
+ import shlex
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable, ClassVar, Optional
13
+ from urllib.parse import unquote, urlparse
14
+
15
+ from agent_env.task_step.context import TaskStepContext
16
+ from agent_env.entity_refs import EntityRef
17
+ from agent_env.task_step.task_step import TaskStep, TaskStepDependency
18
+ from agent_env.utils.paths import validate_relative_filename
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Where producer steps record what they made, keyed by their own step id. Resolving
23
+ # against these lets config name an artifact whose id is minted mid-run, without this
24
+ # step knowing which kind of step produced it. A step writes exactly one of these, so
25
+ # the order arbitrates a malformed context rather than ranking real producers.
26
+ _STEP_ARTIFACT_SOURCES: tuple[tuple[str, Callable[[dict], Optional[dict]]], ...] = (
27
+ ("collected_artifacts", lambda entry: entry.get("file_artifact_universe")),
28
+ ("env_snapshotted_universes", lambda entry: entry),
29
+ )
30
+
31
+
32
+ @dataclass
33
+ class _LoadInputs:
34
+ """What a load_artifact step loads and where, after per-run overrides."""
35
+
36
+ artifacts: list[dict]
37
+ urls: list[str]
38
+ destination_path: Optional[str]
39
+
40
+
41
+ class LoadArtifactTaskStep(TaskStep):
42
+ type: ClassVar[str] = "load_artifact"
43
+ entity_refs = (
44
+ EntityRef.env("env_id"),
45
+ EntityRef.artifact("artifact_id", version_field="artifact_version"),
46
+ EntityRef.artifact("artifacts[].id", version_field="version"),
47
+ )
48
+
49
+ def __init__(
50
+ self,
51
+ id: str,
52
+ version: Optional[int],
53
+ env_id: Optional[str] = None,
54
+ env_step_id: Optional[str] = None,
55
+ artifacts: Optional[list[dict]] = None,
56
+ artifact_id: Optional[str] = None,
57
+ artifact_version: Optional[int] = None,
58
+ urls: Optional[list[str]] = None,
59
+ collected_artifacts_step_id: Optional[str] = None,
60
+ artifact_from_step_id: Optional[str] = None,
61
+ destination_path: Optional[str] = None,
62
+ agent_name: Optional[str] = None,
63
+ sandbox_name: Optional[str] = None,
64
+ container_name: Optional[str] = None,
65
+ depends_on: Optional[list[TaskStepDependency]] = None,
66
+ fail_task_on_error: bool = True,
67
+ snapshot_after_load: Optional[bool] = None,
68
+ ):
69
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
70
+ if artifacts is not None and artifact_id is not None:
71
+ raise ValueError("Pass either `artifacts=[...]` or `artifact_id=...`, not both")
72
+ if artifacts is None and artifact_id is not None:
73
+ artifacts = [{"id": artifact_id, "version": artifact_version}]
74
+ if collected_artifacts_step_id and (artifacts or urls):
75
+ raise ValueError(
76
+ "`collected_artifacts_step_id` is mutually exclusive with `artifacts`/`artifact_id`/`urls`"
77
+ )
78
+ if artifact_from_step_id and (artifacts or urls or collected_artifacts_step_id):
79
+ raise ValueError(
80
+ "`artifact_from_step_id` is mutually exclusive with "
81
+ "`artifacts`/`artifact_id`/`urls`/`collected_artifacts_step_id`"
82
+ )
83
+ if not (artifacts or urls or collected_artifacts_step_id or artifact_from_step_id):
84
+ raise ValueError(
85
+ "Must pass at least one of `artifacts=[...]`, `artifact_id=...`, `urls=[...]`, "
86
+ "`artifact_from_step_id=...`, or `collected_artifacts_step_id=...`"
87
+ )
88
+ if agent_name is not None and container_name is not None:
89
+ raise ValueError("Pass either `agent_name` or `container_name`, not both")
90
+ if env_id is None and agent_name is None and container_name is None and sandbox_name is None:
91
+ raise ValueError(
92
+ "Must pass at least one of `env_id`, `agent_name`, `sandbox_name`, or "
93
+ "`container_name`+`sandbox_name`"
94
+ )
95
+ if container_name is not None and sandbox_name is None:
96
+ raise ValueError("`container_name` requires `sandbox_name`")
97
+ if urls and container_name is None and agent_name is None and sandbox_name is None:
98
+ raise ValueError(
99
+ "`urls` requires `agent_name`, `sandbox_name`, or `container_name`+`sandbox_name`"
100
+ )
101
+ if collected_artifacts_step_id and agent_name is None and container_name is None and sandbox_name is None:
102
+ raise ValueError(
103
+ "`collected_artifacts_step_id` requires `agent_name`, `sandbox_name`, or "
104
+ "`container_name`+`sandbox_name`"
105
+ )
106
+ self.env_id = env_id
107
+ self.env_step_id = env_step_id
108
+ self.artifacts = artifacts or []
109
+ self.urls = list(urls or [])
110
+ # Superseded by the producer-agnostic `artifact_from_step_id`, but kept
111
+ # readable/writable: it is persisted on existing task-step documents.
112
+ self.collected_artifacts_step_id = collected_artifacts_step_id
113
+ self.artifact_from_step_id = artifact_from_step_id
114
+ self.destination_path = destination_path
115
+ self.agent_name = agent_name
116
+ self.sandbox_name = sandbox_name
117
+ self.container_name = container_name
118
+ # None = defer to the AGENT_ENV_SNAPSHOT_AFTER_LOAD flag on the worker, so a task
119
+ # doc doesn't hard-code a cost decision an operator may want to flip.
120
+ self.snapshot_after_load = snapshot_after_load
121
+
122
+ def to_dict(self) -> dict:
123
+ base = super().to_dict()
124
+ base["env_id"] = self.env_id
125
+ base["env_step_id"] = self.env_step_id
126
+ base["artifacts"] = self.artifacts
127
+ base["urls"] = self.urls
128
+ base["collected_artifacts_step_id"] = self.collected_artifacts_step_id
129
+ base["artifact_from_step_id"] = self.artifact_from_step_id
130
+ base["destination_path"] = self.destination_path
131
+ base["agent_name"] = self.agent_name
132
+ base["sandbox_name"] = self.sandbox_name
133
+ base["container_name"] = self.container_name
134
+ base["snapshot_after_load"] = self.snapshot_after_load
135
+ return base
136
+
137
+ @classmethod
138
+ def from_dict(cls, data: dict) -> LoadArtifactTaskStep:
139
+ if "artifacts" in data:
140
+ artifacts = data["artifacts"]
141
+ elif "artifact_id" in data:
142
+ artifacts = [{"id": data["artifact_id"], "version": data.get("artifact_version")}]
143
+ else:
144
+ artifacts = None
145
+ return cls(
146
+ **cls._base_from_dict(data),
147
+ env_id=data.get("env_id"),
148
+ env_step_id=data.get("env_step_id"),
149
+ artifacts=artifacts,
150
+ urls=data.get("urls"),
151
+ collected_artifacts_step_id=data.get("collected_artifacts_step_id"),
152
+ artifact_from_step_id=data.get("artifact_from_step_id"),
153
+ destination_path=data.get("destination_path"),
154
+ agent_name=data.get("agent_name"),
155
+ sandbox_name=data.get("sandbox_name"),
156
+ container_name=data.get("container_name"),
157
+ snapshot_after_load=data.get("snapshot_after_load"),
158
+ )
159
+
160
+ @staticmethod
161
+ def _record_universe_load(context: TaskStepContext, artifact, load_result) -> None:
162
+ """Record which load path ran, so a slow run is explicable after the fact.
163
+
164
+ "This took 40 minutes" and "this took 90 seconds" are the same step with the same
165
+ inputs; the only difference is whether a snapshot was hit. Without this the
166
+ distinction exists solely in worker logs, which are gone by the time anyone asks.
167
+ """
168
+ if load_result is None:
169
+ return
170
+ entry = {
171
+ "id": artifact.id,
172
+ "version": artifact.version,
173
+ "restored_from_snapshot": getattr(load_result, "restored_from_snapshot", False),
174
+ }
175
+ for key in ("snapshot_db_image_artifact_id", "snapshot_baked", "snapshot_bake_error"):
176
+ value = getattr(load_result, key, None)
177
+ if value is not None:
178
+ entry[key] = value
179
+ context.metadata.setdefault("loaded_environment_universes", []).append(entry)
180
+
181
+ @staticmethod
182
+ def _resolve_step_artifact(context: TaskStepContext, step_id: str) -> Optional[dict]:
183
+ """The ``{id, version}`` a prior step recorded under ``step_id``.
184
+
185
+ ``None`` when that step ran but produced nothing; raises when nothing was
186
+ recorded at all, since a wrong id or a producer that never ran is
187
+ misconfiguration rather than an empty result.
188
+ """
189
+ for key, extract in _STEP_ARTIFACT_SOURCES:
190
+ entry = (context.metadata.get(key) or {}).get(step_id)
191
+ if entry is None:
192
+ continue
193
+ ref = extract(entry)
194
+ return {"id": ref["id"], "version": ref.get("version")} if ref else None
195
+ raise RuntimeError(
196
+ f"No artifact recorded by step '{step_id}'. Ensure a step that produces "
197
+ f"one (e.g. `collect_artifacts`, `snapshot_env`) ran with that id before "
198
+ f"this load step."
199
+ )
200
+
201
+ @staticmethod
202
+ def _deploy_step_of(deployed_env) -> Optional[str]:
203
+ return (deployed_env.metadata or {}).get("deploy_step_id")
204
+
205
+ def _deployed_env(self, context: TaskStepContext):
206
+ """The deployment of `env_id` this step loads into, or None if it isn't deployed."""
207
+ matches = [d for d in context.deployed_envs if d.env_id == self.env_id]
208
+ if self.env_step_id is not None:
209
+ # A filter, not a hint: an unmatched id is an error, never a substitute.
210
+ named = [d for d in matches if self._deploy_step_of(d) == self.env_step_id]
211
+ if matches and not named:
212
+ raise RuntimeError(
213
+ f"load_artifact '{self.id}': env_step_id '{self.env_step_id}' names no deployment of "
214
+ f"env '{self.env_id}' (deployed by: {[self._deploy_step_of(d) for d in matches]})"
215
+ )
216
+ matches = named
217
+ if len(matches) > 1:
218
+ raise RuntimeError(
219
+ f"load_artifact '{self.id}': {len(matches)} deployments of env '{self.env_id}' "
220
+ f"(deployed by: {[self._deploy_step_of(d) for d in matches]}). Set env_step_id "
221
+ "to the deploy_env step this load should target."
222
+ )
223
+ return matches[0] if matches else None
224
+
225
+ def _resolve_inputs(self, context: TaskStepContext) -> _LoadInputs:
226
+ """Resolve ``(artifacts, urls, destination_path)``, honoring per-run
227
+ ``step_overrides``. An override wins over the stored value; an explicit
228
+ artifact override also wins over ``artifact_from_step_id`` /
229
+ ``collected_artifacts_step_id`` wiring.
230
+ Topology (env_id/agent_name/container_name) is not overridable here.
231
+ """
232
+ overrides = self.step_param_overrides(context)
233
+
234
+ if "artifacts" in overrides:
235
+ resolved_artifacts = list(overrides.get("artifacts") or [])
236
+ elif "artifact_id" in overrides:
237
+ if not overrides["artifact_id"]:
238
+ raise ValueError(
239
+ f"load '{self.id}': step_overrides 'artifact_id' must be a non-empty string, "
240
+ f"got {overrides['artifact_id']!r}"
241
+ )
242
+ resolved_artifacts = [
243
+ {"id": overrides["artifact_id"], "version": overrides.get("artifact_version")}
244
+ ]
245
+ else:
246
+ resolved_artifacts = list(self.artifacts)
247
+ step_id = self.artifact_from_step_id or self.collected_artifacts_step_id
248
+ if step_id:
249
+ ref = self._resolve_step_artifact(context, step_id)
250
+ # Produced nothing → skip the load and let a downstream verifier
251
+ # score the empty result, rather than failing the task here.
252
+ if ref is None:
253
+ logger.warning("step %r produced no artifacts — skipping load", step_id)
254
+ resolved_artifacts = []
255
+ else:
256
+ resolved_artifacts = [ref]
257
+
258
+ # `or []` so a `{"urls": null}` override cleanly clears rather than crashing on list(None).
259
+ urls = list(overrides["urls"] or []) if "urls" in overrides else list(self.urls)
260
+ destination_path = overrides.get("destination_path", self.destination_path)
261
+
262
+ if overrides:
263
+ logger.info("load '%s': applying step_overrides for %s", self.id, sorted(overrides))
264
+ return _LoadInputs(artifacts=resolved_artifacts, urls=urls, destination_path=destination_path)
265
+
266
+ async def _load_url_onto_vm(self, sandbox, url: str, destination_path: str) -> None:
267
+ """Host counterpart of ``sandbox.load_s3_file``; ``write_file_from_url`` targets a container instead."""
268
+ from agent_env.providers.sandbox_providers.sandbox import CURL_RETRY_FLAGS
269
+
270
+ parent = posixpath.dirname(destination_path)
271
+ if parent:
272
+ await sandbox.exec_script(f"mkdir -p {shlex.quote(parent)}")
273
+ await sandbox.exec_script(
274
+ f"curl -fsSL {CURL_RETRY_FLAGS} {shlex.quote(url)} -o {shlex.quote(destination_path)}"
275
+ )
276
+
277
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
278
+ from agent_env.a2a_agent import A2AAgent
279
+ from agent_env.a2a_agent.store import get_a2a_agent_instance_store
280
+ from agent_env.artifact import (
281
+ Artifact,
282
+ CliArtifact,
283
+ FileArtifact,
284
+ FileArtifactUniverse,
285
+ EnvironmentArtifact,
286
+ EnvironmentUniverseArtifact,
287
+ )
288
+ from agent_env.env.env import Env, require_gateway_url
289
+ from agent_env.providers.sandbox_providers.sandbox import VmSandbox
290
+ from agent_env.providers.sandbox_providers.sandbox_provider import (
291
+ build_sandbox_provider,
292
+ get_agent_sandbox_provider,
293
+ get_sandbox_provider,
294
+ )
295
+
296
+ deployed = None
297
+ if self.env_id is not None:
298
+ deployed = self._deployed_env(context)
299
+ if deployed is None:
300
+ raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs")
301
+
302
+ inputs = self._resolve_inputs(context)
303
+ resolved_artifacts = inputs.artifacts
304
+ urls = inputs.urls
305
+ destination_path = inputs.destination_path
306
+
307
+ # With container_name it hosts that container; alone it is the target itself.
308
+ target_sandbox = None
309
+ if self.sandbox_name is not None:
310
+ if self.container_name is not None:
311
+ containers = context.metadata.get("deployed_docker_containers", [])
312
+ container_entry = next(
313
+ (
314
+ c for c in containers
315
+ if c.get("container_name") == self.container_name
316
+ and c.get("sandbox_name") == self.sandbox_name
317
+ ),
318
+ None,
319
+ )
320
+ if container_entry is None:
321
+ raise RuntimeError(
322
+ f"Container '{self.container_name}' not found on sandbox '{self.sandbox_name}' in "
323
+ f"context.metadata['deployed_docker_containers']"
324
+ )
325
+ ds = next((s for s in context.deployed_sandboxes if s.sandbox_name == self.sandbox_name), None)
326
+ if ds is None:
327
+ raise RuntimeError(f"Sandbox '{self.sandbox_name}' not found in context.deployed_sandboxes")
328
+ provider = (
329
+ build_sandbox_provider(ds.sandbox_type) if ds.sandbox_type else get_sandbox_provider()
330
+ )
331
+ target_sandbox = await provider.get_sandbox(ds.sandbox_id)
332
+
333
+ env = None
334
+ for ref in resolved_artifacts:
335
+ artifact = Artifact.get(ref["id"], ref.get("version"))
336
+ # Split on the target, not the type: a service universe aimed at an
337
+ # agent/container is a grading load (stage the frozen exports as files),
338
+ # aimed at an env it still means restore into the live MCP services.
339
+ stage_as_files = isinstance(artifact, (FileArtifactUniverse, FileArtifact)) or (
340
+ isinstance(artifact, EnvironmentUniverseArtifact)
341
+ and deployed is None
342
+ and (
343
+ self.agent_name is not None
344
+ or self.container_name is not None
345
+ or self.sandbox_name is not None
346
+ )
347
+ )
348
+ if stage_as_files:
349
+ if self.sandbox_name is not None:
350
+ destination = (destination_path or "/loaded").rstrip("/") or "/"
351
+ if self.container_name is not None:
352
+ files = await _load_universe_into_container(
353
+ target_sandbox, self.container_name, artifact, destination,
354
+ )
355
+ else:
356
+ files = await _load_universe_onto_vm(target_sandbox, artifact, destination)
357
+ context.metadata.setdefault("loaded_file_artifact_universes", []).append({
358
+ "id": artifact.id,
359
+ "version": artifact.version,
360
+ "artifact_type": artifact.type,
361
+ "env_id": None,
362
+ "agent_name": None,
363
+ "sandbox_name": self.sandbox_name,
364
+ "container_name": self.container_name,
365
+ "destination_path": destination,
366
+ "files": files,
367
+ })
368
+ continue
369
+ if (self.env_id is None) == (self.agent_name is None):
370
+ raise RuntimeError(
371
+ f"{artifact.type} '{artifact.id}' requires exactly one of `env_id` or `agent_name`"
372
+ )
373
+ if self.env_id is not None:
374
+ # Only a FileArtifactUniverse or a FileArtifact reaches here — `stage_as_files`
375
+ # excludes a service universe whenever an env is targeted.
376
+ if env is None:
377
+ env_obj = Env.get(deployed.env_id, deployed.env_version)
378
+ env = await type(env_obj).from_deployed_env(deployed)
379
+ result = await env.load_file_artifact_universe(artifact, destination_path=destination_path)
380
+ context.metadata.setdefault("loaded_file_artifact_universes", []).append({
381
+ "id": artifact.id,
382
+ "version": artifact.version,
383
+ "artifact_type": artifact.type,
384
+ "env_id": deployed.env_id,
385
+ "agent_name": None,
386
+ "destination_path": result.destination_path,
387
+ "files": result.files,
388
+ })
389
+ else:
390
+ agent = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
391
+ if agent is None or not agent.instance_id:
392
+ raise RuntimeError(f"Agent '{self.agent_name}' not found in context.deployed_agents (or missing instance_id)")
393
+ deployed_agent = get_a2a_agent_instance_store().get(agent.instance_id)
394
+ destination = (destination_path or "/tmp/file_artifacts").rstrip("/") or "/"
395
+ files = await A2AAgent.load_file_artifact_universe(deployed_agent, artifact, destination)
396
+ context.metadata.setdefault("loaded_file_artifact_universes", []).append({
397
+ "id": artifact.id,
398
+ "version": artifact.version,
399
+ "artifact_type": artifact.type,
400
+ "env_id": None,
401
+ "agent_name": self.agent_name,
402
+ "destination_path": destination,
403
+ "files": files,
404
+ })
405
+ elif isinstance(artifact, (EnvironmentArtifact, EnvironmentUniverseArtifact)):
406
+ if (
407
+ deployed is None
408
+ and isinstance(artifact, EnvironmentArtifact)
409
+ and (
410
+ self.agent_name is not None
411
+ or self.container_name is not None
412
+ or self.sandbox_name is not None
413
+ )
414
+ ):
415
+ # Stage the service payload (filesystem-style data.json with
416
+ # `files[]`, or a `data.json` + `root/` doc-carrier zip) as a
417
+ # plain file tree inside the agent/container — no MCP server
418
+ # involved.
419
+ if self.sandbox_name is not None:
420
+ sandbox = target_sandbox
421
+ # None targets the VM host itself.
422
+ container = self.container_name
423
+ else:
424
+ agent = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
425
+ if agent is None or not agent.sandbox_id:
426
+ raise RuntimeError(
427
+ f"Agent '{self.agent_name}' not found in context.deployed_agents (or missing sandbox_id)"
428
+ )
429
+ provider = (
430
+ build_sandbox_provider(agent.sandbox_type)
431
+ if agent.sandbox_type
432
+ else get_agent_sandbox_provider()
433
+ )
434
+ sandbox = await provider.get_sandbox(agent.sandbox_id)
435
+ container = sandbox.container_name if isinstance(sandbox, VmSandbox) else None
436
+ destination = (destination_path or "/app/files").rstrip("/") or "/"
437
+ files = await _stage_environment_payload_into_container(
438
+ sandbox, container, artifact, destination,
439
+ )
440
+ context.metadata.setdefault("loaded_environment_artifacts", []).append({
441
+ "id": artifact.id,
442
+ "version": artifact.version,
443
+ "service_name": artifact.environment_name,
444
+ "environment_name": artifact.environment_name,
445
+ "agent_name": self.agent_name,
446
+ "sandbox_name": self.sandbox_name,
447
+ "container_name": self.container_name,
448
+ "destination_path": destination,
449
+ "file_count": len(files),
450
+ # Cap the recorded list — TaskStepContext is persisted and
451
+ # can be shipped to an external runner; a huge corpus must
452
+ # not bloat the payload.
453
+ "files": files[:_ENVIRONMENT_PAYLOAD_MAX_RECORDED_FILES],
454
+ })
455
+ continue
456
+ if deployed is None:
457
+ raise RuntimeError(f"`env_id` is required when loading a {artifact.type}")
458
+ if env is None:
459
+ env_obj = Env.get(deployed.env_id, deployed.env_version)
460
+ env = await type(env_obj).from_deployed_env(deployed)
461
+ if isinstance(artifact, EnvironmentArtifact):
462
+ await env.load_environment_artifact(artifact)
463
+ else:
464
+ from agent_env.env.envs.multi_env import MultiEnv
465
+
466
+ # Only MultiEnv has a servicedb to snapshot; the other env types
467
+ # don't accept the kwarg, so don't invent one for them.
468
+ if isinstance(env, MultiEnv):
469
+ load_result = await env.load_environment_universe_artifact(
470
+ artifact, snapshot_after_load=self.snapshot_after_load
471
+ )
472
+ else:
473
+ load_result = await env.load_environment_universe_artifact(artifact)
474
+ self._record_universe_load(context, artifact, load_result)
475
+ elif isinstance(artifact, CliArtifact):
476
+ if not self.agent_name:
477
+ raise RuntimeError("agent_name is required when loading a CliArtifact")
478
+ if deployed is None:
479
+ raise RuntimeError("env_id is required when loading a CliArtifact (for gateway_url)")
480
+ agent = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
481
+ if agent is None or not agent.instance_id:
482
+ raise RuntimeError(f"Agent '{self.agent_name}' not found in context.deployed_agents (or missing instance_id)")
483
+ deployed_agent = get_a2a_agent_instance_store().get(agent.instance_id)
484
+ gateway_url = require_gateway_url(deployed, "Installing a CliArtifact")
485
+ install_path = await A2AAgent.install_cli(deployed_agent, artifact, gateway_url)
486
+ agent_clis = context.metadata.setdefault("installed_clis", {}).setdefault(self.agent_name, {})
487
+ agent_clis[artifact.id] = {
488
+ "install_path": install_path,
489
+ "command_name": artifact.command_name,
490
+ }
491
+ else:
492
+ raise ValueError(f"Unsupported artifact type for loading: '{artifact.type}'")
493
+
494
+ if urls:
495
+ if self.sandbox_name is not None:
496
+ sandbox = target_sandbox
497
+ else:
498
+ agent = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
499
+ if agent is None or not agent.sandbox_id:
500
+ raise RuntimeError(f"Agent '{self.agent_name}' not found in context.deployed_agents (or missing sandbox_id)")
501
+ provider = (
502
+ build_sandbox_provider(agent.sandbox_type)
503
+ if agent.sandbox_type
504
+ else get_agent_sandbox_provider()
505
+ )
506
+ sandbox = await provider.get_sandbox(agent.sandbox_id)
507
+ destination = (destination_path or "/tmp/file_artifacts").rstrip("/") or "/"
508
+
509
+ files: list[str] = []
510
+ seen: dict[str, int] = {}
511
+ for url in urls:
512
+ base = unquote(os.path.basename(urlparse(url).path)) or "downloaded"
513
+ count = seen.get(base, 0)
514
+ seen[base] = count + 1
515
+ if count == 0:
516
+ files.append(base)
517
+ else:
518
+ stem, ext = os.path.splitext(base)
519
+ files.append(f"{stem}-{count}{ext}")
520
+
521
+ semaphore = asyncio.Semaphore(8)
522
+
523
+ async def _load_one(url: str, filename: str) -> None:
524
+ async with semaphore:
525
+ dest = f"{destination}/{filename}"
526
+ if self.sandbox_name is not None and self.container_name is None:
527
+ await self._load_url_onto_vm(sandbox, url, dest)
528
+ else:
529
+ await sandbox.write_file_from_url(url, dest)
530
+ target_desc = (
531
+ f"VM sandbox '{self.sandbox_name}'"
532
+ if self.sandbox_name is not None and self.container_name is None
533
+ else "agent"
534
+ )
535
+ logger.info(f"Loaded URL into {target_desc}: {url} -> {dest}")
536
+
537
+ await asyncio.gather(*(_load_one(u, f) for u, f in zip(urls, files)))
538
+ context.metadata.setdefault("loaded_urls", []).append({
539
+ "step_id": self.id,
540
+ "agent_name": None if self.sandbox_name is not None else self.agent_name,
541
+ "sandbox_name": self.sandbox_name,
542
+ "destination_path": destination,
543
+ "files": files,
544
+ })
545
+
546
+ return context
547
+
548
+
549
+ # Cap on how many staged paths get recorded in TaskStepContext metadata
550
+ # (persisted, and shipped to external runners) — `file_count` always carries
551
+ # the real total.
552
+ _ENVIRONMENT_PAYLOAD_MAX_RECORDED_FILES = 200
553
+
554
+ # Expands a service payload into a plain file tree using only the Python
555
+ # stdlib, so it can run on the sandbox VM or inside the agent container
556
+ # without any agent-env install. Mirrors the synthetic filesystem server's
557
+ # load contract (BaseService._reset_from_zip_bundle / load_from_json):
558
+ # • zip bundle: `root/` members are real files (streamed out first, zip-slip
559
+ # guarded); an optional top-level `data.json` provides `files[]` entries
560
+ # that are skipped when the path already exists (root/ takes precedence).
561
+ # • bare data.json: `files[]` entries with `path`, `content`, and optional
562
+ # `encoding: "base64"` for binary content.
563
+ # argv: [<payload path>, <destination dir>]; prints one staged relative path
564
+ # per line.
565
+ _ENVIRONMENT_PAYLOAD_EXPAND_SCRIPT = """\
566
+ import base64, json, os, shutil, sys, zipfile
567
+
568
+ payload, dest = sys.argv[1], sys.argv[2]
569
+ staged = []
570
+
571
+
572
+ def _target(rel):
573
+ rel = rel.replace("\\\\", "/")
574
+ parts = [p for p in rel.split("/") if p not in ("", ".")]
575
+ if not parts or ".." in parts:
576
+ raise SystemExit("unsafe or empty path in payload: %r" % rel)
577
+ path = os.path.join(dest, *parts)
578
+ os.makedirs(os.path.dirname(path), exist_ok=True)
579
+ return path, "/".join(parts)
580
+
581
+
582
+ os.makedirs(dest, exist_ok=True)
583
+ entries = []
584
+ if zipfile.is_zipfile(payload):
585
+ with zipfile.ZipFile(payload) as zf:
586
+ data_json = None
587
+ # root/ members land first so data.json entries defer to real files —
588
+ # same precedence as the filesystem server's zip-bundle reset.
589
+ for info in zf.infolist():
590
+ if info.is_dir():
591
+ continue
592
+ if info.filename == "data.json":
593
+ data_json = info
594
+ continue
595
+ if info.filename.startswith("root/") and info.filename[len("root/"):]:
596
+ path, rel = _target(info.filename[len("root/"):])
597
+ with zf.open(info) as src, open(path, "wb") as out:
598
+ shutil.copyfileobj(src, out)
599
+ staged.append(rel)
600
+ if data_json is not None:
601
+ with zf.open(data_json) as f:
602
+ entries = json.load(f).get("files", []) or []
603
+ else:
604
+ with open(payload, "r", encoding="utf-8") as f:
605
+ entries = json.load(f).get("files", []) or []
606
+
607
+ for entry in entries:
608
+ rel = entry.get("path")
609
+ if not rel:
610
+ continue
611
+ path, rel_norm = _target(rel)
612
+ if os.path.exists(path):
613
+ continue
614
+ content = entry.get("content", "")
615
+ if entry.get("encoding") == "base64":
616
+ with open(path, "wb") as f:
617
+ f.write(base64.b64decode(content))
618
+ else:
619
+ with open(path, "w", encoding="utf-8") as f:
620
+ f.write(content)
621
+ staged.append(rel_norm)
622
+
623
+ for rel in staged:
624
+ print(rel)
625
+ """
626
+
627
+
628
+ async def _stage_environment_payload_into_container(
629
+ sandbox, container_name: Optional[str], environment_artifact, destination: str
630
+ ) -> list[str]:
631
+ """Expand an EnvironmentArtifact's payload into a file tree at ``destination`` — in the container, or on the VM host when ``container_name`` is None."""
632
+ from agent_env.providers.sandbox_providers.sandbox import VmSandbox
633
+
634
+ file_artifact = environment_artifact.get_file_artifact()
635
+ token = uuid.uuid4().hex[:8]
636
+ safe_name = file_artifact.filename.replace("/", "_")
637
+
638
+ if isinstance(sandbox, VmSandbox):
639
+ vm_payload = f"/tmp/_svc_{token}_{safe_name}"
640
+ vm_stage = f"/tmp/_svc_stage_{token}"
641
+ await sandbox.load_s3_file(file_artifact.object_url, vm_payload)
642
+ try:
643
+ # exec_script raises on a non-zero exit, but only with the raw
644
+ # script output — re-raise with the artifact context so a failed
645
+ # expansion (corrupt payload, zip-slip refusal) is diagnosable,
646
+ # matching the container-mode branch below.
647
+ try:
648
+ out = await sandbox.exec_script(
649
+ f"python3 - {shlex.quote(vm_payload)} {shlex.quote(vm_stage)} <<'AGENTENV_EXPAND_PY'\n"
650
+ f"{_ENVIRONMENT_PAYLOAD_EXPAND_SCRIPT}\nAGENTENV_EXPAND_PY"
651
+ )
652
+ except RuntimeError as e:
653
+ raise RuntimeError(
654
+ f"Service payload expansion failed for "
655
+ f"'{environment_artifact.id}' v{environment_artifact.version}: {e}"
656
+ ) from e
657
+ if container_name is None:
658
+ # Targeting the VM host: the tree is already on it, so it only moves.
659
+ await sandbox.exec_script(
660
+ f"mkdir -p {shlex.quote(destination)} && "
661
+ f"cp -a {shlex.quote(vm_stage)}/. {shlex.quote(destination)}/"
662
+ )
663
+ else:
664
+ await sandbox.exec_script(
665
+ f"sudo docker exec -u 0 {shlex.quote(container_name)} mkdir -p {shlex.quote(destination)}"
666
+ )
667
+ await sandbox.exec_script(
668
+ f"sudo docker cp {shlex.quote(vm_stage)}/. "
669
+ f"{shlex.quote(container_name)}:{shlex.quote(destination)}"
670
+ )
671
+ finally:
672
+ try:
673
+ await sandbox.exec_script(f"rm -rf {shlex.quote(vm_payload)} {shlex.quote(vm_stage)}")
674
+ except Exception as e:
675
+ logger.warning(f"Best-effort cleanup of service payload staging failed (ignored): {e}")
676
+ else:
677
+ # Container-mode sandbox: the sandbox IS the agent container — expand
678
+ # in place at the destination.
679
+ payload_tmp = f"/tmp/_svc_{token}_{safe_name}"
680
+ await sandbox.write_file_from_s3(file_artifact.object_url, payload_tmp)
681
+ try:
682
+ exit_code, out, err = await sandbox.exec_with_output(
683
+ "python3", "-c", _ENVIRONMENT_PAYLOAD_EXPAND_SCRIPT, payload_tmp, destination
684
+ )
685
+ if exit_code != 0:
686
+ raise RuntimeError(
687
+ f"Service payload expansion failed (exit {exit_code}) for "
688
+ f"'{environment_artifact.id}' v{environment_artifact.version}: {err[-1500:]}"
689
+ )
690
+ finally:
691
+ try:
692
+ await sandbox.exec("rm", "-rf", payload_tmp)
693
+ except Exception as e:
694
+ logger.warning(f"Best-effort cleanup of service payload staging failed (ignored): {e}")
695
+
696
+ files = [line.strip() for line in out.splitlines() if line.strip()]
697
+ if not files:
698
+ logger.warning(
699
+ f"EnvironmentArtifact '{environment_artifact.id}' v{environment_artifact.version} "
700
+ f"({file_artifact.filename}) produced no files — payload has no root/ tree "
701
+ f"and no files[] entries; is this a filesystem-style component?"
702
+ )
703
+ else:
704
+ logger.info(
705
+ f"Staged {len(files)} file(s) from EnvironmentArtifact '{environment_artifact.id}' "
706
+ f"v{environment_artifact.version} into "
707
+ f"{'the VM host' if container_name is None else repr(container_name)} at {destination}"
708
+ )
709
+ return files
710
+
711
+
712
+ async def _load_universe_onto_vm(sandbox, universe, destination: str) -> list[str]:
713
+ """Pull each file in `universe` from S3 straight onto the VM host — no temp file, no copy inward."""
714
+ file_artifacts = universe.get_file_artifacts()
715
+ if not file_artifacts:
716
+ logger.warning(
717
+ f"FileArtifactUniverse '{universe.id}' v{universe.version} has no files; nothing to load"
718
+ )
719
+ return []
720
+
721
+ await sandbox.exec_script(f"mkdir -p {shlex.quote(destination)}")
722
+ loaded: list[str] = []
723
+ total = len(file_artifacts)
724
+ logger.info(
725
+ f"Loading FileArtifactUniverse '{universe.id}' v{universe.version} ({total} file(s)) "
726
+ f"onto the VM host at {destination}"
727
+ )
728
+ for idx, (filename, fa) in enumerate(file_artifacts.items(), 1):
729
+ validate_relative_filename(filename)
730
+ dest_path = posixpath.join(destination, filename)
731
+ parent = posixpath.dirname(dest_path)
732
+ logger.info(f" [{idx}/{total}] {fa.object_url} -> {dest_path}")
733
+ if parent and parent != destination:
734
+ await sandbox.exec_script(f"mkdir -p {shlex.quote(parent)}")
735
+ await sandbox.load_s3_file(fa.object_url, dest_path)
736
+ loaded.append(filename)
737
+ return loaded
738
+
739
+
740
+ async def _load_universe_into_container(sandbox, container_name: str, universe, destination: str) -> list[str]:
741
+ """For each file in `universe`: pull from S3 to a VM temp path, then `docker cp` into `container_name` at `destination/<rel_path>`."""
742
+ file_artifacts = universe.get_file_artifacts()
743
+ if not file_artifacts:
744
+ logger.warning(
745
+ f"FileArtifactUniverse '{universe.id}' v{universe.version} has no files; nothing to load"
746
+ )
747
+ return []
748
+
749
+ await sandbox.exec_script(
750
+ f"sudo docker exec {shlex.quote(container_name)} mkdir -p {shlex.quote(destination)}"
751
+ )
752
+ loaded: list[str] = []
753
+ total = len(file_artifacts)
754
+ logger.info(
755
+ f"Loading FileArtifactUniverse '{universe.id}' v{universe.version} ({total} file(s)) "
756
+ f"into container '{container_name}' at {destination}"
757
+ )
758
+ for idx, (filename, fa) in enumerate(file_artifacts.items(), 1):
759
+ validate_relative_filename(filename)
760
+ dest_path = posixpath.join(destination, filename)
761
+ parent = posixpath.dirname(dest_path)
762
+ vm_temp = f"/tmp/_load_{uuid.uuid4().hex[:8]}_{filename.replace('/', '_')}"
763
+ logger.info(f" [{idx}/{total}] {fa.object_url} -> {container_name}:{dest_path}")
764
+ await sandbox.load_s3_file(fa.object_url, vm_temp)
765
+ if parent and parent != destination:
766
+ await sandbox.exec_script(
767
+ f"sudo docker exec {shlex.quote(container_name)} mkdir -p {shlex.quote(parent)}"
768
+ )
769
+ await sandbox.exec_script(
770
+ f"sudo docker cp {shlex.quote(vm_temp)} "
771
+ f"{shlex.quote(container_name)}:{shlex.quote(dest_path)} && "
772
+ f"rm -f {shlex.quote(vm_temp)}"
773
+ )
774
+ loaded.append(filename)
775
+ return loaded