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,244 @@
1
+ """Entry-point loaders for CLI plugins.
2
+
3
+ ``agent_env.cli_plugins`` entries must resolve to top-level click commands/groups;
4
+ ``agent_env.cli_root_options`` entries to ``click.Option`` instances that attach to the
5
+ root ``agent-env`` group. Broken plugins warn and are skipped; core names win over plugins;
6
+ two different root options on the same flag are both left off and reported as a conflict.
7
+ Full contract in the README ("Extending the CLI (plugins)").
8
+ """
9
+
10
+ import sys
11
+ from contextlib import redirect_stdout
12
+ from importlib.metadata import EntryPoint, entry_points
13
+ from typing import Any
14
+ from weakref import WeakKeyDictionary
15
+
16
+ import click
17
+ from click.core import ParameterSource
18
+
19
+ from agent_env.plugins import _report, _requirements
20
+ from agent_env.plugins._discovery import discovery_error, dist_name, identity, sort_key
21
+ from agent_env.plugins._inventory import Claimant, Contribution, Diagnostic
22
+
23
+ CLI_PLUGINS_GROUP = "agent_env.cli_plugins"
24
+ CLI_ROOT_OPTIONS_GROUP = "agent_env.cli_root_options"
25
+
26
+
27
+ # What each loader did, per root group, so `agent-env plugin` can report it without reloading.
28
+ _OUTCOMES: WeakKeyDictionary[click.Group, dict[tuple[str, str], list[Contribution]]] = WeakKeyDictionary()
29
+ # Each entry-point group whose installed metadata could not be read, per root group.
30
+ _DISCOVERY_ERRORS: WeakKeyDictionary[click.Group, dict[str, Diagnostic]] = WeakKeyDictionary()
31
+ # Where each guarded root option came from, for the warning and the record when its callback fails.
32
+ _SOURCES: WeakKeyDictionary[click.Option, tuple[EntryPoint, str]] = WeakKeyDictionary()
33
+
34
+
35
+ def cli_contributions(group: click.Group) -> dict[tuple[str, str], list[Contribution]]:
36
+ """The CLI entry points loaded onto ``group``, by (distribution, version), with their outcome."""
37
+ return {key: list(found) for key, found in _OUTCOMES.get(group, {}).items()}
38
+
39
+
40
+ def cli_discovery_errors(group: click.Group) -> dict[str, Diagnostic]:
41
+ """The CLI entry-point groups that could not be read while loading onto ``group``, with why."""
42
+ return dict(_DISCOVERY_ERRORS.get(group, {}))
43
+
44
+
45
+ def load_cli_plugins(group: click.Group) -> None:
46
+ """Register installed ``agent_env.cli_plugins`` entry points onto ``group``."""
47
+ added_by: dict[str, tuple[str, EntryPoint]] = {}
48
+ for ep, origin, plugin in _discover(group, CLI_PLUGINS_GROUP, "CLI plugin"):
49
+ if _incompatible(group, ep, CLI_PLUGINS_GROUP, origin):
50
+ continue
51
+ try:
52
+ command = _load(ep)
53
+ except (Exception, SystemExit) as exc:
54
+ _warn(f"{origin} failed to load and was skipped: {exc!r}")
55
+ _record(group, ep, CLI_PLUGINS_GROUP, "failed", code=_report.LOAD_FAILED,
56
+ reason=f"failed to load: {exc!r}")
57
+ continue
58
+ if not isinstance(command, click.Command):
59
+ _warn(f"{origin} is not a click.Command and was skipped")
60
+ _record(group, ep, CLI_PLUGINS_GROUP, "failed", code=_report.INVALID_PLUGIN,
61
+ reason="is not a click.Command")
62
+ continue
63
+ # The entry-point name, as in every group: what the report, --help and the user all use.
64
+ name = ep.name
65
+ if name in group.commands:
66
+ _warn(f"{origin} clashes with existing command {name!r} and was skipped")
67
+ if name in added_by:
68
+ owner, owner_ep = added_by[name]
69
+ _record(group, ep, CLI_PLUGINS_GROUP, "skipped", code=_report.NAME_CONFLICT,
70
+ reason=f"clashes with existing command {name!r} from plugin {owner}",
71
+ conflicts_with=(_claimant(owner_ep),))
72
+ else:
73
+ _record(group, ep, CLI_PLUGINS_GROUP, "skipped", code=_report.BUILTIN_NAME,
74
+ reason=f"clashes with existing command {name!r}")
75
+ continue
76
+ try:
77
+ group.add_command(command, name=name)
78
+ except Exception as exc:
79
+ _warn(f"{origin} could not be registered and was skipped: {exc!r}")
80
+ _record(group, ep, CLI_PLUGINS_GROUP, "failed", code=_report.INVALID_PLUGIN,
81
+ reason=f"could not be registered: {exc!r}")
82
+ continue
83
+ added_by[name] = (plugin, ep)
84
+ _record(group, ep, CLI_PLUGINS_GROUP, "active")
85
+
86
+
87
+ def load_cli_root_options(group: click.Group) -> None:
88
+ """Attach installed ``agent_env.cli_root_options`` entry points to ``group``.
89
+
90
+ Each must be an optional ``click.Option`` with ``expose_value=False``: it is parsed before
91
+ the subcommand and acts through its callback (which click also invokes with ``None`` when
92
+ the flag is absent), never through the root callback's arguments. A flag core already owns
93
+ is skipped with a warning. Different options on the same flag are all left off and recorded
94
+ as a conflict: a collision never silently picks a winner, and the CLI still starts, so
95
+ `plugin remove` can fix it. One option object exported twice, even by two packages, is one
96
+ callback, so it is attached once.
97
+ """
98
+ core_flags = set(group.context_settings.get("help_option_names") or ["--help"])
99
+ core_flags.update(flag for param in group.params for flag in (*param.opts, *param.secondary_opts))
100
+ candidates: list[tuple[EntryPoint, str, str, click.Option]] = []
101
+ for ep, origin, plugin in _discover(group, CLI_ROOT_OPTIONS_GROUP, "CLI root option"):
102
+ if _incompatible(group, ep, CLI_ROOT_OPTIONS_GROUP, origin):
103
+ continue
104
+ try:
105
+ option = _load(ep)
106
+ except (Exception, SystemExit) as exc:
107
+ _warn(f"{origin} failed to load and was skipped: {exc!r}")
108
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "failed", code=_report.LOAD_FAILED,
109
+ reason=f"failed to load: {exc!r}")
110
+ continue
111
+ if not isinstance(option, click.Option):
112
+ _warn(f"{origin} is not a click.Option and was skipped")
113
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "failed", code=_report.INVALID_PLUGIN,
114
+ reason="is not a click.Option")
115
+ continue
116
+ if option.expose_value or option.required:
117
+ _warn(f"{origin} must be optional with expose_value=False and was skipped")
118
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "failed", code=_report.INVALID_PLUGIN,
119
+ reason="must be optional with expose_value=False")
120
+ continue
121
+ clash = sorted(set(_flags(option)) & core_flags)
122
+ if clash:
123
+ _warn(f"{origin} clashes with core root option {clash[0]!r} and was skipped")
124
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "skipped", code=_report.BUILTIN_NAME,
125
+ reason=f"clashes with core root option {clash[0]!r}")
126
+ continue
127
+ candidates.append((ep, origin, plugin, option))
128
+ for ep, origin, _, option in candidates:
129
+ # Other option objects on one of its flags; the same object listed twice is not a rival.
130
+ rivals = [(other_ep, other_plugin, set(_flags(other)) & set(_flags(option)))
131
+ for other_ep, _, other_plugin, other in candidates if other is not option]
132
+ rivals = [(other_ep, other_plugin, shared) for other_ep, other_plugin, shared in rivals if shared]
133
+ if rivals:
134
+ flags = ", ".join(repr(flag) for flag in sorted(set().union(*(shared for *_, shared in rivals))))
135
+ reason = f"root option {flags} is also registered by {' and '.join(p for _, p, _ in rivals)}"
136
+ _warn(f"{origin} was not attached: {reason}")
137
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "conflict", code=_report.NAME_CONFLICT, reason=reason,
138
+ conflicts_with=tuple(_claimant(other_ep) for other_ep, _, _ in rivals))
139
+ continue
140
+ if option in group.params:
141
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "active", code=_report.ALREADY_ATTACHED,
142
+ reason="the same option object is already attached")
143
+ continue
144
+ _guard(option, ep, origin)
145
+ group.params.append(option)
146
+ _record(group, ep, CLI_ROOT_OPTIONS_GROUP, "active")
147
+
148
+
149
+ def _incompatible(group: click.Group, ep: EntryPoint, kind: str, origin: str) -> bool:
150
+ """Record and warn about ``ep`` when its distribution's requirements exclude the installed agent-env."""
151
+ unmet = _requirements.incompatibility(getattr(ep, "dist", None))
152
+ if unmet is None:
153
+ return False
154
+ _warn(f"{origin} was skipped: it {unmet}")
155
+ _record(group, ep, kind, "failed", code=_report.INCOMPATIBLE_CORE, reason=unmet)
156
+ return True
157
+
158
+
159
+ def _flags(option: click.Option) -> tuple[str, ...]:
160
+ return (*option.opts, *option.secondary_opts)
161
+
162
+
163
+ def _claimant(ep: EntryPoint) -> Claimant:
164
+ return Claimant(*identity(ep), getattr(ep, "value", "") or "")
165
+
166
+
167
+ def _guard(option: click.Option, ep: EntryPoint, origin: str) -> None:
168
+ """Wrap ``option``'s callback so that, if it raises while the flag is absent, it is skipped and
169
+ recorded as failed: click calls it on every invocation, so it would otherwise stop every
170
+ command, `plugin remove` included. A flag the user gave still raises. What it prints with the
171
+ flag absent goes to stderr, so it cannot corrupt a command's `--json`. The option object stays
172
+ the plugin's own, so code holding it still finds it in the root group's params."""
173
+ _SOURCES[option] = (ep, origin)
174
+ callback = option.callback
175
+ if callback is None or getattr(callback, "_agent_env_guard", False):
176
+ return
177
+
178
+ def call(ctx: click.Context, param: click.Parameter, value: Any) -> Any:
179
+ given = ctx.get_parameter_source(param.name) if param.name else None
180
+ absent = given in (None, ParameterSource.DEFAULT)
181
+ try:
182
+ if not absent:
183
+ return callback(ctx, param, value)
184
+ with redirect_stdout(sys.stderr):
185
+ return callback(ctx, param, value)
186
+ except (Exception, SystemExit) as exc:
187
+ source, where = _SOURCES.get(param, (ep, origin))
188
+ if not absent:
189
+ if isinstance(exc, (click.ClickException, click.exceptions.Exit, click.exceptions.Abort)):
190
+ raise
191
+ via = f" (set by ${param.envvar})" if given == ParameterSource.ENVIRONMENT and param.envvar else ""
192
+ raise click.ClickException(f"{where}{via} failed: {exc!r}") from exc
193
+ _warn(f"{where} failed with its flag absent and was skipped: {exc!r}")
194
+ _mark(ctx.find_root().command, source, f"its callback failed with the flag absent: {exc!r}")
195
+ return None
196
+
197
+ call._agent_env_guard = True # type: ignore[attr-defined]
198
+ option.callback = call
199
+
200
+
201
+ def _mark(group: click.Group, ep: EntryPoint, reason: str) -> None:
202
+ """Record an attached root option as failed after all."""
203
+ found = _OUTCOMES.get(group, {}).get(identity(ep), [])
204
+ for i, c in enumerate(found):
205
+ if (c.group, c.name) == (CLI_ROOT_OPTIONS_GROUP, ep.name):
206
+ found[i] = Contribution(c.group, c.name, c.value, "failed", code=_report.LOAD_FAILED, reason=reason)
207
+
208
+
209
+ def _discover(group: click.Group, group_name: str, label: str) -> list[tuple[EntryPoint, str, str]]:
210
+ """``(entry point, origin for messages, plugin label)`` per installed entry point, by name."""
211
+ try:
212
+ eps = sorted(entry_points(group=group_name), key=sort_key)
213
+ except Exception as exc:
214
+ reason = discovery_error(exc)
215
+ _warn(f"{label} discovery failed; none loaded: {reason}")
216
+ _DISCOVERY_ERRORS.setdefault(group, {})[group_name] = Diagnostic(_report.ENTRY_POINTS_UNREADABLE, reason)
217
+ return []
218
+ found = []
219
+ for ep in eps:
220
+ dist = dist_name(ep)
221
+ plugin = f"{ep.name!r}" + (f" from {dist!r}" if dist else "")
222
+ found.append((ep, f"{label} {plugin}", plugin))
223
+ return found
224
+
225
+
226
+ def _load(ep: EntryPoint) -> Any:
227
+ # What a plugin prints while it is imported must not land in a command's stdout, e.g. --json.
228
+ with redirect_stdout(sys.stderr):
229
+ return ep.load()
230
+
231
+
232
+ def _record(
233
+ group: click.Group, ep: EntryPoint, kind: str, status: str, *, code: str | None = None, reason: str | None = None,
234
+ conflicts_with: tuple[Claimant, ...] = (),
235
+ ) -> None:
236
+ contribution = Contribution(
237
+ kind, ep.name, getattr(ep, "value", "") or "", status, code=code, reason=reason, conflicts_with=conflicts_with
238
+ )
239
+ _OUTCOMES.setdefault(group, {}).setdefault(identity(ep), []).append(contribution)
240
+
241
+
242
+ def _warn(message: str) -> None:
243
+ # Logging isn't configured yet at import time; warn on stderr instead.
244
+ click.echo(f"Warning: {message}", err=True)
@@ -0,0 +1,97 @@
1
+ """Reading installed entry points: which distribution claims which name in each plugin group.
2
+
3
+ Internal: the public contract is ``agent_env.plugins``. Discovery reads installed metadata only
4
+ and imports no plugin code; ``_registration`` imports a group's plugins when its registry is built.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass
12
+ from importlib.metadata import EntryPoint, distributions, entry_points
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Plugin:
20
+ """Where an entry-point registration came from."""
21
+
22
+ name: str
23
+ value: str
24
+ dist: str
25
+ version: str
26
+
27
+ def __str__(self) -> str:
28
+ source = f"{self.dist} {self.version}".strip() or "an unreadable distribution"
29
+ return f"{self.name!r} from {source} ({self.value})"
30
+
31
+
32
+ def claims(group: str) -> dict[str, list[tuple[Plugin, EntryPoint]]]:
33
+ """Every distinct claim per entry-point name in ``group``, in a deterministic order."""
34
+ eps = sorted(entry_points(group=group), key=sort_key)
35
+ found: dict[str, list[tuple[Plugin, EntryPoint]]] = {}
36
+ for ep in eps:
37
+ plugin = Plugin(ep.name, getattr(ep, "value", "") or "", dist_name(ep), dist_version(ep))
38
+ seen = found.setdefault(ep.name, [])
39
+ # The same distribution found twice on the path is one registration, not a conflict.
40
+ if all((p.dist, p.value) != (plugin.dist, plugin.value) for p, _ in seen):
41
+ seen.append((plugin, ep))
42
+ return found
43
+
44
+
45
+ def discover(group: str) -> dict[str, list[tuple[Plugin, EntryPoint]]]:
46
+ """``claims(group)``, or none with a warning when installed metadata cannot be read."""
47
+ try:
48
+ return claims(group)
49
+ except Exception:
50
+ logger.warning("Entry-point discovery for %s failed; no plugins loaded", group, exc_info=True)
51
+ return {}
52
+
53
+
54
+ def discovery_error(exc: Exception) -> str:
55
+ """``exc`` from reading installed entry points, naming the distributions that cannot be read."""
56
+ unreadable = []
57
+ with suppress(Exception):
58
+ for dist in distributions():
59
+ try:
60
+ _ = dist.entry_points # parses entry_points.txt
61
+ except Exception:
62
+ unreadable.append(str(getattr(dist, "_path", None) or "a distribution with no path"))
63
+ where = f" (unreadable entry points: {', '.join(unreadable)})" if unreadable else ""
64
+ return f"{type(exc).__name__}: {exc}{where}"
65
+
66
+
67
+ def identity(ep: EntryPoint) -> tuple[str, str]:
68
+ """The (name, version) a contribution is listed under. A distribution with unreadable metadata
69
+ is named by where it is installed, so two such packages are not merged."""
70
+ name, version = dist_name(ep), dist_version(ep)
71
+ if name:
72
+ return name, version
73
+ try:
74
+ where = getattr(getattr(ep, "dist", None), "_path", None)
75
+ except Exception:
76
+ where = None
77
+ return (f"(unreadable metadata: {Path(where).name})" if where else "(unreadable metadata)"), version
78
+
79
+
80
+ def sort_key(ep: EntryPoint) -> tuple[str, str, str]:
81
+ # ep.value tie-breaks even when both colliding dists have unreadable metadata.
82
+ return (ep.name, dist_name(ep), getattr(ep, "value", "") or "")
83
+
84
+
85
+ def dist_name(ep: EntryPoint) -> str:
86
+ # Tolerates corrupt/missing distribution metadata — the name is only a label.
87
+ try:
88
+ return getattr(getattr(ep, "dist", None), "name", "") or ""
89
+ except Exception:
90
+ return ""
91
+
92
+
93
+ def dist_version(ep: EntryPoint) -> str:
94
+ try:
95
+ return getattr(getattr(ep, "dist", None), "version", "") or ""
96
+ except Exception:
97
+ return ""
@@ -0,0 +1,262 @@
1
+ """``agent_env.plugins.inventory``: every installed plugin's contributions, and what became of each.
2
+
3
+ Internal: the public names are re-exported from ``agent_env.plugins``. The registries are built on
4
+ a throwaway Config that reads the caller's document, so the Config in use keeps its registries and
5
+ its ``load_failures()``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import KW_ONLY, dataclass
12
+ from pathlib import Path
13
+ from types import MappingProxyType
14
+ from typing import Any, Literal, Optional
15
+
16
+ from agent_env.config import runtime
17
+ from agent_env.config import snapshot as config_snapshot
18
+ from agent_env.config.errors import ConfigError
19
+ from agent_env.plugins import _discovery, _registration, _report, _requirements
20
+
21
+ Status = Literal["active", "replaced", "failed", "skipped", "conflict", "blocked", "unloaded"]
22
+
23
+ # Statuses a group-level error does not override: each already says why the name is missing.
24
+ _SETTLED = frozenset({"failed", "skipped", "conflict"})
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Diagnostic:
29
+ """Why a group, the config or entry-point discovery failed."""
30
+
31
+ code: str
32
+ reason: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Claimant:
37
+ """Another entry point that claims the same name."""
38
+
39
+ package: str
40
+ version: str
41
+ value: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Replacement:
46
+ """The config that registers a different class under a contribution's name."""
47
+
48
+ file: Optional[Path]
49
+ table: str
50
+ impl: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Contribution:
55
+ """One entry point a distribution declares, and what became of it. ``code`` says why it has
56
+ its status, and is set exactly when ``reason`` is."""
57
+
58
+ group: str
59
+ name: str
60
+ value: str
61
+ status: Status
62
+ _: KW_ONLY
63
+ code: Optional[str] = None
64
+ reason: Optional[str] = None
65
+ replaced_by: Optional[Replacement] = None
66
+ conflicts_with: tuple[Claimant, ...] = ()
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Distribution:
71
+ """An installed distribution that declares entry points in the type groups."""
72
+
73
+ name: str
74
+ version: str
75
+ contributions: tuple[Contribution, ...]
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Inventory:
80
+ """Every installed plugin distribution, and the error each group's real build would raise."""
81
+
82
+ distributions: tuple[Distribution, ...]
83
+ # With load=False, only what metadata proves: that no config file was found.
84
+ group_errors: Mapping[str, Diagnostic]
85
+ # Groups whose installed entry points could not be read, so none of their plugins is listed.
86
+ discovery_errors: Mapping[str, Diagnostic]
87
+ config_path: Optional[Path]
88
+ config_error: Optional[Diagnostic]
89
+ loaded: bool
90
+
91
+
92
+ for _public in (Claimant, Contribution, Diagnostic, Distribution, Inventory, Replacement):
93
+ _public.__module__ = "agent_env.plugins"
94
+
95
+
96
+ def _groups() -> dict[str, tuple[Callable[[], Any], Callable[[runtime.Config], Any]]]:
97
+ """Per group: its built-in names, and how the probe builds it."""
98
+ # Imported here, not at the top: every registry imports agent_env.plugins, and so this module.
99
+ from agent_env.artifact.registry import ARTIFACT_REGISTRY
100
+ from agent_env.env import registry as env_registry
101
+ from agent_env.explorer.plugin import load_plugins
102
+ from agent_env.providers.env_providers import env_provider
103
+ from agent_env.providers.sandbox_providers.sandbox_provider import _BUILTIN_SANDBOX_PROVIDERS
104
+ from agent_env.providers.env_state.env_state_provider import _BUILTIN_STATE_PROVIDERS
105
+ from agent_env.task_step import registry as task_step_registry
106
+
107
+ return {
108
+ _registration.ENVS: (env_registry._builtin_registry, lambda probe: probe.env_registry()),
109
+ _registration.ARTIFACTS: (lambda: ARTIFACT_REGISTRY, lambda probe: probe.artifact_registry()),
110
+ _registration.TASK_STEPS: (task_step_registry._builtin_registry, lambda probe: probe.task_step_registry()),
111
+ _registration.SANDBOX_PROVIDERS: (lambda: _BUILTIN_SANDBOX_PROVIDERS, lambda probe: probe.sandbox_registry()),
112
+ _registration.STATE_PROVIDERS: (lambda: _BUILTIN_STATE_PROVIDERS, lambda probe: probe.state_registry()),
113
+ _registration.ENV_PROVIDERS: (env_provider._builtin_env_providers, lambda probe: probe.env_provider_registry()),
114
+ _registration.EXPLORER_PLUGINS: (dict, lambda probe: load_plugins(source=probe)),
115
+ }
116
+
117
+
118
+ def inventory(config: Optional[runtime.Config] = None, *, load: bool = True) -> Inventory:
119
+ """Every installed distribution declaring entry points in the type groups, with a status per
120
+ contribution.
121
+
122
+ ``load=False`` reads installed metadata only: no plugin code runs, and a status that needs a
123
+ build (``active``, ``replaced``, ``failed``) is reported as ``unloaded``. ``load=True`` imports
124
+ the plugins, constructs the explorer plugins, and builds each group on a throwaway Config that
125
+ reads ``config``'s document (default: the process Config). ``config`` keeps its registries and
126
+ its ``load_failures()``; like any registry build, this pins its document first if nothing has
127
+ yet. Importing or constructing a plugin can still have process-wide effects, such as a package
128
+ that sets ``AGENT_ENV_CONFIG`` on import.
129
+ """
130
+ base = config or runtime.get_config()
131
+ try:
132
+ document = base._document()
133
+ missing = None
134
+ except ConfigError as exc:
135
+ # No config file was found: every real build raises this before merging anything, so the
136
+ # plugins are checked against an empty document and then blocked.
137
+ document, missing = config_snapshot.Snapshot(path=None, _document={}), exc
138
+ if missing is not None:
139
+ config_error = Diagnostic(_report.CONFIG_NOT_FOUND, str(missing))
140
+ elif document.error is not None:
141
+ config_error = Diagnostic(_report.CONFIG_UNREADABLE, str(document.error))
142
+ else:
143
+ config_error = None
144
+ # A malformed document is used as it is, so each build raises its parse error where a real one does.
145
+ probe = _registration.InventoryProbe(_snapshot=document) if load else None
146
+ group_errors: dict[str, Diagnostic] = {}
147
+ discovery_errors: dict[str, Diagnostic] = {}
148
+ by_distribution: dict[tuple[str, str], list[Contribution]] = {}
149
+ groups = _groups()
150
+ for group, (builtins_of, build) in groups.items():
151
+ try:
152
+ claims = _discovery.claims(group)
153
+ except Exception as exc:
154
+ discovery_errors[group] = Diagnostic(_report.ENTRY_POINTS_UNREADABLE, _discovery.discovery_error(exc))
155
+ continue
156
+ if not claims:
157
+ continue
158
+ builtins = frozenset(builtins_of())
159
+ registrations = None
160
+ if probe is not None:
161
+ try:
162
+ build(probe)
163
+ except (Exception, SystemExit) as exc:
164
+ group_errors[group] = Diagnostic(_build_error_code(exc, document), f"{type(exc).__name__}: {exc}")
165
+ registrations = probe.registrations.get(group)
166
+ if missing is not None:
167
+ group_errors[group] = Diagnostic(_report.CONFIG_NOT_FOUND, f"{type(missing).__name__}: {missing}")
168
+ for name, found in claims.items():
169
+ for plugin, ep in found:
170
+ contribution = _classify(group, name, plugin, ep, found, builtins, registrations, document.path)
171
+ if group in group_errors and contribution.status not in _SETTLED:
172
+ error = group_errors[group]
173
+ contribution = Contribution(
174
+ group, name, plugin.value, "blocked", code=error.code, reason=error.reason
175
+ )
176
+ by_distribution.setdefault(_discovery.identity(ep), []).append(contribution)
177
+ order = list(groups)
178
+ distributions = tuple(
179
+ Distribution(dist, version, tuple(sorted(found, key=lambda c: (order.index(c.group), c.name))))
180
+ for (dist, version), found in sorted(by_distribution.items(), key=lambda item: (item[0][0].lower(), item[0][1]))
181
+ )
182
+ return Inventory(
183
+ distributions=distributions,
184
+ group_errors=MappingProxyType(group_errors),
185
+ discovery_errors=MappingProxyType(discovery_errors),
186
+ config_path=document.path,
187
+ config_error=config_error,
188
+ loaded=load,
189
+ )
190
+
191
+
192
+ def _classify(
193
+ group: str,
194
+ name: str,
195
+ plugin: _discovery.Plugin,
196
+ ep: Any,
197
+ found: list[tuple[_discovery.Plugin, Any]],
198
+ builtins: frozenset[str],
199
+ registrations: Optional[_registration.Registrations],
200
+ config_path: Optional[Path],
201
+ ) -> Contribution:
202
+ # Same precedence as merge: a built-in name is skipped for every claimant, before conflicts.
203
+ if name in builtins:
204
+ return Contribution(
205
+ group, name, plugin.value, "skipped", code=_report.BUILTIN_NAME, reason="clashes with a built-in"
206
+ )
207
+ # Read from metadata, so it is reported without loading. A claim that cannot load claims nothing,
208
+ # so it takes no part in a conflict, as in a build.
209
+ if (unmet := _requirements.incompatibility(getattr(ep, "dist", None))) is not None:
210
+ return Contribution(group, name, plugin.value, "failed", code=_report.INCOMPATIBLE_CORE, reason=unmet)
211
+ found = _usable(found)
212
+ if len(found) > 1:
213
+ others = [(other, other_ep) for other, other_ep in found if other != plugin]
214
+ alone = all((other.dist, other.version) == (plugin.dist, plugin.version) for other, _ in found)
215
+ who = "; ".join(str(other) for other, _ in others)
216
+ return Contribution(
217
+ group, name, plugin.value, "conflict", code=_report.NAME_CONFLICT,
218
+ reason=(f"this package declares the name more than once: {who}; report it to its author" if alone
219
+ else f"also registered by {who}; remove all but one with `agent-env plugin remove <package>`"),
220
+ conflicts_with=tuple(_claimant(other, other_ep) for other, other_ep in others),
221
+ )
222
+ if registrations is None:
223
+ return Contribution(
224
+ group, name, plugin.value, "unloaded", code=_report.NOT_LOADED, reason="not imported (installed metadata only)"
225
+ )
226
+ if name in registrations.added:
227
+ return Contribution(group, name, plugin.value, "active")
228
+ if name in registrations.released:
229
+ table, impl, replacement = registrations.released[name]
230
+ if replacement is registrations.classes.get(name):
231
+ return Contribution(group, name, plugin.value, "active")
232
+ where = f"[{table}] of {config_path}" if config_path else f"[{table}]"
233
+ return Contribution(
234
+ group, name, plugin.value, "replaced", code=_report.REPLACED_BY_CONFIG,
235
+ reason=f"config names {impl!r} in {where}", replaced_by=Replacement(config_path, table, impl),
236
+ )
237
+ if name in registrations.failures:
238
+ reason = registrations.failures[name].removeprefix(f"registered by {plugin} but ")
239
+ return Contribution(group, name, plugin.value, "failed", code=registrations.codes[name], reason=reason)
240
+ # Unreachable while every registry records what it did; visible, not silent, if not.
241
+ return Contribution(
242
+ group, name, plugin.value, "unloaded", code=_report.STATUS_UNKNOWN, reason="its status could not be determined"
243
+ )
244
+
245
+
246
+ def _usable(found: list[tuple[_discovery.Plugin, Any]]) -> list[tuple[_discovery.Plugin, Any]]:
247
+ """The claims whose requirements admit this agent-env; the others cannot load."""
248
+ return [(p, ep) for p, ep in found if _requirements.incompatibility(getattr(ep, "dist", None)) is None]
249
+
250
+
251
+ def _claimant(plugin: _discovery.Plugin, ep: Any) -> Claimant:
252
+ package, version = _discovery.identity(ep)
253
+ return Claimant(package, version, plugin.value)
254
+
255
+
256
+ def _build_error_code(exc: BaseException, document: config_snapshot.Snapshot) -> str:
257
+ """The code for what a group's build raised."""
258
+ if document.error is not None and exc is document.error:
259
+ return _report.CONFIG_UNREADABLE
260
+ if isinstance(exc, ConfigError):
261
+ return _report.CONFIG_INVALID
262
+ return _report.GROUP_BUILD_FAILED