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,1067 @@
1
+ """Read-only inspection of what the config surface actually resolved to.
2
+
3
+ Answers "what am I pointed at" for both audiences the config serves: a bare install that
4
+ gets local stores with nothing said about it, and a deployment whose stage is whichever
5
+ file ``AGENT_ENV_CONFIG`` landed on. It reports the *resolution*, not the constructed
6
+ stores — no client is built, no network call is made, and nothing is written to disk.
7
+
8
+ Secret values cannot leak from here because nothing is interpolated: an ``env:`` /
9
+ ``secret:`` reference is reported as the reference it is. Literal values under a
10
+ secret-shaped key, and the userinfo of a connection URI, are masked anyway — the schema
11
+ says secrets live behind references, but a report is the wrong place to find out otherwise.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import difflib
17
+ import os
18
+ import re
19
+ from dataclasses import dataclass, field, fields, replace
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Literal, Mapping, Optional, Union
23
+
24
+ from packaging.utils import canonicalize_name
25
+
26
+ from agent_env.config import loader as config_loader
27
+ from agent_env.config import plugin_tables
28
+ from agent_env.config.errors import ConfigError
29
+ from agent_env.config.model import ModelConfig
30
+ from agent_env.config.provenance import (
31
+ KIND_DEFAULT,
32
+ KIND_ENV,
33
+ KIND_FILE,
34
+ Layer,
35
+ class_name,
36
+ )
37
+ from agent_env.config.runtime import (
38
+ ALIASED_SECTIONS,
39
+ RESOLVED_KEYS,
40
+ Config,
41
+ validated_agents_section,
42
+ )
43
+
44
+ MASK = "***"
45
+
46
+ # How the winning file was found. A discriminator, not prose: `--json` exists so a tool can
47
+ # read the provenance, and prose it has to parse back is not provenance.
48
+ ConfigSource = Literal["env", "walk-up", "none", "error", "installed"]
49
+
50
+ SOURCE_ENV: ConfigSource = "env"
51
+ SOURCE_WALK_UP: ConfigSource = "walk-up"
52
+ SOURCE_NONE: ConfigSource = "none"
53
+ SOURCE_ERROR: ConfigSource = "error"
54
+ # A Config carrying its own document: discovery would not have found this path, so no
55
+ # discovery source may be claimed for it.
56
+ SOURCE_INSTALLED: ConfigSource = "installed"
57
+
58
+ # Substrings that make a key secret-shaped. Deliberately broad: over-masking costs a
59
+ # debugging round-trip, under-masking prints a credential into a terminal and a paste. On
60
+ # that trade `auth` is in even though it also catches `author` — a store `config` table is
61
+ # where `authorization = "Bearer ..."` lives, and a masked author is the cheaper mistake.
62
+ _SECRET_KEY_PARTS = ("password", "passwd", "passphrase", "secret", "token", "credential",
63
+ "api_key", "private_key", "auth", "bearer")
64
+ _URI_USERINFO = re.compile(r"(?P<scheme>[a-zA-Z][\w+.-]*://)(?P<userinfo>[^/@\s]+)@")
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SectionReport:
69
+ """One section: the layer that supplied it, the layers it beat, and the value it names.
70
+
71
+ Masks itself on construction. Doing it at each call site is what this module tried
72
+ first, and three separate leaks shipped that way — a scalar layer's `summary()` prints
73
+ the value, so a single forgotten call publishes a secret. Here it cannot be forgotten.
74
+
75
+ `value` is the masked section as resolved — an impl table for an aliased section, the
76
+ file's table for a directly-read one, `None` when the winning layer had nothing to give.
77
+ There is no combination of fields that describes a section twice.
78
+ """
79
+
80
+ name: str
81
+ winner: Layer
82
+ shadowed: tuple[Layer, ...] = ()
83
+ value: Any = None
84
+ unresolved: Optional[str] = None
85
+
86
+ def __post_init__(self) -> None:
87
+ object.__setattr__(self, "winner", _masked_layer(self.winner))
88
+ object.__setattr__(self, "shadowed", tuple(_masked_layer(b) for b in self.shadowed))
89
+ object.__setattr__(self, "value", _mask_table(self.value))
90
+
91
+ @property
92
+ def error(self) -> Optional[str]:
93
+ """Why this section has no value — the winning layer would not read, or its value
94
+ would not resolve. One question with one answer, wherever the failure happened."""
95
+ return self.unresolved or self.winner.error
96
+
97
+ @property
98
+ def impl(self) -> Optional[str]:
99
+ return self.value.get("impl") if isinstance(self.value, dict) else None
100
+
101
+ @property
102
+ def impl_name(self) -> str:
103
+ return class_name(self.impl) or "(unresolved)"
104
+
105
+ @property
106
+ def config(self) -> dict:
107
+ under = self.value.get("config") if isinstance(self.value, dict) else None
108
+ return under if isinstance(under, dict) else {}
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class PluginOwner:
113
+ """The distribution a ``[plugins.<name>]`` table belongs to, as installed, if it is."""
114
+
115
+ name: str
116
+ installed: Optional[plugin_tables.Installed] = None
117
+
118
+ @property
119
+ def version(self) -> Optional[str]:
120
+ return self.installed.version if self.installed else None
121
+
122
+ @property
123
+ def plugin(self) -> bool:
124
+ return bool(self.installed and self.installed.plugin)
125
+
126
+ @property
127
+ def core(self) -> bool:
128
+ return bool(self.installed and self.installed.core)
129
+
130
+ @property
131
+ def label(self) -> str:
132
+ if self.installed is None:
133
+ return f"{self.name} (not installed)"
134
+ version = f"{self.name} {self.version}".strip()
135
+ if self.core:
136
+ return f"{version} (agent-env itself)"
137
+ return version if self.plugin else f"{version} (declares no agent_env entry point)"
138
+
139
+ @property
140
+ def note(self) -> str:
141
+ """What core can say about a key in this table: whose it is, never whether it is read."""
142
+ if self.plugin:
143
+ return f"in the table of the plugin {self.label}; agent-env does not read or check it"
144
+ if self.core:
145
+ return (f"in the table of {self.name} {self.version}, which is agent-env itself, not a plugin; "
146
+ "agent-env does not read it")
147
+ if self.installed:
148
+ return (f"in the table of {self.name} {self.version}, which is installed but declares no "
149
+ "agent_env entry point; agent-env does not read it")
150
+ return f"in the table of {self.name}, which is not installed; agent-env does not read it"
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class PluginTableReport:
155
+ """One plugin's own settings table, as written; agent-env does not read it.
156
+
157
+ Masked on construction, one table at a time: masking ``[plugins]`` whole would scope on the
158
+ distribution names, and ``agentenv-oauth-bridge`` would hide its every setting.
159
+ """
160
+
161
+ owner: PluginOwner
162
+ keys: tuple[str, ...]
163
+ value: Any = None
164
+ error: Optional[str] = None
165
+
166
+ @property
167
+ def where(self) -> str:
168
+ return ", ".join(plugin_tables.where(key) for key in self.keys)
169
+
170
+ def __post_init__(self) -> None:
171
+ object.__setattr__(self, "value", _mask_table(self.value))
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class PluginsReport:
176
+ """Every ``[plugins.<name>]`` table, or why ``[plugins]`` itself could not be read."""
177
+
178
+ tables: tuple[PluginTableReport, ...] = ()
179
+ error: Optional[str] = None
180
+
181
+
182
+ @dataclass(frozen=True)
183
+ class ConfigReport:
184
+ """The whole resolution chain: the file that won, and each section under it."""
185
+
186
+ config_path: Optional[Path]
187
+ config_source: ConfigSource
188
+ search_root: Path
189
+ sections: list[SectionReport] = field(default_factory=list)
190
+ error: Optional[str] = None
191
+ warnings: list[str] = field(default_factory=list)
192
+ plugins: PluginsReport = field(default_factory=PluginsReport)
193
+
194
+
195
+ def _is_secret_key(key: str) -> bool:
196
+ return any(part in key.lower() for part in _SECRET_KEY_PARTS)
197
+
198
+
199
+ def mask_value(key: str, value: Any, *, secret_scope: bool = False) -> Any:
200
+ """Mask a config value for display. A reference passes through — it names a secret
201
+ rather than carrying one, and the name is the useful half when debugging.
202
+
203
+ ``secret_scope`` masks the value regardless of its own key, for a value that sits
204
+ somewhere under a secret-shaped key rather than directly at one."""
205
+ # Absence is not a secret. Masking it invents one: `"value": "***"` where the honest
206
+ # answer is `null` tells an operator a key is configured when nothing supplies it.
207
+ if value is None:
208
+ return None
209
+ if isinstance(value, str) and value.startswith(
210
+ (config_loader.ENV_REF_PREFIX, config_loader.SECRET_REF_PREFIX)
211
+ ):
212
+ # The reference names a secret, but a `?default` after it is a literal like any other.
213
+ ref, sep, default = value.partition("?")
214
+ return f"{ref}?{mask_value(key, default, secret_scope=secret_scope)}" if sep else value
215
+ # Before the string check: a secret is no less a secret for being written as a number.
216
+ if secret_scope or _is_secret_key(key):
217
+ return MASK
218
+ if not isinstance(value, str):
219
+ return value
220
+ return _URI_USERINFO.sub(lambda m: f"{m.group('scheme')}{MASK}@", value)
221
+
222
+
223
+ def _mask_table(table: Any, *, key: str = "", secret_scope: bool = False) -> Any:
224
+ """Mask a resolved config subtree.
225
+
226
+ A secret-shaped key masks *everything* beneath it, not just a scalar sitting directly
227
+ at it: a backend's ``config`` is an arbitrary TOML table, so a credential arrives just
228
+ as easily as ``tokens = ["…"]`` or ``credentials = { pw = "…" }``, and a mask that only
229
+ consulted the leaf's own key would print both.
230
+ """
231
+ if isinstance(table, dict):
232
+ return {k: _mask_table(v, key=k, secret_scope=secret_scope or _is_secret_key(k))
233
+ for k, v in table.items()}
234
+ if isinstance(table, list):
235
+ # A list carries no keys of its own — its items stay in the enclosing key's scope.
236
+ return [_mask_table(v, key=key, secret_scope=secret_scope) for v in table]
237
+ return mask_value(key, table, secret_scope=secret_scope)
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class Named:
242
+ """A table whose keys the file chooses, such as ``[sandbox.providers]``: each holds ``shape``."""
243
+
244
+ shape: "Shape"
245
+
246
+
247
+ # The keys a table may hold, each with its own shape. None is a value, or a table of free-form
248
+ # keys, that the unread-key check does not descend into: a seam's `config`, `[model.params]`.
249
+ Shape = Union[Mapping[str, Any], Named, None]
250
+
251
+ _SEAM: Mapping[str, Shape] = dict.fromkeys(config_loader.SEAM_KEYS)
252
+
253
+
254
+ def _resolved_keys(table: str) -> dict[str, Shape]:
255
+ return dict.fromkeys(key.toml_path[1] for key in RESOLVED_KEYS if key.toml_path[0] == table)
256
+
257
+
258
+ # Sections read straight out of the file. Their fallbacks live in the reader, not here, so
259
+ # the report names where to look rather than guessing a value it would have to keep in sync.
260
+ @dataclass(frozen=True)
261
+ class FileSection:
262
+ """A section with no env var and no alias — a table the reader takes as written.
263
+
264
+ A type rather than a tuple so everything about a section lives on it: without one,
265
+ its check, its lookup key, its prefix and its keys each needed a parallel table to live in.
266
+ """
267
+
268
+ name: str
269
+ toml_path: tuple[str, ...]
270
+ owner: str
271
+ keys: Mapping[str, Shape]
272
+ check: Optional[Callable[[Mapping[str, Any]], Any]] = None
273
+
274
+
275
+ _FILE_SECTIONS: tuple[FileSection, ...] = (
276
+ FileSection("model", ("model",), "config.runtime", dict.fromkeys(f.name for f in fields(ModelConfig))),
277
+ FileSection("conversations", ("conversations",), "config.runtime", _resolved_keys("conversations")),
278
+ FileSection("agents", ("agents",), "config.runtime", _resolved_keys("agents"), validated_agents_section),
279
+ FileSection("sandbox", ("sandbox",), "providers.sandbox_providers.sandbox_provider",
280
+ {"default": None, "agent_default": None, "attribution": None, "providers": Named(_SEAM)}),
281
+ FileSection("state", ("state",), "providers.env_state.env_state_provider", {"providers": Named(_SEAM)}),
282
+ FileSection("envs", ("envs",), "env.registry", {"impls": None}),
283
+ FileSection("artifacts", ("artifacts",), "artifact.registry", {"impls": None, "type_aliases": None}),
284
+ FileSection("task_steps", ("task_steps",), "task_step.registry", {"impls": None}),
285
+ FileSection("explorer", ("explorer",), "explorer.app",
286
+ {**dict.fromkeys(("port", "cors_origins", "allowed_hosts", "static_dir")),
287
+ "plugins": {"impls": None}}),
288
+ )
289
+
290
+ _TOP_LEVEL_SECTIONS = ({s.toml_path[0] for s in _FILE_SECTIONS}
291
+ | {section.toml_path[0] for section in ALIASED_SECTIONS})
292
+
293
+
294
+ # The core tables with a key of their own named `plugins`: [explorer.plugins] lists explorer plugins.
295
+ _OWN_PLUGINS_KEY = frozenset({"explorer"})
296
+
297
+
298
+ def _misplaced(table: str, key: str) -> bool:
299
+ if key == plugin_tables.TABLE:
300
+ return table not in _OWN_PLUGINS_KEY
301
+ return key in _TOP_LEVEL_SECTIONS
302
+
303
+
304
+ def _table(*path: str) -> str:
305
+ """A table's header as the file writes it, each part quoted where TOML needs it."""
306
+ return "[" + ".".join(plugin_tables.toml_key(part) for part in path) + "]"
307
+
308
+
309
+ def _misplaced_sections(document: Mapping[str, Any]) -> list[str]:
310
+ """Section names appearing as a key *inside* another table.
311
+
312
+ TOML binds a bare key to the table above it, so `envs = "..."` typed at the foot of a
313
+ file becomes `[<last table>].envs`: a well-formed document that configures nothing.
314
+ Nothing else can catch it — the section is simply absent — so the command reached for
315
+ when config "is not taking effect" is the place to say so.
316
+ """
317
+ found = []
318
+ for table, body in document.items():
319
+ # Every key in [plugins] is a package name, even one spelled like a core section.
320
+ if not isinstance(body, dict) or table == plugin_tables.TABLE:
321
+ continue
322
+ for key in body:
323
+ if _misplaced(table, key):
324
+ found.append(
325
+ f"{_table(table)} has a key named {key!r}, which is also a top-level section — "
326
+ f"a bare key binds to the table above it. Did you mean [{key}]?"
327
+ )
328
+ return found
329
+
330
+
331
+ def _inner(shape: Any, key: str) -> Any:
332
+ if isinstance(shape, Named):
333
+ return shape.shape
334
+ return shape.get(key) if isinstance(shape, Mapping) else None
335
+
336
+
337
+ def _keys_shadowed_by_config(document: Mapping[str, Any], path: tuple = (), shape: Any = None) -> list[str]:
338
+ """Keys set both beside a `config` table and inside it.
339
+
340
+ Every reader takes `config`, so the one beside it is dead — and silently, because the
341
+ section is well-formed and the value that does nothing looks like the value that does.
342
+ """
343
+ found = []
344
+ for key, body in document.items():
345
+ if not isinstance(body, dict):
346
+ continue
347
+ here = path + (key,)
348
+ inner = _inner(shape, key)
349
+ if inner is None:
350
+ continue # free-form, or a table nothing reads: a `config` in it is just a key
351
+ config = body.get("config")
352
+ if isinstance(config, dict) and isinstance(inner, Mapping) and "config" in inner:
353
+ # not `impl`: the one beside the table names the class, the one inside is a
354
+ # from_config kwarg. Both are read, which is why `lines` excludes it too.
355
+ for duplicate in sorted(set(body) & set(config) - {"impl"}):
356
+ found.append(
357
+ f"{_table(*here)} sets {duplicate!r} both beside its config table and "
358
+ f"inside it; only {_table(*here, 'config')} {duplicate} is read."
359
+ )
360
+ found += _keys_shadowed_by_config(body, here, inner)
361
+ return found
362
+
363
+
364
+ def _masked_layer(layer: Layer, key: str = "") -> Layer:
365
+ """A layer safe to publish. `summary()` on a table yields a class name *or* its
366
+ scalars, and on a scalar the value itself — so any layer a report prints or serializes
367
+ must be masked first, not just the resolved value beside it."""
368
+ return layer if layer.error is not None else replace(layer, raw=_mask_table(layer.raw, key=key))
369
+
370
+
371
+ def _unresolved(config: Config, name: str, error: str) -> SectionReport:
372
+ """A section whose winning layer could not be turned into one — still naming that layer.
373
+
374
+ Reporting the default here would be the report telling a reassuring story about a layer
375
+ that had no part in the failure.
376
+ """
377
+ try:
378
+ candidates = config.section_candidates(name)
379
+ except ConfigError:
380
+ candidates = []
381
+ winner = candidates[0] if candidates else Layer(KIND_DEFAULT, "(unresolved)")
382
+ # Not when the winner already carries it: an unreadable layer and a layer whose value
383
+ # would not resolve are different failures, but one layer cannot be both.
384
+ return SectionReport(name=name, winner=winner, shadowed=tuple(candidates[1:]),
385
+ unresolved=None if winner.error else error)
386
+
387
+
388
+ def _describe_section(config: Config, name: str) -> SectionReport:
389
+ try:
390
+ traced = config.trace_section(name)
391
+ except ConfigError as e:
392
+ return _unresolved(config, name, str(e))
393
+ return SectionReport(name=name, winner=traced.winner, shadowed=traced.shadowed,
394
+ value=traced.value)
395
+
396
+
397
+ def _describe_file_section(config: Config, section: "FileSection") -> SectionReport:
398
+ """A section read straight out of the file — no env var, no alias. Its fallback lives in
399
+ `section.owner`, so an absent table names that module rather than guessing a value."""
400
+ layer = config.file_candidate(section.toml_path)
401
+ if layer is None:
402
+ return SectionReport(name=section.name,
403
+ winner=Layer(KIND_DEFAULT, f"default in {section.owner}"))
404
+ if layer.error is not None:
405
+ return SectionReport(name=section.name, winner=layer)
406
+ try:
407
+ # The shape rule lives in `section`, so the report cannot disagree with the reader
408
+ # about it. Echoing `sandbox = 5` back while the reader refuses it is this command
409
+ # confirming a value the process never used.
410
+ value = config.section(*section.toml_path)
411
+ if section.check is not None:
412
+ section.check(value)
413
+ except ConfigError as e:
414
+ return SectionReport(name=section.name, winner=layer, unresolved=str(e))
415
+ return SectionReport(name=section.name, winner=layer, value=value)
416
+
417
+
418
+ @dataclass(frozen=True)
419
+ class Candidate:
420
+ """One path the loader would consider, and whether it is there."""
421
+
422
+ path: Path
423
+ why: str
424
+ exists: bool
425
+
426
+
427
+ def search_path() -> list[Candidate]:
428
+ """Every path discovery would consider, in the order it considers them.
429
+
430
+ Answers "why is agent-env reading that file" without resolving anything, so it still
431
+ reports when resolution itself is what is failing.
432
+ """
433
+ return [Candidate(path, why, path.is_file())
434
+ for path, why in config_loader.config_search_path()]
435
+
436
+
437
+ # The live object each store section's getter returns, when one has been built or
438
+ # installed. `set_*_store()` writes straight to these.
439
+ _LIVE_STORES = {"document": "_document_store", "object": "_object_store",
440
+ "image": "_image_store", "secret": "_secret_store"}
441
+
442
+
443
+ def _live_store_disagreements(config: Config, sections: list[SectionReport]) -> list[str]:
444
+ """Sections whose live object is not what the config resolves to.
445
+
446
+ `get_document_store()` returns an installed object verbatim, so a report naming the
447
+ file's backend would describe a store the process is not using -- and that is the
448
+ failure this whole module exists to catch, so it cannot be allowed in through the
449
+ report's own back door.
450
+ """
451
+ found = []
452
+ by_name = {s.name: s for s in sections}
453
+ for name, attr in _LIVE_STORES.items():
454
+ live = getattr(config, attr, None)
455
+ section = by_name.get(name)
456
+ if live is None or section is None:
457
+ continue
458
+ resolved = class_name(section.impl)
459
+ actual = type(live).__name__
460
+ if resolved is not None and resolved != actual:
461
+ found.append(
462
+ f"[stores.{name}] resolves to {resolved}, but this config already holds a "
463
+ f"live {actual} -- an installed store, or one built before the config moved."
464
+ )
465
+ return found
466
+
467
+
468
+ def describe_config(config: Optional[Config] = None) -> ConfigReport:
469
+ """Report what the config surface resolves to, without side effects.
470
+
471
+ With no argument this builds its own `Config`, so the report describes what a *fresh*
472
+ resolution would pick rather than any store a caller installed. That is the right answer
473
+ for a CLI, which is asked what a command *would* use.
474
+
475
+ A caller that passes its own `Config` gets a report about **that object**, installed
476
+ state and all. A long-running service needs this and a fresh resolution actively
477
+ misleads there: it calls `configure()` at startup, so a fresh report describes a
478
+ process that does not exist rather than the one serving the request — the same
479
+ reassuring story this module refuses everywhere else, one level out.
480
+
481
+ The report then reflects installed doubles, deliberately.
482
+ """
483
+ config = config if config is not None else Config()
484
+ search_root = Path.cwd().resolve()
485
+ # Shared with `sources`, so the two reports cannot disagree about whether a file is in
486
+ # play. A file that won't parse comes back as the report's error rather than a
487
+ # traceback — this command is precisely what gets reached for then — and the sections
488
+ # are still reported, because a higher layer may be resolving over the top of it.
489
+ config_path, config_source, file_error = _classify_file(config)
490
+ if config_source == SOURCE_ERROR:
491
+ return ConfigReport(config_path=None, config_source=SOURCE_ERROR,
492
+ search_root=search_root, error=file_error,
493
+ plugins=PluginsReport(error=file_error))
494
+
495
+ document = {} if file_error else config.config_file()
496
+ # A plugin's table is the plugin's to read, so no claim is made about a `config` key in it.
497
+ core = {k: v for k, v in document.items() if k != plugin_tables.TABLE}
498
+ warnings = (_misplaced_sections(document) + _keys_shadowed_by_config(core, (), _KNOWN)
499
+ + _misspelled_plugins_table(document) + _unread(document, _KNOWN))
500
+
501
+ sections = [_describe(config, section) for section in _SECTIONS]
502
+ warnings += _live_store_disagreements(config, sections)
503
+ return ConfigReport(config_path=config_path, config_source=config_source, warnings=warnings,
504
+ search_root=search_root, sections=sections, error=file_error,
505
+ plugins=_describe_plugins(config))
506
+
507
+
508
+ def _near_plugins(key: str) -> bool:
509
+ return key != plugin_tables.TABLE and bool(
510
+ difflib.get_close_matches(key.lower(), [plugin_tables.TABLE], cutoff=0.85))
511
+
512
+
513
+ def _misspelled_plugins_table(document: Mapping[str, Any]) -> list[str]:
514
+ """A top-level table one letter from ``[plugins]``: nothing reads it, and the plugin whose
515
+ settings it holds silently gets none."""
516
+ return [
517
+ f"agent-env does not read {_table(key)}: a plugin's settings go under "
518
+ f"[{plugin_tables.TABLE}.<package>]. Did you mean [{plugin_tables.TABLE}]?"
519
+ for key in document
520
+ if _near_plugins(key)
521
+ ]
522
+
523
+
524
+ # How close a key must be to a known one to be offered as the one meant: `implz` is 0.8 from
525
+ # `impls`, and `export`, 0.71 from `explorer`, gets no advice rather than wrong advice.
526
+ _NEAR = 0.75
527
+
528
+
529
+ def _fits(body: Any, shape: Shape) -> bool:
530
+ """Whether what the file wrote could be what a known key takes: a table where one is taken,
531
+ sharing a key with it, or for a seam the string that names its class or backend."""
532
+ if isinstance(shape, Named):
533
+ return isinstance(body, dict)
534
+ if shape is _SEAM and isinstance(body, str):
535
+ return True
536
+ if isinstance(shape, Mapping):
537
+ return isinstance(body, dict) and (not body or bool(set(body) & set(shape)))
538
+ return True
539
+
540
+
541
+ def _near(key: str, known: Mapping[str, Any], body: Any = None) -> Optional[str]:
542
+ folded = {k.lower().replace("-", "_"): k for k in known if _fits(body, known[k])}
543
+ match = difflib.get_close_matches(key.lower().replace("-", "_"), list(folded), n=1, cutoff=_NEAR)
544
+ return folded[match[0]] if match else None
545
+
546
+
547
+ def _reported_elsewhere(table: Mapping[str, Any], path: tuple[str, ...], key: str,
548
+ shape: Mapping[str, Shape]) -> bool:
549
+ """Whether another warning already names this key, so it is reported once."""
550
+ if not path:
551
+ return _near_plugins(key)
552
+ if len(path) == 1 and _misplaced(path[0], key):
553
+ return True
554
+ # the shadowed-by-config warning covers only a table that takes `config`
555
+ config = table.get("config")
556
+ return "config" in shape and isinstance(config, dict) and key in config and key != "impl"
557
+
558
+
559
+ def _listed(names: list[str]) -> str:
560
+ quoted = [repr(n) for n in names]
561
+ if not quoted:
562
+ return "no keys"
563
+ return quoted[0] if len(quoted) == 1 else f"{', '.join(quoted[:-1])} and {quoted[-1]}"
564
+
565
+
566
+ def _tables_taking(key: str) -> list[str]:
567
+ """The tables that take a key named ``key``: where a key written above every header belongs."""
568
+ return [f"[{name}]" for name, shape in _KNOWN.items() if isinstance(shape, Mapping) and key in shape]
569
+
570
+
571
+ def _unread_message(path: tuple[str, ...], key: str, shape: Mapping[str, Shape], body: Any = None) -> str:
572
+ if path and (key in _TOP_LEVEL_SECTIONS or key == plugin_tables.TABLE):
573
+ return (f"{_table(*path)} has a key named {key!r}, which is also a top-level section — "
574
+ f"a bare key binds to the table above it. Did you mean [{key}]?")
575
+ near = _near(key, shape, body)
576
+ if not path and not isinstance(body, dict):
577
+ # A bare key above the first header: a key, not a table.
578
+ if near and near != plugin_tables.TABLE:
579
+ return f"agent-env does not read the top-level key {key!r}. Did you mean {near!r}?"
580
+ tables = _tables_taking(key)
581
+ takes = "takes" if len(tables) == 1 else "take"
582
+ where = f"; {' and '.join(tables)} {takes} a key of that name" if tables else ""
583
+ return f"The top-level key {key!r} is outside every table, and agent-env does not read it{where}."
584
+ if not path:
585
+ if near:
586
+ return f"agent-env does not read {_table(key)}: it has no such table. Did you mean [{near}]?"
587
+ return (f"agent-env does not read {_table(key)}: it has no such table, and a plugin's own settings "
588
+ f"go under [{plugin_tables.TABLE}.<package>].")
589
+ table = _table(*path)
590
+ said = f"{table} has a key named {key!r}, which agent-env does not read"
591
+ if near:
592
+ return f"{said}. Did you mean {near!r}?"
593
+ if shape is _SEAM:
594
+ return (f"{said}: {table} takes {_listed(list(shape))}, and the class's own settings go "
595
+ f"under {_table(*path, 'config')}.")
596
+ return f"{said}: {table} takes {_listed(list(shape))}."
597
+
598
+
599
+ def _unread(document: Mapping[str, Any], shape: Mapping[str, Shape], path: tuple[str, ...] = ()) -> list[str]:
600
+ """Tables and keys no reader takes, checked against the keys each section declares.
601
+
602
+ Checks the file rather than what won, since a typo in a table an env var replaces is still
603
+ a typo. A free-form table is not descended into, and a key another warning names is skipped.
604
+ """
605
+ found = []
606
+ for key, body in document.items():
607
+ if key not in shape:
608
+ if not _reported_elsewhere(document, path, key, shape):
609
+ found.append(_unread_message(path, key, shape, body))
610
+ continue
611
+ inner = shape[key]
612
+ if isinstance(body, dict) and isinstance(inner, Named):
613
+ for name, entry in body.items():
614
+ if isinstance(entry, dict):
615
+ found += _unread(entry, inner.shape, path + (key, name))
616
+ elif isinstance(body, dict) and isinstance(inner, Mapping):
617
+ found += _unread(body, inner, path + (key,))
618
+ return found
619
+
620
+
621
+ def _plugins_table(config: Config) -> tuple[Mapping[str, Any], Optional[str]]:
622
+ try:
623
+ return config.section(plugin_tables.TABLE), None
624
+ except (ConfigError, OSError) as e:
625
+ return {}, str(e)
626
+
627
+
628
+ def _plugin_table(config: Config, owner: PluginOwner, keys: list[str]) -> PluginTableReport:
629
+ """One plugin's table, found as ``agent_env.plugins.settings`` finds it, so the two agree."""
630
+ if len(keys) > 1:
631
+ return PluginTableReport(owner, tuple(keys), error=plugin_tables.duplicate(owner.name, keys))
632
+ try:
633
+ return PluginTableReport(owner, tuple(keys), value=config.section(plugin_tables.TABLE, keys[0]))
634
+ except ConfigError as e:
635
+ return PluginTableReport(owner, tuple(keys), error=str(e))
636
+
637
+
638
+ def _describe_plugins(config: Config) -> PluginsReport:
639
+ table, error = _plugins_table(config)
640
+ if error is not None:
641
+ return PluginsReport(error=error)
642
+ names = plugin_tables.spellings(table)
643
+ installed = plugin_tables.installed() if names else {}
644
+ return PluginsReport(tables=tuple(
645
+ _plugin_table(config, PluginOwner(name, installed.get(name)), keys)
646
+ for name, keys in names.items()
647
+ ))
648
+
649
+
650
+ # Every environment variable the config package reads, and the TOML path it shadows.
651
+ # Declared once so `config sources` cannot promise a complete checklist and then omit a
652
+ # variable, and so a new one is a test failure rather than a silent hole in the report.
653
+ @dataclass(frozen=True)
654
+ class EnvSource:
655
+ name: str
656
+ shadows: Optional[tuple[str, ...]] = None
657
+ note: Optional[str] = None
658
+
659
+
660
+ @lru_cache(maxsize=1)
661
+ def env_sources() -> tuple[EnvSource, ...]:
662
+ """Every environment variable agent-env reads, and the TOML path it shadows."""
663
+ return tuple(
664
+ [EnvSource(s.env_var, s.toml_path) for s in ALIASED_SECTIONS]
665
+ + [
666
+ EnvSource("LITELLM_API_KEY", ("model", "api_key")),
667
+ EnvSource("LITELLM_BASE_URL", ("model", "base_url")),
668
+ EnvSource("AGENT_ENV_HUMAN_A2A_URL", ("conversations", "default_human_a2a_url")),
669
+ # Read by the resolver but shadowing nothing in the file — listed so the checklist
670
+ # is complete, with no `shadows` because claiming one would be false.
671
+ EnvSource("AGENT_SANDBOX_MODE", None, "no file equivalent"),
672
+ EnvSource("AGENT_ENV_MODAL_REGION", None, "no file equivalent"),
673
+ EnvSource("AGENT_ENV_MODAL_APP_NAME", None, "no file equivalent"),
674
+ EnvSource("AGENT_ENV_FIXTURE_PREFIX", None, "no file equivalent"),
675
+ EnvSource("MODAL_TOKEN_ID", None, "credential, no file equivalent"),
676
+ EnvSource("MODAL_TOKEN_SECRET", None, "credential, no file equivalent"),
677
+ ]
678
+ )
679
+
680
+
681
+ _RESOLVED_BY_KEY = {key.name: key for key in RESOLVED_KEYS}
682
+
683
+
684
+ @dataclass(frozen=True)
685
+ class SourceReport:
686
+ """One layer of the resolution chain, and whether it is contributing anything."""
687
+
688
+ kind: str
689
+ where: str
690
+ present: bool
691
+ detail: Optional[str] = None
692
+ shadows: Optional[str] = None
693
+
694
+
695
+ def _classify_file(config: Config) -> tuple[Optional[Path], ConfigSource, Optional[str]]:
696
+ """The winning file, how it was found, and why it could not be read. One place, because
697
+ `describe_config` and `sources` disagreeing about whether a file is in play would be
698
+ the reports contradicting each other."""
699
+ try:
700
+ path = config.config_path()
701
+ except ConfigError as e:
702
+ return None, SOURCE_ERROR, str(e)
703
+ if path is None:
704
+ return None, SOURCE_NONE, None
705
+ # Against what discovery would find, not against the environment variable alone: a
706
+ # supplied Config can carry a document discovery never chose, and crediting
707
+ # $AGENT_ENV_CONFIG for that path would name a file the object never read.
708
+ try:
709
+ discovered = config_loader.discover_config_path()
710
+ except (ConfigError, OSError):
711
+ discovered = None
712
+ if discovered != path:
713
+ source: ConfigSource = SOURCE_INSTALLED
714
+ else:
715
+ source = SOURCE_ENV if os.getenv(config_loader.ENV_CONFIG_PATH) else SOURCE_WALK_UP
716
+ try:
717
+ config.config_file()
718
+ except (ConfigError, OSError) as e:
719
+ return path, source, f"{path}: {e}"
720
+ return path, source, None
721
+
722
+
723
+ def sources(config: Optional[Config] = None) -> list[SourceReport]:
724
+ """Every layer the resolver consults, **lowest precedence first**.
725
+
726
+ `search_path` answers "which file", `describe_config` answers "what did each section
727
+ resolve to"; this answers the question between them — which layers are in play at all.
728
+ A layer contributing nothing is still listed, because "the file I edited is not being
729
+ read" and "a variable I forgot about is overriding it" are what this is reached for,
730
+ and neither is visible from a list of what won.
731
+
732
+ Ordering is the array's own, not a numbered layer id: the numbering of the target
733
+ precedence chain is not settled, and publishing one that later renumbers would be worse
734
+ than publishing none.
735
+ """
736
+ config = config if config is not None else Config()
737
+ out = [SourceReport(KIND_DEFAULT, "built-in defaults", True)]
738
+
739
+ path, source, error = _classify_file(config)
740
+ if source == SOURCE_ERROR:
741
+ out.append(SourceReport(KIND_FILE, "(discovery failed)", False, error))
742
+ elif path is None:
743
+ out.append(SourceReport(KIND_FILE, "(no config file)", False,
744
+ "none found on the search path"))
745
+ else:
746
+ # How it was found is a layer distinction, not trivia: $AGENT_ENV_CONFIG is terminal
747
+ # over a discovered file, so conflating the two hides which one could still apply.
748
+ how = (f"via ${config_loader.ENV_CONFIG_PATH}" if source == SOURCE_ENV
749
+ else "discovered by walking up")
750
+ detail = how if error is None else f"{how}; unreadable: {error}"
751
+ out.append(SourceReport(KIND_FILE, str(path), error is None, detail))
752
+
753
+ # One row per variable: a name can be both a declared override and referenced by the
754
+ # file, and printing it twice would read as two different sources.
755
+ referenced = _env_references(config)
756
+ for env in env_sources():
757
+ value = os.getenv(env.name)
758
+ detail = env.note if value is None else mask_value(env.name, value)
759
+ where = referenced.pop(env.name, None)
760
+ if where is not None:
761
+ detail = "; ".join(filter(None, [detail, f"also referenced by {where}"]))
762
+ out.append(SourceReport(KIND_ENV, f"${env.name}", value is not None, detail,
763
+ None if env.shadows is None else ".".join(env.shadows)))
764
+ for name, where in sorted(referenced.items()):
765
+ present = os.getenv(name) is not None
766
+ # No `shadows`: a reference does not override that path, it supplies it — the
767
+ # distinction the precedence chain turns on.
768
+ out.append(SourceReport(KIND_ENV, f"${name}", present,
769
+ f"referenced by {where}" if present
770
+ else f"referenced by {where}, unset"))
771
+ return out
772
+
773
+
774
+ def _env_references(config: Config) -> dict[str, str]:
775
+ """`env:NAME` references the active file makes, name -> every path that references it.
776
+
777
+ Not a precedence layer — a reference resolves *inside* the document that wrote it — but
778
+ it is still an environment variable supplying a value, and a checklist that omitted it
779
+ would leave an operator hunting for an override that is really a missing variable.
780
+ """
781
+ try:
782
+ document = config.config_file()
783
+ except (ConfigError, OSError):
784
+ return {}
785
+ found: dict[str, list[str]] = {}
786
+
787
+ def walk(node: Any, path: tuple[str, ...]) -> None:
788
+ if isinstance(node, dict):
789
+ for k, v in node.items():
790
+ walk(v, path + (k,))
791
+ elif isinstance(node, list):
792
+ for item in node:
793
+ walk(item, path)
794
+ elif isinstance(node, str) and node.startswith(config_loader.ENV_REF_PREFIX):
795
+ name = node[len(config_loader.ENV_REF_PREFIX):].partition("?")[0]
796
+ # Every site, not the first: one variable feeding two keys is exactly the
797
+ # coupling worth seeing, and naming one of them would hide the other.
798
+ found.setdefault(name, []).append(".".join(path))
799
+
800
+ walk(document, ())
801
+ return {name: ", ".join(f"[{p}]" for p in sorted(set(paths)))
802
+ for name, paths in found.items()}
803
+
804
+
805
+ @dataclass(frozen=True)
806
+ class PathReport:
807
+ """One dotted path: what it resolves to, and which layer supplied it."""
808
+
809
+ path: str
810
+ value: Any = None
811
+ winner: Optional[Layer] = None
812
+ shadowed: tuple[Layer, ...] = ()
813
+ section: Optional[str] = None
814
+ error: Optional[str] = None
815
+ file_only: bool = False
816
+ plugin: Optional[PluginOwner] = None
817
+
818
+ def __post_init__(self) -> None:
819
+ # Masked on construction, never by remembering: doing it at each call site is what
820
+ # this module tried first, and three separate leaks shipped that way — a scalar
821
+ # layer's `summary()` prints the value, so one forgotten call publishes a secret.
822
+ leaf = self.path.rsplit(".", 1)[-1]
823
+ if self.winner is not None:
824
+ object.__setattr__(self, "winner", _masked_layer(self.winner, leaf))
825
+ object.__setattr__(self, "shadowed", tuple(_masked_layer(b, leaf) for b in self.shadowed))
826
+ object.__setattr__(self, "value", _mask_table(self.value, key=leaf))
827
+
828
+
829
+ @dataclass(frozen=True)
830
+ class SectionsReport:
831
+ """A path that is an *ancestor* of real sections, such as `stores`.
832
+
833
+ Its own type rather than a `children` field on `PathReport`: such a path has no single
834
+ winner and no value of its own, so every field describing one would have to be read as
835
+ "not applicable here" — a second shape wearing the first one's clothes.
836
+ """
837
+
838
+ path: str
839
+ children: tuple[PathReport, ...] = ()
840
+
841
+
842
+ Explained = Union[PathReport, SectionsReport]
843
+
844
+
845
+ # Both spellings resolve: an operator reads the TOML path off a config file and should not
846
+ # have to know the internal section name exists.
847
+ _SECTIONS: tuple = ALIASED_SECTIONS + _FILE_SECTIONS
848
+ _SECTION_BY_KEY = {k: s for s in _SECTIONS
849
+ for k in (s.name, ".".join(s.toml_path))}
850
+
851
+
852
+ def _known_tables() -> dict[str, Shape]:
853
+ """Every table agent-env reads, nested as the file nests them, plus `[plugins]`, which it
854
+ reads none of."""
855
+ known: dict[str, Any] = {plugin_tables.TABLE: None}
856
+ for section in _SECTIONS:
857
+ *parents, leaf = section.toml_path
858
+ node = known
859
+ for part in parents:
860
+ node = node.setdefault(part, {})
861
+ node[leaf] = section.keys if isinstance(section, FileSection) else _SEAM
862
+ return known
863
+
864
+
865
+ _KNOWN = _known_tables()
866
+
867
+
868
+ def _describe(config: Config, section) -> SectionReport:
869
+ """Whichever kind of section it is, described the way that kind resolves."""
870
+ return (_describe_file_section(config, section) if isinstance(section, FileSection)
871
+ else _describe_section(config, section.name))
872
+
873
+
874
+ def explain_path(path: str, config: Optional[Config] = None) -> Explained:
875
+ """Where one dotted path's value came from — the same layers the resolver would take.
876
+
877
+ A path below a section is read out of whichever layer *won that section*, not out of
878
+ the file: reporting the file's nested value while an environment variable replaced the
879
+ whole section is the reassuring story this module exists to refuse.
880
+ """
881
+ parts = tuple(part for part in path.split(".") if part)
882
+ if not parts:
883
+ return PathReport(path=path, error="empty path")
884
+ key = ".".join(parts)
885
+ config = config if config is not None else Config()
886
+ if parts[0] == plugin_tables.TABLE:
887
+ return _explain_plugins(config, path, _segments(path)[1:])
888
+
889
+ section = _SECTION_BY_KEY.get(key)
890
+ if section is not None:
891
+ return _as_path_report(path, section.name, _describe(config, section))
892
+ resolved = _RESOLVED_BY_KEY.get(key)
893
+ if resolved is not None:
894
+ return _explain_resolved_key(config, path, resolved)
895
+
896
+ below = [s for s in _SECTIONS
897
+ if len(parts) > len(s.toml_path) and parts[:len(s.toml_path)] == s.toml_path]
898
+ if below:
899
+ return _explain_below_section(config, path, below[0], parts[len(below[0].toml_path):])
900
+
901
+ above = [s for s in _SECTIONS
902
+ if len(s.toml_path) > len(parts) and s.toml_path[:len(parts)] == parts]
903
+ if above:
904
+ return SectionsReport(path=path, children=tuple(
905
+ _as_path_report(".".join(s.toml_path), s.name, _describe(config, s)) for s in above))
906
+
907
+ layer = config.file_candidate(parts)
908
+ if layer is None:
909
+ return PathReport(path=path, file_only=True)
910
+ if layer.error is not None:
911
+ return PathReport(path=path, winner=layer, error=layer.error, file_only=True)
912
+ return PathReport(path=path, value=layer.raw, winner=layer, file_only=True)
913
+
914
+
915
+ def _segments(path: str) -> list[str]:
916
+ """``path`` split on the dots TOML splits on: a ``"quoted"`` segment is one key, so a table
917
+ label copied from ``config show``, such as ``plugins."AgentEnv.Toy"``, names that table."""
918
+ out, current, quoted, escaped = [], "", False, False
919
+ for char in path:
920
+ if escaped:
921
+ current, escaped = current + char, False
922
+ elif quoted and char == "\\":
923
+ escaped = True
924
+ elif char == '"':
925
+ quoted = not quoted
926
+ elif char == "." and not quoted:
927
+ out.append(current)
928
+ current = ""
929
+ else:
930
+ current += char
931
+ return [segment for segment in (*out, current) if segment]
932
+
933
+
934
+ def _plugin_named(rest: list[str], *known: Mapping[str, Any]) -> tuple[str, list[str]]:
935
+ """The distribution a path below ``plugins.`` names, and the key path under its table. The
936
+ first segment is the table, as TOML reads it; failing that, a dotted name as ``plugin list``
937
+ prints one (``AgentEnv.Toy``), longest first."""
938
+ first = canonicalize_name(rest[0])
939
+ if any(first in names for names in known):
940
+ return first, rest[1:]
941
+ for names in known:
942
+ for end in range(len(rest), 1, -1):
943
+ if (name := canonicalize_name(".".join(rest[:end]))) in names:
944
+ return name, rest[end:]
945
+ return first, rest[1:]
946
+
947
+
948
+ def _explain_plugins(config: Config, path: str, rest: list[str]) -> Explained:
949
+ """A path in ``[plugins]``: the file is its only layer, and agent-env does not read it; the
950
+ report says whose table it is. Looked up by canonical name, as the plugin's own read is."""
951
+ table, error = _plugins_table(config)
952
+ if error is not None:
953
+ return PathReport(path=path, section=plugin_tables.TABLE, error=error,
954
+ winner=Layer(KIND_FILE, f"[{plugin_tables.TABLE}]", error=error))
955
+ names = plugin_tables.spellings(table)
956
+ if not rest and not names:
957
+ return PathReport(path=path, section=plugin_tables.TABLE)
958
+ installed = plugin_tables.installed()
959
+ if not rest:
960
+ return SectionsReport(path=path, children=tuple(
961
+ _explain_plugin(config, f"{plugin_tables.TABLE}.{name}",
962
+ PluginOwner(name, installed.get(name)), keys, [])
963
+ for name, keys in names.items()))
964
+ name, below = _plugin_named(rest, names, installed)
965
+ owner = PluginOwner(name, installed.get(name))
966
+ return _explain_plugin(config, path, owner, names.get(name, []), below)
967
+
968
+
969
+ def _explain_plugin(config: Config, path: str, owner: PluginOwner, keys: list[str],
970
+ below: list[str]) -> PathReport:
971
+ if not keys:
972
+ return PathReport(path=path, section=plugin_tables.TABLE, plugin=owner)
973
+ found = _plugin_table(config, owner, keys)
974
+ if found.error is not None:
975
+ return PathReport(path=path, section=plugin_tables.TABLE, plugin=owner, error=found.error,
976
+ winner=Layer(KIND_FILE, found.where, error=found.error))
977
+ # Walked in the masked table, so a key beneath a secret-shaped one stays masked.
978
+ node: Any = found.value
979
+ for part in below:
980
+ node = node.get(part) if isinstance(node, dict) else None
981
+ return PathReport(path=path, value=node, winner=Layer(KIND_FILE, found.where, found.value),
982
+ section=plugin_tables.TABLE, plugin=owner)
983
+
984
+
985
+ def _explain_resolved_key(config: Config, path: str, key) -> PathReport:
986
+ """A key the file is not the only source for, resolved through the same
987
+ `Config.key_candidates` the getter takes — so the two cannot disagree about the winner."""
988
+ candidates = config.key_candidates(key)
989
+ if not candidates:
990
+ return PathReport(path=path)
991
+ winner = candidates[0]
992
+ if winner.error is not None:
993
+ return PathReport(path=path, winner=winner, shadowed=tuple(candidates[1:]),
994
+ error=winner.error)
995
+ return PathReport(path=path, value=winner.raw, winner=winner,
996
+ shadowed=tuple(candidates[1:]))
997
+
998
+
999
+ def _explain_below_section(config: Config, path: str, section, rest: tuple[str, ...]) -> PathReport:
1000
+ """A path inside a section: read out of the layer that won the section, never the file.
1001
+
1002
+ Walking the *resolved* section is what keeps this honest — when an environment variable
1003
+ replaced the section, the file's nested key is not what the process reads, and a report
1004
+ naming it would be describing a value nothing uses.
1005
+ """
1006
+ described = _describe(config, section)
1007
+ if described.error is not None:
1008
+ return PathReport(path=path, winner=described.winner, shadowed=described.shadowed,
1009
+ section=section.name, error=described.error)
1010
+ node: Any = described.value
1011
+ for part in rest:
1012
+ if not isinstance(node, dict) or part not in node:
1013
+ node = None
1014
+ break
1015
+ node = node[part]
1016
+ return PathReport(path=path, value=node, winner=described.winner,
1017
+ shadowed=described.shadowed, section=section.name)
1018
+
1019
+
1020
+ def _as_path_report(path: str, name: str, report: SectionReport) -> PathReport:
1021
+ return PathReport(path=path, value=report.value, winner=report.winner,
1022
+ shadowed=report.shadowed, section=name, error=report.error)
1023
+
1024
+
1025
+ def as_dict(report: ConfigReport) -> dict:
1026
+ """The report as JSON-able data. Provenance is preserved — every surveyed tool drops it
1027
+ from its machine-readable form, which makes that form useless for the one question
1028
+ worth asking.
1029
+
1030
+ Lives here rather than in the CLI so a service can serve the same shape without
1031
+ importing a command module.
1032
+ """
1033
+ return {
1034
+ "config_path": str(report.config_path) if report.config_path else None,
1035
+ "config_source": report.config_source,
1036
+ "search_root": str(report.search_root),
1037
+ "error": report.error,
1038
+ "warnings": report.warnings,
1039
+ "sections": [
1040
+ {
1041
+ "name": s.name,
1042
+ "winner": {"kind": s.winner.kind, "where": s.winner.where, "value": s.winner.summary()},
1043
+ "shadowed": [{"kind": b.kind, "where": b.where, "value": b.summary(), "error": b.error}
1044
+ for b in s.shadowed],
1045
+ "impl": s.impl,
1046
+ "config": s.config,
1047
+ "value": s.value,
1048
+ "error": s.error,
1049
+ }
1050
+ for s in report.sections
1051
+ ],
1052
+ "plugins": {
1053
+ "error": report.plugins.error,
1054
+ "tables": [
1055
+ {
1056
+ "name": t.owner.name,
1057
+ "installed": t.owner.installed is not None,
1058
+ "version": t.owner.version,
1059
+ "plugin": t.owner.plugin,
1060
+ "keys": list(t.keys),
1061
+ "value": t.value,
1062
+ "error": t.error,
1063
+ }
1064
+ for t in report.plugins.tables
1065
+ ],
1066
+ },
1067
+ }