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,142 @@
1
+ """Tear down the sandboxes a finished run deployed.
2
+
3
+ ``teardown_run`` terminates every sandbox a run's context records: its bare sandboxes', its envs' and its
4
+ agents'. It removes compute only; the instance, its outputs and the artifacts it collected stay. A local
5
+ sandbox's work folder goes too, once the sandbox is down. ``agent-env run`` and ``agent-env eval run`` call
6
+ it after each run. ``Task.run`` never does, so a caller that keeps a run's environment up, as the hub and
7
+ the worker do, owns its teardown.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from typing import NamedTuple, Optional
16
+
17
+ from agent_env.env.env import DeployedEnv
18
+ from agent_env.providers.sandbox_providers.local_sandbox import LocalSandbox, remove_local_work_dir
19
+ from agent_env.providers.sandbox_providers.sandbox_provider import build_sandbox_provider
20
+ from agent_env.task.interrupts import tearing_down
21
+ from agent_env.task_step.context import TaskStepContext
22
+ from agent_env.task_step.task_steps.teardown_sandboxes import _DEFAULT_PROVIDERS, TORN_DOWN_KEY, _env_sandbox_ids
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ TERMINATE_TIMEOUT_SECONDS = 120
27
+
28
+
29
+ class RecordedSandbox(NamedTuple):
30
+ sandbox_id: str
31
+ sandbox_type: Optional[str] # the backend that made it; None leaves it to the slot's configured provider
32
+ slot: str # "sandbox", "env" or "agent"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TeardownReport:
37
+ terminated: tuple[RecordedSandbox, ...] = ()
38
+ failed: tuple[tuple[RecordedSandbox, str], ...] = () # each with why
39
+ left: tuple[RecordedSandbox, ...] = () # not reached, because the teardown was stopped
40
+
41
+ @property
42
+ def still_up(self) -> tuple[RecordedSandbox, ...]:
43
+ """What may still be running: what failed to terminate and what the teardown didn't reach."""
44
+ return (*(sandbox for sandbox, _ in self.failed), *self.left)
45
+
46
+ @classmethod
47
+ def skipped(cls, context: TaskStepContext) -> TeardownReport:
48
+ """A teardown that didn't start: everything ``context`` records that's still up is left."""
49
+ return cls(left=_pending(context))
50
+
51
+
52
+ def recorded_sandboxes(context: TaskStepContext) -> tuple[RecordedSandbox, ...]:
53
+ """Every sandbox ``context`` records, once each. An agent placed on another record's sandbox shares its
54
+ id, so the sandbox is attributed to whichever made it."""
55
+ found: dict[str, RecordedSandbox] = {}
56
+ for sandbox in context.deployed_sandboxes:
57
+ found.setdefault(sandbox.sandbox_id, RecordedSandbox(sandbox.sandbox_id, sandbox.sandbox_type, "sandbox"))
58
+ for env in context.deployed_envs:
59
+ for sandbox_id, sandbox_type in _env_sandbox_ids(env):
60
+ found.setdefault(sandbox_id, RecordedSandbox(sandbox_id, sandbox_type, "env"))
61
+ for agent in context.deployed_agents:
62
+ if agent.sandbox_id:
63
+ found.setdefault(agent.sandbox_id, RecordedSandbox(agent.sandbox_id, agent.sandbox_type, "agent"))
64
+ return tuple(found.values())
65
+
66
+
67
+ def sandbox_ids(record) -> list[str]:
68
+ """The ids of the sandboxes behind one deployed env, agent or bare sandbox record."""
69
+ if isinstance(record, DeployedEnv):
70
+ return [sandbox_id for sandbox_id, _ in _env_sandbox_ids(record)]
71
+ return [record.sandbox_id] if record.sandbox_id else []
72
+
73
+
74
+ async def teardown_run(context: TaskStepContext, *, timeout: float = TERMINATE_TIMEOUT_SECONDS) -> TeardownReport:
75
+ """Terminate every sandbox ``context`` records, all at once, each within ``timeout`` seconds, and remove a
76
+ local one's work folder once it's down.
77
+
78
+ Best-effort: a failure is logged and reported, never raised. A sandbox already torn down, by a
79
+ ``teardown_sandboxes`` step or an earlier call, isn't terminated again, though a local one's folder
80
+ still goes. Under ``Interrupts.gather`` the first signal spares it. Cancelling it stops it early: it
81
+ returns what it reached, with the rest in ``left``, rather than raising."""
82
+ tearing_down()
83
+ pending = _pending(context)
84
+ terminated, failed = [], []
85
+
86
+ async def take_down(sandbox: RecordedSandbox) -> None:
87
+ if sandbox not in pending: # torn down already: only a local one's folder can be left
88
+ if why := await _remove_folder(sandbox):
89
+ failed.append((sandbox, why))
90
+ return
91
+ try:
92
+ async with asyncio.timeout(timeout):
93
+ handle = await _terminate(sandbox)
94
+ except Exception as e:
95
+ reason = f"timed out after {timeout:g}s" if isinstance(e, TimeoutError) else _described(e)
96
+ logger.warning("teardown: %s (%s) was not terminated: %s", sandbox.sandbox_id, kind(sandbox), reason)
97
+ failed.append((sandbox, reason))
98
+ return
99
+ context.metadata[TORN_DOWN_KEY] = [*context.metadata.get(TORN_DOWN_KEY, []), sandbox.sandbox_id]
100
+ if isinstance(handle, LocalSandbox) and (why := await _remove_folder(sandbox)):
101
+ failed.append((sandbox, why))
102
+ else:
103
+ terminated.append(sandbox)
104
+
105
+ try:
106
+ await asyncio.gather(*(take_down(sandbox) for sandbox in recorded_sandboxes(context)))
107
+ except asyncio.CancelledError:
108
+ pass
109
+ reached = {sandbox.sandbox_id for sandbox in terminated} | {sandbox.sandbox_id for sandbox, _ in failed}
110
+ return TeardownReport(tuple(terminated), tuple(failed), tuple(s for s in pending if s.sandbox_id not in reached))
111
+
112
+
113
+ def _pending(context: TaskStepContext) -> tuple[RecordedSandbox, ...]:
114
+ torn_down = set(context.metadata.get(TORN_DOWN_KEY) or [])
115
+ return tuple(sandbox for sandbox in recorded_sandboxes(context) if sandbox.sandbox_id not in torn_down)
116
+
117
+
118
+ async def _remove_folder(sandbox: RecordedSandbox) -> str | None:
119
+ """Remove a local sandbox's work folder, and say why when it can't be."""
120
+ try:
121
+ await asyncio.to_thread(remove_local_work_dir, sandbox.sandbox_id)
122
+ except OSError as e:
123
+ logger.warning("teardown: %s's work folder was not removed: %s", sandbox.sandbox_id, e)
124
+ return f"terminated, but its work folder wasn't removed: {e}"
125
+ return None
126
+
127
+
128
+ async def _terminate(sandbox: RecordedSandbox):
129
+ provider = (build_sandbox_provider(sandbox.sandbox_type) if sandbox.sandbox_type
130
+ else _DEFAULT_PROVIDERS[sandbox.slot]())
131
+ handle = await provider.get_sandbox(sandbox.sandbox_id)
132
+ await handle.terminate()
133
+ return handle
134
+
135
+
136
+ def _described(error: Exception) -> str:
137
+ return f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
138
+
139
+
140
+ def kind(sandbox: RecordedSandbox) -> str:
141
+ """The backend a sandbox is on, as the CLI prints it."""
142
+ return sandbox.sandbox_type or f"the configured {sandbox.slot} provider"
@@ -0,0 +1,47 @@
1
+ """TaskStep module for AgentEnv."""
2
+
3
+ from agent_env.env.env import DeployedEnv
4
+ from .context import DeployedAgent, PromptResponse, TaskStepContext
5
+ from .task_step import TaskStep
6
+ from .task_steps import AddSkillsTaskStep, AgentPromptResponseVerifierTaskStep, BuildMcpCliTaskStep, CombineUniverseVerdictsStep, DeployAgentTaskStep, DeployEnvTaskStep, EnvOutcomeVerifierTaskStep, LoadArtifactTaskStep, ModifyEnvToolAccessStep, PromptAgentTaskStep, RubricsVerifierTaskStep, ScoreAggregator, ValidationGateAggregatorStep, VerifyCoreA2AProtocolStep, VerifyCoreEnvironmentProtocolStep, VerifyEnvironmentCardStep, VerifyMCPEnvAssessmentStep, VerifyMCPToolSchemaTaskStep, VerifySpecConformanceTaskStep, VerifyUniverseLoadExportRoundtripStep, VerifyA2AAgentCardStep, VerifyA2AAgentMCPStep, VerifyA2ASkillConfigStep, VerifyA2ATrajectoryStep
7
+ from .task_steps.verifiers.judge_utils.judge_output_format import JudgeOutputFormat, get_judge_output_format_spec
8
+ from .store import TaskStepStore, TaskStepQuery, get_task_step_store, set_task_step_store, reset_task_step_store
9
+
10
+ __all__ = [
11
+ "DeployedAgent",
12
+ "DeployedEnv",
13
+ "JudgeOutputFormat",
14
+ "get_judge_output_format_spec",
15
+ "PromptResponse",
16
+ "ScoreAggregator",
17
+ "TaskStepContext",
18
+ "TaskStep",
19
+ "AddSkillsTaskStep",
20
+ "AgentPromptResponseVerifierTaskStep",
21
+ "BuildMcpCliTaskStep",
22
+ "DeployAgentTaskStep",
23
+ "DeployEnvTaskStep",
24
+ "EnvOutcomeVerifierTaskStep",
25
+ "LoadArtifactTaskStep",
26
+ "ModifyEnvToolAccessStep",
27
+ "PromptAgentTaskStep",
28
+ "RubricsVerifierTaskStep",
29
+ "VerifyMCPEnvAssessmentStep",
30
+ "VerifyMCPToolSchemaTaskStep",
31
+ "VerifySpecConformanceTaskStep",
32
+ "ValidationGateAggregatorStep",
33
+ "VerifyUniverseLoadExportRoundtripStep",
34
+ "CombineUniverseVerdictsStep",
35
+ "VerifyEnvironmentCardStep",
36
+ "VerifyCoreEnvironmentProtocolStep",
37
+ "VerifyA2AAgentCardStep",
38
+ "VerifyA2AAgentMCPStep",
39
+ "VerifyCoreA2AProtocolStep",
40
+ "VerifyA2ASkillConfigStep",
41
+ "VerifyA2ATrajectoryStep",
42
+ "TaskStepStore",
43
+ "TaskStepQuery",
44
+ "get_task_step_store",
45
+ "set_task_step_store",
46
+ "reset_task_step_store",
47
+ ]
@@ -0,0 +1,195 @@
1
+ """Context dataclass for task step execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from agent_env.env.env import DeployedEnv
10
+
11
+ _REDACTED_KEYS = {
12
+ "litellm_api_key", "judge_litellm_api_key", "usersim_api_key", "remote_tokens", "cf_access_client_secret",
13
+ }
14
+
15
+
16
+ def _holds_redacted_key(value: Any) -> bool:
17
+ """Whether ``value`` carries a ``_REDACTED_KEYS`` entry at any depth."""
18
+ return isinstance(value, dict) and any(
19
+ k in _REDACTED_KEYS or _holds_redacted_key(v) for k, v in value.items()
20
+ )
21
+
22
+
23
+ def regraft_redacted_keys(source: dict, target: dict) -> None:
24
+ """Copy every ``_REDACTED_KEYS`` value in ``source`` into ``target`` in place; the inverse
25
+ of ``_strip_redacted_keys`` for a context rebuilt from a stored document."""
26
+ for k, v in source.items():
27
+ if k in _REDACTED_KEYS:
28
+ target[k] = v
29
+ elif isinstance(v, dict):
30
+ child = target.get(k)
31
+ if not isinstance(child, dict):
32
+ # A secrets-only parent leaves nothing to rebuild from, so recreate it —
33
+ # but never over a value the rebuild did write.
34
+ if target.get(k) is not None or not _holds_redacted_key(v):
35
+ continue
36
+ child = target[k] = {}
37
+ regraft_redacted_keys(v, child)
38
+
39
+
40
+ def _strip_redacted_keys(value: Any) -> Any:
41
+ """Recursively strip `_REDACTED_KEYS` from any nested dicts in `value`.
42
+
43
+ Single source of truth used by both `to_safe_dict` (full-blob writes via
44
+ `record_task_failure`) and the path-level diff in `context_ops` so secrets
45
+ never reach Mongo regardless of which code path persists them.
46
+ """
47
+ if isinstance(value, dict):
48
+ return {k: _strip_redacted_keys(v) for k, v in value.items() if k not in _REDACTED_KEYS}
49
+ if isinstance(value, list):
50
+ return [_strip_redacted_keys(v) for v in value]
51
+ return value
52
+
53
+
54
+ @dataclass
55
+ class DeployedAgent:
56
+ agent_name: str
57
+ api_url: str
58
+ sandbox_id: str | None = None
59
+ sandbox_type: str | None = None
60
+ a2a_url: str | None = None
61
+ a2a_card: dict | None = None
62
+ instance_id: str | None = None
63
+ role: str | None = None
64
+ network_policy: dict | None = None
65
+ # Installed straight onto the sandbox host (install_agent host mode), not into a container.
66
+ on_host: bool = False
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: dict) -> DeployedAgent:
70
+ return cls(
71
+ agent_name=data["agent_name"],
72
+ api_url=data["api_url"],
73
+ sandbox_id=data.get("sandbox_id"),
74
+ sandbox_type=data.get("sandbox_type"),
75
+ a2a_url=data.get("a2a_url"),
76
+ a2a_card=data.get("a2a_card"),
77
+ instance_id=data.get("instance_id"),
78
+ role=data.get("role"),
79
+ network_policy=data.get("network_policy"),
80
+ on_host=bool(data.get("on_host", False)),
81
+ )
82
+
83
+
84
+ @dataclass
85
+ class DeployedSandbox:
86
+ sandbox_name: str
87
+ sandbox_id: str
88
+ sandbox_mode: str
89
+ sandbox_type: str | None = None
90
+ tunnel_urls: dict[str, str] | None = None
91
+ vnc_url: str | None = None
92
+ instance_id: str | None = None
93
+ created_at_utc: str | None = None
94
+ expires_at_utc: str | None = None
95
+ network_policy: dict | None = None
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: dict) -> DeployedSandbox:
99
+ return cls(
100
+ sandbox_name=data["sandbox_name"],
101
+ sandbox_id=data["sandbox_id"],
102
+ sandbox_mode=data["sandbox_mode"],
103
+ sandbox_type=data.get("sandbox_type"),
104
+ tunnel_urls=data.get("tunnel_urls"),
105
+ vnc_url=data.get("vnc_url"),
106
+ instance_id=data.get("instance_id"),
107
+ created_at_utc=data.get("created_at_utc"),
108
+ expires_at_utc=data.get("expires_at_utc"),
109
+ network_policy=data.get("network_policy"),
110
+ )
111
+
112
+
113
+ @dataclass
114
+ class PromptResponse:
115
+ prompt_id: str
116
+ response: str
117
+ prompt_text: str | None = None
118
+ agent_trajectory_s3_uri: str | None = None
119
+ agent_trajectory_s3_prefix: str | None = None
120
+ agent_trajectory_file_path: str | None = None
121
+ target_agent_per_turn_trajectory_s3_uris: list[str | None] | None = None
122
+ # A None entry means "identical to prompt_text" — the first turn of a
123
+ # prompt-mode step is not stored twice.
124
+ source_agent_per_turn_prompt_parts: list[list[dict] | None] | None = None
125
+ compact_trajectory_s3_uri: str | None = None
126
+ tool_call_count: int | None = None
127
+ model: str | None = None
128
+ error_type: str | None = None
129
+ error_code: str | None = None
130
+ error_message: str | None = None
131
+ agent_session_id: str = ""
132
+ a2a_context_id: str | None = None
133
+ agent_name: str | None = None
134
+ step_id: str | None = None
135
+ structured_output: Any = None
136
+
137
+ @classmethod
138
+ def from_dict(cls, data: dict) -> PromptResponse:
139
+ return cls(
140
+ prompt_id=data["prompt_id"],
141
+ response=data["response"],
142
+ prompt_text=data.get("prompt_text"),
143
+ agent_trajectory_s3_uri=data.get("agent_trajectory_s3_uri"),
144
+ agent_trajectory_s3_prefix=data.get("agent_trajectory_s3_prefix"),
145
+ agent_trajectory_file_path=data.get("agent_trajectory_file_path"),
146
+ target_agent_per_turn_trajectory_s3_uris=data.get("target_agent_per_turn_trajectory_s3_uris"),
147
+ source_agent_per_turn_prompt_parts=data.get("source_agent_per_turn_prompt_parts"),
148
+ compact_trajectory_s3_uri=data.get("compact_trajectory_s3_uri"),
149
+ tool_call_count=data.get("tool_call_count"),
150
+ model=data.get("model"),
151
+ error_type=data.get("error_type"),
152
+ error_code=data.get("error_code"),
153
+ error_message=data.get("error_message"),
154
+ agent_session_id=data.get("agent_session_id", ""),
155
+ a2a_context_id=data.get("a2a_context_id"),
156
+ agent_name=data.get("agent_name"),
157
+ step_id=data.get("step_id"),
158
+ structured_output=data.get("structured_output"),
159
+ )
160
+
161
+
162
+ @dataclass
163
+ class TaskStepContext:
164
+ deployed_envs: list[DeployedEnv] = field(default_factory=list)
165
+ deployed_agents: list[DeployedAgent] = field(default_factory=list)
166
+ deployed_sandboxes: list[DeployedSandbox] = field(default_factory=list)
167
+ prompt_responses: list[PromptResponse] = field(default_factory=list)
168
+ metadata: dict[str, Any] = field(default_factory=dict)
169
+ agent_model: str | None = None
170
+ default_agent_model: str | None = None
171
+ agent_artifact_id: str | None = None
172
+ agent_harness: str | None = None
173
+ instance_id: str | None = None
174
+
175
+ def to_safe_dict(self) -> dict[str, Any]:
176
+ """Return a dict representation with sensitive keys recursively removed."""
177
+ d = dataclasses.asdict(self)
178
+ if "metadata" in d:
179
+ d["metadata"] = _strip_redacted_keys(d["metadata"])
180
+ return d
181
+
182
+ @classmethod
183
+ def from_dict(cls, data: dict[str, Any]) -> TaskStepContext:
184
+ return cls(
185
+ deployed_envs=[DeployedEnv.from_dict(e) for e in data.get("deployed_envs", [])],
186
+ deployed_agents=[DeployedAgent.from_dict(a) for a in data.get("deployed_agents", [])],
187
+ deployed_sandboxes=[DeployedSandbox.from_dict(s) for s in data.get("deployed_sandboxes", [])],
188
+ prompt_responses=[PromptResponse.from_dict(p) for p in data.get("prompt_responses", [])],
189
+ metadata=data.get("metadata", {}),
190
+ agent_model=data.get("agent_model"),
191
+ default_agent_model=data.get("default_agent_model"),
192
+ agent_artifact_id=data.get("agent_artifact_id"),
193
+ agent_harness=data.get("agent_harness"),
194
+ instance_id=data.get("instance_id"),
195
+ )
@@ -0,0 +1,224 @@
1
+ """Build MongoDB update specs from TaskStepContext diffs.
2
+
3
+ Replaces the read-merge-write CAS pattern with path-level Mongo ops. A single
4
+ document's disjoint-path writes are atomic in Mongo, so concurrent workers no
5
+ longer need application-layer merging or a retry loop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import types
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Union, get_args, get_origin, get_type_hints
14
+
15
+ from agent_env.task_step.context import TaskStepContext, _REDACTED_KEYS, _strip_redacted_keys
16
+
17
+
18
+ @dataclass
19
+ class ContextUpdateOps:
20
+ """A backend-agnostic update spec describing a single step's changes to TaskInstance.context.
21
+
22
+ Dot-paths are rooted at the TaskInstance document (they include the
23
+ ``context.`` prefix). Compile to a backend-agnostic ``UpdateSpec`` via `to_update_spec`.
24
+
25
+ Lists are set-semantic: when a step's "post" snapshot reads the shared in-memory
26
+ context it sees concurrent siblings' appends too, so the diff's tail may include
27
+ their items and the write must dedupe (``$addToSet`` via ``to_update_spec``, or
28
+ the Python ``_union`` the completion CAS in ``task/store.py`` applies). **Constraint:**
29
+ items in any list must be uniquely identifiable by value, or duplicates are silently
30
+ dropped on write; ``deployed_envs`` are told apart by ``instance_id`` (``step_journal._item_key``).
31
+ """
32
+
33
+ sets: dict[str, Any] = field(default_factory=dict)
34
+ unsets: set[str] = field(default_factory=set)
35
+ add_to_sets: dict[str, list[Any]] = field(default_factory=dict)
36
+
37
+ def is_empty(self) -> bool:
38
+ return not (self.sets or self.unsets or self.add_to_sets)
39
+
40
+ def to_update_spec(self) -> "UpdateSpec":
41
+ """Convert to a backend-agnostic UpdateSpec (1:1 field mapping)."""
42
+ from agent_env.store.document_store import UpdateSpec
43
+
44
+ return UpdateSpec(
45
+ set=dict(self.sets),
46
+ unset=set(self.unsets),
47
+ add_to_set={p: list(v) for p, v in self.add_to_sets.items()},
48
+ )
49
+
50
+ def to_journal_dict(self) -> dict:
51
+ """Mongo-safe encoding: dotted paths as values, never as keys."""
52
+ return {
53
+ "sets": [{"path": p, "value": v} for p, v in self.sets.items()],
54
+ "unsets": sorted(self.unsets),
55
+ "add_to_sets": [{"path": p, "items": list(v)} for p, v in self.add_to_sets.items()],
56
+ }
57
+
58
+ @classmethod
59
+ def from_journal_dict(cls, d: dict) -> "ContextUpdateOps":
60
+ return cls(
61
+ sets={e["path"]: e["value"] for e in d.get("sets", [])},
62
+ unsets=set(d.get("unsets", [])),
63
+ add_to_sets={e["path"]: list(e["items"]) for e in d.get("add_to_sets", [])},
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class _FieldClassification:
69
+ scalars: tuple[str, ...]
70
+ lists: tuple[str, ...]
71
+ dicts: tuple[str, ...]
72
+
73
+
74
+ def _classify_context_fields() -> _FieldClassification:
75
+ """Partition TaskStepContext fields into scalar/list/dict via type introspection.
76
+
77
+ Lists are assumed to be set-semantic (emitted via `$addToSet`). Dicts are
78
+ walked recursively for leaf-level path diffs. If a future field needs
79
+ different semantics, classification must be made explicit here.
80
+ """
81
+ scalars: list[str] = []
82
+ lists: list[str] = []
83
+ dicts: list[str] = []
84
+ hints = get_type_hints(TaskStepContext)
85
+ for f in dataclasses.fields(TaskStepContext):
86
+ kind = _origin_kind(hints[f.name])
87
+ if kind is list:
88
+ lists.append(f.name)
89
+ elif kind is dict:
90
+ dicts.append(f.name)
91
+ else:
92
+ scalars.append(f.name)
93
+ return _FieldClassification(tuple(scalars), tuple(lists), tuple(dicts))
94
+
95
+
96
+ def _origin_kind(tp: Any) -> Any:
97
+ """Resolve ``tp`` to list/dict/other, stripping Optional/Union where needed."""
98
+ origin = get_origin(tp)
99
+ if origin is Union or origin is types.UnionType:
100
+ non_none = [a for a in get_args(tp) if a is not type(None)]
101
+ if len(non_none) == 1:
102
+ return _origin_kind(non_none[0])
103
+ return None
104
+ return origin
105
+
106
+
107
+ _FIELDS = _classify_context_fields()
108
+
109
+
110
+ def build_context_update_ops(
111
+ pre: TaskStepContext | None,
112
+ post: TaskStepContext,
113
+ ) -> ContextUpdateOps:
114
+ """Diff two context snapshots into a path-level Mongo update spec.
115
+
116
+ - Scalar fields: `$set` on change, `$unset` on value→None.
117
+ - List fields (set-semantic): new items (by equality) go to `$addToSet`.
118
+ - Dict fields (e.g. `metadata`): walked recursively so only leaf-level
119
+ differences emit. Concurrent writers to sibling leaves of the same
120
+ parent don't overwrite each other. Lists inside dicts use `$addToSet`
121
+ for prefix-extending writes (so concurrent appenders don't double-add);
122
+ non-prefix mutations (reorder, pop, replacement) emit wholesale `$set`.
123
+ - Redaction (`_REDACTED_KEYS`) applied at every emission point.
124
+
125
+ Dict keys containing ``.`` or starting with ``$`` fall back to a
126
+ wholesale ``$set`` of the nearest enclosing subtree (see ``_diff_dict``).
127
+ """
128
+ ops = ContextUpdateOps()
129
+ pre_ctx = pre if pre is not None else TaskStepContext()
130
+
131
+ for name in _FIELDS.scalars:
132
+ pre_v = getattr(pre_ctx, name)
133
+ post_v = getattr(post, name)
134
+ if pre_v == post_v:
135
+ continue
136
+ path = f"context.{name}"
137
+ if post_v is None:
138
+ ops.unsets.add(path)
139
+ else:
140
+ ops.sets[path] = post_v
141
+
142
+ for name in _FIELDS.lists:
143
+ pre_items = getattr(pre_ctx, name)
144
+ post_items = getattr(post, name)
145
+ new_items = [x for x in post_items if x not in pre_items]
146
+ if new_items:
147
+ ops.add_to_sets[f"context.{name}"] = [
148
+ dataclasses.asdict(x) if dataclasses.is_dataclass(x) else x
149
+ for x in new_items
150
+ ]
151
+
152
+ for name in _FIELDS.dicts:
153
+ _diff_dict(f"context.{name}", getattr(pre_ctx, name), getattr(post, name), ops)
154
+
155
+ return ops
156
+
157
+
158
+ def _diff_dict(
159
+ path: str, pre: dict[str, Any], post: dict[str, Any], ops: ContextUpdateOps,
160
+ ) -> None:
161
+ """Recursively diff `pre` vs `post`, emitting path-level ops:
162
+
163
+ - Keys removed from pre → `$unset`.
164
+ - New dict-valued keys → recurse against `{}` so concurrent writers of
165
+ disjoint sub-keys emit distinct leaf paths (not the whole subtree).
166
+ - New list-valued keys → `$addToSet` (creates array if missing; dedupes
167
+ against what concurrent first-creators already wrote).
168
+ - New scalar keys → `$set`.
169
+ - Both dicts → recurse.
170
+ - Both lists, pre is prefix of post → `$addToSet` of the tail.
171
+ - Lists otherwise (reorder, pop, replacement) → wholesale `$set` (last write wins).
172
+ - Scalars differ → `$set`.
173
+
174
+ Redacted keys (`_REDACTED_KEYS`) are skipped at every level; redacted
175
+ descendants of emitted subtrees are stripped via `_strip_redacted_keys`.
176
+
177
+ If immediate keys on either side are unmappable to Mongo paths
178
+ (contain ``.`` or start with ``$``), emit a wholesale ``$set`` of the
179
+ whole dict at ``path`` instead of recursing.
180
+ """
181
+ if _has_unmappable_keys(pre) or _has_unmappable_keys(post):
182
+ ops.sets[path] = _strip_redacted_keys(post)
183
+ return
184
+
185
+ for k in pre.keys() - post.keys():
186
+ if k in _REDACTED_KEYS:
187
+ continue
188
+ ops.unsets.add(f"{path}.{k}")
189
+
190
+ for k, post_v in post.items():
191
+ if k in _REDACTED_KEYS:
192
+ continue
193
+ sub_path = f"{path}.{k}"
194
+ if k not in pre:
195
+ if isinstance(post_v, dict):
196
+ _diff_dict(sub_path, {}, post_v, ops)
197
+ elif isinstance(post_v, list):
198
+ if post_v:
199
+ ops.add_to_sets[sub_path] = [_strip_redacted_keys(x) for x in post_v]
200
+ else:
201
+ ops.sets[sub_path] = _strip_redacted_keys(post_v)
202
+ continue
203
+ pre_v = pre[k]
204
+ if isinstance(pre_v, dict) and isinstance(post_v, dict):
205
+ _diff_dict(sub_path, pre_v, post_v, ops)
206
+ elif isinstance(pre_v, list) and isinstance(post_v, list):
207
+ if _is_prefix(pre_v, post_v):
208
+ # A re-append is a no-op on the write, so the journaled diff must be one too.
209
+ tail = [x for x in post_v[len(pre_v):] if x not in pre_v]
210
+ if tail:
211
+ ops.add_to_sets[sub_path] = [_strip_redacted_keys(x) for x in tail]
212
+ elif pre_v != post_v:
213
+ ops.sets[sub_path] = _strip_redacted_keys(post_v)
214
+ elif pre_v != post_v:
215
+ ops.sets[sub_path] = _strip_redacted_keys(post_v)
216
+
217
+
218
+ def _is_prefix(pre: list[Any], post: list[Any]) -> bool:
219
+ return len(post) >= len(pre) and post[: len(pre)] == pre
220
+
221
+
222
+ def _has_unmappable_keys(d: dict[str, Any]) -> bool:
223
+ """True if any immediate key in `d` can't be a Mongo dot-notation path component."""
224
+ return any(not isinstance(k, str) or "." in k or k.startswith("$") for k in d)