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.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- 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
|
+
}
|