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,486 @@
1
+ """Verify a deployed MCP server conforms to its OpenAPI spec (request side).
2
+
3
+ Diffs the baked spec (served at ``/openapi.yaml``) against the live ``list_tools()``
4
+ surface: tool presence, parameter names, types, enums, required-ness. Response shape
5
+ is out of scope.
6
+
7
+ The spec is a FLOOR: what it promises must match, but a server may offer more
8
+ (``extra_tool`` / ``extra_param`` are reported, not blocking) — that keeps framework tools
9
+ like ``<service>_health`` from failing every server. The exception is an undeclared param
10
+ the tool *requires*, which no spec-following caller can satisfy.
11
+
12
+ Record-only, and not in ``DEFAULT_REQUIRED_GATES``: findings persist under
13
+ ``mcp_spec_conformance`` so drift stays measurable, and a pipeline can opt back into
14
+ enforcement by passing ``spec_conformance`` in ``ValidationGateAggregatorStep``'s
15
+ ``required_gates`` (a step arg, not env metadata). No baked spec = ``skipped``. Execution
16
+ errors (unreachable MCP) still raise.
17
+
18
+ Keep the diff logic in sync with the universe-generation pipeline's
19
+ ``synthetic_mcp_server_generation/validation/spec_conformance.py`` (same diff at generation
20
+ time); tool shape differs — MCP objects here, dicts there.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import logging
26
+ from typing import Any, ClassVar, Optional
27
+
28
+ import httpx
29
+ import yaml
30
+ from mcp import ClientSession
31
+ from mcp.client.streamable_http import streamable_http_client
32
+
33
+ from agent_env.env import legacy_protocol
34
+ from agent_env.task_step.context import TaskStepContext
35
+ from agent_env.entity_refs import EntityRef
36
+ from agent_env.task_step.task_step import TaskStep, TaskStepDependency
37
+ from agent_env.task_step.task_steps.mcp_env_validator.verify_mcp_tool_schema import (
38
+ resolve_defs_ref,
39
+ )
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # OpenAPI HTTP methods that can carry an operationId → MCP tool.
44
+ _HTTP_METHODS = ("get", "post", "put", "delete", "patch", "head", "options", "trace")
45
+
46
+ # Shared by the spec fetch and the MCP tool-list read; httpx's default is too short.
47
+ _HTTP_TIMEOUT_S = 30.0
48
+
49
+
50
+ def _resolve_openapi_ref(node: Any, spec: dict) -> Any:
51
+ """Follow ``#/components/...`` ``$ref`` pointers against the whole spec.
52
+
53
+ Sibling keys on the referencing node (e.g. ``description``) override the
54
+ resolved target. Bounded against ``$ref`` cycles; returns the node unchanged
55
+ if a pointer can't be resolved.
56
+ """
57
+ seen: set[str] = set()
58
+ while isinstance(node, dict) and "$ref" in node:
59
+ ref = node["$ref"]
60
+ if not isinstance(ref, str) or not ref.startswith("#/") or ref in seen:
61
+ return node
62
+ seen.add(ref)
63
+ target: Any = spec
64
+ for part in ref[2:].split("/"):
65
+ if not isinstance(target, dict) or part not in target:
66
+ return node
67
+ target = target[part]
68
+ siblings = {k: v for k, v in node.items() if k != "$ref"}
69
+ node = {**target, **siblings} if isinstance(target, dict) else target
70
+ return node
71
+
72
+
73
+ def _schema_type_and_enum(schema: Any, spec: dict) -> tuple[Optional[str], Optional[list]]:
74
+ """Extract a comparable (type, sorted-enum) pair from an OpenAPI schema node.
75
+
76
+ Handles ``$ref`` and ``anyOf``/``oneOf``/``allOf`` (first non-null typed variant).
77
+ """
78
+ schema = _resolve_openapi_ref(schema, spec)
79
+ if not isinstance(schema, dict):
80
+ return None, None
81
+ typ = schema.get("type")
82
+ enum = schema.get("enum")
83
+ if typ is None:
84
+ for key in ("anyOf", "oneOf", "allOf"):
85
+ for variant in schema.get(key, []) or []:
86
+ v = _resolve_openapi_ref(variant, spec)
87
+ if isinstance(v, dict) and v.get("type") and v.get("type") != "null":
88
+ typ = v.get("type")
89
+ enum = enum or v.get("enum")
90
+ break
91
+ if typ:
92
+ break
93
+ return typ, (sorted(str(e) for e in enum) if enum else None)
94
+
95
+
96
+ def normalize_spec_params(spec: dict) -> dict[str, dict[str, dict]]:
97
+ """Map each ``operationId`` → ``{param_name: {type, enum, required}}``.
98
+
99
+ Collects path-item-level and operation-level ``parameters`` plus ``requestBody``
100
+ JSON properties.
101
+ """
102
+ result: dict[str, dict[str, dict]] = {}
103
+ paths = spec.get("paths", {}) or {}
104
+ for _path, methods in paths.items():
105
+ if not isinstance(methods, dict):
106
+ continue
107
+ # Path-item-level parameters apply to every operation on the path.
108
+ shared_params = methods.get("parameters", []) or []
109
+ for method, op in methods.items():
110
+ if method.lower() not in _HTTP_METHODS or not isinstance(op, dict):
111
+ continue
112
+ op_id = op.get("operationId")
113
+ if not op_id:
114
+ continue
115
+ params: dict[str, dict] = {}
116
+
117
+ # Operation-level last so it overrides a same-named path-item parameter.
118
+ for p in [*shared_params, *(op.get("parameters", []) or [])]:
119
+ p = _resolve_openapi_ref(p, spec)
120
+ if not isinstance(p, dict) or "name" not in p:
121
+ continue
122
+ typ, enum = _schema_type_and_enum(p.get("schema", {}) or {}, spec)
123
+ params[p["name"]] = {
124
+ "type": typ,
125
+ "enum": enum,
126
+ "required": bool(p.get("required", False)),
127
+ }
128
+
129
+ body = op.get("requestBody")
130
+ if isinstance(body, dict):
131
+ body = _resolve_openapi_ref(body, spec)
132
+ schema = (
133
+ (body.get("content", {}) or {})
134
+ .get("application/json", {})
135
+ .get("schema", {})
136
+ )
137
+ schema = _resolve_openapi_ref(schema, spec) if schema else {}
138
+ if isinstance(schema, dict):
139
+ req = set(schema.get("required", []) or [])
140
+ for name, psch in (schema.get("properties", {}) or {}).items():
141
+ typ, enum = _schema_type_and_enum(psch or {}, spec)
142
+ entry = {"type": typ, "enum": enum, "required": name in req}
143
+ # A parameter and a body property can share a name: keep the
144
+ # parameter's type/enum, require if either side requires.
145
+ existing = params.get(name)
146
+ if existing is not None:
147
+ entry["type"] = existing["type"] if existing["type"] is not None else typ
148
+ entry["enum"] = existing["enum"] if existing["enum"] is not None else enum
149
+ entry["required"] = existing["required"] or entry["required"]
150
+ params[name] = entry
151
+
152
+ result[op_id] = params
153
+ return result
154
+
155
+
156
+ def normalize_tool_params(input_schema: dict) -> dict[str, dict]:
157
+ """Map a live MCP tool's ``inputSchema`` → ``{param_name: {type, enum, required}}``.
158
+
159
+ Reuses ``resolve_defs_ref`` for ``#/$defs/``; handles ``anyOf``/``oneOf``/``allOf``
160
+ symmetrically with the spec side (``_schema_type_and_enum``).
161
+ """
162
+ input_schema = input_schema or {}
163
+ props = input_schema.get("properties", {}) or {}
164
+ required = set(input_schema.get("required", []) or [])
165
+ out: dict[str, dict] = {}
166
+ for name, psch in props.items():
167
+ if not isinstance(psch, dict):
168
+ out[name] = {"type": None, "enum": None, "required": name in required}
169
+ continue
170
+ resolved = resolve_defs_ref(psch, input_schema)
171
+ typ = resolved.get("type")
172
+ enum = resolved.get("enum")
173
+ if typ is None:
174
+ for key in ("anyOf", "oneOf", "allOf"):
175
+ for v in resolved.get(key, []) or []:
176
+ rv = (
177
+ resolve_defs_ref(v, input_schema)
178
+ if isinstance(v, dict)
179
+ else v
180
+ )
181
+ if isinstance(rv, dict) and rv.get("type") and rv.get("type") != "null":
182
+ typ = rv.get("type")
183
+ enum = enum or rv.get("enum")
184
+ break
185
+ if typ:
186
+ break
187
+ out[name] = {
188
+ "type": typ,
189
+ "enum": sorted(str(e) for e in enum) if enum else None,
190
+ "required": name in required,
191
+ }
192
+ return out
193
+
194
+
195
+ # Findings that break a spec-following caller. `extra_tool`/`extra_param` are omitted on
196
+ # purpose: a server offering more than it documents breaks nobody, so it's informational.
197
+ BLOCKING_FINDING_KINDS = frozenset({
198
+ "missing_tool",
199
+ "missing_param",
200
+ "type_mismatch",
201
+ "enum_mismatch",
202
+ "required_mismatch",
203
+ "ambiguous_tool", # can't tell two tools apart -> could be masking a missing one
204
+ # The one extra that isn't free: a caller reading only the spec omits an undeclared
205
+ # required param, so the call fails. Undeclared-and-optional stays informational.
206
+ "extra_required_param",
207
+ })
208
+
209
+
210
+ def strip_service_prefix(name: str, service_name: Optional[str]) -> str:
211
+ """Drop a leading ``<service_name>_`` to exclude the prefix from name comparison.
212
+
213
+ Tools are namespaced (one gateway fronts every service); spec ``operationId``s mostly
214
+ omit it. Stripped on BOTH sides because the prefix sits on the tool for some servers
215
+ (``city_get_crime_rate`` vs ``get_crime_rate``) and on the spec for others (``jira``
216
+ declares ``jira_add_comment`` for tool ``add_comment``). Symmetric stripping keeps
217
+ ``city_hall_info`` matching itself. Params/types/enums still compared strictly.
218
+ """
219
+ if not service_name:
220
+ return name
221
+ prefix = f"{service_name}_"
222
+ return name[len(prefix):] if name.startswith(prefix) else name
223
+
224
+
225
+ def _index_by_normalized(
226
+ pairs: list[tuple[str, dict]], service_name: Optional[str]
227
+ ) -> tuple[dict[str, tuple[str, dict]], list[tuple[str, str, str]]]:
228
+ """Key ``(original_name, params)`` pairs by prefix-stripped name.
229
+
230
+ Returns ``(index, collisions)``. On a collision (``health`` and ``<service>_health``)
231
+ the later name takes the slot and the earlier one drops out of the diff, so the pair is
232
+ also returned and reported as a blocking ``ambiguous_tool`` rather than lost silently.
233
+ """
234
+ index: dict[str, tuple[str, dict]] = {}
235
+ collisions: list[tuple[str, str, str]] = []
236
+ for original, params in pairs:
237
+ key = strip_service_prefix(original, service_name)
238
+ if key in index:
239
+ collisions.append((key, index[key][0], original))
240
+ index[key] = (original, params)
241
+ return index, collisions
242
+
243
+
244
+ def _collision_reason(key: str, service_name: Optional[str]) -> str:
245
+ """Why two names collided. Without a ``service_name`` nothing was stripped, so they are
246
+ literal duplicates — don't claim a prefix was involved."""
247
+ if not service_name:
248
+ return "are declared more than once"
249
+ return f"both reduce to '{key}' after stripping the '{service_name}_' prefix"
250
+
251
+
252
+ def diff_conformance(
253
+ spec: dict, tools: list, service_name: Optional[str] = None
254
+ ) -> dict[str, Any]:
255
+ """Request-side conformance diff between an OpenAPI spec and live MCP tools.
256
+
257
+ ``tools`` is a list of MCP SDK tool objects. ``service_name`` excludes the
258
+ ``<service>_`` prefix from name comparison; omit it to compare verbatim.
259
+
260
+ Returns ``{passed, total_tools, spec_operations, findings, blocking_findings}``. Kinds:
261
+ ``missing_tool``, ``extra_tool``, ``ambiguous_tool``, ``missing_param``,
262
+ ``extra_param``, ``extra_required_param``, ``type_mismatch``, ``enum_mismatch``,
263
+ ``required_mismatch``. Each finding carries ``blocking`` (see
264
+ :data:`BLOCKING_FINDING_KINDS`); ``passed`` reflects blocking findings only. Findings
265
+ name the ORIGINAL, unstripped tool.
266
+ """
267
+ spec_params = normalize_spec_params(spec)
268
+ spec_ops, spec_collisions = _index_by_normalized(list(spec_params.items()), service_name)
269
+ live, live_collisions = _index_by_normalized(
270
+ [(t.name, normalize_tool_params(t.inputSchema or {})) for t in tools],
271
+ service_name,
272
+ )
273
+
274
+ findings: list[dict] = []
275
+ spec_names, live_names = set(spec_ops), set(live)
276
+
277
+ for key, first, second in spec_collisions:
278
+ findings.append({
279
+ "kind": "ambiguous_tool", "tool": second,
280
+ "detail": f"spec operationIds '{first}' and '{second}' "
281
+ f"{_collision_reason(key, service_name)}",
282
+ })
283
+ for key, first, second in live_collisions:
284
+ findings.append({
285
+ "kind": "ambiguous_tool", "tool": second,
286
+ "detail": f"MCP tools '{first}' and '{second}' "
287
+ f"{_collision_reason(key, service_name)}",
288
+ })
289
+
290
+ for key in sorted(spec_names - live_names):
291
+ name = spec_ops[key][0]
292
+ findings.append({
293
+ "kind": "missing_tool", "tool": name,
294
+ "detail": f"operationId '{name}' in spec has no matching MCP tool",
295
+ })
296
+ for key in sorted(live_names - spec_names):
297
+ name = live[key][0]
298
+ findings.append({
299
+ "kind": "extra_tool", "tool": name,
300
+ "detail": f"MCP tool '{name}' is not declared in the spec",
301
+ })
302
+
303
+ for key in sorted(spec_names & live_names):
304
+ _spec_name, sp = spec_ops[key]
305
+ # Report against the live tool name — that's what a caller actually invokes.
306
+ name, lv = live[key]
307
+ for pname in sorted(set(sp) - set(lv)):
308
+ findings.append({
309
+ "kind": "missing_param", "tool": name, "param": pname,
310
+ "detail": f"spec param '{pname}' absent from tool inputSchema",
311
+ })
312
+ for pname in sorted(set(lv) - set(sp)):
313
+ required = lv[pname]["required"]
314
+ findings.append({
315
+ "kind": "extra_required_param" if required else "extra_param",
316
+ "tool": name, "param": pname,
317
+ "detail": f"tool param '{pname}' not declared in spec"
318
+ + (" and is required, so a spec-following caller cannot call this"
319
+ " tool" if required else ""),
320
+ })
321
+ for pname in sorted(set(sp) & set(lv)):
322
+ s, l = sp[pname], lv[pname]
323
+ if s["type"] and l["type"] and s["type"] != l["type"]:
324
+ findings.append({
325
+ "kind": "type_mismatch", "tool": name, "param": pname,
326
+ "detail": f"spec type '{s['type']}' != tool type '{l['type']}'",
327
+ })
328
+ if s["enum"] is not None and l["enum"] is not None and s["enum"] != l["enum"]:
329
+ findings.append({
330
+ "kind": "enum_mismatch", "tool": name, "param": pname,
331
+ "detail": f"spec enum {s['enum']} != tool enum {l['enum']}",
332
+ })
333
+ if s["required"] != l["required"]:
334
+ findings.append({
335
+ "kind": "required_mismatch", "tool": name, "param": pname,
336
+ "detail": f"spec required={s['required']} != tool required={l['required']}",
337
+ })
338
+
339
+ for f in findings:
340
+ f["blocking"] = f["kind"] in BLOCKING_FINDING_KINDS
341
+ blocking = [f for f in findings if f["blocking"]]
342
+
343
+ return {
344
+ "passed": len(blocking) == 0,
345
+ "total_tools": len(tools),
346
+ # Raw spec count, not the collision-collapsed index: it would otherwise under-report
347
+ # exactly when an `ambiguous_tool` finding says something is wrong.
348
+ "spec_operations": len(spec_params),
349
+ "findings": findings,
350
+ "blocking_findings": len(blocking),
351
+ }
352
+
353
+
354
+ def skipped_result(reason: str) -> dict[str, Any]:
355
+ """Advisory 'nothing to check' verdict: ``skipped`` frees the gate, ``passed``
356
+ stays False so nothing reads it as a real conformance pass.
357
+ """
358
+ return {
359
+ "passed": False, "skipped": True, "reason": reason,
360
+ "findings": [], "blocking_findings": 0,
361
+ }
362
+
363
+
364
+ # Statuses meaning "no spec is served here", not "the fetch broke".
365
+ _SPEC_ABSENT_STATUSES = (404, 405, 501)
366
+
367
+
368
+ def _summarize(findings: list[dict], limit: int = 10) -> str:
369
+ """One-line digest, blocking first so truncation drops noise rather than signal."""
370
+ ordered = sorted(findings, key=lambda f: not f.get("blocking"))
371
+ parts = []
372
+ for f in ordered[:limit]:
373
+ loc = f.get("tool", "")
374
+ if f.get("param"):
375
+ loc = f"{loc}.{f['param']}"
376
+ parts.append(f"{f['kind']}:{loc}")
377
+ extra = len(ordered) - limit
378
+ if extra > 0:
379
+ parts.append(f"(+{extra} more)")
380
+ return "; ".join(parts)
381
+
382
+
383
+ class VerifySpecConformanceTaskStep(TaskStep):
384
+ type: ClassVar[str] = "verify_spec_conformance"
385
+ entity_refs = (EntityRef.env("env_id"),)
386
+
387
+ def __init__(
388
+ self,
389
+ id: str,
390
+ version: Optional[int],
391
+ env_id: str,
392
+ depends_on: Optional[list[TaskStepDependency]] = None,
393
+ fail_task_on_error: bool = True,
394
+ ):
395
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
396
+ self.env_id = env_id
397
+
398
+ def to_dict(self) -> dict:
399
+ base = super().to_dict()
400
+ base["env_id"] = self.env_id
401
+ return base
402
+
403
+ @classmethod
404
+ def from_dict(cls, data: dict) -> VerifySpecConformanceTaskStep:
405
+ return cls(**cls._base_from_dict(data), env_id=data["env_id"])
406
+
407
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
408
+ from agent_env.env.env import Env, gateway_url_of
409
+
410
+ deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
411
+ if deployed is None:
412
+ raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs")
413
+
414
+ env = Env.get(self.env_id, deployed.env_version)
415
+ environment_name = env.environment_name
416
+
417
+ # 1. Fetch the baked OpenAPI spec; no spec = skip, not block. A server the card doesn't list keeps today's path.
418
+ base_url = (await legacy_protocol.v1_base_url(deployed, gateway_url_of(deployed), environment_name)
419
+ or legacy_protocol.environment_base_url(gateway_url_of(deployed), environment_name))
420
+ spec_url = f"{base_url}/openapi.yaml"
421
+ logger.info(f"Fetching OpenAPI spec for '{environment_name}' at {spec_url}")
422
+ async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
423
+ resp = await client.get(spec_url)
424
+ if resp.status_code in _SPEC_ABSENT_STATUSES:
425
+ return self._record(
426
+ context, env, environment_name,
427
+ skipped_result(f"no OpenAPI spec served at {spec_url} (HTTP {resp.status_code})"),
428
+ )
429
+ resp.raise_for_status()
430
+ spec = yaml.safe_load(resp.text)
431
+ if not isinstance(spec, dict) or not spec.get("paths"):
432
+ # Served but unusable (error page, empty doc): skip rather than emit a
433
+ # wall of bogus `extra_tool`s.
434
+ return self._record(
435
+ context, env, environment_name,
436
+ skipped_result(f"OpenAPI spec at {spec_url} has no usable 'paths' section"),
437
+ )
438
+
439
+ # 2. Read the live tool surface. Same explicit timeout as the spec fetch so a
440
+ # slow gateway can't trip httpx's short default and block the gate.
441
+ mcp_url = deployed.mcp_url
442
+ logger.info(f"Connecting to MCP at {mcp_url} to read live tools")
443
+ http_client = httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S)
444
+ try:
445
+ async with streamable_http_client(mcp_url, http_client=http_client) as (read_stream, write_stream, _):
446
+ async with ClientSession(read_stream, write_stream) as session:
447
+ await session.initialize()
448
+ tools_result = await session.list_tools()
449
+ finally:
450
+ await http_client.aclose()
451
+ tools = tools_result.tools
452
+
453
+ # 3. Diff. Without the tool-name prefix every tool reads as both missing and extra.
454
+ result = diff_conformance(spec, tools, service_name=environment_name)
455
+ logger.info(
456
+ f"Spec conformance for '{environment_name}': passed={result['passed']} "
457
+ f"tools={result['total_tools']} spec_ops={result['spec_operations']} "
458
+ f"blocking={result['blocking_findings']} findings={len(result['findings'])}"
459
+ )
460
+
461
+ # 4. Persist (record-only): a failed verdict doesn't raise, so validate()
462
+ # completes and cleans up its sandboxes. `put` enforces.
463
+ return self._record(context, env, environment_name, result)
464
+
465
+ def _record(self, context: TaskStepContext, env, environment_name: str, result: dict) -> TaskStepContext:
466
+ """Persist a verdict to env + context metadata and log it. ``merge_metadata``
467
+ because the schema verifier writes a sibling key on the same doc concurrently.
468
+ """
469
+ env.merge_metadata({"mcp_spec_conformance": result})
470
+ context.metadata.setdefault("verifications", {})["spec_conformance"] = result
471
+ if result.get("skipped"):
472
+ logger.info(f"Spec conformance skipped for '{environment_name}': {result['reason']}")
473
+ elif result["findings"]:
474
+ # Logged whenever there is drift, not only on a failed verdict: informational-
475
+ # only findings still pass, and keying off `passed` silenced the normal case
476
+ # (every server reports its framework tools as `extra_*`). Advisory either
477
+ # way — this never blocks. Level tracks severity so expected framework-tool
478
+ # drift doesn't warn on every run and train operators to ignore it.
479
+ blocking = result["blocking_findings"]
480
+ log = logger.warning if blocking else logger.info
481
+ log(
482
+ f"Spec conformance drift for '{environment_name}' "
483
+ f"({blocking} blocking of {len(result['findings'])} "
484
+ f"finding(s)): {_summarize(result['findings'])}"
485
+ )
486
+ return context
@@ -0,0 +1,80 @@
1
+ """Task step that toggles role-based tool access on a deployed env's gateway."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import ClassVar, Optional
7
+
8
+ from agent_env.env.gateway import TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION
9
+ from agent_env.env.gateway.constants import EXT_DISABLE_TOOL_URI, EXT_ENABLE_TOOL_URI
10
+ from agent_env.task_step.context import TaskStepContext
11
+ from agent_env.entity_refs import EntityRef
12
+ from agent_env.task_step.task_step import TaskStep, TaskStepDependency
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ModifyEnvToolAccessStep(TaskStep):
18
+ type: ClassVar[str] = "modify_env_tool_access"
19
+ entity_refs = (EntityRef.env("env_id"),)
20
+
21
+ def __init__(
22
+ self,
23
+ id: str,
24
+ version: Optional[int],
25
+ env_id: str,
26
+ action: str,
27
+ role: str,
28
+ tools: list[str],
29
+ depends_on: Optional[list[TaskStepDependency]] = None,
30
+ fail_task_on_error: bool = True,
31
+ ):
32
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
33
+ if action not in (TOOL_DISABLE_ACTION, TOOL_ENABLE_ACTION):
34
+ raise ValueError(f"action must be {TOOL_DISABLE_ACTION!r} or {TOOL_ENABLE_ACTION!r}, got {action!r}")
35
+ if not isinstance(role, str) or not role:
36
+ raise ValueError("role must be a non-empty string")
37
+ if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
38
+ raise ValueError("tools must be a list of strings")
39
+ self.env_id = env_id
40
+ self.action = action
41
+ self.role = role
42
+ self.tools = tools
43
+
44
+ def to_dict(self) -> dict:
45
+ base = super().to_dict()
46
+ base["env_id"] = self.env_id
47
+ base["action"] = self.action
48
+ base["role"] = self.role
49
+ base["tools"] = self.tools
50
+ return base
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict) -> ModifyEnvToolAccessStep:
54
+ return cls(
55
+ **cls._base_from_dict(data),
56
+ env_id=data["env_id"],
57
+ action=data["action"],
58
+ role=data["role"],
59
+ tools=data["tools"],
60
+ )
61
+
62
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
63
+ deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
64
+ if deployed is None:
65
+ raise RuntimeError(f"Env '{self.env_id}' not found in context.deployed_envs")
66
+ body = await deployed.invoke(_TOOL_ACCESS_URIS[self.action], self.action, {"role": self.role, "tools": self.tools})
67
+ logger.info(f"{self.action} role={self.role!r} tools={self.tools} on env={self.env_id}: {body}")
68
+ context.metadata.setdefault("tool_access_changes", []).append({
69
+ "step_id": self.id,
70
+ "env_id": self.env_id,
71
+ "action": self.action,
72
+ "role": self.role,
73
+ "tools_requested": self.tools,
74
+ "role_state_after": {"disabled": body.get("disabled", []), "allowed": body.get("allowed", [])},
75
+ })
76
+ return context
77
+
78
+
79
+ # The gateway extension each action is advertised under on the env card.
80
+ _TOOL_ACCESS_URIS = {TOOL_DISABLE_ACTION: EXT_DISABLE_TOOL_URI, TOOL_ENABLE_ACTION: EXT_ENABLE_TOOL_URI}
@@ -0,0 +1,3 @@
1
+ from .verify_universe_roundtrip import VerifyUniverseLoadExportRoundtripStep
2
+
3
+ __all__ = ["VerifyUniverseLoadExportRoundtripStep"]
@@ -0,0 +1,107 @@
1
+ """Combine the programmatic + agent-judge universe-compatibility verdicts.
2
+
3
+ This is the single authoritative writer of the ``UNIVERSE_COMPATIBILITY`` artifact. The round-trip
4
+ step computes the programmatic verdict into ``context.metadata`` (without persisting it), the rubrics
5
+ verifier records the agent-judge verdict, and this step — running last — merges them and persists the
6
+ result once, atomically. Keeping the write here (rather than in the round-trip step) means a crash or
7
+ judge failure before this step leaves NO half-written, ungated result masquerading as final.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import ClassVar, Optional
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
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class CombineUniverseVerdictsStep(TaskStep):
23
+ type: ClassVar[str] = "combine_universe_verdicts"
24
+ entity_refs = (
25
+ EntityRef.env("env_id"),
26
+ EntityRef.artifact("universe_artifact_id", version_field="universe_artifact_version"),
27
+ )
28
+
29
+ def __init__(
30
+ self,
31
+ id: str,
32
+ version: Optional[int],
33
+ env_id: str,
34
+ universe_artifact_id: str,
35
+ universe_artifact_version: int,
36
+ judge_verifier_id: str,
37
+ depends_on: Optional[list[TaskStepDependency]] = None,
38
+ fail_task_on_error: bool = True,
39
+ ):
40
+ super().__init__(id, version, depends_on=depends_on, fail_task_on_error=fail_task_on_error)
41
+ self.env_id = env_id
42
+ self.universe_artifact_id = universe_artifact_id
43
+ self.universe_artifact_version = universe_artifact_version
44
+ self.judge_verifier_id = judge_verifier_id
45
+
46
+ def to_dict(self) -> dict:
47
+ base = super().to_dict()
48
+ base["env_id"] = self.env_id
49
+ base["universe_artifact_id"] = self.universe_artifact_id
50
+ base["universe_artifact_version"] = self.universe_artifact_version
51
+ base["judge_verifier_id"] = self.judge_verifier_id
52
+ return base
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict) -> CombineUniverseVerdictsStep:
56
+ return cls(
57
+ **cls._base_from_dict(data),
58
+ env_id=data["env_id"],
59
+ universe_artifact_id=data["universe_artifact_id"],
60
+ universe_artifact_version=data["universe_artifact_version"],
61
+ judge_verifier_id=data["judge_verifier_id"],
62
+ )
63
+
64
+ async def execute(self, context: TaskStepContext) -> TaskStepContext:
65
+ from agent_env.env.env_artifact_store import EnvArtifactType, get_env_artifact_store
66
+ from .verify_universe_agent_judge import apply_judge_verdict
67
+
68
+ verifs = context.metadata.get("verifications", {})
69
+ compat = verifs.get(EnvArtifactType.UNIVERSE_COMPATIBILITY)
70
+ if compat is None:
71
+ logger.warning("combine: no programmatic UNIVERSE_COMPATIBILITY result in context; nothing to persist")
72
+ return context
73
+
74
+ judge = verifs.get(self.judge_verifier_id)
75
+ if judge is not None:
76
+ apply_judge_verdict(compat, judge) # mutates compat in place; gates `compatible`
77
+ logger.info(f"combine: merged agent-judge verdict; compatible={compat.get('compatible')}")
78
+ else:
79
+ # Defensive: with a load-bearing judge (fail_task_on_error=True on the judge steps) we
80
+ # only reach here when the judge succeeded, so this is unexpected. Persist the
81
+ # programmatic result rather than drop it, but MARK it so downstream/UI can tell the
82
+ # verdict was NOT gated by the judge (don't mistake an ungated result for a complete one).
83
+ compat["agent_judge_skipped"] = True
84
+ logger.warning("combine: agent-judge verdict missing; persisting programmatic result UNGATED (agent_judge_skipped=True)")
85
+
86
+ # Re-point the dual-written "environments" twin at the merged "services" dict. The two are
87
+ # aliased when built, but serializing the context re-materializes them as separate
88
+ # dicts, and apply_judge_verdict only writes "services" — so without this the twin can
89
+ # persist stale.
90
+ if "services" in compat:
91
+ compat["environments"] = compat["services"]
92
+
93
+ # env_version must match what the round-trip step used so this upserts the same row.
94
+ deployed = next((d for d in context.deployed_envs if d.env_id == self.env_id), None)
95
+ if deployed is None:
96
+ logger.warning(f"combine: env '{self.env_id}' not in deployed_envs; skipping persist")
97
+ return context
98
+
99
+ get_env_artifact_store().put(
100
+ env_id=self.env_id,
101
+ env_version=deployed.env_version,
102
+ artifact_id=self.universe_artifact_id,
103
+ artifact_version=self.universe_artifact_version,
104
+ type=EnvArtifactType.UNIVERSE_COMPATIBILITY,
105
+ data=compat,
106
+ )
107
+ return context