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,13 @@
1
+ """Errors for the ``.agentenv/config.toml`` configuration surface."""
2
+
3
+
4
+ class ConfigError(ValueError):
5
+ """Raised when the ``.agentenv/config.toml`` (or a backend/section selection) is invalid."""
6
+
7
+ pass
8
+
9
+
10
+ class EmptySecretBundleError(ConfigError, KeyError):
11
+ """Empty secret bundle — also a KeyError so key-tolerant callers keep degrading."""
12
+
13
+ __str__ = Exception.__str__ # KeyError.__str__ would repr-quote the message
@@ -0,0 +1,245 @@
1
+ """Loader for the ``.agentenv/config.toml`` config surface.
2
+
3
+ Turns a store section (an ``impl`` dotted path + a ``config`` table) into a
4
+ constructed store: resolve ``env:`` / ``secret:`` references (each with an optional
5
+ ``?default``), import the class, guard it against the target ABC, and call
6
+ ``from_config``. Secret values never live in the toml, only references. See the
7
+ README for the schema and examples.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import inspect
14
+ import logging
15
+ import os
16
+ import re
17
+ import tomllib
18
+ from importlib.metadata import PackageNotFoundError, metadata
19
+ from pathlib import Path
20
+ from typing import Any, Callable, Optional
21
+
22
+ from packaging.requirements import Requirement
23
+
24
+ from agent_env.config.errors import ConfigError
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ DISTRIBUTION = "agentenv-framework"
29
+ _CANNOT_IMPORT_NAME = re.compile(r"cannot import name '([\w.]+)' from '([\w.]+)'")
30
+ _NAME_SEPARATORS = re.compile(r"[-_.]+")
31
+ _CONFIG_DIR = ".agentenv"
32
+ _CONFIG_FILE = "config.toml"
33
+ ENV_CONFIG_PATH = "AGENT_ENV_CONFIG"
34
+
35
+ SecretResolver = Callable[[str], Optional[str]]
36
+
37
+ ENV_REF_PREFIX = "env:"
38
+ SECRET_REF_PREFIX = "secret:"
39
+
40
+ # The keys a seam table may hold: the class, and the kwargs it is built with.
41
+ SEAM_KEYS = ("impl", "config")
42
+
43
+
44
+ def discover_config_path(start: Optional[Path] = None) -> Optional[Path]:
45
+ """Locate the config.toml: ``AGENT_ENV_CONFIG`` wins, else the nearest
46
+ ``.agentenv/config.toml`` walking up from ``start`` (CWD by default).
47
+
48
+ An explicit ``AGENT_ENV_CONFIG`` override must point to an existing file —
49
+ fail loud rather than silently drop the whole config (and derive local-store
50
+ paths from a non-existent directory) on a typo'd override path."""
51
+ override = os.getenv(ENV_CONFIG_PATH)
52
+ if override:
53
+ path = Path(override)
54
+ if not path.is_file():
55
+ raise ConfigError(f"{ENV_CONFIG_PATH}={override!r} does not point to an existing file")
56
+ return path
57
+ for candidate in _walk_up_candidates(start):
58
+ if candidate.is_file():
59
+ return candidate
60
+ return None
61
+
62
+
63
+ def _walk_up_candidates(start: Optional[Path] = None) -> list[Path]:
64
+ here = (start or Path.cwd()).resolve()
65
+ return [directory / _CONFIG_DIR / _CONFIG_FILE for directory in (here, *here.parents)]
66
+
67
+
68
+ def config_search_path(start: Optional[Path] = None) -> list[tuple[Path, str]]:
69
+ """Every path ``discover_config_path`` would consider, in order, and why.
70
+
71
+ Lives here because ``agent-env config debug`` exists to report discovery: a second copy
72
+ of the walk would put the one command that must not lie about the search path in charge
73
+ of guessing it.
74
+ """
75
+ override = os.getenv(ENV_CONFIG_PATH)
76
+ if override:
77
+ return [(Path(override), f"${ENV_CONFIG_PATH}")]
78
+ return [(path, "walk-up") for path in _walk_up_candidates(start)]
79
+
80
+
81
+ _UTF8_BOM = b"\xef\xbb\xbf"
82
+
83
+
84
+ def load_config_file(path: Optional[Path]) -> dict:
85
+ """Parse the config.toml at ``path`` (``{}`` when absent).
86
+
87
+ Deliberately lenient about a path that is not there: this is a public helper and the
88
+ sdk's exporter calls it with a freshly discovered path. Knowing that a path *was* just
89
+ discovered — so that losing it is a race and not an absent config — belongs to the
90
+ caller that discovered it, which is ``snapshot``; it uses ``read_config_file``.
91
+ """
92
+ if path is None or not path.is_file():
93
+ return {}
94
+ return read_config_file(path)
95
+
96
+
97
+ def read_config_file(path: Path) -> dict:
98
+ """Parse the config.toml at ``path``, or raise.
99
+
100
+ No existence check: the open *is* the check. Testing first and reading second leaves a
101
+ window where the file can go, and a caller that has to ask "does it exist" before every
102
+ read has a race rather than an answer.
103
+ """
104
+ with open(path, "rb") as f:
105
+ raw = f.read()
106
+ # An editor that writes a UTF-8 BOM produces a file correct to its author and rejected by
107
+ # tomllib at line 1, column 1. The bytes carry no meaning here, so drop them.
108
+ raw = raw[len(_UTF8_BOM):] if raw.startswith(_UTF8_BOM) else raw
109
+ try:
110
+ return tomllib.loads(raw.decode("utf-8"))
111
+ except tomllib.TOMLDecodeError as e:
112
+ raise ConfigError(f"Malformed config.toml at {path}: {e}") from e
113
+ except UnicodeDecodeError as e:
114
+ raise ConfigError(f"config.toml at {path} is not valid UTF-8: {e}") from e
115
+
116
+
117
+ def interpolate(value: Any, *, secret_resolver: Optional[SecretResolver] = None) -> Any:
118
+ """Resolve ``env:NAME`` / ``secret:KEY`` string references (each with an optional
119
+ ``?default`` suffix), recursing into dicts/lists; pass any other value through
120
+ unchanged. ``secret:`` needs a ``secret_resolver``; when none is available (e.g.
121
+ while building the secret store itself) a ``secret:`` reference fails loud."""
122
+ if isinstance(value, dict):
123
+ return {k: interpolate(v, secret_resolver=secret_resolver) for k, v in value.items()}
124
+ if isinstance(value, list):
125
+ return [interpolate(v, secret_resolver=secret_resolver) for v in value]
126
+ if not isinstance(value, str):
127
+ return value
128
+ if value.startswith(ENV_REF_PREFIX):
129
+ return _resolve_ref(ENV_REF_PREFIX, value, os.getenv)
130
+ if value.startswith(SECRET_REF_PREFIX):
131
+ if secret_resolver is None:
132
+ raise ConfigError(f"Cannot resolve {value!r}: no secret store is available for secret: references here")
133
+ return _resolve_ref(SECRET_REF_PREFIX, value, secret_resolver)
134
+ return value
135
+
136
+
137
+ def _resolve_ref(prefix: str, value: str, resolve: SecretResolver) -> str:
138
+ ref, sep, default = value[len(prefix):].partition("?")
139
+ resolved = resolve(ref)
140
+ if resolved is not None:
141
+ return resolved
142
+ if sep:
143
+ return default
144
+ raise ConfigError(f"Unresolved {prefix}{ref} reference (no value and no default)")
145
+
146
+
147
+ def load_impl(impl: str | type, abc: type) -> type:
148
+ """Import the ``module.path:ClassName`` pointer ``impl`` and guard the class
149
+ against its target interface ``abc``. Shared by every ``.agentenv`` seam that
150
+ resolves a dotted impl pointer (stores, task steps). An already-loaded class, which is
151
+ what an entry-point plugin registers, is only guarded."""
152
+ if isinstance(impl, type):
153
+ if not issubclass(impl, abc):
154
+ raise ConfigError(f"impl {impl.__qualname__!r} is not a subclass of {abc.__name__}")
155
+ return impl
156
+ module_path, sep, attr = impl.partition(":")
157
+ if not sep or not attr:
158
+ raise ConfigError(f"impl must be 'module.path:ClassName', got {impl!r}")
159
+ try:
160
+ cls = getattr(importlib.import_module(module_path), attr)
161
+ except ImportError as e:
162
+ raise ConfigError(f"Cannot import impl {impl!r}: {e}{_install_hint(_missing_module(e))}") from e
163
+ except AttributeError as e:
164
+ raise ConfigError(f"Cannot import impl {impl!r}: {e}") from e
165
+ if not (isinstance(cls, type) and issubclass(cls, abc)):
166
+ raise ConfigError(f"impl {impl!r} is not a subclass of {abc.__name__}")
167
+ return cls
168
+
169
+
170
+ def _missing_module(error: ImportError) -> str | None:
171
+ """The module an import failed to find. ``from pkg import mod`` on an absent ``mod`` names
172
+ ``pkg``, and ``mod`` only as ``name_from`` (3.12+) or in the message (3.11)."""
173
+ name_from = getattr(error, "name_from", None)
174
+ if name_from is None and (match := _CANNOT_IMPORT_NAME.match(str(error))):
175
+ return f"{match[2]}.{match[1]}"
176
+ return f"{error.name}.{name_from}" if error.name and name_from else error.name
177
+
178
+
179
+ def _install_hint(module: str | None) -> str:
180
+ """Name the smallest extra of this distribution with a requirement named for the missing
181
+ ``module``: by name, because the missing distribution's files are not installed to read.
182
+ Separators are dropped before comparing, as ``google-cloud-secret-manager`` installs
183
+ ``google.cloud.secretmanager``."""
184
+ if not module:
185
+ return ""
186
+ try:
187
+ package = metadata(DISTRIBUTION)
188
+ except PackageNotFoundError:
189
+ return ""
190
+ wanted = _NAME_SEPARATORS.sub("", module).lower()
191
+ sizes: dict[str, int] = {}
192
+ matches: set[str] = set()
193
+ for line in package.get_all("Requires-Dist") or []:
194
+ requirement = Requirement(line)
195
+ if requirement.marker is None:
196
+ continue
197
+ for extra in package.get_all("Provides-Extra") or []:
198
+ if requirement.marker.evaluate({"extra": extra}):
199
+ sizes[extra] = sizes.get(extra, 0) + 1
200
+ if _NAME_SEPARATORS.sub("", requirement.name).lower() == wanted:
201
+ matches.add(extra)
202
+ if not matches:
203
+ return ""
204
+ extra = min(matches, key=lambda e: (sizes[e], e))
205
+ return f"; it needs the {extra!r} extra, as in pip install '{DISTRIBUTION}[{extra}]'"
206
+
207
+
208
+ def build_store(section: dict, abc: type, *, secret_resolver: Optional[SecretResolver] = None):
209
+ """Import the ``impl`` class named by ``section``, guard it against ``abc``,
210
+ and construct it via ``from_config`` with the interpolated ``config`` table."""
211
+ impl = section.get("impl")
212
+ if not impl:
213
+ raise ConfigError(f"Store section is missing an 'impl' dotted path: {section!r}")
214
+ for key in section:
215
+ if key not in SEAM_KEYS:
216
+ logger.warning("Ignoring unknown key %r in store section (expected 'impl'/'config')", key)
217
+ cls = load_impl(impl, abc)
218
+ config = interpolate(section.get("config", {}), secret_resolver=secret_resolver)
219
+ _check_config_keys(cls, abc, config, impl)
220
+ return cls.from_config(**config)
221
+
222
+
223
+ def _check_config_keys(cls: type, abc: type, config: dict, impl: str | type) -> None:
224
+ """Refuse a config table the store can't take as a ConfigError naming the keys, where Python
225
+ would raise a TypeError that reads as a bug in the store. A class on its interface's default
226
+ ``from_config`` hands the table to its constructor, so that is what the table is checked
227
+ against; any other ``from_config`` is checked against its own parameters."""
228
+ target = cls if getattr(cls.from_config, "__func__", None) is getattr(abc.from_config, "__func__", ...) else cls.from_config
229
+ try:
230
+ parameters = inspect.signature(target).parameters.values()
231
+ except (TypeError, ValueError):
232
+ return
233
+ named = [p for p in parameters if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)]
234
+ takes_any = any(p.kind is p.VAR_KEYWORD for p in parameters)
235
+ unknown = [] if takes_any else sorted(set(config) - {p.name for p in named})
236
+ missing = [p.name for p in named if p.default is p.empty and p.name not in config]
237
+ if not unknown and not missing:
238
+ return
239
+ name = impl if isinstance(impl, str) else impl.__qualname__
240
+ problems = [f"unknown key {', '.join(map(repr, unknown))}"] if unknown else []
241
+ problems += [f"no value for {', '.join(map(repr, missing))}"] if missing else []
242
+ raise ConfigError(
243
+ f"The config table for {name!r} has {' and '.join(problems)}; "
244
+ f"it takes {', '.join(p.name for p in named) or 'no keys'}"
245
+ )
@@ -0,0 +1,154 @@
1
+ """Resolution of the ``[model]`` config.toml block: endpoint, default/per-role models, and
2
+ provider params. A ``ModelConfig`` value object parsed from the ``[model]`` table; a
3
+ ``secret_resolver`` is injected per call so secret references resolve lazily."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from dataclasses import dataclass, field, fields
9
+ from enum import StrEnum
10
+ from typing import Optional
11
+
12
+ from agent_env.config.errors import ConfigError
13
+ from agent_env.config.loader import SecretResolver, interpolate
14
+
15
+ _ENV_BASE_URL = "LITELLM_BASE_URL"
16
+ _ENV_API_KEY = "LITELLM_API_KEY"
17
+
18
+
19
+ class ModelParam(StrEnum):
20
+ """The ``litellm`` call kwargs agent-env sets itself; ``[model.params]`` may not override these."""
21
+
22
+ MODEL = "model"
23
+ MESSAGES = "messages"
24
+ API_KEY = "api_key"
25
+ API_BASE = "api_base"
26
+ USER = "user"
27
+ METADATA = "metadata"
28
+ TIMEOUT = "timeout"
29
+ RESPONSE_FORMAT = "response_format"
30
+
31
+
32
+ MODEL_PARAMS_RESERVED = frozenset(p.value for p in ModelParam)
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ModelCallConfig:
37
+ """Resolved api_base / api_key / params for one model call."""
38
+
39
+ api_key: Optional[str]
40
+ api_base: Optional[str]
41
+ params: dict
42
+
43
+ def client_kwargs(self) -> dict:
44
+ """``api_key`` + ``params``, plus ``api_base`` only when set."""
45
+ kwargs = {ModelParam.API_KEY: self.api_key, **self.params}
46
+ if self.api_base:
47
+ kwargs[ModelParam.API_BASE] = self.api_base
48
+ return kwargs
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class ModelConfig:
53
+ """The parsed ``[model]`` block (every field optional; all-default when unconfigured)."""
54
+
55
+ base_url: Optional[str] = None
56
+ api_key: Optional[str] = None # may hold an unresolved env:/secret: reference
57
+ default: Optional[str] = None
58
+ roles: dict = field(default_factory=dict)
59
+ params: dict = field(default_factory=dict)
60
+
61
+ @classmethod
62
+ def from_section(cls, section: dict, *, secret_resolver: SecretResolver) -> ModelConfig:
63
+ """Build from the raw ``[model]`` section: reject unknown keys and reserved
64
+ ``[model.params]`` keys, then resolve ``env:``/``secret:`` refs — except
65
+ ``api_key``, which keeps its raw reference and resolves lazily per call
66
+ (an eager secret fetch would make every ``[model]`` read need credentials)."""
67
+ allowed = {f.name for f in fields(cls)}
68
+ unknown = set(section) - allowed
69
+ if unknown:
70
+ raise ConfigError(f"[model] has unknown keys {sorted(unknown)}; allowed: {sorted(allowed)}")
71
+ reserved = set(section.get("params", {})) & MODEL_PARAMS_RESERVED
72
+ if reserved:
73
+ raise ConfigError(
74
+ f"[model.params] may not set reserved keys {sorted(reserved)} "
75
+ "(agent-env sets these on the model call)"
76
+ )
77
+ cfg = interpolate(
78
+ {k: v for k, v in section.items() if k != "api_key"},
79
+ secret_resolver=secret_resolver,
80
+ )
81
+ return cls(
82
+ base_url=cfg.get("base_url"),
83
+ api_key=section.get("api_key"),
84
+ default=cfg.get("default"),
85
+ roles=cfg.get("roles", {}),
86
+ params=cfg.get("params", {}),
87
+ )
88
+
89
+ def model_for_role(self, role: str) -> Optional[str]:
90
+ """The ``[model.roles].<role>`` model, else ``default`` (open map)."""
91
+ return self.roles.get(role) or self.default
92
+
93
+ def resolve_api_key(self, *, secret_resolver: SecretResolver) -> str:
94
+ """Key: env ``LITELLM_API_KEY`` > ``api_key`` (resolving a reference lazily);
95
+ actionable failure when neither is set."""
96
+ key = os.getenv(_ENV_API_KEY) or self._resolved_api_key(secret_resolver)
97
+ if not key:
98
+ raise ConfigError(
99
+ "No model API key configured: set [model] api_key in .agentenv/config.toml "
100
+ f"(an env:/secret: reference is fine) or the {_ENV_API_KEY} env var."
101
+ )
102
+ return key
103
+
104
+ def _resolved_api_key(self, secret_resolver: SecretResolver) -> Optional[str]:
105
+ if self.api_key is None:
106
+ return None
107
+ return interpolate(self.api_key, secret_resolver=secret_resolver)
108
+
109
+ def resolve_call(
110
+ self,
111
+ model: str,
112
+ *,
113
+ default_api_key: Optional[str] = None,
114
+ base_override: Optional[str] = None,
115
+ model_overrides: Optional[dict[str, dict[str, str]]] = None,
116
+ secret_resolver: SecretResolver,
117
+ ) -> ModelCallConfig:
118
+ """A provider-prefixed model routes natively when no endpoint is configured; a bare
119
+ model name needs one (override/env/config) and fails actionably without it."""
120
+ if model_overrides:
121
+ override = model_overrides.get(model)
122
+ if override:
123
+ key = secret_resolver(override["secret_key"])
124
+ if key is None:
125
+ raise ConfigError(
126
+ f"Model override for {model!r} names secret {override['secret_key']!r}, "
127
+ "which the configured secret store has no value for"
128
+ )
129
+ return ModelCallConfig(
130
+ api_key=key,
131
+ api_base=base_override or override["base_url"],
132
+ params={},
133
+ )
134
+
135
+ key = default_api_key or os.getenv(_ENV_API_KEY)
136
+ configured_base = os.getenv(_ENV_BASE_URL) or self.base_url
137
+ if base_override:
138
+ api_base = base_override
139
+ elif configured_base:
140
+ api_base = configured_base
141
+ elif "/" in model:
142
+ api_base = None # provider-prefixed: litellm routes natively
143
+ else:
144
+ raise ConfigError(
145
+ f"No model endpoint configured for {model!r}: set [model] base_url in "
146
+ f".agentenv/config.toml or {_ENV_BASE_URL}, or use a provider-prefixed "
147
+ "model name (e.g. 'openai/gpt-4o') for native routing."
148
+ )
149
+ # The configured key is scoped to the configured endpoint (and native routing):
150
+ # a base_override pointing elsewhere must not inherit it, or a caller-supplied
151
+ # URL would receive the configured proxy's credential.
152
+ if key is None and (api_base is None or api_base == configured_base):
153
+ key = self._resolved_api_key(secret_resolver)
154
+ return ModelCallConfig(api_key=key, api_base=api_base, params=dict(self.params))
@@ -0,0 +1,25 @@
1
+ """Where agent-env keeps per-user files outside any project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ _APP_DIR = "agent-env"
10
+
11
+
12
+ def state_root() -> Path:
13
+ """The per-user state root: ``$XDG_STATE_HOME/agent-env`` when that is an absolute path,
14
+ else ``%LOCALAPPDATA%\\agent-env`` on Windows, else ``~/.local/state/agent-env``.
15
+
16
+ A path only; nothing is created. A relative ``$XDG_STATE_HOME`` is ignored, as the XDG
17
+ Base Directory spec requires.
18
+ """
19
+ xdg = os.environ.get("XDG_STATE_HOME", "")
20
+ if os.path.isabs(xdg):
21
+ return Path(xdg) / _APP_DIR
22
+ local_app_data = os.environ.get("LOCALAPPDATA", "")
23
+ if sys.platform == "win32" and os.path.isabs(local_app_data):
24
+ return Path(local_app_data) / _APP_DIR
25
+ return Path.home() / ".local" / "state" / _APP_DIR
@@ -0,0 +1,101 @@
1
+ """A plugin's own settings: its ``[plugins.<distribution name>]`` table.
2
+
3
+ Internal: the public reader is ``agent_env.plugins.settings``. ``config show`` and ``config explain``
4
+ find a table with the same lookup, so the report and the plugin agree about which table it reads.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Mapping
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from importlib.metadata import distributions
14
+ from typing import Any, Optional
15
+
16
+ from packaging.utils import canonicalize_name
17
+
18
+ from agent_env.config import loader as config_loader
19
+ from agent_env.config import runtime
20
+ from agent_env.config.errors import ConfigError
21
+
22
+ TABLE = "plugins"
23
+ _BARE_KEY = re.compile(r"[A-Za-z0-9_-]+")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Installed:
28
+ """An installed distribution: its version, whether it is agent-env itself, and whether it is a
29
+ plugin, which is any other distribution that declares an ``agent_env.*`` entry point."""
30
+
31
+ version: str
32
+ plugin: bool
33
+ core: bool = False
34
+
35
+
36
+ def spellings(table: Mapping[str, Any]) -> dict[str, list[str]]:
37
+ """Each canonical distribution name in ``[plugins]``, and the keys that spell it, in file order."""
38
+ found: dict[str, list[str]] = {}
39
+ for key in table:
40
+ found.setdefault(canonicalize_name(key), []).append(key)
41
+ return found
42
+
43
+
44
+ def where(key: str) -> str:
45
+ """The table as written in TOML, a dotted name quoted so it does not read as nested."""
46
+ return f"[{TABLE}.{toml_key(key)}]"
47
+
48
+
49
+ def toml_key(key: str) -> str:
50
+ """A key as TOML writes it: bare where it can be, quoted otherwise."""
51
+ return key if _BARE_KEY.fullmatch(key) else _quoted(key)
52
+
53
+
54
+ def _quoted(key: str) -> str:
55
+ return '"' + key.replace("\\", "\\\\").replace('"', '\\"') + '"'
56
+
57
+
58
+ def duplicate(name: str, keys: list[str]) -> str:
59
+ tables = ", ".join(where(k) for k in keys)
60
+ return f"config.toml sets {name}'s settings more than once, as {tables}; keep one"
61
+
62
+
63
+ def installed() -> dict[str, Installed]:
64
+ """Every installed distribution, by canonical name. One whose metadata cannot be read is left out."""
65
+ found: dict[str, Installed] = {}
66
+ with suppress(Exception):
67
+ for dist in distributions():
68
+ try:
69
+ name = dist.name
70
+ if name:
71
+ core = canonicalize_name(name) == config_loader.DISTRIBUTION
72
+ plugin = not core and any(ep.group.startswith("agent_env.") for ep in dist.entry_points)
73
+ found.setdefault(canonicalize_name(name), Installed(dist.version or "", plugin, core))
74
+ except Exception:
75
+ continue
76
+ return found
77
+
78
+
79
+ def settings(name: str, *, config: Optional[runtime.Config] = None) -> dict[str, Any]:
80
+ """The plugin ``name``'s own settings: its ``[plugins.<name>]`` table, ``{}`` when there is none.
81
+
82
+ ``name`` is the plugin's distribution name. Keys are matched by canonical name (PEP 503), so
83
+ ``[plugins.agentenv_browser]`` is ``agentenv-browser``'s table. ``env:`` and ``secret:``
84
+ references are resolved, as in every table agent-env reads. Reads ``config``'s document
85
+ (default: the process Config) on every call, so call it where the value is used, not at import.
86
+ Raises ``agent_env.config.ConfigError`` when ``[plugins]`` or the table is not a table, when two
87
+ keys name the same distribution, or when a reference without a default cannot be resolved.
88
+ """
89
+ config = config or runtime.get_config()
90
+ wanted = canonicalize_name(name)
91
+ keys = spellings(config.section(TABLE)).get(wanted, [])
92
+ if not keys:
93
+ return {}
94
+ if len(keys) > 1:
95
+ raise ConfigError(duplicate(wanted, keys))
96
+ try:
97
+ return config_loader.interpolate(
98
+ config.section(TABLE, keys[0]), secret_resolver=lambda key: config.get_secret_store().get(key)
99
+ )
100
+ except ConfigError as e:
101
+ raise ConfigError(f"{where(keys[0])}: {e}") from e
@@ -0,0 +1,134 @@
1
+ """Where a resolved config value came from, and how to say it in one phrase.
2
+
3
+ The resolvers in ``runtime`` compute a value and, on the traced path, the ordered list of
4
+ layers that could have supplied it. Reporting reuses that list rather than re-deriving the
5
+ precedence rule, so the two can never drift.
6
+
7
+ Display lives here too, for the same reason: a winning layer and a shadowed one are the
8
+ same kind of thing said at different lengths, so they share one renderer instead of each
9
+ consumer growing its own.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Any, Literal, Optional
16
+
17
+ LayerKind = Literal["env", "file", "default", "installed"]
18
+
19
+ KIND_ENV: LayerKind = "env"
20
+ KIND_FILE: LayerKind = "file"
21
+ KIND_DEFAULT: LayerKind = "default"
22
+ # Set on the Config itself by `configure()`, above the file and below the environment.
23
+ KIND_INSTALLED: LayerKind = "installed"
24
+
25
+
26
+ def class_name(impl: Any) -> Optional[str]:
27
+ """The class alone — ``S3ObjectStore``, not the dotted path."""
28
+ return impl.rpartition(":")[2] if isinstance(impl, str) else None
29
+
30
+
31
+ def lines(value: Any, *, seam: bool = True) -> list[str]:
32
+ """A config node in full: its class and scalars first, then everything nested, indented.
33
+
34
+ Nothing is elided. `config show` is a diagnostic, so a summary that hid a nested table
35
+ behind its key names hid the values an operator ran the command to read.
36
+
37
+ ``seam=False`` shows a table exactly as written: a plugin's own table gives ``impl`` and
38
+ ``config`` no meaning agent-env knows, so neither is read as a class and its settings.
39
+ """
40
+ if isinstance(value, list):
41
+ return [""] + ([line for item in value for line in _entry(item, seam)] or ["(empty)"])
42
+ if not isinstance(value, dict):
43
+ return ["(unset)" if value is None else _scalar(value)]
44
+ impl = class_name(value.get("impl")) if seam else None
45
+ if not impl:
46
+ return [_scalars(value)] + _nested(value, seam)
47
+ # An impl's `config` reads at the same level as the class it configures; anything else
48
+ # beside them is a key at the wrong level, which is the thing worth seeing. `config`
49
+ # wins a collision, because that is the one the reader takes.
50
+ config = value.get("config")
51
+ hoisted = config if isinstance(config, dict) else {}
52
+ # a `config` that is not a table is an ordinary key, and hiding it would elide it
53
+ skip = ("impl", "config") if hoisted else ("impl",)
54
+ table = {**{k: v for k, v in value.items() if k not in skip}, **hoisted}
55
+ return [" ".join(filter(None, [_scalar(impl), _scalars(table)]))] + _nested(table, seam)
56
+
57
+
58
+ def headline(value: Any, *, seam: bool = True) -> str:
59
+ """What a config node says at shadowed length: the class it names, else the keys it sets."""
60
+ if isinstance(value, dict):
61
+ impl = class_name(value.get("impl")) if seam else None
62
+ return (_scalar(impl) if impl else "") or _scalars(value) or ", ".join(value) or "(unset)"
63
+ return "(unset)" if value is None else _scalar(value)
64
+
65
+
66
+ def _scalar(value: Any) -> str:
67
+ """A value on one physical line: TOML strings may carry newlines, the layout may not."""
68
+ return str(value).replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r")
69
+
70
+
71
+ def _scalars(table: dict) -> str:
72
+ return " ".join(f"{k}={_scalar(v)}" for k, v in table.items()
73
+ if not isinstance(v, (dict, list)))
74
+
75
+
76
+ def _nested(table: dict, seam: bool) -> list[str]:
77
+ """Each table or list under `table`, beneath its own heading."""
78
+ out: list[str] = []
79
+ for key, value in table.items():
80
+ if isinstance(value, list):
81
+ out.append(f"{key}:")
82
+ out += [f" {line}" for item in value for line in _entry(item, seam)] or [" (empty)"]
83
+ elif isinstance(value, dict):
84
+ out.append(f"{key}:")
85
+ out += [f" {row}" for row in _rows(value, seam)] or [" (empty)"]
86
+ return out
87
+
88
+
89
+ def _rows(table: dict, seam: bool) -> list[str]:
90
+ """One entry per line: `name=value` for a scalar, else the node's own lines under it."""
91
+ out: list[str] = []
92
+ for name, node in table.items():
93
+ if not isinstance(node, (dict, list)):
94
+ out.append(f"{name}={_scalar(node)}")
95
+ continue
96
+ body = [line for line in lines(node, seam=seam) if line]
97
+ # a one-key table inlines fine; a one-element list would read as a scalar
98
+ if len(body) == 1 and not isinstance(node, list):
99
+ out.append(f"{name} {body[0]}")
100
+ else:
101
+ out.append(f"{name}:")
102
+ out += [f" {line}" for line in body]
103
+ return out
104
+
105
+
106
+ def _entry(item: Any, seam: bool) -> list[str]:
107
+ """One list entry, as its own block: continuation lines indent under the first."""
108
+ if not isinstance(item, (dict, list)):
109
+ return [_scalar(item)]
110
+ body = [line for line in lines(item, seam=seam) if line]
111
+ return body[:1] + [f" {line}" for line in body[1:]]
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class Layer:
116
+ """One candidate source for a value, resolved or not."""
117
+
118
+ kind: LayerKind
119
+ where: str
120
+ raw: Any = None
121
+ error: Optional[str] = None
122
+
123
+ def summary(self) -> str:
124
+ """The candidate at shadowed length: what was written, not what it became."""
125
+ return f"(unreadable: {self.error})" if self.error is not None else headline(self.raw)
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Traced:
130
+ """A resolved value plus the layer that won and the ones it beat, highest priority first."""
131
+
132
+ value: Any
133
+ winner: Layer
134
+ shadowed: tuple[Layer, ...] = ()