agentforge-core 0.2.4__tar.gz → 0.3.0__tar.gz
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.
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/.gitignore +10 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/PKG-INFO +1 -1
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/pyproject.toml +1 -1
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/__init__.py +6 -0
- agentforge_core-0.3.0/src/agentforge_core/config/app_sections.py +124 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/loader.py +70 -8
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/schema.py +34 -1
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/resolver/discover.py +8 -0
- agentforge_core-0.3.0/tests/integration/test_app_sections_real_discovery.py +170 -0
- agentforge_core-0.3.0/tests/integration/test_config_imports_cli.py +86 -0
- agentforge_core-0.3.0/tests/unit/test_config_app_passthrough.py +154 -0
- agentforge_core-0.3.0/tests/unit/test_config_app_sections.py +169 -0
- agentforge_core-0.3.0/tests/unit/test_config_imports.py +216 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_resolver_discovery.py +37 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/LICENSE +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/README.md +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/_bm25.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/module_schemas.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/auth.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/chat.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/embedding.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/evaluator.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/finding.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/graph_store.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/guardrails.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/llm.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/memory.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/migrator.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/protocol_bridge.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/renderer.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/reranker.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/strategy.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/task.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/tool.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/vector_store.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/migrations/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/migrations/discover.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/migrations/template.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/observability/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/observability/tracing.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/budget.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/exceptions.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/fallback.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/log_filter.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/log_format.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/run_context.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/py.typed +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/resolver/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/resolver/resolve.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/testing/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/testing/conformance.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/__init__.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/auth.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/chat.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/claim.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/graph.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/guardrails.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/manifest.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/messages.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/module.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/pipeline.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/retrieval.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/state.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/vector.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/conftest.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/.gitkeep +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_auth_values.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_bm25.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_budget.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_capability_extensions.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_chat_conformance.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_chat_values.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_claim.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_chat.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_feat012.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_module_schemas.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_pipeline.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_retrieval.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_auth.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_chat.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_evaluator.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_finding.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_llm.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_memory.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_strategy.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_task.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_tool.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_embedding_client.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_exceptions.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_fallback_chain.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_graph_store_contract.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_guardrails_config.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_guardrails_contracts.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_log_filter.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_log_format.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_messages.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_migrations.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_pipeline_values.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_provider_errors.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_reranker_contract.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_resolver.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_run_context.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_state.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_strategy_conformance.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_strategy_stream_default.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_task_conformance.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_values_graph.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_values_vector.py +0 -0
- {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_vector_store_contract.py +0 -0
|
@@ -47,3 +47,13 @@ Thumbs.db
|
|
|
47
47
|
# Project-local
|
|
48
48
|
*.local
|
|
49
49
|
.agentforge-state/.session-cache
|
|
50
|
+
|
|
51
|
+
# AI-assistant working state — local session tracking, not part of
|
|
52
|
+
# the published project. The process docs under .claude/ (standards,
|
|
53
|
+
# checklists, CLAUDE.md) are intentionally kept tracked; only the
|
|
54
|
+
# churny per-session state files are ignored.
|
|
55
|
+
.claude/state/
|
|
56
|
+
|
|
57
|
+
# Launch / go-to-market drafts — local-only marketing material,
|
|
58
|
+
# never published to the repo.
|
|
59
|
+
launch/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentforge-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AgentForge core — stable contracts (ABCs, value types) for the agentic framework
|
|
5
5
|
Project-URL: Homepage, https://github.com/Scaffoldic/agentforge-py
|
|
6
6
|
Project-URL: Repository, https://github.com/Scaffoldic/agentforge-py
|
|
@@ -20,6 +20,10 @@ bump; removing or renaming requires a major bump.
|
|
|
20
20
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
|
+
from agentforge_core.config.app_sections import (
|
|
24
|
+
discover_app_sections,
|
|
25
|
+
validate_app_config,
|
|
26
|
+
)
|
|
23
27
|
from agentforge_core.config.loader import load_config, parse_overrides
|
|
24
28
|
from agentforge_core.config.module_schemas import validate_module_configs
|
|
25
29
|
from agentforge_core.config.schema import (
|
|
@@ -56,7 +60,9 @@ __all__ = [
|
|
|
56
60
|
"RerankerEntry",
|
|
57
61
|
"RetrievalConfig",
|
|
58
62
|
"RetrieverModuleConfig",
|
|
63
|
+
"discover_app_sections",
|
|
59
64
|
"load_config",
|
|
60
65
|
"parse_overrides",
|
|
66
|
+
"validate_app_config",
|
|
61
67
|
"validate_module_configs",
|
|
62
68
|
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Registered app-config section validation (feat-026 Phase 2).
|
|
2
|
+
|
|
3
|
+
Phase 1 (enh-002) added the reserved ``app:`` namespace and the
|
|
4
|
+
``app_as`` accessor: the framework stored the subtree but validated
|
|
5
|
+
nothing inside it — a derived agent owned validation entirely. Phase 2
|
|
6
|
+
closes the parity gap with *module* config. A derived agent or plugin
|
|
7
|
+
registers a Pydantic schema per ``app.<section>`` through a new
|
|
8
|
+
entry-point group, exactly mirroring how modules register their classes
|
|
9
|
+
(ADR-0004)::
|
|
10
|
+
|
|
11
|
+
[project.entry-points."agentforge.config_sections"]
|
|
12
|
+
graph = "agentforge_graph.config:GraphConfig"
|
|
13
|
+
|
|
14
|
+
``agentforge config validate`` then validates each registered section
|
|
15
|
+
the same way :func:`validate_module_configs` validates ``modules.*``:
|
|
16
|
+
|
|
17
|
+
- A section present in ``app:`` **and** registered → validated strictly
|
|
18
|
+
against its schema; a typo or bad value fails the command.
|
|
19
|
+
- A section present in ``app:`` but **not** registered → left untouched
|
|
20
|
+
(free-form, like an undocumented ``[tool.x]`` in ``pyproject.toml``).
|
|
21
|
+
- A registered section **absent** from ``app:`` → nothing to validate.
|
|
22
|
+
- A section whose package isn't installed → simply never discovered, so
|
|
23
|
+
validation degrades gracefully with no special-casing (the lenient
|
|
24
|
+
behaviour ``validate_module_configs`` gets from ``strict=False``).
|
|
25
|
+
|
|
26
|
+
This keeps the single ``app:`` boundary (feat-026 §8): registration maps
|
|
27
|
+
names *under* ``app:`` to schemas; it never opens new top-level keys.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
from importlib.metadata import entry_points
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, ValidationError
|
|
37
|
+
|
|
38
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
|
|
43
|
+
from agentforge_core.config.schema import AgentForgeConfig
|
|
44
|
+
|
|
45
|
+
_log = logging.getLogger("agentforge.config")
|
|
46
|
+
|
|
47
|
+
#: Entry-point group mapping an ``app.<section>`` name to a Pydantic
|
|
48
|
+
#: schema. Parallel to the ``agentforge.<category>`` groups the resolver
|
|
49
|
+
#: scans, but consumed here rather than by the runtime resolver (the
|
|
50
|
+
#: resolver skips this group — see ``resolver.discover``).
|
|
51
|
+
SECTIONS_GROUP = "agentforge.config_sections"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def discover_app_sections() -> dict[str, type[BaseModel]]:
|
|
55
|
+
"""Scan the ``agentforge.config_sections`` entry-point group.
|
|
56
|
+
|
|
57
|
+
Returns a mapping of section name → Pydantic model class. Entries
|
|
58
|
+
that fail to import, or whose target is not a ``BaseModel`` subclass,
|
|
59
|
+
are skipped with a warning. On a duplicate name across distributions
|
|
60
|
+
the first registration wins (matches the resolver's §8 conflict
|
|
61
|
+
rule).
|
|
62
|
+
"""
|
|
63
|
+
sections: dict[str, type[BaseModel]] = {}
|
|
64
|
+
for ep in entry_points(group=SECTIONS_GROUP):
|
|
65
|
+
if ep.name in sections:
|
|
66
|
+
_log.warning(
|
|
67
|
+
"duplicate app-config section %r ignored (first registration wins)",
|
|
68
|
+
ep.name,
|
|
69
|
+
)
|
|
70
|
+
continue
|
|
71
|
+
try:
|
|
72
|
+
model = ep.load()
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
_log.warning(
|
|
75
|
+
"skipping app-config section %r: load failed (%s: %s)",
|
|
76
|
+
ep.name,
|
|
77
|
+
type(exc).__name__,
|
|
78
|
+
exc,
|
|
79
|
+
)
|
|
80
|
+
continue
|
|
81
|
+
if not (isinstance(model, type) and issubclass(model, BaseModel)):
|
|
82
|
+
_log.warning(
|
|
83
|
+
"skipping app-config section %r: %r is not a pydantic BaseModel subclass",
|
|
84
|
+
ep.name,
|
|
85
|
+
model,
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
sections[ep.name] = model
|
|
89
|
+
return sections
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_app_config(
|
|
93
|
+
cfg: AgentForgeConfig,
|
|
94
|
+
*,
|
|
95
|
+
sections: Mapping[str, type[BaseModel]] | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Validate each registered ``app.<section>`` against its schema.
|
|
98
|
+
|
|
99
|
+
Mirrors :func:`validate_module_configs`. Only sections that are both
|
|
100
|
+
present in ``cfg.app`` and registered via an entry point are checked;
|
|
101
|
+
unregistered sections are left untouched (free-form). A registered
|
|
102
|
+
section that fails its schema always raises :class:`ModuleError` —
|
|
103
|
+
validation failures are fatal, the same as for module config.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
cfg: a loaded :class:`AgentForgeConfig` (post-``load_config``).
|
|
107
|
+
sections: pre-discovered section map. Defaults to
|
|
108
|
+
:func:`discover_app_sections`; injectable so tests (and
|
|
109
|
+
future callers with a cached registry) can supply their own.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ModuleError: a registered ``app.<section>`` subtree fails its
|
|
113
|
+
schema.
|
|
114
|
+
"""
|
|
115
|
+
registry = sections if sections is not None else discover_app_sections()
|
|
116
|
+
for name, model in registry.items():
|
|
117
|
+
if name not in cfg.app:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
model.model_validate(cfg.app[name])
|
|
121
|
+
except ValidationError as exc:
|
|
122
|
+
raise ModuleError(
|
|
123
|
+
f"app.{name} failed validation: {exc.errors(include_url=False)}"
|
|
124
|
+
) from exc
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
Resolution order (last wins) per spec §4.3:
|
|
4
4
|
|
|
5
5
|
1. Defaults from each Pydantic model.
|
|
6
|
-
2.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
2. Files pulled in via an `imports:` directive (feat-026 Phase 3),
|
|
7
|
+
lower precedence than the file that imports them.
|
|
8
|
+
3. agentforge.yaml on disk (if present).
|
|
9
|
+
4. agentforge.<env>.yaml (if AGENTFORGE_ENV set).
|
|
10
|
+
5. Env-var interpolation inside YAML values.
|
|
11
|
+
6. CLI / loader-API `--override agent.budget.usd=10` arguments.
|
|
12
|
+
7. Constructor kwargs to Agent (handled in `agentforge.agent`).
|
|
11
13
|
|
|
12
14
|
Env-var interpolation syntax (feat-001):
|
|
13
15
|
- `${VAR}` — required; raises at load if missing.
|
|
@@ -155,6 +157,65 @@ def _read_yaml(path: Path) -> dict[str, Any]:
|
|
|
155
157
|
return raw
|
|
156
158
|
|
|
157
159
|
|
|
160
|
+
#: Reserved top-level loader directive (feat-026 Phase 3): a list of
|
|
161
|
+
#: paths to other config files to pull in. Consumed by the loader
|
|
162
|
+
#: (popped before validation), so it never reaches `AgentForgeConfig`.
|
|
163
|
+
_IMPORTS_KEY = "imports"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_file_with_imports(path: Path, *, _visiting: tuple[Path, ...] = ()) -> dict[str, Any]:
|
|
167
|
+
"""Read a config file and resolve its ``imports:`` directive (feat-026
|
|
168
|
+
Phase 3 — pluggable config *sources*).
|
|
169
|
+
|
|
170
|
+
``imports:`` is a top-level list of paths to other config files,
|
|
171
|
+
resolved **relative to this file's directory** (absolute paths are
|
|
172
|
+
honoured as-is). Import path strings get ``${ENV}`` interpolation so
|
|
173
|
+
``imports: ["${EXTRA_CONFIG}"]`` works.
|
|
174
|
+
|
|
175
|
+
Precedence follows Spring Boot's ``spring.config.import``: an imported
|
|
176
|
+
file is **lower precedence than the file that imports it** — you
|
|
177
|
+
import shared defaults and override them locally. Within one
|
|
178
|
+
``imports:`` list, later entries beat earlier ones. Imports compose
|
|
179
|
+
transitively (an imported file may itself import); cycles raise
|
|
180
|
+
``ModuleError``.
|
|
181
|
+
|
|
182
|
+
The directive is consumed here — the returned mapping never contains
|
|
183
|
+
an ``imports`` key, so it doesn't collide with the root model's
|
|
184
|
+
``extra="forbid"`` and ``config show --resolved`` shows the merged
|
|
185
|
+
result, not the directive.
|
|
186
|
+
"""
|
|
187
|
+
resolved = path.resolve()
|
|
188
|
+
if resolved in _visiting:
|
|
189
|
+
chain = " -> ".join(str(p) for p in (*_visiting, resolved))
|
|
190
|
+
raise ModuleError(f"Circular config import detected: {chain}")
|
|
191
|
+
|
|
192
|
+
raw = _read_yaml(path)
|
|
193
|
+
imports = raw.pop(_IMPORTS_KEY, None)
|
|
194
|
+
if imports is None:
|
|
195
|
+
return raw
|
|
196
|
+
if not isinstance(imports, list):
|
|
197
|
+
raise ModuleError(
|
|
198
|
+
f"`imports:` in {path} must be a list of file paths; got {type(imports).__name__}."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
merged: dict[str, Any] = {}
|
|
202
|
+
for entry in imports:
|
|
203
|
+
if not isinstance(entry, str):
|
|
204
|
+
raise ModuleError(f"`imports:` entries in {path} must be strings; got {entry!r}.")
|
|
205
|
+
import_path = Path(_interp(entry))
|
|
206
|
+
if not import_path.is_absolute():
|
|
207
|
+
import_path = path.parent / import_path
|
|
208
|
+
if not import_path.exists():
|
|
209
|
+
raise ModuleError(
|
|
210
|
+
f"Imported config file not found: {import_path} (imported by {path})."
|
|
211
|
+
)
|
|
212
|
+
imported = _load_file_with_imports(import_path, _visiting=(*_visiting, resolved))
|
|
213
|
+
merged = _deep_merge(merged, imported)
|
|
214
|
+
|
|
215
|
+
# The importing file's own keys win over everything it imports.
|
|
216
|
+
return _deep_merge(merged, raw)
|
|
217
|
+
|
|
218
|
+
|
|
158
219
|
def _env_overlay_path(base: Path, env: str) -> Path:
|
|
159
220
|
"""Compute the overlay path next to `base`: foo.yaml → foo.<env>.yaml."""
|
|
160
221
|
return base.with_suffix(f".{env}{base.suffix}")
|
|
@@ -191,14 +252,15 @@ def load_config(
|
|
|
191
252
|
if resolved_path is None or not resolved_path.exists():
|
|
192
253
|
merged: dict[str, Any] = {}
|
|
193
254
|
else:
|
|
194
|
-
merged =
|
|
255
|
+
merged = _load_file_with_imports(resolved_path)
|
|
195
256
|
# Layered env file overlays the base. Missing overlay is fine
|
|
196
|
-
# (env-without-file is just "use base").
|
|
257
|
+
# (env-without-file is just "use base"). The overlay is itself
|
|
258
|
+
# import-aware, so an `agentforge.<env>.yaml` may pull in files too.
|
|
197
259
|
resolved_env = env if env is not None else os.environ.get("AGENTFORGE_ENV")
|
|
198
260
|
if resolved_env:
|
|
199
261
|
overlay_path = _env_overlay_path(resolved_path, resolved_env)
|
|
200
262
|
if overlay_path.exists():
|
|
201
|
-
merged = _deep_merge(merged,
|
|
263
|
+
merged = _deep_merge(merged, _load_file_with_imports(overlay_path))
|
|
202
264
|
|
|
203
265
|
interpolated = _walk(merged)
|
|
204
266
|
if overrides:
|
|
@@ -15,10 +15,12 @@ is the data-side representation.
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
17
|
from pathlib import Path
|
|
18
|
-
from typing import Any, Literal
|
|
18
|
+
from typing import Any, Literal, TypeVar
|
|
19
19
|
|
|
20
20
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
21
21
|
|
|
22
|
+
_AppModelT = TypeVar("_AppModelT", bound=BaseModel)
|
|
23
|
+
|
|
22
24
|
|
|
23
25
|
def _normalise_named_entry(value: Any) -> Any:
|
|
24
26
|
"""Normalise the terse YAML sugar for `name + config` entries into the
|
|
@@ -470,3 +472,34 @@ class AgentForgeConfig(BaseModel):
|
|
|
470
472
|
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
471
473
|
output: OutputConfig = Field(default_factory=OutputConfig)
|
|
472
474
|
guardrail_policy: GuardrailPolicy = Field(default_factory=GuardrailPolicy)
|
|
475
|
+
app: dict[str, Any] = Field(default_factory=dict)
|
|
476
|
+
"""Reserved namespace for **application** config (enh-002, feat-026
|
|
477
|
+
Phase 1). The framework accepts this subtree but does not interpret
|
|
478
|
+
it: a consuming agent puts its own config here and validates it with
|
|
479
|
+
its own Pydantic model via :meth:`app_as`. Every other top-level key
|
|
480
|
+
stays strict (`extra="forbid"`), so framework-key typos are still
|
|
481
|
+
caught. Values inside `app:` get `${ENV}` interpolation, env-file
|
|
482
|
+
layering, dotted-path overrides, and `config show --resolved` for
|
|
483
|
+
free — they ride the same loader passes as framework keys. The
|
|
484
|
+
framework performs no *registered-schema* validation inside `app:`
|
|
485
|
+
in Phase 1; that arrives in feat-026 Phase 2."""
|
|
486
|
+
|
|
487
|
+
def app_as(self, model: type[_AppModelT], key: str | None = None) -> _AppModelT:
|
|
488
|
+
"""Validate and return an application-config subtree.
|
|
489
|
+
|
|
490
|
+
`key=None` validates the whole `app:` mapping; otherwise the
|
|
491
|
+
`app[key]` subtree (missing key → empty mapping, so the caller's
|
|
492
|
+
model supplies its own defaults). The caller's model owns its own
|
|
493
|
+
strictness, so app-key typos surface here — strictness is
|
|
494
|
+
delegated into `app:`, not lost.
|
|
495
|
+
|
|
496
|
+
Example::
|
|
497
|
+
|
|
498
|
+
class GraphConfig(BaseModel):
|
|
499
|
+
store: StoreConfig
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
graph_cfg = cfg.app_as(GraphConfig, "graph")
|
|
503
|
+
"""
|
|
504
|
+
raw = self.app if key is None else self.app.get(key, {})
|
|
505
|
+
return model.model_validate(raw)
|
|
@@ -34,6 +34,12 @@ _log = logging.getLogger("agentforge.resolver")
|
|
|
34
34
|
|
|
35
35
|
_GROUP_PREFIX = "agentforge."
|
|
36
36
|
|
|
37
|
+
# Groups under `agentforge.*` that are NOT runtime-resolver categories.
|
|
38
|
+
# `config_sections` maps app-config section names to pydantic schemas
|
|
39
|
+
# (feat-026 Phase 2); it is consumed by `config.app_sections`, not the
|
|
40
|
+
# resolver, so skip it here to keep schemas out of the module registry.
|
|
41
|
+
_NON_RESOLVER_GROUPS = frozenset({"config_sections"})
|
|
42
|
+
|
|
37
43
|
# Module-level state held on a mutable list to avoid `global` (PLW0603).
|
|
38
44
|
_discovered: list[bool] = [False]
|
|
39
45
|
# Cache of resolved ModuleInfo per registered entry — keyed by
|
|
@@ -75,6 +81,8 @@ def discover_entry_points(resolver: Resolver, *, force: bool = False) -> int:
|
|
|
75
81
|
if not ep.group.startswith(_GROUP_PREFIX):
|
|
76
82
|
continue
|
|
77
83
|
category = ep.group[len(_GROUP_PREFIX) :]
|
|
84
|
+
if category in _NON_RESOLVER_GROUPS:
|
|
85
|
+
continue
|
|
78
86
|
try:
|
|
79
87
|
cls = ep.load()
|
|
80
88
|
except Exception as exc:
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Real end-to-end discovery test for feat-026 Phase 2 — *no* mocking.
|
|
2
|
+
|
|
3
|
+
`test_config_app_sections.py` (unit) injects a section map or
|
|
4
|
+
monkeypatches `entry_points`. This test goes the whole way: it drops a
|
|
5
|
+
**real installed distribution** on `sys.path` — a real `.dist-info` with
|
|
6
|
+
a real `entry_points.txt` declaring the `agentforge.config_sections`
|
|
7
|
+
group, plus a real importable module exposing the schema class — and
|
|
8
|
+
exercises the actual `importlib.metadata` discovery path:
|
|
9
|
+
|
|
10
|
+
- `discover_app_sections()` finds the section via real entry-point
|
|
11
|
+
resolution and `ep.load()` imports the real schema class.
|
|
12
|
+
- `validate_app_config()` validates a real `app.<section>` subtree
|
|
13
|
+
against that freshly-imported schema, and rejects a typo.
|
|
14
|
+
|
|
15
|
+
This is the proof the monkeypatched tests can't give: that a derived
|
|
16
|
+
agent declaring `[project.entry-points."agentforge.config_sections"]` in
|
|
17
|
+
its own `pyproject.toml` is actually discovered and validated.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import importlib
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess # nosec B404 — fixed argv, no shell
|
|
26
|
+
import sys
|
|
27
|
+
import textwrap
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import pytest
|
|
31
|
+
from agentforge_core.config import (
|
|
32
|
+
AgentForgeConfig,
|
|
33
|
+
discover_app_sections,
|
|
34
|
+
validate_app_config,
|
|
35
|
+
)
|
|
36
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
37
|
+
|
|
38
|
+
_MODULE = "af_fake_section_pkg"
|
|
39
|
+
_DIST = "af-fake-section"
|
|
40
|
+
_SECTION = "graph"
|
|
41
|
+
_ATTR = "GraphConfig"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _install_fake_dist(site: Path) -> None:
|
|
45
|
+
"""Lay down a real importable module + a real `.dist-info` whose
|
|
46
|
+
`entry_points.txt` registers our section — the on-disk shape pip / uv
|
|
47
|
+
produce when a package declares the entry point."""
|
|
48
|
+
(site / f"{_MODULE}.py").write_text(
|
|
49
|
+
textwrap.dedent(
|
|
50
|
+
f"""
|
|
51
|
+
from pydantic import BaseModel, ConfigDict
|
|
52
|
+
|
|
53
|
+
class _Store(BaseModel):
|
|
54
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
55
|
+
path: str
|
|
56
|
+
|
|
57
|
+
class {_ATTR}(BaseModel):
|
|
58
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
59
|
+
store: _Store
|
|
60
|
+
max_hops: int = 3
|
|
61
|
+
"""
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
dist_info = site / f"{_DIST.replace('-', '_')}-1.0.0.dist-info"
|
|
65
|
+
dist_info.mkdir()
|
|
66
|
+
(dist_info / "METADATA").write_text(f"Metadata-Version: 2.1\nName: {_DIST}\nVersion: 1.0.0\n")
|
|
67
|
+
(dist_info / "entry_points.txt").write_text(
|
|
68
|
+
f"[agentforge.config_sections]\n{_SECTION} = {_MODULE}:{_ATTR}\n"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.fixture
|
|
73
|
+
def fake_section(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
|
74
|
+
"""Install the fake distribution on a throwaway site dir, put it on
|
|
75
|
+
`sys.path`, and tear it all down afterwards."""
|
|
76
|
+
site = tmp_path / "site-packages"
|
|
77
|
+
site.mkdir()
|
|
78
|
+
_install_fake_dist(site)
|
|
79
|
+
monkeypatch.syspath_prepend(str(site))
|
|
80
|
+
importlib.invalidate_caches()
|
|
81
|
+
try:
|
|
82
|
+
yield
|
|
83
|
+
finally:
|
|
84
|
+
sys.modules.pop(_MODULE, None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_real_entry_point_is_discovered(fake_section: None) -> None:
|
|
88
|
+
found = discover_app_sections()
|
|
89
|
+
assert _SECTION in found, "real entry point was not discovered via importlib.metadata"
|
|
90
|
+
assert found[_SECTION].__name__ == _ATTR
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_real_section_validates_ok(fake_section: None) -> None:
|
|
94
|
+
cfg = AgentForgeConfig.model_validate(
|
|
95
|
+
{"app": {_SECTION: {"store": {"path": ".ckg"}, "max_hops": 4}}}
|
|
96
|
+
)
|
|
97
|
+
# Real discovery + real schema, no injected registry → must not raise.
|
|
98
|
+
validate_app_config(cfg)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_real_section_typo_is_rejected(fake_section: None) -> None:
|
|
102
|
+
cfg = AgentForgeConfig.model_validate(
|
|
103
|
+
{"app": {_SECTION: {"store": {"path": ".ckg"}, "max_hopz": 4}}}
|
|
104
|
+
)
|
|
105
|
+
with pytest.raises(ModuleError) as exc:
|
|
106
|
+
validate_app_config(cfg)
|
|
107
|
+
assert f"app.{_SECTION}" in str(exc.value)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_unregistered_section_untouched_with_real_discovery(fake_section: None) -> None:
|
|
111
|
+
"""Even with a real registered `graph` section discovered, an
|
|
112
|
+
*unregistered* sibling section stays free-form."""
|
|
113
|
+
cfg = AgentForgeConfig.model_validate(
|
|
114
|
+
{"app": {"telemetry": {"whatever": "is fine", "deeply": {"nested": 1}}}}
|
|
115
|
+
)
|
|
116
|
+
validate_app_config(cfg) # graph not present; telemetry unregistered → no raise
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# --- full CLI subprocess e2e: the real `agentforge` binary --------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _agentforge_bin() -> str | None:
|
|
123
|
+
"""The installed `agentforge` console script next to this interpreter."""
|
|
124
|
+
candidate = Path(sys.executable).parent / "agentforge"
|
|
125
|
+
if candidate.exists():
|
|
126
|
+
return str(candidate)
|
|
127
|
+
return shutil.which("agentforge")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run_validate(site: Path, yaml_path: Path) -> subprocess.CompletedProcess[str]:
|
|
131
|
+
"""Run `agentforge config validate` in a real subprocess with the
|
|
132
|
+
fake distribution discoverable via PYTHONPATH (so the child's own
|
|
133
|
+
`importlib.metadata` finds the entry point — no in-process state)."""
|
|
134
|
+
env = dict(os.environ)
|
|
135
|
+
env["PYTHONPATH"] = os.pathsep.join([str(site), *([p] if (p := env.get("PYTHONPATH")) else [])])
|
|
136
|
+
return subprocess.run( # nosec B603 — fixed argv, no shell
|
|
137
|
+
[_agentforge_bin() or "agentforge", "config", "validate", "--path", str(yaml_path)],
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
env=env,
|
|
141
|
+
check=False,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.skipif(_agentforge_bin() is None, reason="agentforge console script not installed")
|
|
146
|
+
def test_cli_subprocess_validates_registered_section(tmp_path: Path) -> None:
|
|
147
|
+
"""End-to-end: the real `agentforge` binary discovers a real installed
|
|
148
|
+
`agentforge.config_sections` entry point and validates `app.graph`."""
|
|
149
|
+
site = tmp_path / "site-packages"
|
|
150
|
+
site.mkdir()
|
|
151
|
+
_install_fake_dist(site)
|
|
152
|
+
yaml_path = tmp_path / "agentforge.yaml"
|
|
153
|
+
yaml_path.write_text("app:\n graph:\n store:\n path: .ckg\n max_hops: 4\n")
|
|
154
|
+
|
|
155
|
+
result = _run_validate(site, yaml_path)
|
|
156
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
157
|
+
assert "OK" in result.stdout
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@pytest.mark.skipif(_agentforge_bin() is None, reason="agentforge console script not installed")
|
|
161
|
+
def test_cli_subprocess_rejects_section_typo(tmp_path: Path) -> None:
|
|
162
|
+
site = tmp_path / "site-packages"
|
|
163
|
+
site.mkdir()
|
|
164
|
+
_install_fake_dist(site)
|
|
165
|
+
yaml_path = tmp_path / "agentforge.yaml"
|
|
166
|
+
yaml_path.write_text("app:\n graph:\n store:\n path: .ckg\n max_hopz: 4\n")
|
|
167
|
+
|
|
168
|
+
result = _run_validate(site, yaml_path)
|
|
169
|
+
assert result.returncode == 1
|
|
170
|
+
assert "app.graph" in result.stderr
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Full CLI subprocess e2e for the `imports:` directive (feat-026
|
|
2
|
+
Phase 3).
|
|
3
|
+
|
|
4
|
+
The unit tests in `tests/unit/test_config_imports.py` load real files
|
|
5
|
+
through the loader API. These go one level further: they run the real
|
|
6
|
+
`agentforge config {show,validate}` binary in a subprocess against a
|
|
7
|
+
multi-file config, proving the directive works end-to-end through the
|
|
8
|
+
CLI exactly as a user invokes it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess # nosec B404 — fixed argv, no shell
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _agentforge_bin() -> str | None:
|
|
24
|
+
candidate = Path(sys.executable).parent / "agentforge"
|
|
25
|
+
if candidate.exists():
|
|
26
|
+
return str(candidate)
|
|
27
|
+
return shutil.which("agentforge")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
|
|
31
|
+
return subprocess.run( # nosec B603 — fixed argv, no shell
|
|
32
|
+
[_agentforge_bin() or "agentforge", *args],
|
|
33
|
+
cwd=cwd,
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
env=dict(os.environ),
|
|
37
|
+
check=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
pytestmark = pytest.mark.skipif(
|
|
42
|
+
_agentforge_bin() is None, reason="agentforge console script not installed"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_cli_show_resolved_merges_imports(tmp_path: Path) -> None:
|
|
47
|
+
(tmp_path / "shared.yaml").write_text("agent:\n model: shared-model\n max_iterations: 9\n")
|
|
48
|
+
(tmp_path / "agentforge.yaml").write_text(
|
|
49
|
+
"imports:\n - shared.yaml\nagent:\n name: local-agent\n"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result = _run(["config", "show", "--resolved", "--path", "agentforge.yaml"], cwd=tmp_path)
|
|
53
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
54
|
+
parsed = yaml.safe_load(result.stdout)
|
|
55
|
+
# Imported value present, local value present, directive consumed.
|
|
56
|
+
assert parsed["agent"]["model"] == "shared-model"
|
|
57
|
+
assert parsed["agent"]["max_iterations"] == 9
|
|
58
|
+
assert parsed["agent"]["name"] == "local-agent"
|
|
59
|
+
assert "imports" not in parsed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_cli_validate_passes_with_imports(tmp_path: Path) -> None:
|
|
63
|
+
(tmp_path / "shared.yaml").write_text("agent:\n budget:\n usd: 3.0\n")
|
|
64
|
+
(tmp_path / "agentforge.yaml").write_text("imports:\n - shared.yaml\n")
|
|
65
|
+
|
|
66
|
+
result = _run(["config", "validate", "--path", "agentforge.yaml"], cwd=tmp_path)
|
|
67
|
+
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
68
|
+
assert "OK" in result.stdout
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_cli_validate_fails_on_bad_imported_value(tmp_path: Path) -> None:
|
|
72
|
+
"""A schema violation that lives in an imported file is caught the
|
|
73
|
+
same as one in the base file — imports are fully validated."""
|
|
74
|
+
(tmp_path / "shared.yaml").write_text("agent:\n budget:\n usd: -5\n") # negative
|
|
75
|
+
(tmp_path / "agentforge.yaml").write_text("imports:\n - shared.yaml\n")
|
|
76
|
+
|
|
77
|
+
result = _run(["config", "validate", "--path", "agentforge.yaml"], cwd=tmp_path)
|
|
78
|
+
assert result.returncode == 1
|
|
79
|
+
assert "agent.budget.usd" in result.stderr
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_cli_validate_reports_missing_import(tmp_path: Path) -> None:
|
|
83
|
+
(tmp_path / "agentforge.yaml").write_text("imports:\n - nope.yaml\n")
|
|
84
|
+
result = _run(["config", "validate", "--path", "agentforge.yaml"], cwd=tmp_path)
|
|
85
|
+
assert result.returncode == 1
|
|
86
|
+
assert "not found" in result.stderr
|