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.
Files changed (112) hide show
  1. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/.gitignore +10 -0
  2. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/PKG-INFO +1 -1
  3. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/pyproject.toml +1 -1
  4. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/__init__.py +6 -0
  5. agentforge_core-0.3.0/src/agentforge_core/config/app_sections.py +124 -0
  6. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/loader.py +70 -8
  7. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/schema.py +34 -1
  8. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/resolver/discover.py +8 -0
  9. agentforge_core-0.3.0/tests/integration/test_app_sections_real_discovery.py +170 -0
  10. agentforge_core-0.3.0/tests/integration/test_config_imports_cli.py +86 -0
  11. agentforge_core-0.3.0/tests/unit/test_config_app_passthrough.py +154 -0
  12. agentforge_core-0.3.0/tests/unit/test_config_app_sections.py +169 -0
  13. agentforge_core-0.3.0/tests/unit/test_config_imports.py +216 -0
  14. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_resolver_discovery.py +37 -0
  15. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/LICENSE +0 -0
  16. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/README.md +0 -0
  17. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/__init__.py +0 -0
  18. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/_bm25.py +0 -0
  19. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/config/module_schemas.py +0 -0
  20. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/__init__.py +0 -0
  21. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/auth.py +0 -0
  22. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/chat.py +0 -0
  23. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/embedding.py +0 -0
  24. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/evaluator.py +0 -0
  25. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/finding.py +0 -0
  26. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/graph_store.py +0 -0
  27. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/guardrails.py +0 -0
  28. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/llm.py +0 -0
  29. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/memory.py +0 -0
  30. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/migrator.py +0 -0
  31. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/protocol_bridge.py +0 -0
  32. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/renderer.py +0 -0
  33. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/reranker.py +0 -0
  34. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/strategy.py +0 -0
  35. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/task.py +0 -0
  36. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/tool.py +0 -0
  37. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/contracts/vector_store.py +0 -0
  38. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/migrations/__init__.py +0 -0
  39. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/migrations/discover.py +0 -0
  40. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/migrations/template.py +0 -0
  41. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/observability/__init__.py +0 -0
  42. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/observability/tracing.py +0 -0
  43. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/__init__.py +0 -0
  44. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/budget.py +0 -0
  45. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/exceptions.py +0 -0
  46. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/fallback.py +0 -0
  47. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/log_filter.py +0 -0
  48. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/log_format.py +0 -0
  49. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/production/run_context.py +0 -0
  50. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/py.typed +0 -0
  51. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/resolver/__init__.py +0 -0
  52. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/resolver/resolve.py +0 -0
  53. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/testing/__init__.py +0 -0
  54. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/testing/conformance.py +0 -0
  55. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/__init__.py +0 -0
  56. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/auth.py +0 -0
  57. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/chat.py +0 -0
  58. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/claim.py +0 -0
  59. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/graph.py +0 -0
  60. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/guardrails.py +0 -0
  61. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/manifest.py +0 -0
  62. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/messages.py +0 -0
  63. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/module.py +0 -0
  64. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/pipeline.py +0 -0
  65. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/retrieval.py +0 -0
  66. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/state.py +0 -0
  67. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/src/agentforge_core/values/vector.py +0 -0
  68. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/conftest.py +0 -0
  69. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/.gitkeep +0 -0
  70. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_auth_values.py +0 -0
  71. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_bm25.py +0 -0
  72. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_budget.py +0 -0
  73. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_capability_extensions.py +0 -0
  74. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_chat_conformance.py +0 -0
  75. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_chat_values.py +0 -0
  76. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_claim.py +0 -0
  77. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_chat.py +0 -0
  78. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_feat012.py +0 -0
  79. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_module_schemas.py +0 -0
  80. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_pipeline.py +0 -0
  81. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_config_retrieval.py +0 -0
  82. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_auth.py +0 -0
  83. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_chat.py +0 -0
  84. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_evaluator.py +0 -0
  85. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_finding.py +0 -0
  86. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_llm.py +0 -0
  87. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_memory.py +0 -0
  88. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_strategy.py +0 -0
  89. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_task.py +0 -0
  90. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_contracts_tool.py +0 -0
  91. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_embedding_client.py +0 -0
  92. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_exceptions.py +0 -0
  93. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_fallback_chain.py +0 -0
  94. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_graph_store_contract.py +0 -0
  95. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_guardrails_config.py +0 -0
  96. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_guardrails_contracts.py +0 -0
  97. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_log_filter.py +0 -0
  98. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_log_format.py +0 -0
  99. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_messages.py +0 -0
  100. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_migrations.py +0 -0
  101. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_pipeline_values.py +0 -0
  102. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_provider_errors.py +0 -0
  103. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_reranker_contract.py +0 -0
  104. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_resolver.py +0 -0
  105. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_run_context.py +0 -0
  106. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_state.py +0 -0
  107. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_strategy_conformance.py +0 -0
  108. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_strategy_stream_default.py +0 -0
  109. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_task_conformance.py +0 -0
  110. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_values_graph.py +0 -0
  111. {agentforge_core-0.2.4 → agentforge_core-0.3.0}/tests/unit/test_values_vector.py +0 -0
  112. {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.2.4
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
@@ -9,7 +9,7 @@
9
9
 
10
10
  [project]
11
11
  name = "agentforge-core"
12
- version = "0.2.4"
12
+ version = "0.3.0"
13
13
  description = "AgentForge core — stable contracts (ABCs, value types) for the agentic framework"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.13"
@@ -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. agentforge.yaml on disk (if present).
7
- 3. agentforge.<env>.yaml (if AGENTFORGE_ENV set).
8
- 4. Env-var interpolation inside YAML values.
9
- 5. CLI / loader-API `--override agent.budget.usd=10` arguments.
10
- 6. Constructor kwargs to Agent (handled in `agentforge.agent`).
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 = _read_yaml(resolved_path)
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, _read_yaml(overlay_path))
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