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,380 @@
1
+ """Run a Docker container task step — builds a user-provided Dockerfile on a
2
+ pre-deployed VM sandbox and runs the resulting container in the background."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import posixpath
8
+ import shlex
9
+ from typing import ClassVar, Optional
10
+ from urllib.parse import urlparse
11
+
12
+ from agent_env.task_step.context import TaskStepContext
13
+ from agent_env.entity_refs import EntityRef
14
+ from agent_env.task_step.task_step import TaskStep, TaskStepDependency
15
+ from agent_env.utils.paths import validate_relative_filename
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class RunDockerContainerTaskStep(TaskStep):
21
+ type: ClassVar[str] = "run_docker_container"
22
+ entity_refs = (
23
+ EntityRef.artifact(
24
+ "docker_context_artifact_id",
25
+ version_field="docker_context_artifact_version",
26
+ artifact_type="file_artifact_universe",
27
+ ),
28
+ )
29
+
30
+ def __init__(
31
+ self,
32
+ id: str,
33
+ version: Optional[int],
34
+ sandbox_name: str,
35
+ docker_context_artifact_id: Optional[str] = None,
36
+ docker_context_artifact_version: Optional[int] = None,
37
+ docker_context_url: Optional[str] = None,
38
+ dockerfile_path: str = "Dockerfile",
39
+ container_name: str = "task-container",
40
+ image_tag: Optional[str] = None,
41
+ ports: Optional[list[int]] = None,
42
+ env_vars: Optional[dict[str, str]] = None,
43
+ build_args: Optional[dict[str, str]] = None,
44
+ command_override: Optional[str] = None,
45
+ keep_alive_with_base_command: bool = False,
46
+ network: Optional[str] = None,
47
+ extra_networks: Optional[list[str]] = None,
48
+ ready_command: Optional[str] = None,
49
+ devices: Optional[list[str]] = None,
50
+ cap_add: Optional[list[str]] = None,
51
+ privileged: bool = False,
52
+ volumes: Optional[list[str]] = None,
53
+ shm_size: Optional[str] = None,
54
+ depends_on: Optional[list[TaskStepDependency]] = None,
55
+ fail_task_on_error: bool = True,
56
+ ):
57
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
58
+ modes = sum(x is not None for x in (docker_context_artifact_id, docker_context_url))
59
+ if modes != 1:
60
+ raise ValueError(
61
+ "Must pass exactly one of `docker_context_artifact_id` or `docker_context_url`"
62
+ )
63
+ if command_override and keep_alive_with_base_command:
64
+ raise ValueError(
65
+ "Pass either `command_override` or `keep_alive_with_base_command`, not both"
66
+ )
67
+ self.sandbox_name = sandbox_name
68
+ self.docker_context_artifact_id = docker_context_artifact_id
69
+ self.docker_context_artifact_version = docker_context_artifact_version
70
+ self.docker_context_url = docker_context_url
71
+ self.dockerfile_path = dockerfile_path
72
+ self.container_name = container_name
73
+ self.image_tag = image_tag
74
+ self.ports = ports
75
+ self.env_vars = env_vars
76
+ self.build_args = build_args
77
+ self.command_override = command_override
78
+ self.keep_alive_with_base_command = keep_alive_with_base_command
79
+ self.network = network
80
+ self.extra_networks = list(extra_networks or [])
81
+ # host / none / container:<name> replace the container's whole netns, so they can't be attached as extra networks.
82
+ netns_extra = [n for n in self.extra_networks if n in ("host", "none") or n.startswith("container:")]
83
+ if netns_extra:
84
+ raise ValueError(f"extra_networks cannot contain netns-replacing modes {netns_extra}; use user-defined bridges")
85
+ self.ready_command = ready_command
86
+ self.devices = list(devices or [])
87
+ self.cap_add = list(cap_add or [])
88
+ self.privileged = privileged
89
+ self.volumes = list(volumes or [])
90
+ self.shm_size = shm_size
91
+
92
+ def to_dict(self) -> dict:
93
+ base = super().to_dict()
94
+ base["sandbox_name"] = self.sandbox_name
95
+ base["docker_context_artifact_id"] = self.docker_context_artifact_id
96
+ base["docker_context_artifact_version"] = self.docker_context_artifact_version
97
+ base["docker_context_url"] = self.docker_context_url
98
+ base["dockerfile_path"] = self.dockerfile_path
99
+ base["container_name"] = self.container_name
100
+ base["image_tag"] = self.image_tag
101
+ base["ports"] = self.ports
102
+ base["env_vars"] = self.env_vars
103
+ base["build_args"] = self.build_args
104
+ base["command_override"] = self.command_override
105
+ base["keep_alive_with_base_command"] = self.keep_alive_with_base_command
106
+ base["network"] = self.network
107
+ base["extra_networks"] = self.extra_networks
108
+ base["ready_command"] = self.ready_command
109
+ base["devices"] = self.devices
110
+ base["cap_add"] = self.cap_add
111
+ base["privileged"] = self.privileged
112
+ base["volumes"] = self.volumes
113
+ base["shm_size"] = self.shm_size
114
+ return base
115
+
116
+ @classmethod
117
+ def from_dict(cls, data: dict) -> RunDockerContainerTaskStep:
118
+ return cls(
119
+ **cls._base_from_dict(data),
120
+ sandbox_name=data["sandbox_name"],
121
+ docker_context_artifact_id=data.get("docker_context_artifact_id"),
122
+ docker_context_artifact_version=data.get("docker_context_artifact_version"),
123
+ docker_context_url=data.get("docker_context_url"),
124
+ dockerfile_path=data.get("dockerfile_path", "Dockerfile"),
125
+ container_name=data.get("container_name", "task-container"),
126
+ image_tag=data.get("image_tag"),
127
+ ports=data.get("ports"),
128
+ env_vars=data.get("env_vars"),
129
+ build_args=data.get("build_args"),
130
+ command_override=data.get("command_override"),
131
+ keep_alive_with_base_command=data.get("keep_alive_with_base_command", False),
132
+ network=data.get("network"),
133
+ extra_networks=data.get("extra_networks"),
134
+ ready_command=data.get("ready_command"),
135
+ devices=data.get("devices"),
136
+ cap_add=data.get("cap_add"),
137
+ privileged=data.get("privileged", False),
138
+ volumes=data.get("volumes"),
139
+ shm_size=data.get("shm_size"),
140
+ )
141
+
142
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
143
+ from agent_env.providers.sandbox_providers.sandbox_provider import (
144
+ SANDBOX_MODE_VM,
145
+ build_sandbox_provider,
146
+ get_sandbox_provider,
147
+ )
148
+
149
+ ds = next(
150
+ (s for s in context.deployed_sandboxes if s.sandbox_name == self.sandbox_name),
151
+ None,
152
+ )
153
+ if ds is None:
154
+ raise RuntimeError(
155
+ f"Sandbox '{self.sandbox_name}' not found in context.deployed_sandboxes; "
156
+ f"deploy it with DeploySandboxTaskStep before this step"
157
+ )
158
+ if ds.sandbox_mode != SANDBOX_MODE_VM:
159
+ raise RuntimeError(
160
+ f"Cannot run docker container on sandbox '{self.sandbox_name}' "
161
+ f"(mode={ds.sandbox_mode!r}); only VM-mode sandboxes support docker build/run"
162
+ )
163
+
164
+ provider = (
165
+ build_sandbox_provider(ds.sandbox_type) if ds.sandbox_type else get_sandbox_provider()
166
+ )
167
+ sandbox = await provider.get_sandbox(ds.sandbox_id)
168
+
169
+ image_tag = self.image_tag or f"{self.container_name}:latest"
170
+
171
+ work_dir = f"/tmp/docker-context-{self.container_name}"
172
+ await sandbox.exec_script(
173
+ f"rm -rf {shlex.quote(work_dir)} && mkdir -p {shlex.quote(work_dir)}"
174
+ )
175
+
176
+ if self.docker_context_artifact_id is not None:
177
+ await self._stage_from_universe(sandbox, ds.sandbox_id, work_dir)
178
+ else:
179
+ await self._stage_zip_from_url(sandbox, self.docker_context_url, work_dir)
180
+
181
+ dockerfile_abs = posixpath.join(work_dir, self.dockerfile_path)
182
+ await sandbox.exec_script(
183
+ f"test -f {shlex.quote(dockerfile_abs)} "
184
+ f"|| (echo 'Dockerfile not found at {self.dockerfile_path} inside the build context' >&2; exit 1)"
185
+ )
186
+
187
+ build_arg_flags = ""
188
+ if self.build_args:
189
+ build_arg_flags = " ".join(
190
+ f"--build-arg {shlex.quote(f'{k}={v}')}" for k, v in self.build_args.items()
191
+ )
192
+ build_cmd = (
193
+ f"cd {shlex.quote(work_dir)} && "
194
+ f"docker build --platform linux/amd64 "
195
+ f"-f {shlex.quote(self.dockerfile_path)} "
196
+ f"-t {shlex.quote(image_tag)} "
197
+ f"{build_arg_flags} ."
198
+ )
199
+ logger.info(
200
+ f"Building docker image '{image_tag}' on sandbox {ds.sandbox_id} "
201
+ f"(dockerfile={self.dockerfile_path})"
202
+ )
203
+ await sandbox.exec_script(build_cmd)
204
+
205
+ network_flag = ""
206
+ if self.network:
207
+ network_flag = f"--network {await self._ensure_network(sandbox, self.network)} "
208
+
209
+ port_flags = " ".join(f"-p {p}:{p}" for p in (self.ports or []))
210
+ env_flag_parts = []
211
+ if self.env_vars:
212
+ for k, v in self.env_vars.items():
213
+ escaped = v.replace("'", "'\\''")
214
+ env_flag_parts.append(f"-e {k}='{escaped}'")
215
+ env_flags = " ".join(env_flag_parts)
216
+ device_flags = " ".join(f"--device {shlex.quote(d)}" for d in self.devices)
217
+ cap_flags = " ".join(f"--cap-add {shlex.quote(c)}" for c in self.cap_add)
218
+ volume_flags = " ".join(f"-v {shlex.quote(v)}" for v in self.volumes)
219
+ extra_flags = " ".join(f for f in (
220
+ "--privileged" if self.privileged else "",
221
+ f"--shm-size {shlex.quote(self.shm_size)}" if self.shm_size else "",
222
+ device_flags, cap_flags, volume_flags,
223
+ ) if f)
224
+ entrypoint_flag = ""
225
+ if self.command_override:
226
+ command_tail = f" {self.command_override}"
227
+ elif self.keep_alive_with_base_command:
228
+ # Keep the container alive for install/exec WITHOUT discarding the
229
+ # image's own startup (a `sleep infinity` override would stop a base
230
+ # CMD that launches a DB/server the task needs). Replay the recorded
231
+ # ENTRYPOINT+CMD in the background, then hold PID 1 open.
232
+ base_cmd = await self._inspect_base_command(sandbox, image_tag)
233
+ if base_cmd:
234
+ base_str = " ".join(shlex.quote(part) for part in base_cmd)
235
+ script = f"{base_str} & exec sleep infinity"
236
+ entrypoint_flag = "--entrypoint /bin/sh "
237
+ command_tail = f" -c {shlex.quote(script)}"
238
+ else:
239
+ command_tail = " sleep infinity"
240
+ else:
241
+ command_tail = ""
242
+ run_cmd = (
243
+ f"docker run -d --name {shlex.quote(self.container_name)} "
244
+ f"{network_flag}{entrypoint_flag}{extra_flags} {port_flags} {env_flags} "
245
+ f"{shlex.quote(image_tag)}{command_tail}"
246
+ )
247
+ logger.info(
248
+ f"Starting container '{self.container_name}' from image '{image_tag}' "
249
+ f"on sandbox {ds.sandbox_id}"
250
+ )
251
+ await sandbox.exec_script(run_cmd)
252
+
253
+ # Dual-home the container onto additional networks (e.g. a jump host straddling segments).
254
+ # Dedup and skip the primary: `docker network connect` errors on an already-attached endpoint.
255
+ for extra in dict.fromkeys(self.extra_networks):
256
+ if extra == self.network:
257
+ continue
258
+ enet = await self._ensure_network(sandbox, extra)
259
+ await sandbox.exec_script(f"docker network connect {enet} {shlex.quote(self.container_name)}")
260
+ logger.info(f"Connected container '{self.container_name}' to extra network '{extra}'")
261
+
262
+ if self.ready_command:
263
+ ready = shlex.quote(self.ready_command)
264
+ logger.info(f"Waiting for container '{self.container_name}' readiness: {self.ready_command}")
265
+ await sandbox.exec_script(
266
+ f"for i in $(seq 1 60); do "
267
+ f"if docker exec {shlex.quote(self.container_name)} sh -c {ready} >/dev/null 2>&1; then exit 0; fi; "
268
+ f"sleep 2; done; "
269
+ f"echo 'container {self.container_name} failed readiness check within 120s' >&2; exit 1"
270
+ )
271
+
272
+ context.metadata.setdefault("deployed_docker_containers", []).append({
273
+ "container_name": self.container_name,
274
+ "sandbox_name": self.sandbox_name,
275
+ "sandbox_id": ds.sandbox_id,
276
+ "image_tag": image_tag,
277
+ "ports": self.ports,
278
+ "network": self.network,
279
+ })
280
+ return context
281
+
282
+ async def _ensure_network(self, sandbox, name: str) -> str:
283
+ net = shlex.quote(name)
284
+ # host / none / bridge / container:<name> are Docker built-in modes, not user bridges -- nothing to create.
285
+ if name in ("host", "none", "bridge") or name.startswith("container:"):
286
+ return net
287
+ await sandbox.exec_script(
288
+ f"docker network inspect {net} >/dev/null 2>&1 " # already exists -> reuse it
289
+ f"|| docker network create {net} >/dev/null 2>&1 " # else create the user bridge
290
+ f"|| docker network inspect {net} >/dev/null" # lost a create race -> confirm it exists
291
+ )
292
+ return net
293
+
294
+ async def _inspect_base_command(self, sandbox, image_tag: str) -> list[str]:
295
+ """Return the built image's ENTRYPOINT+CMD as a single argv list (or [])."""
296
+ import json
297
+
298
+ out = await sandbox.exec_script(
299
+ "docker inspect --format "
300
+ "'{{json .Config.Entrypoint}}|{{json .Config.Cmd}}' "
301
+ f"{shlex.quote(image_tag)}"
302
+ )
303
+ ep_raw, _, cmd_raw = out.strip().partition("|")
304
+ entrypoint = json.loads(ep_raw) if ep_raw and ep_raw != "null" else []
305
+ cmd = json.loads(cmd_raw) if cmd_raw and cmd_raw != "null" else []
306
+ return list(entrypoint or []) + list(cmd or [])
307
+
308
+ async def _stage_from_universe(self, sandbox, sandbox_id: str, work_dir: str) -> None:
309
+ from agent_env.artifact.artifacts.file_artifact_universe import FileArtifactUniverse
310
+
311
+ universe = FileArtifactUniverse.get(
312
+ self.docker_context_artifact_id, self.docker_context_artifact_version,
313
+ )
314
+ file_artifacts = universe.get_file_artifacts()
315
+ if not file_artifacts:
316
+ raise RuntimeError(
317
+ f"FileArtifactUniverse '{universe.id}' v{universe.version} has no files; "
318
+ f"nothing to use as docker build context"
319
+ )
320
+
321
+ dirs_to_make: set[str] = set()
322
+ loaded: dict[str, str] = {}
323
+ for filename in file_artifacts:
324
+ validate_relative_filename(filename)
325
+ dest_path = posixpath.join(work_dir, filename)
326
+ parent = posixpath.dirname(dest_path)
327
+ if parent and parent != work_dir:
328
+ dirs_to_make.add(parent)
329
+ loaded[filename] = dest_path
330
+ if dirs_to_make:
331
+ mkdir_cmd = " && ".join(f"mkdir -p {shlex.quote(d)}" for d in sorted(dirs_to_make))
332
+ await sandbox.exec_script(mkdir_cmd)
333
+
334
+ total = len(file_artifacts)
335
+ logger.info(
336
+ f"Loading docker context universe '{universe.id}' v{universe.version} "
337
+ f"({total} file(s)) onto sandbox {sandbox_id} at {work_dir}"
338
+ )
339
+ for idx, (filename, fa) in enumerate(file_artifacts.items(), 1):
340
+ dest_path = loaded[filename]
341
+ logger.info(f" [{idx}/{total}] {fa.object_url} -> {dest_path}")
342
+ await sandbox.load_s3_file(fa.object_url, dest_path)
343
+
344
+ @staticmethod
345
+ async def _stage_zip_from_url(sandbox, url: str, work_dir: str) -> None:
346
+ """http(s) urls are fetched with curl; any other url is read through the configured
347
+ object store, which reaches whatever its backend can."""
348
+ parsed = urlparse(url)
349
+ if not parsed.scheme:
350
+ raise ValueError(
351
+ f"docker_context_url={url!r} must be an object store url or an http(s) url"
352
+ )
353
+ from_store = parsed.scheme not in ("http", "https")
354
+ path = url.rsplit("/", 1)[-1] if from_store else parsed.path
355
+ if path.endswith(".zip"):
356
+ pass
357
+ elif path.endswith((".tar.gz", ".tgz", ".tar.bz2", ".tar.xz", ".tar")):
358
+ raise NotImplementedError(
359
+ f"tar archives are not yet supported (URL: {url}); only .zip is supported today"
360
+ )
361
+ else:
362
+ raise ValueError(
363
+ f"Unsupported archive format for docker_context_url={url!r}; "
364
+ f"only .zip is supported"
365
+ )
366
+
367
+ archive_path = posixpath.join(work_dir, "_context.zip")
368
+ logger.info(f"Downloading docker context from {url} into {work_dir}")
369
+ if from_store:
370
+ await sandbox.load_s3_file(url, archive_path)
371
+ else:
372
+ await sandbox.exec_script(
373
+ f"curl -fsSL {shlex.quote(url)} -o {shlex.quote(archive_path)}"
374
+ )
375
+
376
+ await sandbox.exec_script(
377
+ f"unzip -q -o {shlex.quote(archive_path)} -d {shlex.quote(work_dir)} && "
378
+ f"rm -f {shlex.quote(archive_path)}"
379
+ )
380
+ logger.info(f"Extracted {url} into {work_dir}")
@@ -0,0 +1,79 @@
1
+ """Sandbox diagnostics shared by the steps that drive an A2A agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider, get_sandbox_provider
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Room for the agent's own account of a failure (its protocol ``error_message``).
13
+ _AGENT_ERROR_TEXT_LIMIT = 2000
14
+ # Containers tailed when none is the agent's own, e.g. an agent installed into an env's containers.
15
+ _FALLBACK_CONTAINER_LIMIT = 3
16
+ # Core names the agent containers it starts ``agent-api``, ``agent-<id>`` or ``a2a-agent-*``.
17
+ _AGENT_CONTAINER_MARKER = "agent"
18
+ # Per stream, so a long stdout cannot push the stderr error out of the window.
19
+ _STREAM_TAIL_CHARS = 500
20
+
21
+
22
+ def agent_error_text(error_message: Optional[str], response_text: Optional[str]) -> str:
23
+ """The agent's own failure text, bounded: its ``error_message``, else its response."""
24
+ return (error_message or response_text or "")[:_AGENT_ERROR_TEXT_LIMIT]
25
+
26
+
27
+ async def fetch_container_logs(agent, tail: int = 500) -> Optional[str]:
28
+ """Tail the docker logs of the agent's container, so a failed A2A task stays diagnosable
29
+ after its sandbox is reaped.
30
+
31
+ The agent's own container is the one named ``sandbox.container_name``. When none is, the
32
+ containers are tailed up to ``_FALLBACK_CONTAINER_LIMIT``, agent-named ones first. Best
33
+ effort: None on any failure, including a sandbox with no docker, so it never masks the
34
+ task's own failure.
35
+ """
36
+ sandbox_id = getattr(agent, "sandbox_id", None)
37
+ if not sandbox_id:
38
+ return None
39
+ try:
40
+ sandbox_type = getattr(agent, "sandbox_type", None)
41
+ provider = (
42
+ build_sandbox_provider(sandbox_type) if sandbox_type else get_sandbox_provider()
43
+ )
44
+ sandbox = await provider.get_sandbox(sandbox_id)
45
+ chunks: list[str] = []
46
+ try:
47
+ _, ps_out, _ = await sandbox.exec_with_output(
48
+ "sudo", "docker", "ps", "-a", "--format", "{{.ID}}|{{.Names}}",
49
+ )
50
+ rows = [
51
+ (cid.strip(), name.strip())
52
+ for cid, _, name in (line.partition("|") for line in (ps_out or "").splitlines())
53
+ if cid.strip()
54
+ ]
55
+ own = [cid for cid, name in rows if name == sandbox.container_name]
56
+ by_agent_name = sorted(rows, key=lambda row: _AGENT_CONTAINER_MARKER not in row[1])
57
+ cids = own or [cid for cid, _ in by_agent_name][:_FALLBACK_CONTAINER_LIMIT]
58
+ for cid in cids:
59
+ _, out, err = await sandbox.exec_with_output(
60
+ "sudo", "docker", "logs", "--tail", str(tail), cid,
61
+ )
62
+ parts = []
63
+ out_tail = (out or "").strip()[-_STREAM_TAIL_CHARS:]
64
+ err_tail = (err or "").strip()[-_STREAM_TAIL_CHARS:]
65
+ if out_tail:
66
+ parts.append(f"[stdout]\n{out_tail}")
67
+ if err_tail:
68
+ parts.append(f"[stderr]\n{err_tail}")
69
+ if parts:
70
+ chunks.append(f"[container {cid}]\n" + "\n".join(parts))
71
+ except Exception as e:
72
+ logger.debug("docker logs unavailable on sandbox %s: %s", sandbox_id, e)
73
+ return "\n\n".join(chunks) or None
74
+ except Exception as e:
75
+ logger.warning(
76
+ "Could not fetch agent container logs from sandbox %s: %s",
77
+ sandbox_id, e, exc_info=True,
78
+ )
79
+ return None
@@ -0,0 +1,205 @@
1
+ """Snapshot a deployed A2A agent's conversation state to a FileArtifactUniverse."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from typing import ClassVar, Optional
8
+
9
+ from agent_env.task_step.context import TaskStepContext
10
+ from agent_env.entity_refs import EntityRef, RefRole
11
+ from agent_env.task_step.task_step import TaskStep, TaskStepDependency
12
+ from agent_env.task_step.snapshot_utils import agent_state_capture
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SnapshotAgentStateTaskStep(TaskStep):
18
+ """Capture a deployed agent's conversation state and persist it as a
19
+ ``FileArtifactUniverse``.
20
+
21
+ Looks up the deployed agent (by ``agent_name``) and the source ``context_id``
22
+ via a previously-recorded ``PromptResponse`` (by ``prompt_id``). Calls the
23
+ agent's ``/ext/snapshot`` extension to write the conversation transcript to
24
+ S3, then wraps that S3 prefix as a ``FileArtifactUniverse`` via
25
+ ``put_existing`` (no download/re-upload). Appends an entry recording
26
+ ``{id, version, bundle_object_url, source_agent_name, source_context_id}`` to
27
+ ``context.metadata['agent_snapshots']`` (a list of such entries).
28
+
29
+ When ``env_id`` is set, the step ALSO dumps each MCP service's current
30
+ state by hitting ``{gateway_url}/svc/mcp-{service}/export-state`` on the
31
+ deployed env, uploads each as JSON alongside the workspace tarball, and
32
+ writes a JSON-stringified ``{service: s3_url}`` (unsigned ``s3://`` object
33
+ references) to ``context.metadata['snapshot_json_url']``. A downstream
34
+ verifier re-signs each value before handing the map to its test script, so
35
+ the script can fetch each service's state and assert against it. Without this, only the
36
+ agent's *workspace files* are visible to the verifier — not per-MCP-service
37
+ DB state.
38
+ """
39
+
40
+ type: ClassVar[str] = "snapshot_agent_state"
41
+ entity_refs = (
42
+ EntityRef.artifact("artifact_id", role=RefRole.OUTPUT, artifact_type="file_artifact_universe"),
43
+ EntityRef.env("env_id"),
44
+ EntityRef.artifact("universe_artifact_id", artifact_type="environment_universe"),
45
+ )
46
+ DEFAULT_TIMEOUT_SECONDS: ClassVar[int] = 120
47
+
48
+ def __init__(
49
+ self,
50
+ id: str,
51
+ version: Optional[int],
52
+ artifact_id: str,
53
+ prompt_id: str,
54
+ agent_name: Optional[str] = None,
55
+ env_id: Optional[str] = None,
56
+ universe_artifact_id: Optional[str] = None,
57
+ timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
58
+ depends_on: Optional[list[TaskStepDependency]] = None,
59
+ fail_task_on_error: bool = True,
60
+ ):
61
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
62
+ self.artifact_id = artifact_id
63
+ self.prompt_id = prompt_id
64
+ self.agent_name = agent_name or TaskStep.DEFAULT_AGENT_NAME
65
+ # Set both `env_id` (which deployed env to query for /export-state) AND
66
+ # `universe_artifact_id` (to enumerate the env's service names) to
67
+ # enable per-MCP-service capture. Leaving either unset captures only
68
+ # the agent's workspace (legacy behaviour).
69
+ self.env_id = env_id
70
+ self.universe_artifact_id = universe_artifact_id
71
+ self.timeout_seconds = timeout_seconds
72
+
73
+ def to_dict(self) -> dict:
74
+ base = super().to_dict()
75
+ base["artifact_id"] = self.artifact_id
76
+ base["prompt_id"] = self.prompt_id
77
+ base["agent_name"] = self.agent_name
78
+ base["env_id"] = self.env_id
79
+ base["universe_artifact_id"] = self.universe_artifact_id
80
+ base["timeout_seconds"] = self.timeout_seconds
81
+ return base
82
+
83
+ @classmethod
84
+ def from_dict(cls, data: dict) -> SnapshotAgentStateTaskStep:
85
+ return cls(
86
+ **cls._base_from_dict(data),
87
+ artifact_id=data["artifact_id"],
88
+ prompt_id=data["prompt_id"],
89
+ agent_name=data.get("agent_name"),
90
+ env_id=data.get("env_id"),
91
+ universe_artifact_id=data.get("universe_artifact_id"),
92
+ timeout_seconds=data.get("timeout_seconds", cls.DEFAULT_TIMEOUT_SECONDS),
93
+ )
94
+
95
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
96
+ deployed = next((a for a in context.deployed_agents if a.agent_name == self.agent_name), None)
97
+ if deployed is None:
98
+ raise RuntimeError(f"No deployed agent named '{self.agent_name}' found in context")
99
+ a2a_url = deployed.a2a_url or deployed.api_url
100
+ if not a2a_url:
101
+ raise RuntimeError(f"Deployed agent '{self.agent_name}' has no a2a_url")
102
+
103
+ prompt = next((p for p in context.prompt_responses if p.prompt_id == self.prompt_id), None)
104
+ if prompt is None:
105
+ raise RuntimeError(f"No PromptResponse with prompt_id '{self.prompt_id}' in context")
106
+ a2a_context_id = prompt.a2a_context_id
107
+ if not a2a_context_id:
108
+ raise RuntimeError(
109
+ f"PromptResponse for '{self.prompt_id}' has no a2a_context_id "
110
+ "(prompt step did not capture one — make sure the agent is using a recent gateway)"
111
+ )
112
+
113
+ workspace = await agent_state_capture.capture_workspace(
114
+ a2a_url=a2a_url,
115
+ a2a_card=deployed.a2a_card or {},
116
+ agent_name=self.agent_name,
117
+ a2a_context_id=a2a_context_id,
118
+ artifact_id=self.artifact_id,
119
+ timeout_seconds=self.timeout_seconds,
120
+ )
121
+
122
+ snapshots = context.metadata.setdefault("agent_snapshots", [])
123
+ snapshots.append({
124
+ "id": workspace.universe_id,
125
+ "version": workspace.universe_version,
126
+ "bundle_object_url": workspace.bundle_object_url,
127
+ "source_agent_name": self.agent_name,
128
+ "source_context_id": a2a_context_id,
129
+ })
130
+
131
+ # Per-MCP-service state dump. Only runs when env_id +
132
+ # universe_artifact_id are both configured. Mirrors the export-state
133
+ # pattern in `multienv_validator/verify_universe_roundtrip.py:_export_all`.
134
+ # Result lands in `context.metadata['snapshot_json_url']` as a
135
+ # JSON-stringified `{service: s3_url}` (unsigned `s3://` references).
136
+ # A downstream verifier re-signs each value and hands the map to its test
137
+ # script, which accepts either a single URL or this map.
138
+ if self.env_id and self.universe_artifact_id:
139
+ await self._capture_universe_state(context, workspace.capture_prefix)
140
+
141
+ return context
142
+
143
+ async def _capture_universe_state(
144
+ self, context: TaskStepContext, capture_prefix: str
145
+ ) -> None:
146
+ """GET /svc/mcp-<name>/export-state for each service, upload, publish `s3://` refs."""
147
+ from agent_env.artifact import EnvironmentUniverseArtifact
148
+ from agent_env.config import get_config
149
+
150
+ deployed_env = next(
151
+ (d for d in context.deployed_envs if d.env_id == self.env_id), None
152
+ )
153
+ if deployed_env is None:
154
+ logger.warning(
155
+ "snapshot_agent_state: env_id=%s not found in deployed_envs (%s); "
156
+ "skipping per-service capture",
157
+ self.env_id,
158
+ [d.env_id for d in context.deployed_envs],
159
+ )
160
+ return
161
+
162
+ try:
163
+ universe = EnvironmentUniverseArtifact.get(self.universe_artifact_id)
164
+ environment_names = [sa.environment_name for sa in universe.get_environment_artifacts()]
165
+ except Exception as exc:
166
+ logger.warning(
167
+ "snapshot_agent_state: could not enumerate services from universe %s: %s",
168
+ self.universe_artifact_id, exc,
169
+ )
170
+ return
171
+
172
+ # Reuse the workspace capture's S3 prefix — services land in a
173
+ # `services/` subdir alongside the workspace tarball.
174
+ store = get_config().get_object_store()
175
+ key_prefix = store.get_object_key(capture_prefix).rstrip("/") + "/"
176
+
177
+ from agent_env.env import legacy_protocol
178
+ from agent_env.env.env import gateway_url_of
179
+ from agentenv_protocol import client as protocol_v1
180
+ urls: dict[str, str] = {}
181
+ for name in environment_names:
182
+ try:
183
+ base_url = await legacy_protocol.v1_base_url(deployed_env, gateway_url_of(deployed_env), name, mcp=True)
184
+ if base_url is not None:
185
+ resp = await protocol_v1.get_data(base_url)
186
+ state = resp.parts[0].data if resp.parts else {}
187
+ else:
188
+ state = await legacy_protocol.export_state(gateway_url_of(deployed_env), name)
189
+ urls[name] = await asyncio.to_thread(
190
+ store.put, f"{key_prefix}services/{name}.json", json.dumps(state).encode(),
191
+ content_type="application/json", allow_overwrite=True,
192
+ )
193
+ except Exception as exc:
194
+ logger.warning(
195
+ "snapshot_agent_state: capturing %s state failed: %s — skipping",
196
+ name, exc,
197
+ )
198
+ continue
199
+
200
+ if urls:
201
+ context.metadata["snapshot_json_url"] = json.dumps(urls)
202
+ logger.info(
203
+ "snapshot_agent_state: captured %d service states → snapshot_json_url",
204
+ len(urls),
205
+ )