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,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, ...] = ()
|