agentforge-core 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. agentforge_core/__init__.py +228 -0
  2. agentforge_core/_bm25.py +132 -0
  3. agentforge_core/config/__init__.py +62 -0
  4. agentforge_core/config/loader.py +239 -0
  5. agentforge_core/config/module_schemas.py +208 -0
  6. agentforge_core/config/schema.py +424 -0
  7. agentforge_core/contracts/__init__.py +52 -0
  8. agentforge_core/contracts/auth.py +33 -0
  9. agentforge_core/contracts/chat.py +118 -0
  10. agentforge_core/contracts/embedding.py +71 -0
  11. agentforge_core/contracts/evaluator.py +56 -0
  12. agentforge_core/contracts/finding.py +39 -0
  13. agentforge_core/contracts/graph_store.py +180 -0
  14. agentforge_core/contracts/guardrails.py +129 -0
  15. agentforge_core/contracts/llm.py +152 -0
  16. agentforge_core/contracts/memory.py +113 -0
  17. agentforge_core/contracts/migrator.py +120 -0
  18. agentforge_core/contracts/renderer.py +57 -0
  19. agentforge_core/contracts/reranker.py +91 -0
  20. agentforge_core/contracts/strategy.py +70 -0
  21. agentforge_core/contracts/task.py +73 -0
  22. agentforge_core/contracts/tool.py +71 -0
  23. agentforge_core/contracts/vector_store.py +151 -0
  24. agentforge_core/migrations/__init__.py +14 -0
  25. agentforge_core/migrations/discover.py +77 -0
  26. agentforge_core/migrations/template.py +34 -0
  27. agentforge_core/observability/__init__.py +18 -0
  28. agentforge_core/observability/tracing.py +37 -0
  29. agentforge_core/production/__init__.py +77 -0
  30. agentforge_core/production/budget.py +134 -0
  31. agentforge_core/production/exceptions.py +136 -0
  32. agentforge_core/production/fallback.py +321 -0
  33. agentforge_core/production/log_filter.py +49 -0
  34. agentforge_core/production/log_format.py +117 -0
  35. agentforge_core/production/run_context.py +108 -0
  36. agentforge_core/py.typed +0 -0
  37. agentforge_core/resolver/__init__.py +38 -0
  38. agentforge_core/resolver/discover.py +145 -0
  39. agentforge_core/resolver/resolve.py +168 -0
  40. agentforge_core/testing/__init__.py +45 -0
  41. agentforge_core/testing/conformance.py +1138 -0
  42. agentforge_core/values/__init__.py +103 -0
  43. agentforge_core/values/auth.py +20 -0
  44. agentforge_core/values/chat.py +131 -0
  45. agentforge_core/values/claim.py +30 -0
  46. agentforge_core/values/graph.py +136 -0
  47. agentforge_core/values/guardrails.py +49 -0
  48. agentforge_core/values/manifest.py +129 -0
  49. agentforge_core/values/messages.py +153 -0
  50. agentforge_core/values/module.py +40 -0
  51. agentforge_core/values/pipeline.py +43 -0
  52. agentforge_core/values/retrieval.py +53 -0
  53. agentforge_core/values/state.py +118 -0
  54. agentforge_core/values/vector.py +59 -0
  55. agentforge_core-0.2.1.dist-info/METADATA +66 -0
  56. agentforge_core-0.2.1.dist-info/RECORD +58 -0
  57. agentforge_core-0.2.1.dist-info/WHEEL +4 -0
  58. agentforge_core-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,228 @@
1
+ """AgentForge core — stable contracts (ABCs, value types).
2
+
3
+ Per ADR-0007, this package's public surface is the framework's locked
4
+ contract layer. Adding a method to an ABC is a major version bump.
5
+
6
+ This module re-exports every public symbol so consumers can import
7
+ from `agentforge_core` directly. Submodules (`agentforge_core.contracts`,
8
+ `agentforge_core.values`, `agentforge_core.production`) remain part of
9
+ the public surface for granular imports.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from agentforge_core.config import (
15
+ AgentConfig,
16
+ AgentForgeConfig,
17
+ BudgetConfig,
18
+ EvaluatorEntry,
19
+ GraphModuleConfig,
20
+ LoggingConfig,
21
+ MemoryModuleConfig,
22
+ ModuleEntry,
23
+ ModulesConfig,
24
+ ObservabilityEntry,
25
+ OutputConfig,
26
+ ProviderConfig,
27
+ RerankerEntry,
28
+ RetrievalConfig,
29
+ RetrieverModuleConfig,
30
+ load_config,
31
+ parse_overrides,
32
+ )
33
+ from agentforge_core.contracts import (
34
+ EmbeddingClient,
35
+ EvalResult,
36
+ Evaluator,
37
+ Finding,
38
+ FindingRenderer,
39
+ GraphStore,
40
+ LLMClient,
41
+ MemoryStore,
42
+ Migration,
43
+ MigrationChecksumError,
44
+ MigrationStatus,
45
+ Migrator,
46
+ ReasoningStrategy,
47
+ Reranker,
48
+ Tool,
49
+ VectorStore,
50
+ )
51
+ from agentforge_core.migrations import discover_migrations
52
+ from agentforge_core.observability import SCOPE_NAME as OBSERVABILITY_SCOPE_NAME
53
+ from agentforge_core.observability import get_tracer
54
+ from agentforge_core.production import (
55
+ AgentForgeError,
56
+ AuthenticationError,
57
+ BudgetExceeded,
58
+ BudgetPolicy,
59
+ CapabilityNotSupported,
60
+ GuardrailViolation,
61
+ JsonFormatter,
62
+ ModelNotFoundError,
63
+ ModuleError,
64
+ ProviderError,
65
+ RateLimitError,
66
+ RunContext,
67
+ RunIdFilter,
68
+ ServiceError,
69
+ TimeoutError,
70
+ bind_run,
71
+ current_run,
72
+ install_json_formatter,
73
+ install_run_id_filter,
74
+ new_run,
75
+ reset_run,
76
+ uninstall_json_formatter,
77
+ uninstall_run_id_filter,
78
+ )
79
+
80
+ # Imported from the submodule directly (not via production/__init__)
81
+ # to avoid a circular import — see the comment in production/__init__.py
82
+ # for the chain. This import runs *after* production finishes, so
83
+ # LLMClient is already available.
84
+ from agentforge_core.production.fallback import FallbackChain
85
+ from agentforge_core.resolver import (
86
+ Resolver,
87
+ discover_entry_points,
88
+ parse_model_string,
89
+ register,
90
+ register_embedding_provider,
91
+ register_provider,
92
+ reset_discovery,
93
+ )
94
+ from agentforge_core.values import (
95
+ AgentState,
96
+ AppliedEnvVar,
97
+ AppliedManifest,
98
+ AppliedTemplate,
99
+ Claim,
100
+ EmbeddingResponse,
101
+ EnvVarEntry,
102
+ FinishReason,
103
+ GraphEdge,
104
+ GraphNode,
105
+ GraphPattern,
106
+ GraphSegment,
107
+ LLMResponse,
108
+ Manifest,
109
+ Message,
110
+ MessageRole,
111
+ ModuleInfo,
112
+ Path,
113
+ RunResult,
114
+ Step,
115
+ StepKind,
116
+ StopReason,
117
+ StreamChunk,
118
+ StreamChunkKind,
119
+ TemplateFile,
120
+ TokenUsage,
121
+ ToolCall,
122
+ ToolSpec,
123
+ VectorItem,
124
+ VectorMatch,
125
+ )
126
+
127
+ __version__ = "0.2.1"
128
+
129
+ __all__ = [
130
+ "OBSERVABILITY_SCOPE_NAME",
131
+ "AgentConfig",
132
+ "AgentForgeConfig",
133
+ "AgentForgeError",
134
+ "AgentState",
135
+ "AppliedEnvVar",
136
+ "AppliedManifest",
137
+ "AppliedTemplate",
138
+ "AuthenticationError",
139
+ "BudgetConfig",
140
+ "BudgetExceeded",
141
+ "BudgetPolicy",
142
+ "CapabilityNotSupported",
143
+ "Claim",
144
+ "EmbeddingClient",
145
+ "EmbeddingResponse",
146
+ "EnvVarEntry",
147
+ "EvalResult",
148
+ "Evaluator",
149
+ "EvaluatorEntry",
150
+ "FallbackChain",
151
+ "Finding",
152
+ "FindingRenderer",
153
+ "FinishReason",
154
+ "GraphEdge",
155
+ "GraphModuleConfig",
156
+ "GraphNode",
157
+ "GraphPattern",
158
+ "GraphSegment",
159
+ "GraphStore",
160
+ "GuardrailViolation",
161
+ "JsonFormatter",
162
+ "LLMClient",
163
+ "LLMResponse",
164
+ "LoggingConfig",
165
+ "Manifest",
166
+ "MemoryModuleConfig",
167
+ "MemoryStore",
168
+ "Message",
169
+ "MessageRole",
170
+ "Migration",
171
+ "MigrationChecksumError",
172
+ "MigrationStatus",
173
+ "Migrator",
174
+ "ModelNotFoundError",
175
+ "ModuleEntry",
176
+ "ModuleError",
177
+ "ModuleInfo",
178
+ "ModulesConfig",
179
+ "ObservabilityEntry",
180
+ "OutputConfig",
181
+ "Path",
182
+ "ProviderConfig",
183
+ "ProviderError",
184
+ "RateLimitError",
185
+ "ReasoningStrategy",
186
+ "Reranker",
187
+ "RerankerEntry",
188
+ "Resolver",
189
+ "RetrievalConfig",
190
+ "RetrieverModuleConfig",
191
+ "RunContext",
192
+ "RunIdFilter",
193
+ "RunResult",
194
+ "ServiceError",
195
+ "Step",
196
+ "StepKind",
197
+ "StopReason",
198
+ "StreamChunk",
199
+ "StreamChunkKind",
200
+ "TemplateFile",
201
+ "TimeoutError",
202
+ "TokenUsage",
203
+ "Tool",
204
+ "ToolCall",
205
+ "ToolSpec",
206
+ "VectorItem",
207
+ "VectorMatch",
208
+ "VectorStore",
209
+ "__version__",
210
+ "bind_run",
211
+ "current_run",
212
+ "discover_entry_points",
213
+ "discover_migrations",
214
+ "get_tracer",
215
+ "install_json_formatter",
216
+ "install_run_id_filter",
217
+ "load_config",
218
+ "new_run",
219
+ "parse_model_string",
220
+ "parse_overrides",
221
+ "register",
222
+ "register_embedding_provider",
223
+ "register_provider",
224
+ "reset_discovery",
225
+ "reset_run",
226
+ "uninstall_json_formatter",
227
+ "uninstall_run_id_filter",
228
+ ]
@@ -0,0 +1,132 @@
1
+ """Private pure-Python BM25 helper (feat-022).
2
+
3
+ Used by ``VectorStore`` drivers that don't have a native lexical
4
+ path (e.g. the in-memory store) and by future hybrid retrieval
5
+ test fixtures. Public-facing hybrid retrieval is driven via
6
+ ``Retriever(mode="hybrid")`` — this module is an implementation
7
+ detail and not part of the framework's stable API.
8
+
9
+ Tokeniser: lowercase + ``\\W+`` split + drop tokens of length ≤ 1.
10
+ No stemming / stopword removal in v0.2 (keeps the dependency
11
+ surface zero). Defaults follow Robertson: ``k1=1.5`` and ``b=0.75``.
12
+
13
+ Formula (Okapi BM25):
14
+
15
+ score(D, Q) = Σ_t∈Q IDF(t) · TF_norm(t, D)
16
+
17
+ IDF(t) = ln( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
18
+
19
+ TF_norm(t, D) =
20
+ ( tf(t, D) · (k1 + 1) ) /
21
+ ( tf(t, D) + k1 · (1 - b + b · |D| / avg_dl) )
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import math
27
+ import re
28
+ from collections import Counter
29
+ from typing import Final
30
+
31
+ _TOKEN_RE: Final = re.compile(r"\W+", re.UNICODE)
32
+ _MIN_TOKEN_LEN: Final = 2
33
+
34
+
35
+ def _tokenise(text: str) -> list[str]:
36
+ """Lowercase, split on non-word characters, drop tokens ≤ 1 char."""
37
+ return [tok for tok in _TOKEN_RE.split(text.lower()) if len(tok) >= _MIN_TOKEN_LEN]
38
+
39
+
40
+ class _BM25Index:
41
+ """In-memory BM25 index over ``(doc_id, text)`` pairs.
42
+
43
+ Maintains per-document term frequencies, document lengths, and a
44
+ global document frequency table. Designed for small corpora
45
+ (~thousands of docs) — every ``add`` / ``delete`` is O(|tokens|)
46
+ and ``score`` is O(|query tokens| · |matching docs|).
47
+
48
+ Not thread-safe. Callers wrap with a mutex if needed.
49
+ """
50
+
51
+ def __init__(self, *, k1: float = 1.5, b: float = 0.75) -> None:
52
+ if k1 < 0:
53
+ raise ValueError(f"k1 must be >= 0, got {k1}")
54
+ if not 0.0 <= b <= 1.0:
55
+ raise ValueError(f"b must be in [0, 1], got {b}")
56
+ self._k1 = k1
57
+ self._b = b
58
+ self._tf: dict[str, Counter[str]] = {}
59
+ self._doc_len: dict[str, int] = {}
60
+ self._df: Counter[str] = Counter()
61
+
62
+ def add(self, doc_id: str, text: str) -> None:
63
+ """Insert or replace the document at ``doc_id``."""
64
+ if doc_id in self._tf:
65
+ self.delete(doc_id)
66
+ tokens = _tokenise(text)
67
+ if not tokens:
68
+ self._tf[doc_id] = Counter()
69
+ self._doc_len[doc_id] = 0
70
+ return
71
+ tf = Counter(tokens)
72
+ self._tf[doc_id] = tf
73
+ self._doc_len[doc_id] = len(tokens)
74
+ for term in tf:
75
+ self._df[term] += 1
76
+
77
+ def delete(self, doc_id: str) -> bool:
78
+ """Remove the document. Returns True if it existed."""
79
+ tf = self._tf.pop(doc_id, None)
80
+ if tf is None:
81
+ return False
82
+ self._doc_len.pop(doc_id, None)
83
+ for term in tf:
84
+ self._df[term] -= 1
85
+ if self._df[term] <= 0:
86
+ del self._df[term]
87
+ return True
88
+
89
+ def __len__(self) -> int:
90
+ return len(self._tf)
91
+
92
+ def score(self, query: str, *, limit: int) -> list[tuple[str, float]]:
93
+ """Return up to ``limit`` ``(doc_id, score)`` pairs sorted desc.
94
+
95
+ Scores are raw BM25 (unbounded ≥ 0). Empty corpus or empty
96
+ query returns ``[]``. Callers that want normalised scores
97
+ divide by the top score.
98
+ """
99
+ if limit < 1:
100
+ raise ValueError(f"limit must be >= 1, got {limit}")
101
+ query_tokens = _tokenise(query)
102
+ if not query_tokens or not self._tf:
103
+ return []
104
+
105
+ n_docs = len(self._tf)
106
+ total_len = sum(self._doc_len.values())
107
+ avg_dl = total_len / n_docs if n_docs else 0.0
108
+
109
+ idf_cache: dict[str, float] = {}
110
+ for term in set(query_tokens):
111
+ df = self._df.get(term, 0)
112
+ # +1 inside the log keeps IDF non-negative even when
113
+ # df > N/2 (which can happen on tiny corpora).
114
+ idf_cache[term] = math.log(((n_docs - df + 0.5) / (df + 0.5)) + 1.0)
115
+
116
+ scored: list[tuple[str, float]] = []
117
+ for doc_id, tf in self._tf.items():
118
+ doc_len = self._doc_len[doc_id]
119
+ score = 0.0
120
+ for term in query_tokens:
121
+ tf_term = tf.get(term, 0)
122
+ if tf_term == 0:
123
+ continue
124
+ denom = tf_term + self._k1 * (
125
+ 1.0 - self._b + self._b * (doc_len / avg_dl if avg_dl else 0.0)
126
+ )
127
+ score += idf_cache[term] * (tf_term * (self._k1 + 1.0)) / denom
128
+ if score > 0.0:
129
+ scored.append((doc_id, score))
130
+
131
+ scored.sort(key=lambda kv: kv[1], reverse=True)
132
+ return scored[:limit]
@@ -0,0 +1,62 @@
1
+ """Configuration system for AgentForge (feat-012).
2
+
3
+ `agentforge.yaml` is the single source of truth for an agent's
4
+ runtime wiring. This package ships:
5
+
6
+ - The locked **root schema** (`AgentForgeConfig` + sub-models).
7
+ - The **loader** (`load_config`) with env-var interpolation,
8
+ layered env files, dotted-path overrides, and module-side
9
+ schema validation.
10
+
11
+ Per ADR-0013, configuration is *data* — no Jinja, no dynamic
12
+ imports, no arbitrary template logic. Env-var interpolation
13
+ (`${VAR}`, `${VAR:default}`, `${VAR:?error}`, `$$`) and
14
+ schema-validated YAML are the only ways content flows into the
15
+ runtime.
16
+
17
+ The schema is locked under ADR-0007: adding a field is a minor
18
+ bump; removing or renaming requires a major bump.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from agentforge_core.config.loader import load_config, parse_overrides
24
+ from agentforge_core.config.module_schemas import validate_module_configs
25
+ from agentforge_core.config.schema import (
26
+ AgentConfig,
27
+ AgentForgeConfig,
28
+ BudgetConfig,
29
+ EvaluatorEntry,
30
+ GraphModuleConfig,
31
+ LoggingConfig,
32
+ MemoryModuleConfig,
33
+ ModuleEntry,
34
+ ModulesConfig,
35
+ ObservabilityEntry,
36
+ OutputConfig,
37
+ ProviderConfig,
38
+ RerankerEntry,
39
+ RetrievalConfig,
40
+ RetrieverModuleConfig,
41
+ )
42
+
43
+ __all__ = [
44
+ "AgentConfig",
45
+ "AgentForgeConfig",
46
+ "BudgetConfig",
47
+ "EvaluatorEntry",
48
+ "GraphModuleConfig",
49
+ "LoggingConfig",
50
+ "MemoryModuleConfig",
51
+ "ModuleEntry",
52
+ "ModulesConfig",
53
+ "ObservabilityEntry",
54
+ "OutputConfig",
55
+ "ProviderConfig",
56
+ "RerankerEntry",
57
+ "RetrievalConfig",
58
+ "RetrieverModuleConfig",
59
+ "load_config",
60
+ "parse_overrides",
61
+ "validate_module_configs",
62
+ ]
@@ -0,0 +1,239 @@
1
+ """YAML loader for `agentforge.yaml` (feat-012).
2
+
3
+ Resolution order (last wins) per spec §4.3:
4
+
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`).
11
+
12
+ Env-var interpolation syntax (feat-001):
13
+ - `${VAR}` — required; raises at load if missing.
14
+ - `${VAR:default}` — optional with default.
15
+ - `${VAR:?error message}` — required with custom error.
16
+ - `$$` — literal `$`.
17
+
18
+ Env-var shortcuts honoured by `load_config`:
19
+ - `AGENTFORGE_CONFIG` — overrides the default `./agentforge.yaml`
20
+ path (lowest precedence — still beaten by an explicit `path=`).
21
+ - `AGENTFORGE_ENV` — picks the overlay file (e.g. `production` →
22
+ `agentforge.production.yaml` next to the base file).
23
+ - `AGENTFORGE_LOG_LEVEL` — applied after schema validation to
24
+ `cfg.logging.level`.
25
+
26
+ Per ADR-0013, the loader is data only — no Jinja, no dynamic
27
+ imports, no template logic. Behaviour goes in Python code.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import re
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import yaml
38
+
39
+ from agentforge_core.config.schema import AgentForgeConfig
40
+ from agentforge_core.production.exceptions import ModuleError
41
+
42
+ _INTERP_RE = re.compile(
43
+ r"""
44
+ \$\$ # $$ -> literal $
45
+ | \$\{ # ${
46
+ (?P<name>[A-Z_][A-Z0-9_]*)
47
+ (?:
48
+ :
49
+ (?:
50
+ \?(?P<error>[^}]*)
51
+ | (?P<default>[^}]*)
52
+ )
53
+ )?
54
+ \}
55
+ """,
56
+ re.VERBOSE,
57
+ )
58
+
59
+
60
+ def _interp(value: str) -> str:
61
+ """Interpolate env-var references inside a single string."""
62
+
63
+ def repl(match: re.Match[str]) -> str:
64
+ if match.group(0) == "$$":
65
+ return "$"
66
+ name = match.group("name")
67
+ error = match.group("error")
68
+ default = match.group("default")
69
+ env_value = os.environ.get(name)
70
+ if env_value is not None:
71
+ return env_value
72
+ if error is not None:
73
+ raise ModuleError(f"Required env var {name} not set: {error}")
74
+ if default is not None:
75
+ return default
76
+ raise ModuleError(f"Required env var {name} not set (no default provided).")
77
+
78
+ return _INTERP_RE.sub(repl, value)
79
+
80
+
81
+ def _walk(value: Any) -> Any:
82
+ """Recursively interpolate strings inside a config tree."""
83
+ if isinstance(value, str):
84
+ return _interp(value)
85
+ if isinstance(value, dict):
86
+ return {k: _walk(v) for k, v in value.items()}
87
+ if isinstance(value, list):
88
+ return [_walk(v) for v in value]
89
+ return value
90
+
91
+
92
+ def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
93
+ """Recursive dict merge — overlay wins; lists replace wholesale.
94
+
95
+ Per spec §4.3 the overlay file's lists replace, not append. This
96
+ keeps the YAML behaviour predictable; users who want to extend
97
+ a list write the full list in the overlay.
98
+ """
99
+ out: dict[str, Any] = dict(base)
100
+ for key, value in overlay.items():
101
+ if key in out and isinstance(out[key], dict) and isinstance(value, dict):
102
+ out[key] = _deep_merge(out[key], value)
103
+ else:
104
+ out[key] = value
105
+ return out
106
+
107
+
108
+ def parse_overrides(overrides: list[str]) -> dict[str, Any]:
109
+ """Parse `["agent.budget.usd=10", ...]` into a nested dict.
110
+
111
+ Each entry is `<dotted.path>=<value>`. Values are YAML-parsed via
112
+ `yaml.safe_load` so numbers, booleans, and inline lists / dicts
113
+ work without surprise (`agent.tools=[a, b]` -> `["a", "b"]`).
114
+
115
+ Raises:
116
+ ModuleError: malformed override (missing `=`, empty path, etc.)
117
+ """
118
+ out: dict[str, Any] = {}
119
+ for entry in overrides:
120
+ if "=" not in entry:
121
+ raise ModuleError(f"Invalid override {entry!r}: expected '<path>=<value>'.")
122
+ path, _, raw_value = entry.partition("=")
123
+ path = path.strip()
124
+ if not path:
125
+ raise ModuleError(f"Invalid override {entry!r}: empty path before '='.")
126
+ parts = path.split(".")
127
+ if any(not p for p in parts):
128
+ raise ModuleError(f"Invalid override {entry!r}: empty path segment.")
129
+ try:
130
+ value = yaml.safe_load(raw_value)
131
+ except yaml.YAMLError as exc:
132
+ raise ModuleError(
133
+ f"Invalid override {entry!r}: value not parseable as YAML ({exc})."
134
+ ) from exc
135
+ # Walk down `out`, creating dicts as needed; assign at the leaf.
136
+ cursor = out
137
+ for part in parts[:-1]:
138
+ existing = cursor.get(part)
139
+ if not isinstance(existing, dict):
140
+ cursor[part] = {}
141
+ cursor = cursor[part]
142
+ cursor[parts[-1]] = value
143
+ return out
144
+
145
+
146
+ def _read_yaml(path: Path) -> dict[str, Any]:
147
+ """Read a YAML file; require a mapping at the top level."""
148
+ with path.open() as fh:
149
+ raw = yaml.safe_load(fh) or {}
150
+ if not isinstance(raw, dict):
151
+ raise ModuleError(
152
+ f"agentforge.yaml at {path} must be a mapping at the top level; "
153
+ f"got {type(raw).__name__}."
154
+ )
155
+ return raw
156
+
157
+
158
+ def _env_overlay_path(base: Path, env: str) -> Path:
159
+ """Compute the overlay path next to `base`: foo.yaml → foo.<env>.yaml."""
160
+ return base.with_suffix(f".{env}{base.suffix}")
161
+
162
+
163
+ def load_config(
164
+ path: Path | str | None = None,
165
+ *,
166
+ env: str | None = None,
167
+ overrides: list[str] | None = None,
168
+ ) -> AgentForgeConfig:
169
+ """Load + validate `agentforge.yaml` with full feat-012 resolution.
170
+
171
+ Args:
172
+ path: Explicit path to the YAML file. If `None`, falls back to
173
+ `AGENTFORGE_CONFIG` env var, then `./agentforge.yaml`. If
174
+ no file exists at any of these, returns the default config.
175
+ env: Environment name. Selects the overlay file
176
+ `agentforge.<env>.yaml` next to the base. If `None`, falls
177
+ back to `AGENTFORGE_ENV`.
178
+ overrides: List of `"<dotted.path>=<value>"` strings to apply
179
+ after env-var interpolation and before schema validation.
180
+
181
+ Returns:
182
+ Validated `AgentForgeConfig` with `AGENTFORGE_LOG_LEVEL`
183
+ applied to `cfg.logging.level` post-validation if set.
184
+
185
+ Raises:
186
+ ModuleError: env-var interpolation, layered-file, or override
187
+ problem.
188
+ pydantic.ValidationError: schema validation failed.
189
+ """
190
+ resolved_path = _resolve_path(path)
191
+ if resolved_path is None or not resolved_path.exists():
192
+ merged: dict[str, Any] = {}
193
+ else:
194
+ merged = _read_yaml(resolved_path)
195
+ # Layered env file overlays the base. Missing overlay is fine
196
+ # (env-without-file is just "use base").
197
+ resolved_env = env if env is not None else os.environ.get("AGENTFORGE_ENV")
198
+ if resolved_env:
199
+ overlay_path = _env_overlay_path(resolved_path, resolved_env)
200
+ if overlay_path.exists():
201
+ merged = _deep_merge(merged, _read_yaml(overlay_path))
202
+
203
+ interpolated = _walk(merged)
204
+ if overrides:
205
+ interpolated = _deep_merge(interpolated, parse_overrides(overrides))
206
+
207
+ config = AgentForgeConfig.model_validate(interpolated)
208
+ return _apply_env_log_level(config)
209
+
210
+
211
+ def _resolve_path(path: Path | str | None) -> Path | None:
212
+ """Resolve the config-file path with `AGENTFORGE_CONFIG` fallback.
213
+
214
+ Order of precedence:
215
+ 1. Explicit `path` argument.
216
+ 2. `AGENTFORGE_CONFIG` env var.
217
+ 3. `./agentforge.yaml` (default).
218
+ """
219
+ if path is not None:
220
+ return Path(path)
221
+ env_path = os.environ.get("AGENTFORGE_CONFIG")
222
+ if env_path:
223
+ return Path(env_path)
224
+ candidate = Path.cwd() / "agentforge.yaml"
225
+ return candidate if candidate.exists() else None
226
+
227
+
228
+ def _apply_env_log_level(config: AgentForgeConfig) -> AgentForgeConfig:
229
+ """Apply `AGENTFORGE_LOG_LEVEL` over the validated config.
230
+
231
+ This is a post-validation override so users can flip log level
232
+ without touching the file (debugging, CI). Implemented via
233
+ `model_copy` to keep the model frozen-friendly.
234
+ """
235
+ level = os.environ.get("AGENTFORGE_LOG_LEVEL")
236
+ if not level:
237
+ return config
238
+ new_logging = config.logging.model_copy(update={"level": level})
239
+ return config.model_copy(update={"logging": new_logging})