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,253 @@
1
+ """Pure comparison functions for universe load/export roundtrip validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _dt
6
+ import json
7
+ from typing import Any
8
+
9
+ _EMPTY = "\x00EMPTY\x00" # unifies None / "" / {} / []
10
+
11
+
12
+ def _canon_ts(s: str):
13
+ """Return ('DT', utc-iso) or ('D', date-iso) for a timestamp/date string, else None."""
14
+ if len(s) == 10 and s[4] == "-" and s[7] == "-": # date-only YYYY-MM-DD (fromisoformat would make it midnight)
15
+ try:
16
+ return ("D", _dt.date.fromisoformat(s).isoformat())
17
+ except (ValueError, TypeError):
18
+ return None
19
+ try:
20
+ ss = s.replace("Z", "+00:00")
21
+ if " " in ss and "T" not in ss:
22
+ ss = ss.replace(" ", "T", 1)
23
+ d = _dt.datetime.fromisoformat(ss)
24
+ if d.tzinfo is not None:
25
+ d = d.astimezone(_dt.timezone.utc).replace(tzinfo=None)
26
+ return ("DT", d.isoformat())
27
+ except (ValueError, TypeError):
28
+ pass
29
+ try:
30
+ return ("D", _dt.date.fromisoformat(s).isoformat())
31
+ except (ValueError, TypeError):
32
+ return None
33
+
34
+
35
+ def _canon(v: Any) -> Any:
36
+ """Canonicalize a value so equivalent representations compare equal:
37
+ numeric strings -> float (rounded), timestamps -> UTC instant, empties -> sentinel."""
38
+ if v is None or v == "" or v == {} or v == []:
39
+ return _EMPTY
40
+ if isinstance(v, bool):
41
+ return v
42
+ if isinstance(v, (int, float)):
43
+ # Numbers stay numbers — we do NOT coerce them to timestamps. The old epoch heuristic
44
+ # (1.4e9-2.0e9 s / 1.4e12-2.0e12 ms) collided with financial amounts in that range
45
+ # (e.g. a ~$1.5B balance), and stripping sub-second precision made distinct amounts like
46
+ # 1_500_000_001.50 and 1_500_000_001.75 canonicalize equal — a silent false-negative that
47
+ # would mask real data loss. Timestamps are only canonicalized from strings (ISO/date),
48
+ # which is unambiguous; an epoch carried as a bare number on one side and ISO on the other
49
+ # is not a representation we produce, and if it ever occurred we'd rather flag than swallow.
50
+ return round(float(v), 4)
51
+ if isinstance(v, list):
52
+ return [_canon(x) for x in v]
53
+ if isinstance(v, dict):
54
+ return {k: _canon(x) for k, x in v.items()}
55
+ if isinstance(v, str):
56
+ s = v.strip()
57
+ # numeric string -> route through number canon (so "12.50" == 12.5), never a timestamp
58
+ try:
59
+ return _canon(float(s))
60
+ except (ValueError, TypeError):
61
+ pass
62
+ ts = _canon_ts(s)
63
+ return ts if ts is not None else s
64
+ return v
65
+
66
+
67
+ def _is_ts(c: Any) -> bool:
68
+ return isinstance(c, tuple) and len(c) == 2 and c[0] in ("DT", "D")
69
+
70
+
71
+ def _semeq(ca: Any, cb: Any) -> bool:
72
+ """Recursive semantic equality over already-canonicalized values.
73
+ ``ca`` is the universe (load) side, ``cb`` the export side."""
74
+ if ca == cb:
75
+ return True
76
+ # universe-null vs export-value = server enrichment / stamping (created_time, derived period ids,
77
+ # etc.) — round-trip-safe. Asymmetric: export-null vs universe-value (a real LOSS) stays flagged.
78
+ if ca == _EMPTY:
79
+ return True
80
+ # date vs datetime on the same calendar day (server truncated a datetime to a date)
81
+ if _is_ts(ca) and _is_ts(cb) and ca[1][:10] == cb[1][:10] and (ca[0] == "D" or cb[0] == "D"):
82
+ return True
83
+ # nested dicts: every non-empty universe key must survive in the export and match.
84
+ # Export-only keys are server enrichment (ignored), but DROPPING a non-empty universe key is
85
+ # real data loss and must fail; dropping an already-empty value (_EMPTY) is harmless.
86
+ if isinstance(ca, dict) and isinstance(cb, dict):
87
+ return all(ca[k] == _EMPTY or (k in cb and _semeq(ca[k], cb[k])) for k in ca)
88
+ # lists of dicts: try order-aligned (fast), then order-independent greedy match (the two sides may
89
+ # sort by different keys, e.g. export adds 'id'/'line_number' the universe lacks)
90
+ if isinstance(ca, list) and isinstance(cb, list):
91
+ if len(ca) != len(cb):
92
+ return False
93
+ if all(_semeq(x, y) for x, y in zip(ca, cb)):
94
+ return True
95
+ if ca and isinstance(ca[0], dict):
96
+ pool = list(cb)
97
+ for x in ca:
98
+ for j, y in enumerate(pool):
99
+ if _semeq(x, y):
100
+ pool.pop(j)
101
+ break
102
+ else:
103
+ return False
104
+ return True
105
+ return False
106
+ return False
107
+
108
+
109
+ def _vals_equal(ov: Any, ev: Any) -> bool:
110
+ """Semantic equality: ignores timezone/number/epoch/empty representation, tolerant of
111
+ date-vs-datetime and of server-added keys inside nested structures."""
112
+ return _semeq(_canon(ov), _canon(ev))
113
+
114
+
115
+ def normalize(obj: Any) -> Any:
116
+ """Recursively sort lists of dicts by 'id' (or first key) for stable comparison."""
117
+ if isinstance(obj, dict):
118
+ return {k: normalize(v) for k, v in obj.items()}
119
+ if isinstance(obj, list):
120
+ normalized = [normalize(item) for item in obj]
121
+ if normalized and isinstance(normalized[0], dict):
122
+ sort_key = "id" if "id" in normalized[0] else next(iter(normalized[0]), "")
123
+ try:
124
+ normalized.sort(key=lambda x: str(x.get(sort_key, "")))
125
+ except Exception:
126
+ pass
127
+ return normalized
128
+ return obj
129
+
130
+
131
+ def compare_dicts(a: dict[str, Any], b: dict[str, Any], label_a: str, label_b: str) -> list[dict[str, Any]]:
132
+ """Compare two normalized data dicts, return deduplicated issues."""
133
+ counts: dict[tuple[str, str, str], dict] = {}
134
+
135
+ for entity in sorted(set(a.keys()) | set(b.keys())):
136
+ a_list, b_list = a.get(entity, []), b.get(entity, [])
137
+
138
+ if not isinstance(a_list, list) or not isinstance(b_list, list):
139
+ if type(a_list) != type(b_list):
140
+ counts[(entity, "", "type_coercion")] = {"from_type": type(a_list).__name__, "to_type": type(b_list).__name__, "count": 1}
141
+ continue
142
+
143
+ id_key = _find_id_key(a_list, b_list)
144
+
145
+ def _key(item, i):
146
+ base = str(item.get(id_key, i)) if id_key else str(i)
147
+ ent = item.get("entity_id") if isinstance(item, dict) else None
148
+ return f"{ent}|{base}" if ent is not None else base # entity-scope so same-numbered accounts don't cross-compare
149
+
150
+ a_by_id = {_key(item, i): item for i, item in enumerate(a_list)}
151
+ b_by_id = {_key(item, i): item for i, item in enumerate(b_list)}
152
+
153
+ if len(a_list) != len(b_list):
154
+ counts[(entity, "", "count_mismatch")] = {"a_count": len(a_list), "b_count": len(b_list), "count": 1}
155
+
156
+ for eid in set(a_by_id.keys()) | set(b_by_id.keys()):
157
+ a_item, b_item = a_by_id.get(eid), b_by_id.get(eid)
158
+ if a_item is None or b_item is None or not isinstance(a_item, dict) or not isinstance(b_item, dict):
159
+ continue
160
+
161
+ for field in set(a_item.keys()) | set(b_item.keys()):
162
+ if field in a_item and field not in b_item:
163
+ key = (entity, field, "dropped_field")
164
+ counts.setdefault(key, {"count": 0, "non_empty_count": 0, "value_keys": set()})
165
+ counts[key]["count"] += 1
166
+ val = a_item[field]
167
+ if val not in (None, "", {}, []):
168
+ counts[key]["non_empty_count"] += 1
169
+ if isinstance(val, dict):
170
+ counts[key]["value_keys"].update(val.keys()) # for reshape detection
171
+ elif field not in a_item and field in b_item:
172
+ key = (entity, field, "added_field")
173
+ counts.setdefault(key, {"count": 0})
174
+ counts[key]["count"] += 1
175
+ elif field in a_item and field in b_item:
176
+ ov, ev = a_item[field], b_item[field]
177
+ if _vals_equal(ov, ev):
178
+ continue # semantically equal (timezone/number/empty/date-vs-datetime representation)
179
+ if type(ov).__name__ != type(ev).__name__ and not (ov is None or ev is None):
180
+ key = (entity, field, "type_coercion")
181
+ if key not in counts:
182
+ counts[key] = {"from_type": type(ov).__name__, "to_type": type(ev).__name__, "count": 0}
183
+ counts[key]["count"] += 1
184
+ elif json.dumps(ov, sort_keys=True, default=str) != json.dumps(ev, sort_keys=True, default=str):
185
+ key = (entity, field, "value_mismatch")
186
+ if key not in counts:
187
+ counts[key] = {"sample_a": str(ov)[:100], "sample_b": str(ev)[:100], "count": 0}
188
+ counts[key]["count"] += 1
189
+
190
+ # reshape detection: a dropped dict-field whose keys reappear as this table's added columns
191
+ # is the server expanding a blob into columns (e.g. archived_reconciliations.snapshot) — benign.
192
+ added_by_entity: dict[str, set] = {}
193
+ for (entity, field, issue_type) in counts:
194
+ if issue_type == "added_field":
195
+ added_by_entity.setdefault(entity, set()).add(field)
196
+ for (entity, field, issue_type), info in counts.items():
197
+ if issue_type == "dropped_field":
198
+ vk = info.get("value_keys") or set()
199
+ added = added_by_entity.get(entity, set())
200
+ if vk and len(vk & added) >= 0.6 * len(vk):
201
+ info["benign_reshape"] = True
202
+
203
+ issues = []
204
+ for (entity, field, issue_type), info in sorted(counts.items()):
205
+ if issue_type == "dropped_field":
206
+ detail = f"Field present in {label_a} but missing in {label_b} ({info['count']} occurrences, {info['non_empty_count']} non-empty)"
207
+ elif issue_type == "added_field":
208
+ detail = f"Field present in {label_b} but missing in {label_a} ({info['count']} occurrences)"
209
+ elif issue_type == "type_coercion":
210
+ detail = f"{info['from_type']} \u2192 {info['to_type']} ({info['count']} occurrences)"
211
+ elif issue_type == "count_mismatch":
212
+ detail = f"{label_a} has {info['a_count']} entities, {label_b} has {info['b_count']}"
213
+ elif issue_type == "value_mismatch":
214
+ detail = f"Values differ ({info['count']} occurrences), sample: {info['sample_a']!r} vs {info['sample_b']!r}"
215
+ else:
216
+ detail = f"{info['count']} occurrences"
217
+
218
+ issue: dict[str, Any] = {"entity": entity, "field": field, "type": issue_type, "detail": detail}
219
+ if issue_type == "dropped_field":
220
+ issue["non_empty_count"] = info["non_empty_count"]
221
+ if info.get("benign_reshape"):
222
+ issue["benign_reshape"] = True
223
+ issues.append(issue)
224
+ return issues
225
+
226
+
227
+ def classify_issues(load_issues: list[dict[str, Any]], idempotency_issues: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
228
+ """Classify issues with phase and critical flags. Returns (annotated_issues, is_compatible)."""
229
+ _CRITICAL_TYPES = {"count_mismatch", "value_mismatch"}
230
+ _PHASE = {"added_field": "export"} # everything else is "load"; idempotency issues get "idempotency"
231
+
232
+ def _is_critical(issue: dict) -> bool:
233
+ if issue["type"] in _CRITICAL_TYPES:
234
+ return True
235
+ if issue["type"] == "dropped_field":
236
+ return issue.get("non_empty_count", 0) > 0 and not issue.get("benign_reshape")
237
+ return False
238
+
239
+ annotated = [{**i, "phase": _PHASE.get(i["type"], "load"), "critical": _is_critical(i)} for i in load_issues]
240
+ annotated += [{**i, "phase": "idempotency", "critical": True} for i in idempotency_issues]
241
+ is_compatible = not any(i["critical"] for i in annotated)
242
+ return annotated, is_compatible
243
+
244
+
245
+ def _find_id_key(a_list: list, b_list: list) -> str | None:
246
+ """Detect the best ID key for matching entities between two lists."""
247
+ sample = (a_list or b_list or [None])[0]
248
+ if not isinstance(sample, dict):
249
+ return None
250
+ for candidate in ("id", "Id", "ID", "email_id", "ts", "name"):
251
+ if candidate in sample:
252
+ return candidate
253
+ return None
@@ -0,0 +1,180 @@
1
+ """Prompt + rubric construction for the file-based universe agent-judge (v1).
2
+
3
+ The agent-judge complements the programmatic verifier (``compare_dicts``). It is handed the
4
+ original + exported universes as files on its filesystem (``/universes/original``,
5
+ ``/universes/export_1``, ``/universes/export_2``) and asked to flag only *major* (semantic)
6
+ differences — i.e. real data loss — while ignoring representation noise the programmatic side
7
+ already owns.
8
+
9
+ This module is pure (strings + a small criteria builder); it performs no I/O so it can be
10
+ unit-tested without external services.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # Where LoadArtifactTaskStep stages the FileArtifactUniverse onto the judge agent.
16
+ UNIVERSES_DIR = "/universes"
17
+
18
+ # Prefix for the per-service rubric criteria; shared so judge_issues_from_verdict can recover the
19
+ # service name from a flagged criterion id.
20
+ SERVICE_CRITERION_PREFIX = "service_preserved__"
21
+ # Synthetic service bucket for cross-cutting (non-service-scoped) judge findings, so they render
22
+ # through the same per-service UNIVERSE_COMPATIBILITY path PV uses.
23
+ CROSS_SERVICE_KEY = "(cross-service)"
24
+ # Issue type/phase stamped on judge-derived issues (mirrors PV's issue shape).
25
+ JUDGE_ISSUE_TYPE = "agent_judge"
26
+ JUDGE_PHASE = "agent_judge"
27
+
28
+ # The definition of "semantically equivalent" — the IGNORE preamble (representation noise the
29
+ # programmatic verifier owns) and the FLAG list (real semantic/relational drift = data loss).
30
+ JUDGE_SYSTEM_PROMPT = f"""\
31
+ You are a meticulous data-fidelity judge. You are given two (sometimes three) snapshots of the \
32
+ same multi-service universe as JSON files on disk:
33
+
34
+ - `{UNIVERSES_DIR}/original/<service>.json` — the source-of-truth universe before any round-trip.
35
+ - `{UNIVERSES_DIR}/export_1/<service>.json` — the data after one load -> export-state cycle.
36
+ - `{UNIVERSES_DIR}/export_2/<service>.json` — the data after re-loading export_1 and exporting \
37
+ again (present only when available).
38
+
39
+ Your job is to decide whether the round-trip is **semantically lossless**: does the exported data \
40
+ preserve the *meaning* of the original? Compare `original` against `export_1` (and use `export_2` \
41
+ to corroborate stability). Read the files with your tools; do not guess.
42
+
43
+ VERIFY QUANTITATIVE CLAIMS PROGRAMMATICALLY — you have a code-execution environment, so use it. \
44
+ For anything involving math or counting — deciding whether two timestamps denote the same instant \
45
+ (across timezone offsets or epoch-seconds vs ISO), whether two numbers are equivalent, record \
46
+ counts, or whether a field stays present/populated across many records — WRITE AND RUN A SCRIPT \
47
+ that loads the JSON and checks it. Do NOT convert timestamps, compare numbers, or count records by \
48
+ hand: manual math and eyeballing large/sparse arrays are error-prone and are the most common source \
49
+ of both missed losses and false flags.
50
+
51
+ IGNORE the following — these are benign representation differences, NOT data loss:
52
+ - Timezone representation that resolves to the same instant (e.g. `-04:00` vs `Z`).
53
+ - Numeric formatting: `"12.50"` vs `12.5`, trailing zeros, int-vs-float, epoch-seconds vs ISO \
54
+ timestamp that denote the same moment.
55
+ - Server-added audit/enrichment fields present in the export but absent in the original (e.g. \
56
+ `created_at`, derived ids) — unless they overwrite or replace real data.
57
+ - Empty-equivalent values: `null` / `""` / `{{}}` / `[]`.
58
+ - Record or list ordering when the set of records is unchanged.
59
+ - Generation/scaffolding fields that are clearly build-time provenance (e.g. `noise_theme`) being \
60
+ dropped.
61
+
62
+ FLAG the following as a MAJOR difference (real data loss / corruption):
63
+ - A meaningful record present in the original is missing from the export (dropped record).
64
+ - A content-bearing value (amount, name, description, date meaning) changed or was lost.
65
+ - A populated business field in the original is null/absent in the export.
66
+ - Numeric precision loss beyond representation rounding.
67
+ - Free-text (description/memo/body) truncated, paraphrased, or semantically altered.
68
+ - Broken referential integrity: a reference that resolved in the original now dangles.
69
+ - Structural reshaping that drops information.
70
+ - Server-added values that are nonsensical placeholders replacing real data.
71
+
72
+ When in doubt about whether a dropped/changed field is *meaningful*, lean toward flagging it and \
73
+ explain your reasoning. Be specific: cite the service, entity, field, and an example value.\
74
+ """
75
+
76
+
77
+ def build_user_prompt() -> str:
78
+ """The task message handed to the judge agent."""
79
+ return (
80
+ f"Inspect the universe snapshots under `{UNIVERSES_DIR}/`. For each service, compare "
81
+ f"`original/<service>.json` against `export_1/<service>.json` (corroborate with "
82
+ f"`export_2/<service>.json` when present) and identify any MAJOR differences — real data "
83
+ f"loss or corruption — applying the IGNORE/FLAG rules from your instructions. "
84
+ f"Produce a concise per-service report: for each service state whether it is semantically "
85
+ f"preserved, and list every major difference you found with the service, entity, field, "
86
+ f"and an example (original value vs exported value). If a service is fully preserved "
87
+ f"(only benign representation differences), say so explicitly."
88
+ )
89
+
90
+
91
+ # Cross-cutting FLAG criteria (1.0 = the drift class is ABSENT / data preserved).
92
+ _CROSS_CUTTING_CRITERIA: list[dict] = [
93
+ {"id": "record_preservation", "title": "Every meaningful record in the original (by identity) is present in the export; no real records were dropped (server-added records are fine)."},
94
+ {"id": "value_fidelity", "title": "Content-bearing values (amounts, names, descriptions, dates) are preserved in MEANING across the round-trip; ignore timezone/number/format representation."},
95
+ {"id": "referential_integrity", "title": "References between records/services still resolve after the round-trip; no orphaned or dangling references were introduced."},
96
+ {"id": "numeric_precision", "title": "Numeric values are preserved to full precision; no sub-decimal loss beyond representation rounding."},
97
+ {"id": "freetext_fidelity", "title": "Free-text fields (description/memo/body) are not truncated, paraphrased, or semantically altered."},
98
+ {"id": "enrichment_sanity", "title": "Server-added fields/values are plausible and consistent, not nonsensical placeholders replacing real data."},
99
+ {"id": "structure_preserved", "title": "Any structural reshaping (e.g. blob expanded into columns) preserves all information without loss."},
100
+ ]
101
+
102
+
103
+ def build_criteria(environment_names: list[str]) -> list[dict]:
104
+ """Build the rubric: one per-service preservation criterion plus the cross-cutting FLAG set.
105
+
106
+ Each criterion is a dict with ``id``/``title``/``rubric_category``/``rubric_target`` as
107
+ expected by ``RubricsVerifierTaskStep``. A score of 1.0 means the data was preserved (good).
108
+ """
109
+ criteria: list[dict] = []
110
+ for name in environment_names:
111
+ criteria.append({
112
+ "id": f"{SERVICE_CRITERION_PREFIX}{name}",
113
+ "title": (
114
+ f"All data for the '{name}' service is semantically preserved between the original "
115
+ f"and exported universes — no real records or content-bearing values were lost "
116
+ f"(ignore representation noise and benign server enrichment)."
117
+ ),
118
+ "rubric_category": "Data Fidelity",
119
+ "rubric_target": "Outcome",
120
+ })
121
+ for crit in _CROSS_CUTTING_CRITERIA:
122
+ criteria.append({**crit, "rubric_category": "Data Fidelity", "rubric_target": "Outcome"})
123
+ return criteria
124
+
125
+
126
+ def judge_issues_from_verdict(verdict: dict) -> dict[str, list[dict]]:
127
+ """Map a rubric verdict's FLAGGED criteria into PV-shaped issues, grouped by service.
128
+
129
+ Returns ``{environment_key: [issue, ...]}`` where each issue mirrors the programmatic verifier's
130
+ issue shape (``entity``/``field``/``type``/``phase``/``critical``/``detail``) so it renders
131
+ through the same UNIVERSE_COMPATIBILITY surface. Per-service criteria attach to their service;
132
+ cross-cutting criteria attach under :data:`CROSS_SERVICE_KEY`. A criterion is "flagged" when its
133
+ boolean ``result`` is False (or, absent that, its score is below 1.0). Flagged = the judge found
134
+ a major (semantic) difference, so the issue is ``critical`` — gating the same way a PV critical
135
+ does.
136
+ """
137
+ grouped: dict[str, list[dict]] = {}
138
+ for r in verdict.get("results", []):
139
+ result = r.get("result")
140
+ flagged = result is False or (result is None and r.get("score", 1.0) < 1.0)
141
+ if not flagged:
142
+ continue
143
+ cid = r.get("id", "")
144
+ detail = r.get("justification") or "agent-judge flagged a major difference"
145
+ if cid.startswith(SERVICE_CRITERION_PREFIX):
146
+ service = cid[len(SERVICE_CRITERION_PREFIX):]
147
+ field = "(semantic)"
148
+ else:
149
+ service = CROSS_SERVICE_KEY
150
+ field = cid
151
+ grouped.setdefault(service, []).append({
152
+ "entity": service,
153
+ "field": field,
154
+ "type": JUDGE_ISSUE_TYPE,
155
+ "phase": JUDGE_PHASE,
156
+ "critical": True,
157
+ "detail": detail,
158
+ })
159
+ return grouped
160
+
161
+
162
+ def apply_judge_verdict(compat: dict, verdict: dict) -> dict:
163
+ """Merge a rubric verdict into a programmatic UNIVERSE_COMPATIBILITY result, in place.
164
+
165
+ Flagged criteria become per-service critical issues (cross-cutting ones under
166
+ :data:`CROSS_SERVICE_KEY`); any flag flips the affected service's and the overall ``compatible``
167
+ to False — so the judge gates exactly the way a programmatic critical does. Existing programmatic
168
+ issues are preserved (judge issues are appended). The raw verdict is stored under ``agent_judge``
169
+ for audit. Returns the same (mutated) ``compat`` dict.
170
+ """
171
+ grouped = judge_issues_from_verdict(verdict)
172
+ services = compat.setdefault("services", {})
173
+ for environment_key, issues in grouped.items():
174
+ entry = services.setdefault(environment_key, {"compatible": True, "issues": []})
175
+ entry.setdefault("issues", []).extend(issues)
176
+ entry["compatible"] = False
177
+ if grouped:
178
+ compat["compatible"] = False
179
+ compat["agent_judge"] = verdict
180
+ return compat