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.
- agentforge_core/__init__.py +228 -0
- agentforge_core/_bm25.py +132 -0
- agentforge_core/config/__init__.py +62 -0
- agentforge_core/config/loader.py +239 -0
- agentforge_core/config/module_schemas.py +208 -0
- agentforge_core/config/schema.py +424 -0
- agentforge_core/contracts/__init__.py +52 -0
- agentforge_core/contracts/auth.py +33 -0
- agentforge_core/contracts/chat.py +118 -0
- agentforge_core/contracts/embedding.py +71 -0
- agentforge_core/contracts/evaluator.py +56 -0
- agentforge_core/contracts/finding.py +39 -0
- agentforge_core/contracts/graph_store.py +180 -0
- agentforge_core/contracts/guardrails.py +129 -0
- agentforge_core/contracts/llm.py +152 -0
- agentforge_core/contracts/memory.py +113 -0
- agentforge_core/contracts/migrator.py +120 -0
- agentforge_core/contracts/renderer.py +57 -0
- agentforge_core/contracts/reranker.py +91 -0
- agentforge_core/contracts/strategy.py +70 -0
- agentforge_core/contracts/task.py +73 -0
- agentforge_core/contracts/tool.py +71 -0
- agentforge_core/contracts/vector_store.py +151 -0
- agentforge_core/migrations/__init__.py +14 -0
- agentforge_core/migrations/discover.py +77 -0
- agentforge_core/migrations/template.py +34 -0
- agentforge_core/observability/__init__.py +18 -0
- agentforge_core/observability/tracing.py +37 -0
- agentforge_core/production/__init__.py +77 -0
- agentforge_core/production/budget.py +134 -0
- agentforge_core/production/exceptions.py +136 -0
- agentforge_core/production/fallback.py +321 -0
- agentforge_core/production/log_filter.py +49 -0
- agentforge_core/production/log_format.py +117 -0
- agentforge_core/production/run_context.py +108 -0
- agentforge_core/py.typed +0 -0
- agentforge_core/resolver/__init__.py +38 -0
- agentforge_core/resolver/discover.py +145 -0
- agentforge_core/resolver/resolve.py +168 -0
- agentforge_core/testing/__init__.py +45 -0
- agentforge_core/testing/conformance.py +1138 -0
- agentforge_core/values/__init__.py +103 -0
- agentforge_core/values/auth.py +20 -0
- agentforge_core/values/chat.py +131 -0
- agentforge_core/values/claim.py +30 -0
- agentforge_core/values/graph.py +136 -0
- agentforge_core/values/guardrails.py +49 -0
- agentforge_core/values/manifest.py +129 -0
- agentforge_core/values/messages.py +153 -0
- agentforge_core/values/module.py +40 -0
- agentforge_core/values/pipeline.py +43 -0
- agentforge_core/values/retrieval.py +53 -0
- agentforge_core/values/state.py +118 -0
- agentforge_core/values/vector.py +59 -0
- agentforge_core-0.2.1.dist-info/METADATA +66 -0
- agentforge_core-0.2.1.dist-info/RECORD +58 -0
- agentforge_core-0.2.1.dist-info/WHEEL +4 -0
- 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
|
+
]
|
agentforge_core/_bm25.py
ADDED
|
@@ -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})
|