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,89 @@
1
+ """Environment artifact for wrapping a FileArtifact with environment identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any, Literal, Optional
7
+
8
+ from pydantic import ConfigDict, Field, model_serializer
9
+
10
+ from agent_env.artifact.artifact import Artifact
11
+ from agent_env.artifact.ref import ArtifactRef
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ if TYPE_CHECKING:
16
+ from agent_env.artifact.artifacts.file import FileArtifact
17
+
18
+
19
+ class EnvironmentArtifact(Artifact):
20
+ """A FileArtifact wrapped with the name of the environment it seeds."""
21
+
22
+ model_config = ConfigDict(populate_by_name=True)
23
+
24
+ type: Literal["environment"] = "environment"
25
+ environment_name: str = Field(alias="service_name", description="Name of the environment this artifact belongs to")
26
+ file_artifact_ref: Optional[ArtifactRef] = Field(
27
+ default=None,
28
+ description="Pinned (id, version) ref to the wrapped FileArtifact",
29
+ )
30
+ # Pre-pinning docs only; alias keeps the on-disk key as `file_artifact_id`.
31
+ legacy_file_artifact_id: Optional[str] = Field(
32
+ default=None,
33
+ alias="file_artifact_id",
34
+ description="Legacy unpinned wrapped-FileArtifact id",
35
+ )
36
+
37
+ @model_serializer(mode="wrap")
38
+ def _serialize(self, handler: Any) -> dict[str, Any]:
39
+ # `@model_serializer(mode="wrap")` is the only hook invoked by both
40
+ # `model_dump` and the Rust-implemented `model_dump_json`.
41
+ data = handler(self)
42
+ if "legacy_file_artifact_id" in data:
43
+ data["file_artifact_id"] = data.pop("legacy_file_artifact_id")
44
+ if self.file_artifact_ref is not None: # new doc
45
+ data.pop("file_artifact_id", None)
46
+ # Dual-write. Sourced from the attribute, not from `data`,
47
+ # because `alias=` emits exactly one spelling and which one depends on the
48
+ # caller's `by_alias` — the store persists with by_alias=True
49
+ # (artifact/store.py), while in-process callers dump either way.
50
+ data["service_name"] = self.environment_name
51
+ data["environment_name"] = self.environment_name
52
+ return data
53
+
54
+ @classmethod
55
+ def put(
56
+ cls,
57
+ id: str,
58
+ *,
59
+ environment_name: Optional[str] = None,
60
+ file_artifact: FileArtifact,
61
+ ) -> EnvironmentArtifact:
62
+ from agent_env.artifact.store import get_artifact_store
63
+
64
+ if not environment_name:
65
+ raise ValueError("environment_name cannot be empty")
66
+ instance = cls(
67
+ id=id,
68
+ environment_name=environment_name,
69
+ file_artifact_ref=file_artifact.as_ref(),
70
+ )
71
+ return get_artifact_store().put_document(instance)
72
+
73
+ def get_file_artifact(self) -> FileArtifact:
74
+ from agent_env.artifact.artifacts.file import FileArtifact
75
+
76
+ if self.file_artifact_ref is not None:
77
+ return FileArtifact.get(
78
+ self.file_artifact_ref.id, version=self.file_artifact_ref.version
79
+ )
80
+ if self.legacy_file_artifact_id is not None:
81
+ logger.warning(
82
+ "EnvironmentArtifact %s v%s has unpinned file_artifact_id; falling back to latest. Republish to pin.",
83
+ self.id, self.version,
84
+ )
85
+ return FileArtifact.get(self.legacy_file_artifact_id)
86
+ raise ValueError(
87
+ f"malformed EnvironmentArtifact {self.id} v{self.version}: "
88
+ "neither file_artifact_ref nor file_artifact_id present"
89
+ )
@@ -0,0 +1,156 @@
1
+ """Environment universe artifact for bundling related environment artifacts together."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any, Literal, Optional
7
+
8
+ from pydantic import AliasChoices, ConfigDict, Field, model_serializer
9
+
10
+ from agent_env.artifact.ref import ArtifactRef
11
+ from agent_env.artifact.universe import Universe
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ if TYPE_CHECKING:
16
+ from agent_env.artifact.artifacts.file import FileArtifact
17
+ from agent_env.artifact.artifacts.environment import EnvironmentArtifact
18
+
19
+
20
+ class EnvironmentUniverseArtifact(Universe):
21
+ """A universe artifact that bundles EnvironmentArtifacts together."""
22
+
23
+ model_config = ConfigDict(populate_by_name=True)
24
+
25
+ type: Literal["environment_universe"] = "environment_universe"
26
+ # serialization_alias pins the emitted key: the attribute is renamed, the document is not.
27
+ environment_artifact_refs: Optional[list[ArtifactRef]] = Field(
28
+ default=None,
29
+ serialization_alias="service_artifact_refs",
30
+ validation_alias=AliasChoices("service_artifact_refs", "environment_artifact_refs"),
31
+ description="List of pinned (id, version) refs to EnvironmentArtifacts",
32
+ )
33
+ metadata_refs: Optional[dict[str, ArtifactRef]] = Field(
34
+ default=None,
35
+ description="Optional pinned (id, version) refs to metadata FileArtifacts",
36
+ )
37
+ # Pre-pinning docs only; aliases keep on-disk keys as `service_artifact_ids` / `metadata`.
38
+ legacy_environment_artifact_ids: Optional[list[str]] = Field(
39
+ default=None,
40
+ alias="service_artifact_ids",
41
+ description="Legacy unpinned EnvironmentArtifact ids",
42
+ )
43
+ legacy_metadata: Optional[dict[str, str]] = Field(
44
+ default=None,
45
+ alias="metadata",
46
+ description="Legacy unpinned metadata FileArtifact ids",
47
+ )
48
+
49
+ @model_serializer(mode="wrap")
50
+ def _serialize(self, handler: Any) -> dict[str, Any]:
51
+ # `@model_serializer(mode="wrap")` is the only hook invoked by both
52
+ # `model_dump` and the Rust-implemented `model_dump_json`.
53
+ data = handler(self)
54
+ if "legacy_environment_artifact_ids" in data:
55
+ data["service_artifact_ids"] = data.pop("legacy_environment_artifact_ids")
56
+ if "legacy_metadata" in data:
57
+ data["metadata"] = data.pop("legacy_metadata")
58
+ if self.environment_artifact_refs is not None: # new doc
59
+ data.pop("service_artifact_ids", None)
60
+ data.pop("metadata", None)
61
+ # Dual-write: the pinned refs get a twin, and only them.
62
+ # The scope line also asked for a `service_artifact_ids` twin on these
63
+ # modern docs — declined. That key means "unpinned, resolve at latest"
64
+ # to every reader, and v0.9.1 (pinned permanently by
65
+ # synthetic-artifacts-pipeline) acts on it, so writing it onto a doc
66
+ # that IS pinned tells that worker to ignore the pins. Nothing legacy
67
+ # is dropped here: unpinned docs never reach this branch and still
68
+ # round-trip `service_artifact_ids` / `metadata` above, and
69
+ # get_environment_artifacts still falls back to them.
70
+ # `by_alias=False` dumps under the field name, `by_alias=True` under the
71
+ # serialization alias; emit both keys either way, as this always has.
72
+ refs = data.pop("environment_artifact_refs", None)
73
+ if refs is None:
74
+ refs = data["service_artifact_refs"]
75
+ data["service_artifact_refs"] = refs
76
+ data["environment_artifact_refs"] = refs
77
+ return data
78
+
79
+ @classmethod
80
+ def put(
81
+ cls,
82
+ id: str,
83
+ *,
84
+ environment_artifacts: list[EnvironmentArtifact],
85
+ metadata: Optional[dict[str, FileArtifact]] = None,
86
+ ) -> EnvironmentUniverseArtifact:
87
+ from agent_env.artifact.store import get_artifact_store
88
+
89
+ if not environment_artifacts:
90
+ raise ValueError("environment_artifacts must be non-empty")
91
+ instance = cls(
92
+ id=id,
93
+ environment_artifact_refs=[ea.as_ref() for ea in environment_artifacts],
94
+ metadata_refs=(
95
+ {k: fa.as_ref() for k, fa in metadata.items()} if metadata else None
96
+ ),
97
+ )
98
+ return get_artifact_store().put_document(instance)
99
+
100
+ def get_environment_artifacts(self) -> list[EnvironmentArtifact]:
101
+ from agent_env.artifact.artifacts.environment import EnvironmentArtifact
102
+
103
+ if self.environment_artifact_refs is not None:
104
+ return [
105
+ EnvironmentArtifact.get(ref.id, version=ref.version)
106
+ for ref in self.environment_artifact_refs
107
+ ]
108
+ if self.legacy_environment_artifact_ids:
109
+ logger.warning(
110
+ "EnvironmentUniverseArtifact %s v%s has unpinned service_artifact_ids; falling back to latest. Republish to pin.",
111
+ self.id, self.version,
112
+ )
113
+ return [
114
+ EnvironmentArtifact.get(sa_id) for sa_id in self.legacy_environment_artifact_ids
115
+ ]
116
+ raise ValueError(
117
+ f"malformed EnvironmentUniverseArtifact {self.id} v{self.version}: "
118
+ "neither service_artifact_refs nor service_artifact_ids present"
119
+ )
120
+
121
+ def get_file_artifacts(self) -> dict[str, FileArtifact]:
122
+ """``{relative filename: FileArtifact}`` — a plain-file view of this universe.
123
+
124
+ Matching ``FileArtifactUniverse.get_file_artifacts()`` is what lets the same
125
+ loaders stage a service universe as a file tree (grading a frozen snapshot)
126
+ rather than restoring it into an env. Returns the pinned artifacts as-is —
127
+ nothing is copied or re-registered.
128
+ """
129
+ out: dict[str, FileArtifact] = {}
130
+ for sa in self.get_environment_artifacts():
131
+ fa = sa.get_file_artifact()
132
+ name = f"{sa.environment_name}/{fa.filename}"
133
+ if name in out:
134
+ raise ValueError(
135
+ f"EnvironmentUniverseArtifact {self.id} v{self.version} has duplicate "
136
+ f"environment '{sa.environment_name}'; cannot stage it as a file tree"
137
+ )
138
+ out[name] = fa
139
+ return out
140
+
141
+ def get_metadata(self) -> dict[str, FileArtifact]:
142
+ from agent_env.artifact.artifacts.file import FileArtifact
143
+
144
+ if self.metadata_refs:
145
+ return {
146
+ k: FileArtifact.get(ref.id, version=ref.version)
147
+ for k, ref in self.metadata_refs.items()
148
+ }
149
+ if self.legacy_metadata:
150
+ logger.warning(
151
+ "EnvironmentUniverseArtifact %s v%s has unpinned metadata; falling back to latest. Republish to pin.",
152
+ self.id, self.version,
153
+ )
154
+ return {k: FileArtifact.get(fa_id) for k, fa_id in self.legacy_metadata.items()}
155
+ # Metadata is optional; absence is not a malformed doc (unlike service refs).
156
+ return {}
@@ -0,0 +1,214 @@
1
+ """Generic file artifact for storing arbitrary files in S3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import mimetypes
6
+ import os
7
+ from typing import TYPE_CHECKING, ClassVar, Literal, Self
8
+
9
+ from pydantic import Field
10
+
11
+ from agent_env.artifact.artifact import Artifact
12
+ from agent_env.config import get_config
13
+
14
+ if TYPE_CHECKING:
15
+ from agent_env.bundle.authoring import AuthoringContext
16
+
17
+
18
+ class FileArtifact(Artifact):
19
+ """A generic file artifact that stores any file in S3.
20
+
21
+ Clients define their own schemas and upload files (JSON, images, etc.).
22
+ When loaded, returns raw bytes for the client to parse as needed.
23
+
24
+ Example:
25
+ # Create a file artifact
26
+ artifact = FileArtifact.put(
27
+ id="customer_data",
28
+ description="Customer email data in JSON format",
29
+ file_path="/path/to/emails.json",
30
+ )
31
+
32
+ # Load the file bytes
33
+ data = artifact.load()
34
+ emails = json.loads(data) # Client parses as needed
35
+ """
36
+
37
+ toml_keys: ClassVar[dict[str, type]] = {"description": str} # what an artifact.toml may set
38
+ type: Literal["file"] = "file"
39
+ description: str = Field(description="Human-readable description of the artifact contents")
40
+ filename: str = Field(description="Original filename (preserved for reference)")
41
+ content_type: str = Field(description="MIME type of the file")
42
+ object_url: str = Field(alias="s3_url", description="Object-store locator where the file is stored")
43
+
44
+ @classmethod
45
+ def put(
46
+ cls,
47
+ id: str,
48
+ *,
49
+ description: str,
50
+ file_path: str,
51
+ ) -> Self:
52
+ from agent_env.artifact.store import get_artifact_store
53
+
54
+ store = get_artifact_store()
55
+
56
+ filename = os.path.basename(file_path)
57
+ content_type, _ = mimetypes.guess_type(file_path)
58
+ if content_type is None:
59
+ content_type = "application/octet-stream"
60
+
61
+ version = store.next_version(id)
62
+
63
+ # Use put_object_file (boto3 managed multipart upload) instead of
64
+ # put_object (s3.put_object — hard 5 GB single-object limit). Streams
65
+ # directly from disk so large files don't get loaded into RAM either.
66
+ s3_url = store.put_object_file(
67
+ artifact_type="file",
68
+ id=id,
69
+ version=version,
70
+ object_name=filename,
71
+ file_path=file_path,
72
+ content_type=content_type,
73
+ )
74
+
75
+ # Store artifact document in MongoDB
76
+ instance = cls(
77
+ id=id,
78
+ version=version,
79
+ description=description,
80
+ filename=filename,
81
+ content_type=content_type,
82
+ s3_url=s3_url,
83
+ )
84
+ return store.put_document(instance)
85
+
86
+ @classmethod
87
+ def put_bytes(
88
+ cls,
89
+ id: str,
90
+ *,
91
+ description: str,
92
+ filename: str,
93
+ content: bytes,
94
+ content_type: str = "application/octet-stream",
95
+ ) -> Self:
96
+ """Like ``put``, but takes raw bytes instead of a file path."""
97
+ from agent_env.artifact.store import get_artifact_store
98
+
99
+ store = get_artifact_store()
100
+ version = store.next_version(id)
101
+
102
+ s3_url = store.put_object(
103
+ artifact_type="file",
104
+ id=id,
105
+ version=version,
106
+ object_name=filename,
107
+ data=content,
108
+ content_type=content_type,
109
+ )
110
+
111
+ instance = cls(
112
+ id=id,
113
+ version=version,
114
+ description=description,
115
+ filename=filename,
116
+ content_type=content_type,
117
+ s3_url=s3_url,
118
+ )
119
+ return store.put_document(instance)
120
+
121
+ @classmethod
122
+ def from_toml(cls, data: dict, ctx: AuthoringContext) -> Self:
123
+ """Write the folder's one file under a prefix of its own (``ArtifactStore.attempt_prefix``);
124
+ ``description`` defaults to the file's name."""
125
+ from agent_env.artifact.store import get_artifact_store
126
+
127
+ fields = ctx.accept(data, **cls.toml_keys)
128
+ filename, path = ctx.file()
129
+ prefix = get_artifact_store().attempt_prefix(cls.model_fields["type"].default, ctx.id)
130
+ return cls.put_at(ctx.id, description=fields.get("description", filename), file_path=str(path),
131
+ object_url=prefix + filename, filename=filename)
132
+
133
+ def load(self) -> bytes:
134
+ from agent_env.artifact.store import get_artifact_store
135
+
136
+ return get_artifact_store().get_object(self.object_url)
137
+
138
+ def get_file_artifacts(self) -> dict[str, "FileArtifact"]:
139
+ return {self.filename: self}
140
+
141
+ @classmethod
142
+ def put_at(
143
+ cls,
144
+ id: str,
145
+ *,
146
+ description: str,
147
+ file_path: str,
148
+ object_url: str,
149
+ filename: str | None = None,
150
+ ) -> Self:
151
+ """Upload ``file_path`` to an explicit object_url (write-once) and register it."""
152
+ from agent_env.artifact.store import get_artifact_store
153
+
154
+ store = get_config().get_object_store_to_write(object_url, id)
155
+
156
+ if filename is None:
157
+ filename = os.path.basename(file_path)
158
+ content_type, _ = mimetypes.guess_type(file_path)
159
+ if content_type is None:
160
+ content_type = "application/octet-stream"
161
+
162
+ artifact_store = get_artifact_store()
163
+ version = artifact_store.next_version(id)
164
+ stored_url = store.put_file_at(object_url, file_path, content_type)
165
+ instance = cls(
166
+ id=id,
167
+ version=version,
168
+ description=description,
169
+ filename=filename,
170
+ content_type=content_type,
171
+ s3_url=stored_url,
172
+ )
173
+ return artifact_store.put_document(instance)
174
+
175
+ @classmethod
176
+ def put_existing(
177
+ cls,
178
+ id: str,
179
+ *,
180
+ description: str,
181
+ object_url: str,
182
+ ) -> Self:
183
+ """Register an already-uploaded object as a FileArtifact.
184
+
185
+ Unlike ``put`` and ``put_at``, this does NOT upload bytes; it asserts
186
+ the object exists and records artifact metadata. Use when the file was
187
+ already written by some other process (e.g. an agent's snapshot
188
+ extension) and you want to wrap it without round-tripping through disk.
189
+ """
190
+ from agent_env.artifact.store import get_artifact_store
191
+
192
+ store = get_config().get_object_store_to_write(object_url, id)
193
+ location = object_url.partition("://")[2] or object_url
194
+ if "/" not in location or location.endswith("/"):
195
+ raise ValueError(f"object_url must point at an object, not a prefix: {object_url!r}")
196
+ metadata = store.get_object_metadata_at(object_url)
197
+ if metadata is None:
198
+ raise ValueError(f"Object does not exist at {object_url!r}")
199
+
200
+ # The last segment, not urlparse's path, which ends at a "#" or "?" that keys may hold.
201
+ filename = object_url.rsplit("/", 1)[-1]
202
+ content_type = metadata.content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
203
+
204
+ artifact_store = get_artifact_store()
205
+ version = artifact_store.next_version(id)
206
+ instance = cls(
207
+ id=id,
208
+ version=version,
209
+ description=description,
210
+ filename=filename,
211
+ content_type=content_type,
212
+ s3_url=object_url,
213
+ )
214
+ return artifact_store.put_document(instance)
@@ -0,0 +1,206 @@
1
+ """FileArtifactUniverse — bundles FileArtifacts produced by collect_artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, ClassVar, Literal, Optional
9
+
10
+ from pydantic import Field
11
+
12
+ from agent_env.artifact.ref import ArtifactRef
13
+ from agent_env.artifact.universe import Universe
14
+ from agent_env.config import get_config
15
+ from agent_env.store.ids import derive_id
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ if TYPE_CHECKING:
20
+ from agent_env.artifact.artifacts.file import FileArtifact
21
+ from agent_env.bundle.authoring import AuthoringContext
22
+
23
+
24
+ class FileArtifactUniverse(Universe):
25
+ """A universe that bundles FileArtifacts together.
26
+
27
+ Typically auto-created by the ``collect_artifacts`` task step, which
28
+ turns each collected file into a ``FileArtifact`` and wraps them in one
29
+ of these universes. The universe preserves the original filename of
30
+ each file so downstream browsers can display them without needing to
31
+ load every ``FileArtifact`` document.
32
+ """
33
+
34
+ toml_keys: ClassVar[dict[str, type]] = {} # what an artifact.toml may set
35
+ type: Literal["file_artifact_universe"] = "file_artifact_universe"
36
+ file_artifact_refs: Optional[dict[str, ArtifactRef]] = Field(
37
+ default=None,
38
+ description=(
39
+ "Mapping of original filename -> pinned (id, version) ref to its "
40
+ "FileArtifact. Preferred over file_artifact_ids on read; absence "
41
+ "means a pre-pinning doc that resolves to latest."
42
+ ),
43
+ )
44
+ # Kept alongside file_artifact_refs (additive) so raw-doc readers and CLI
45
+ # attribute access keep working; new docs carry both keys.
46
+ file_artifact_ids: dict[str, str] = Field(
47
+ default_factory=dict,
48
+ description=(
49
+ "Mapping of original filename -> FileArtifact id. The filename "
50
+ "is a denormalized convenience for fast catalog rendering "
51
+ "(avoiding N FileArtifact lookups per card); canonical file "
52
+ "metadata lives on the referenced FileArtifact document."
53
+ ),
54
+ )
55
+ bundle_object_url: str | None = Field(
56
+ default=None,
57
+ alias="bundle_s3_url",
58
+ description=(
59
+ "Optional object-store prefix under which all bundled files "
60
+ "reside. Set when the universe represents a contiguous directory "
61
+ "(e.g. an agent snapshot). Consumers can use this directly without "
62
+ "resolving individual FileArtifact documents."
63
+ ),
64
+ )
65
+
66
+ @classmethod
67
+ def put(
68
+ cls,
69
+ id: str,
70
+ *,
71
+ file_artifacts: dict[str, "FileArtifact"],
72
+ bundle_s3_url: str | None = None,
73
+ ) -> "FileArtifactUniverse":
74
+ from agent_env.artifact.store import get_artifact_store
75
+
76
+ if not file_artifacts:
77
+ raise ValueError("file_artifacts must be non-empty")
78
+ if bundle_s3_url:
79
+ get_config().check_object_url(id, bundle_s3_url)
80
+
81
+ store = get_artifact_store()
82
+ version = store.next_version(id)
83
+ instance = cls(
84
+ id=id,
85
+ version=version,
86
+ file_artifact_refs={
87
+ fname: fa.as_ref() for fname, fa in file_artifacts.items()
88
+ },
89
+ file_artifact_ids={fname: fa.id for fname, fa in file_artifacts.items()},
90
+ bundle_s3_url=bundle_s3_url,
91
+ )
92
+ return store.put_document(instance)
93
+
94
+ def get_file_artifacts(self) -> dict[str, "FileArtifact"]:
95
+ """Resolve each referenced FileArtifact. Returns {filename: FileArtifact}."""
96
+ from agent_env.artifact.artifacts.file import FileArtifact
97
+
98
+ # Truthiness (not `is not None`): a degenerate empty-`{}` refs dict
99
+ # (never produced by put(), only by a hand-crafted/migrated doc) falls
100
+ # through to the ids fallback instead of silently returning zero files.
101
+ # Also keeps this consistent with the hub download branch, which guards
102
+ # on `doc.get("file_artifact_refs")`.
103
+ if self.file_artifact_refs:
104
+ return {
105
+ fname: FileArtifact.get(ref.id, version=ref.version)
106
+ for fname, ref in self.file_artifact_refs.items()
107
+ }
108
+ if self.file_artifact_ids:
109
+ logger.warning(
110
+ "FileArtifactUniverse %s v%s has unpinned file_artifact_ids; "
111
+ "falling back to latest. Republish to pin.",
112
+ self.id,
113
+ self.version,
114
+ )
115
+ return {
116
+ fname: FileArtifact.get(fa_id)
117
+ for fname, fa_id in self.file_artifact_ids.items()
118
+ }
119
+ raise ValueError(
120
+ f"malformed FileArtifactUniverse {self.id} v{self.version}: "
121
+ "neither file_artifact_refs nor file_artifact_ids present"
122
+ )
123
+
124
+ @classmethod
125
+ def put_bundled(
126
+ cls,
127
+ id: str,
128
+ *,
129
+ files: dict[str, Path],
130
+ s3_url: str | None = None,
131
+ ) -> "FileArtifactUniverse":
132
+ """Upload ``files`` (bundle key -> local path) under ``s3_url`` and register them as one universe.
133
+ Without ``s3_url``, each call writes under a prefix of its own (``ArtifactStore.attempt_prefix``)."""
134
+ from agent_env.artifact.artifacts.file import FileArtifact
135
+ from agent_env.artifact.store import get_artifact_store
136
+
137
+ if not files:
138
+ raise ValueError("files must be non-empty")
139
+ if s3_url is None:
140
+ s3_url = get_artifact_store().attempt_prefix(cls.model_fields["type"].default, id)
141
+ if not s3_url.endswith("/"):
142
+ s3_url += "/"
143
+
144
+ file_artifacts: dict[str, FileArtifact] = {}
145
+ for rel_path, local_path in files.items():
146
+ if rel_path.startswith("/"):
147
+ raise ValueError(f"Bundle key {rel_path!r} must be a relative path")
148
+ if ".." in rel_path.split("/"):
149
+ raise ValueError(f"Bundle key {rel_path!r} must not contain '..' segments")
150
+ path_hash = hashlib.sha256(rel_path.encode("utf-8")).hexdigest()[:16]
151
+ fa = FileArtifact.put_at(
152
+ id=derive_id(id, path_hash),
153
+ description=f"Bundled file '{rel_path}' of FileArtifactUniverse '{id}'",
154
+ file_path=str(local_path),
155
+ object_url=s3_url + rel_path,
156
+ filename=rel_path.rsplit("/", 1)[-1],
157
+ )
158
+ file_artifacts[rel_path] = fa
159
+
160
+ return cls.put(id=id, file_artifacts=file_artifacts, bundle_s3_url=s3_url)
161
+
162
+ @classmethod
163
+ def from_toml(cls, data: dict, ctx: AuthoringContext) -> "FileArtifactUniverse":
164
+ """Write the folder's files, each a file artifact keyed by its path in the folder."""
165
+ ctx.accept(data, **cls.toml_keys)
166
+ return cls.put_bundled(ctx.id, files=ctx.files())
167
+
168
+ @classmethod
169
+ def put_existing(
170
+ cls,
171
+ id: str,
172
+ *,
173
+ s3_url: str,
174
+ ) -> "FileArtifactUniverse":
175
+ """Wrap files that already exist under an object-store prefix as a universe.
176
+
177
+ Unlike ``put_bundled`` (which uploads local files), this lists the
178
+ objects already present under *s3_url* and registers each one as a
179
+ ``FileArtifact`` via ``FileArtifact.put_existing``. No bytes are
180
+ downloaded or re-uploaded. Useful when another process (e.g. an
181
+ agent's snapshot extension) has already written the files.
182
+ """
183
+ from agent_env.artifact.artifacts.file import FileArtifact
184
+
185
+ if not s3_url.endswith("/"):
186
+ s3_url += "/"
187
+
188
+ store = get_config().get_object_store_at(s3_url)
189
+
190
+ file_artifacts: dict[str, FileArtifact] = {}
191
+ for object_url in store.list_at(s3_url):
192
+ rel_path = object_url[len(s3_url):]
193
+ if not rel_path:
194
+ continue
195
+ path_hash = hashlib.sha256(rel_path.encode("utf-8")).hexdigest()[:16]
196
+ fa = FileArtifact.put_existing(
197
+ id=derive_id(id, path_hash),
198
+ description=f"Bundled file '{rel_path}' of FileArtifactUniverse '{id}'",
199
+ object_url=object_url,
200
+ )
201
+ file_artifacts[rel_path] = fa
202
+
203
+ if not file_artifacts:
204
+ raise ValueError(f"No files found under prefix {s3_url!r}")
205
+
206
+ return cls.put(id=id, file_artifacts=file_artifacts, bundle_s3_url=s3_url)