friday-framework-runtime 0.1.0a0__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.
- friday_framework_runtime-0.1.0a0.dist-info/METADATA +45 -0
- friday_framework_runtime-0.1.0a0.dist-info/RECORD +8 -0
- friday_framework_runtime-0.1.0a0.dist-info/WHEEL +4 -0
- friday_runtime/__init__.py +15 -0
- friday_runtime/config.py +1681 -0
- friday_runtime/di.py +342 -0
- friday_runtime/kernel.py +673 -0
- friday_runtime/tool_loading.py +177 -0
friday_runtime/config.py
ADDED
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runtime configuration for the Friday Agent Framework.
|
|
3
|
+
|
|
4
|
+
RuntimeConfig preserves the existing flat compatibility surface used by the
|
|
5
|
+
runtime/kernel/CLI while adding the nested Phase 4 config contract and
|
|
6
|
+
dual-source loading helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal, cast
|
|
16
|
+
|
|
17
|
+
import yaml # type: ignore[import-untyped]
|
|
18
|
+
from friday_mcp.config import MCPConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _coerce_bool(value: str) -> bool:
|
|
22
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _deployment_profile_from_env() -> Literal["local", "container", "cloud"]:
|
|
26
|
+
value = os.getenv("FRIDAY_DEPLOYMENT_PROFILE", "local")
|
|
27
|
+
if value in {"local", "container", "cloud"}:
|
|
28
|
+
return cast(Literal["local", "container", "cloud"], value)
|
|
29
|
+
return "local"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ConfigPolicy:
|
|
34
|
+
allow_env_override: bool = False
|
|
35
|
+
strict_agent_llm_binding: bool = False
|
|
36
|
+
workflow_mode: Literal["canonical", "legacy", "auto"] = "canonical"
|
|
37
|
+
unknown_key_policy: Literal["fail"] = "fail"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RuntimePaths:
|
|
42
|
+
install_root: str
|
|
43
|
+
runtime_data_root: str
|
|
44
|
+
workspace_root: str
|
|
45
|
+
cache_root: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ArtifactPolicy:
|
|
50
|
+
enabled: bool = True
|
|
51
|
+
root_dir: str = "artifacts"
|
|
52
|
+
lineage_required: bool = True
|
|
53
|
+
publish_on_task_success: bool = True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class AgentsConfig:
|
|
58
|
+
dir: str | None = None
|
|
59
|
+
role_bindings: dict[str, str] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class WorkflowsConfig:
|
|
64
|
+
dir: str | None = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _normalize_str_tuple(values: Any, *, field_name: str) -> tuple[str, ...]:
|
|
68
|
+
if values is None:
|
|
69
|
+
return ()
|
|
70
|
+
if isinstance(values, (list, tuple)):
|
|
71
|
+
normalized = []
|
|
72
|
+
for value in values:
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
raise ValueError(f"{field_name} entries must be strings")
|
|
75
|
+
stripped = value.strip()
|
|
76
|
+
if stripped:
|
|
77
|
+
normalized.append(stripped)
|
|
78
|
+
return tuple(normalized)
|
|
79
|
+
raise ValueError(f"{field_name} must be a list of strings")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ToolProviderConfig:
|
|
84
|
+
enabled: bool = False
|
|
85
|
+
mode: Literal["all", "selected"] = "all"
|
|
86
|
+
include_categories: tuple[str, ...] = ()
|
|
87
|
+
exclude_categories: tuple[str, ...] = ()
|
|
88
|
+
include_tools: tuple[str, ...] = ()
|
|
89
|
+
exclude_tools: tuple[str, ...] = ()
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
if self.mode not in {"all", "selected"}:
|
|
93
|
+
raise ValueError("tool provider mode must be 'all' or 'selected'")
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_dict(cls, data: Mapping[str, Any]) -> ToolProviderConfig:
|
|
97
|
+
return cls(
|
|
98
|
+
enabled=bool(data.get("enabled", False)),
|
|
99
|
+
mode=cast(Literal["all", "selected"], data.get("mode", "all")),
|
|
100
|
+
include_categories=_normalize_str_tuple(
|
|
101
|
+
data.get("include_categories"),
|
|
102
|
+
field_name="tools provider include_categories",
|
|
103
|
+
),
|
|
104
|
+
exclude_categories=_normalize_str_tuple(
|
|
105
|
+
data.get("exclude_categories"),
|
|
106
|
+
field_name="tools provider exclude_categories",
|
|
107
|
+
),
|
|
108
|
+
include_tools=_normalize_str_tuple(
|
|
109
|
+
data.get("include_tools"),
|
|
110
|
+
field_name="tools provider include_tools",
|
|
111
|
+
),
|
|
112
|
+
exclude_tools=_normalize_str_tuple(
|
|
113
|
+
data.get("exclude_tools"),
|
|
114
|
+
field_name="tools provider exclude_tools",
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class MCPToolProviderConfig:
|
|
121
|
+
enabled: bool = False
|
|
122
|
+
mode: Literal["all", "selected"] = "all"
|
|
123
|
+
include_categories: tuple[str, ...] = ()
|
|
124
|
+
exclude_categories: tuple[str, ...] = ()
|
|
125
|
+
include_tools: tuple[str, ...] = ()
|
|
126
|
+
exclude_tools: tuple[str, ...] = ()
|
|
127
|
+
config: MCPConfig = field(default_factory=MCPConfig)
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
if self.mode not in {"all", "selected"}:
|
|
131
|
+
raise ValueError("tool provider mode must be 'all' or 'selected'")
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
def from_dict(cls, data: Mapping[str, Any]) -> MCPToolProviderConfig:
|
|
135
|
+
raw_config = data.get("config", {})
|
|
136
|
+
if isinstance(raw_config, MCPConfig):
|
|
137
|
+
mcp_config = raw_config
|
|
138
|
+
elif isinstance(raw_config, Mapping):
|
|
139
|
+
mcp_config = MCPConfig.model_validate(dict(raw_config))
|
|
140
|
+
else:
|
|
141
|
+
raise ValueError("tools.providers.mcp.config must be an object")
|
|
142
|
+
|
|
143
|
+
return cls(
|
|
144
|
+
enabled=bool(data.get("enabled", False)),
|
|
145
|
+
mode=cast(Literal["all", "selected"], data.get("mode", "all")),
|
|
146
|
+
include_categories=_normalize_str_tuple(
|
|
147
|
+
data.get("include_categories"),
|
|
148
|
+
field_name="tools provider include_categories",
|
|
149
|
+
),
|
|
150
|
+
exclude_categories=_normalize_str_tuple(
|
|
151
|
+
data.get("exclude_categories"),
|
|
152
|
+
field_name="tools provider exclude_categories",
|
|
153
|
+
),
|
|
154
|
+
include_tools=_normalize_str_tuple(
|
|
155
|
+
data.get("include_tools"),
|
|
156
|
+
field_name="tools provider include_tools",
|
|
157
|
+
),
|
|
158
|
+
exclude_tools=_normalize_str_tuple(
|
|
159
|
+
data.get("exclude_tools"),
|
|
160
|
+
field_name="tools provider exclude_tools",
|
|
161
|
+
),
|
|
162
|
+
config=mcp_config,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ToolProvidersConfig:
|
|
168
|
+
builtin: ToolProviderConfig = field(
|
|
169
|
+
default_factory=lambda: ToolProviderConfig(enabled=True, mode="all")
|
|
170
|
+
)
|
|
171
|
+
friday_tools: ToolProviderConfig = field(default_factory=ToolProviderConfig)
|
|
172
|
+
mcp: MCPToolProviderConfig = field(default_factory=MCPToolProviderConfig)
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_dict(cls, data: Mapping[str, Any]) -> ToolProvidersConfig:
|
|
176
|
+
unknown = set(data) - {"builtin", "friday_tools", "mcp"}
|
|
177
|
+
if unknown:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"Unknown tool providers in runtime config: {sorted(unknown)}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
builtin_raw = data.get("builtin", {})
|
|
183
|
+
friday_tools_raw = data.get("friday_tools", {})
|
|
184
|
+
mcp_raw = data.get("mcp", {})
|
|
185
|
+
if not isinstance(builtin_raw, Mapping):
|
|
186
|
+
raise ValueError("tools.providers.builtin must be an object")
|
|
187
|
+
if not isinstance(friday_tools_raw, Mapping):
|
|
188
|
+
raise ValueError("tools.providers.friday_tools must be an object")
|
|
189
|
+
if not isinstance(mcp_raw, Mapping):
|
|
190
|
+
raise ValueError("tools.providers.mcp must be an object")
|
|
191
|
+
|
|
192
|
+
return cls(
|
|
193
|
+
builtin=ToolProviderConfig.from_dict(builtin_raw),
|
|
194
|
+
friday_tools=ToolProviderConfig.from_dict(friday_tools_raw),
|
|
195
|
+
mcp=MCPToolProviderConfig.from_dict(mcp_raw),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class ToolsConfig:
|
|
201
|
+
providers: ToolProvidersConfig = field(default_factory=ToolProvidersConfig)
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_dict(cls, data: Mapping[str, Any]) -> ToolsConfig:
|
|
205
|
+
unknown = set(data) - {"providers"}
|
|
206
|
+
if unknown:
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"Unknown keys in runtime config section 'tools': {sorted(unknown)}"
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
providers_raw = data.get("providers", {})
|
|
212
|
+
if not isinstance(providers_raw, Mapping):
|
|
213
|
+
raise ValueError("tools.providers must be an object")
|
|
214
|
+
return cls(providers=ToolProvidersConfig.from_dict(providers_raw))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class ResiliencePolicy:
|
|
219
|
+
timeout_seconds: int = 60
|
|
220
|
+
max_retries: int = 2
|
|
221
|
+
|
|
222
|
+
def __post_init__(self) -> None:
|
|
223
|
+
if self.timeout_seconds <= 0:
|
|
224
|
+
raise ValueError("resilience.timeout_seconds must be > 0")
|
|
225
|
+
if self.max_retries < 0:
|
|
226
|
+
raise ValueError("resilience.max_retries must be >= 0")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True)
|
|
230
|
+
class LLMServiceConfig:
|
|
231
|
+
provider: str = "openai"
|
|
232
|
+
model: str = "gpt-4"
|
|
233
|
+
api_key: str | None = None
|
|
234
|
+
api_base: str | None = None
|
|
235
|
+
resilience: ResiliencePolicy = field(default_factory=ResiliencePolicy)
|
|
236
|
+
|
|
237
|
+
def __post_init__(self) -> None:
|
|
238
|
+
if not self.provider:
|
|
239
|
+
raise ValueError("llm service provider must be non-empty")
|
|
240
|
+
if not self.model:
|
|
241
|
+
raise ValueError("llm service model must be non-empty")
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def from_dict(cls, data: Mapping[str, Any]) -> LLMServiceConfig:
|
|
245
|
+
resilience = data.get("resilience", {})
|
|
246
|
+
if isinstance(resilience, ResiliencePolicy):
|
|
247
|
+
resilience_policy = resilience
|
|
248
|
+
elif isinstance(resilience, Mapping):
|
|
249
|
+
resilience_policy = ResiliencePolicy(
|
|
250
|
+
timeout_seconds=int(resilience.get("timeout_seconds", 60)),
|
|
251
|
+
max_retries=int(resilience.get("max_retries", 2)),
|
|
252
|
+
)
|
|
253
|
+
else:
|
|
254
|
+
raise ValueError("llm service resilience must be an object")
|
|
255
|
+
|
|
256
|
+
return cls(
|
|
257
|
+
provider=str(data.get("provider", "openai")),
|
|
258
|
+
model=str(data.get("model", "gpt-4")),
|
|
259
|
+
api_key=cast(str | None, data.get("api_key")),
|
|
260
|
+
api_base=cast(str | None, data.get("api_base")),
|
|
261
|
+
resilience=resilience_policy,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@dataclass(frozen=True)
|
|
266
|
+
class LLMPoolConfig:
|
|
267
|
+
default_service: str
|
|
268
|
+
services: dict[str, LLMServiceConfig]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@dataclass(frozen=True)
|
|
272
|
+
class EmbeddingServiceConfig:
|
|
273
|
+
provider: str = "openai"
|
|
274
|
+
model: str = "text-embedding-3-small"
|
|
275
|
+
api_key: str | None = None
|
|
276
|
+
api_base: str | None = None
|
|
277
|
+
resilience: ResiliencePolicy = field(default_factory=ResiliencePolicy)
|
|
278
|
+
|
|
279
|
+
def __post_init__(self) -> None:
|
|
280
|
+
if not self.provider:
|
|
281
|
+
raise ValueError("embedding service provider must be non-empty")
|
|
282
|
+
if not self.model:
|
|
283
|
+
raise ValueError("embedding service model must be non-empty")
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def from_dict(cls, data: Mapping[str, Any]) -> EmbeddingServiceConfig:
|
|
287
|
+
resilience = data.get("resilience", {})
|
|
288
|
+
if isinstance(resilience, ResiliencePolicy):
|
|
289
|
+
resilience_policy = resilience
|
|
290
|
+
elif isinstance(resilience, Mapping):
|
|
291
|
+
resilience_policy = ResiliencePolicy(
|
|
292
|
+
timeout_seconds=int(resilience.get("timeout_seconds", 60)),
|
|
293
|
+
max_retries=int(resilience.get("max_retries", 2)),
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
raise ValueError("embedding service resilience must be an object")
|
|
297
|
+
|
|
298
|
+
return cls(
|
|
299
|
+
provider=str(data.get("provider", "openai")),
|
|
300
|
+
model=str(data.get("model", "text-embedding-3-small")),
|
|
301
|
+
api_key=cast(str | None, data.get("api_key")),
|
|
302
|
+
api_base=cast(str | None, data.get("api_base")),
|
|
303
|
+
resilience=resilience_policy,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass(frozen=True)
|
|
308
|
+
class EmbeddingPoolConfig:
|
|
309
|
+
default_service: str
|
|
310
|
+
services: dict[str, EmbeddingServiceConfig]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@dataclass(frozen=True)
|
|
314
|
+
class PromptingConfig:
|
|
315
|
+
base_system_prompt: str | None = None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass(frozen=True)
|
|
319
|
+
class TranscriptConfig:
|
|
320
|
+
enabled: bool = False
|
|
321
|
+
backend: Literal["sqlite"] = "sqlite"
|
|
322
|
+
path: str = "transcript.db"
|
|
323
|
+
capture_context_log: bool = True
|
|
324
|
+
|
|
325
|
+
def __post_init__(self) -> None:
|
|
326
|
+
if self.backend != "sqlite":
|
|
327
|
+
raise ValueError("transcript.backend must be 'sqlite'")
|
|
328
|
+
if not self.path:
|
|
329
|
+
raise ValueError("transcript.path must be non-empty")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass(frozen=True)
|
|
333
|
+
class MemoryRecallPolicy:
|
|
334
|
+
semantic_retrieval_enabled: bool = True
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass(frozen=True)
|
|
338
|
+
class MemoryExtractionPolicy:
|
|
339
|
+
enabled: bool = True
|
|
340
|
+
llm_service: str | None = None
|
|
341
|
+
embedding_service: str | None = None
|
|
342
|
+
max_tokens: int | None = None
|
|
343
|
+
rejection_log: str | None = None
|
|
344
|
+
user_name: str = "User"
|
|
345
|
+
assistant_name: str = "Assistant"
|
|
346
|
+
prompt: str | None = None
|
|
347
|
+
ontology: dict[str, Any] | None = None
|
|
348
|
+
|
|
349
|
+
def __post_init__(self) -> None:
|
|
350
|
+
if self.max_tokens is not None and self.max_tokens <= 0:
|
|
351
|
+
raise ValueError("memory.extraction.max_tokens must be > 0")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass
|
|
355
|
+
class RuntimeConfig:
|
|
356
|
+
"""
|
|
357
|
+
Configuration for the Friday Runtime Kernel.
|
|
358
|
+
|
|
359
|
+
The flat fields remain available for compatibility. Nested Phase 4 sections
|
|
360
|
+
are exposed through computed properties and the `from_yaml` / `from_sources`
|
|
361
|
+
helpers.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
# Phase 4 policy fields
|
|
365
|
+
allow_env_override: bool = False
|
|
366
|
+
strict_agent_llm_binding: bool = False
|
|
367
|
+
workflow_mode: Literal["canonical", "legacy", "auto"] = "canonical"
|
|
368
|
+
unknown_key_policy: Literal["fail"] = "fail"
|
|
369
|
+
|
|
370
|
+
# LLM Configuration
|
|
371
|
+
llm_provider: str = "openai"
|
|
372
|
+
llm_model: str = "gpt-4"
|
|
373
|
+
llm_api_key: str | None = None
|
|
374
|
+
llm_api_base: str | None = None
|
|
375
|
+
llm_pool_default_service: str = "default"
|
|
376
|
+
llm_pool_services: dict[str, LLMServiceConfig] = field(default_factory=dict)
|
|
377
|
+
|
|
378
|
+
# Embedding Configuration
|
|
379
|
+
embedding_model: str = "text-embedding-3-small"
|
|
380
|
+
embedding_pool_default_service: str = "default"
|
|
381
|
+
embedding_pool_services: dict[str, EmbeddingServiceConfig] = field(
|
|
382
|
+
default_factory=dict
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Prompting Configuration
|
|
386
|
+
prompting_base_system_prompt: str | None = None
|
|
387
|
+
|
|
388
|
+
# Memory Configuration
|
|
389
|
+
chroma_path: str = ".friday/chroma"
|
|
390
|
+
chroma_collection: str = "friday_memory"
|
|
391
|
+
memory_mode: str = "single_agent"
|
|
392
|
+
memory_recall_semantic_retrieval_enabled: bool = True
|
|
393
|
+
memory_extraction_enabled: bool = True
|
|
394
|
+
memory_extraction_llm_service: str | None = None
|
|
395
|
+
memory_extraction_embedding_service: str | None = None
|
|
396
|
+
memory_extraction_max_tokens: int | None = None
|
|
397
|
+
memory_extraction_rejection_log: str | None = None
|
|
398
|
+
memory_extraction_user_name: str = "User"
|
|
399
|
+
memory_extraction_assistant_name: str = "Assistant"
|
|
400
|
+
memory_extraction_prompt: str | None = None
|
|
401
|
+
memory_extraction_ontology: dict[str, Any] | None = None
|
|
402
|
+
|
|
403
|
+
# Telemetry Configuration
|
|
404
|
+
telemetry_enabled: bool = False
|
|
405
|
+
telemetry_endpoint: str | None = None
|
|
406
|
+
debug_mode: bool = False
|
|
407
|
+
|
|
408
|
+
# Agent Configuration
|
|
409
|
+
agents_dir: str | None = None
|
|
410
|
+
agents_role_bindings: dict[str, str] = field(default_factory=dict)
|
|
411
|
+
workflows_dir: str | None = None
|
|
412
|
+
tools_config: ToolsConfig = field(default_factory=ToolsConfig)
|
|
413
|
+
|
|
414
|
+
# Transcript Configuration
|
|
415
|
+
transcript_enabled: bool = False
|
|
416
|
+
transcript_backend: Literal["sqlite"] = "sqlite"
|
|
417
|
+
transcript_path: str = "transcript.db"
|
|
418
|
+
transcript_capture_context_log: bool = True
|
|
419
|
+
|
|
420
|
+
# Runtime filesystem topology
|
|
421
|
+
deployment_profile: Literal["local", "container", "cloud"] = "local"
|
|
422
|
+
install_root: str | None = None
|
|
423
|
+
runtime_data_root: str | None = None
|
|
424
|
+
workspace_root: str | None = None
|
|
425
|
+
cache_root: str | None = None
|
|
426
|
+
|
|
427
|
+
# Scratch configuration
|
|
428
|
+
scratch_backend: str = "in_memory"
|
|
429
|
+
scratch_path: str | None = None
|
|
430
|
+
|
|
431
|
+
# Artifact policy
|
|
432
|
+
artifacts_enabled: bool = True
|
|
433
|
+
artifacts_root_dir: str = "artifacts"
|
|
434
|
+
artifacts_lineage_required: bool = True
|
|
435
|
+
artifacts_publish_on_task_success: bool = True
|
|
436
|
+
|
|
437
|
+
def __post_init__(self) -> None:
|
|
438
|
+
"""Validate additive config contracts while preserving defaults."""
|
|
439
|
+
if self.memory_mode not in {"single_agent", "multi_agent"}:
|
|
440
|
+
raise ValueError("memory_mode must be 'single_agent' or 'multi_agent'")
|
|
441
|
+
if self.deployment_profile not in {"local", "container", "cloud"}:
|
|
442
|
+
raise ValueError(
|
|
443
|
+
"deployment_profile must be 'local', 'container', or 'cloud'"
|
|
444
|
+
)
|
|
445
|
+
if self.scratch_backend not in {"in_memory", "sqlite"}:
|
|
446
|
+
raise ValueError("scratch_backend must be 'in_memory' or 'sqlite'")
|
|
447
|
+
if self.workflow_mode not in {"canonical", "legacy", "auto"}:
|
|
448
|
+
raise ValueError("workflow_mode must be 'canonical', 'legacy', or 'auto'")
|
|
449
|
+
if self.unknown_key_policy != "fail":
|
|
450
|
+
raise ValueError("unknown_key_policy must be 'fail' in Phase 4")
|
|
451
|
+
if self.transcript_backend != "sqlite":
|
|
452
|
+
raise ValueError("transcript.backend must be 'sqlite'")
|
|
453
|
+
if not self.transcript_path:
|
|
454
|
+
raise ValueError("transcript.path must be non-empty")
|
|
455
|
+
|
|
456
|
+
defaults = self.deployment_profile_defaults(self.deployment_profile)
|
|
457
|
+
if self.install_root is None:
|
|
458
|
+
self.install_root = defaults["install_root"]
|
|
459
|
+
if self.runtime_data_root is None:
|
|
460
|
+
self.runtime_data_root = defaults["runtime_data_root"]
|
|
461
|
+
if self.workspace_root is None:
|
|
462
|
+
self.workspace_root = defaults["workspace_root"]
|
|
463
|
+
|
|
464
|
+
self.agents_role_bindings = dict(self.agents_role_bindings)
|
|
465
|
+
if isinstance(self.tools_config, Mapping):
|
|
466
|
+
self.tools_config = ToolsConfig.from_dict(self.tools_config)
|
|
467
|
+
elif not isinstance(self.tools_config, ToolsConfig):
|
|
468
|
+
raise ValueError("tools_config must be a ToolsConfig object")
|
|
469
|
+
self.llm_pool_services = {
|
|
470
|
+
name: self._coerce_service_config(service)
|
|
471
|
+
for name, service in self.llm_pool_services.items()
|
|
472
|
+
}
|
|
473
|
+
if not self.llm_pool_services:
|
|
474
|
+
self.llm_pool_services = {
|
|
475
|
+
self.llm_pool_default_service: LLMServiceConfig(
|
|
476
|
+
provider=self.llm_provider,
|
|
477
|
+
model=self.llm_model,
|
|
478
|
+
api_key=self.llm_api_key,
|
|
479
|
+
api_base=self.llm_api_base,
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if self.llm_pool_default_service not in self.llm_pool_services:
|
|
484
|
+
raise ValueError("llm_pool.default_service must reference a configured service")
|
|
485
|
+
|
|
486
|
+
self.embedding_pool_services = {
|
|
487
|
+
name: self._coerce_embedding_service_config(service)
|
|
488
|
+
for name, service in self.embedding_pool_services.items()
|
|
489
|
+
}
|
|
490
|
+
if not self.embedding_pool_services:
|
|
491
|
+
default_llm_service = self.get_llm_service_config()
|
|
492
|
+
self.embedding_pool_services = {
|
|
493
|
+
self.embedding_pool_default_service: EmbeddingServiceConfig(
|
|
494
|
+
provider=default_llm_service.provider,
|
|
495
|
+
model=self.embedding_model,
|
|
496
|
+
api_key=default_llm_service.api_key,
|
|
497
|
+
api_base=default_llm_service.api_base,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if self.embedding_pool_default_service not in self.embedding_pool_services:
|
|
502
|
+
raise ValueError(
|
|
503
|
+
"embedding_pool.default_service must reference a configured service"
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
self.embedding_model = self.get_embedding_service_config().model
|
|
507
|
+
|
|
508
|
+
if self.memory_extraction_llm_service is None:
|
|
509
|
+
self.memory_extraction_llm_service = self.llm_pool_default_service
|
|
510
|
+
|
|
511
|
+
if self.memory_extraction_max_tokens is not None and self.memory_extraction_max_tokens <= 0:
|
|
512
|
+
raise ValueError("memory.extraction.max_tokens must be > 0")
|
|
513
|
+
if not self.memory_extraction_user_name:
|
|
514
|
+
raise ValueError("memory.extraction.user_name must be non-empty")
|
|
515
|
+
if not self.memory_extraction_assistant_name:
|
|
516
|
+
raise ValueError("memory.extraction.assistant_name must be non-empty")
|
|
517
|
+
if self.memory_extraction_llm_service not in self.llm_pool_services:
|
|
518
|
+
raise ValueError("memory.extraction.llm_service must reference a configured service")
|
|
519
|
+
if (
|
|
520
|
+
self.memory_extraction_embedding_service is not None
|
|
521
|
+
and self.memory_extraction_embedding_service not in self.embedding_pool_services
|
|
522
|
+
):
|
|
523
|
+
raise ValueError(
|
|
524
|
+
"memory.extraction.embedding_service must reference a configured service"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
self.validate_runtime_roots()
|
|
528
|
+
self.validate_scratch_path()
|
|
529
|
+
self.validate_artifacts_policy()
|
|
530
|
+
self.validate_transcript_path()
|
|
531
|
+
|
|
532
|
+
@staticmethod
|
|
533
|
+
def _coerce_service_config(value: LLMServiceConfig | Mapping[str, Any]) -> LLMServiceConfig:
|
|
534
|
+
if isinstance(value, LLMServiceConfig):
|
|
535
|
+
return value
|
|
536
|
+
if isinstance(value, Mapping):
|
|
537
|
+
return LLMServiceConfig.from_dict(value)
|
|
538
|
+
raise ValueError("llm_pool.services entries must be objects")
|
|
539
|
+
|
|
540
|
+
@staticmethod
|
|
541
|
+
def _coerce_embedding_service_config(
|
|
542
|
+
value: EmbeddingServiceConfig | Mapping[str, Any]
|
|
543
|
+
) -> EmbeddingServiceConfig:
|
|
544
|
+
if isinstance(value, EmbeddingServiceConfig):
|
|
545
|
+
return value
|
|
546
|
+
if isinstance(value, Mapping):
|
|
547
|
+
return EmbeddingServiceConfig.from_dict(value)
|
|
548
|
+
raise ValueError("embedding_pool.services entries must be objects")
|
|
549
|
+
|
|
550
|
+
@staticmethod
|
|
551
|
+
def _resolve(path_value: str) -> Path:
|
|
552
|
+
return Path(path_value).expanduser().resolve()
|
|
553
|
+
|
|
554
|
+
@property
|
|
555
|
+
def config_policy(self) -> ConfigPolicy:
|
|
556
|
+
return ConfigPolicy(
|
|
557
|
+
allow_env_override=self.allow_env_override,
|
|
558
|
+
strict_agent_llm_binding=self.strict_agent_llm_binding,
|
|
559
|
+
workflow_mode=self.workflow_mode,
|
|
560
|
+
unknown_key_policy=self.unknown_key_policy,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
@property
|
|
564
|
+
def runtime_paths(self) -> RuntimePaths:
|
|
565
|
+
assert self.install_root is not None
|
|
566
|
+
assert self.runtime_data_root is not None
|
|
567
|
+
assert self.workspace_root is not None
|
|
568
|
+
return RuntimePaths(
|
|
569
|
+
install_root=self.install_root,
|
|
570
|
+
runtime_data_root=self.runtime_data_root,
|
|
571
|
+
workspace_root=self.workspace_root,
|
|
572
|
+
cache_root=self.cache_root,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
@property
|
|
576
|
+
def artifacts(self) -> ArtifactPolicy:
|
|
577
|
+
return ArtifactPolicy(
|
|
578
|
+
enabled=self.artifacts_enabled,
|
|
579
|
+
root_dir=self.artifacts_root_dir,
|
|
580
|
+
lineage_required=self.artifacts_lineage_required,
|
|
581
|
+
publish_on_task_success=self.artifacts_publish_on_task_success,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
@property
|
|
585
|
+
def agents(self) -> AgentsConfig:
|
|
586
|
+
return AgentsConfig(
|
|
587
|
+
dir=self.agents_dir,
|
|
588
|
+
role_bindings=dict(self.agents_role_bindings),
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
@property
|
|
592
|
+
def workflows(self) -> WorkflowsConfig:
|
|
593
|
+
return WorkflowsConfig(dir=self.workflows_dir)
|
|
594
|
+
|
|
595
|
+
@property
|
|
596
|
+
def tools(self) -> ToolsConfig:
|
|
597
|
+
return self.tools_config
|
|
598
|
+
|
|
599
|
+
@property
|
|
600
|
+
def llm_pool(self) -> LLMPoolConfig:
|
|
601
|
+
return LLMPoolConfig(
|
|
602
|
+
default_service=self.llm_pool_default_service,
|
|
603
|
+
services=dict(self.llm_pool_services),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
@property
|
|
607
|
+
def embedding_pool(self) -> EmbeddingPoolConfig:
|
|
608
|
+
return EmbeddingPoolConfig(
|
|
609
|
+
default_service=self.embedding_pool_default_service,
|
|
610
|
+
services=dict(self.embedding_pool_services),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
@property
|
|
614
|
+
def prompting(self) -> PromptingConfig:
|
|
615
|
+
return PromptingConfig(
|
|
616
|
+
base_system_prompt=self.prompting_base_system_prompt,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
@property
|
|
620
|
+
def memory_recall(self) -> MemoryRecallPolicy:
|
|
621
|
+
return MemoryRecallPolicy(
|
|
622
|
+
semantic_retrieval_enabled=self.memory_recall_semantic_retrieval_enabled
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
@property
|
|
626
|
+
def memory_extraction(self) -> MemoryExtractionPolicy:
|
|
627
|
+
return MemoryExtractionPolicy(
|
|
628
|
+
enabled=self.memory_extraction_enabled,
|
|
629
|
+
llm_service=self.memory_extraction_llm_service,
|
|
630
|
+
embedding_service=self.memory_extraction_embedding_service,
|
|
631
|
+
max_tokens=self.memory_extraction_max_tokens,
|
|
632
|
+
rejection_log=self.memory_extraction_rejection_log,
|
|
633
|
+
user_name=self.memory_extraction_user_name,
|
|
634
|
+
assistant_name=self.memory_extraction_assistant_name,
|
|
635
|
+
prompt=self.memory_extraction_prompt,
|
|
636
|
+
ontology=self.memory_extraction_ontology,
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
@property
|
|
640
|
+
def transcript(self) -> TranscriptConfig:
|
|
641
|
+
return TranscriptConfig(
|
|
642
|
+
enabled=self.transcript_enabled,
|
|
643
|
+
backend=self.transcript_backend,
|
|
644
|
+
path=self.transcript_path,
|
|
645
|
+
capture_context_log=self.transcript_capture_context_log,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def get_llm_service_config(self, service_name: str | None = None) -> LLMServiceConfig:
|
|
649
|
+
selected = service_name or self.llm_pool_default_service
|
|
650
|
+
try:
|
|
651
|
+
return self.llm_pool_services[selected]
|
|
652
|
+
except KeyError as exc:
|
|
653
|
+
raise ValueError(f"Unknown llm service: {selected}") from exc
|
|
654
|
+
|
|
655
|
+
def get_embedding_service_config(
|
|
656
|
+
self,
|
|
657
|
+
service_name: str | None = None,
|
|
658
|
+
) -> EmbeddingServiceConfig:
|
|
659
|
+
selected = service_name or self.embedding_pool_default_service
|
|
660
|
+
try:
|
|
661
|
+
return self.embedding_pool_services[selected]
|
|
662
|
+
except KeyError as exc:
|
|
663
|
+
raise ValueError(f"Unknown embedding service: {selected}") from exc
|
|
664
|
+
|
|
665
|
+
def validate_runtime_roots(self) -> None:
|
|
666
|
+
"""
|
|
667
|
+
Validate runtime root topology.
|
|
668
|
+
|
|
669
|
+
Rules:
|
|
670
|
+
- runtime_data_root and workspace_root cannot overlap.
|
|
671
|
+
- runtime_data_root parent must be writable.
|
|
672
|
+
"""
|
|
673
|
+
assert self.install_root is not None
|
|
674
|
+
assert self.runtime_data_root is not None
|
|
675
|
+
assert self.workspace_root is not None
|
|
676
|
+
|
|
677
|
+
install_root = self._resolve(self.install_root)
|
|
678
|
+
runtime_data_root = self._resolve(self.runtime_data_root)
|
|
679
|
+
workspace_root = self._resolve(self.workspace_root)
|
|
680
|
+
|
|
681
|
+
runtime_in_workspace = (
|
|
682
|
+
runtime_data_root == workspace_root
|
|
683
|
+
or workspace_root in runtime_data_root.parents
|
|
684
|
+
)
|
|
685
|
+
workspace_in_runtime = runtime_data_root in workspace_root.parents
|
|
686
|
+
if runtime_in_workspace or workspace_in_runtime:
|
|
687
|
+
raise ValueError("runtime_data_root and workspace_root must not overlap")
|
|
688
|
+
|
|
689
|
+
for root in (install_root, workspace_root):
|
|
690
|
+
if not root.exists():
|
|
691
|
+
raise ValueError(f"runtime root does not exist: {root}")
|
|
692
|
+
|
|
693
|
+
runtime_parent = runtime_data_root.parent
|
|
694
|
+
if not runtime_parent.exists():
|
|
695
|
+
raise ValueError(
|
|
696
|
+
f"runtime_data_root parent does not exist: {runtime_parent}"
|
|
697
|
+
)
|
|
698
|
+
if not os.access(runtime_parent, os.W_OK):
|
|
699
|
+
raise ValueError(
|
|
700
|
+
f"runtime_data_root parent is not writable: {runtime_parent}"
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
if self.cache_root is not None:
|
|
704
|
+
cache_root = self._resolve(self.cache_root)
|
|
705
|
+
cache_parent = cache_root.parent
|
|
706
|
+
if not cache_parent.exists():
|
|
707
|
+
raise ValueError(f"cache_root parent does not exist: {cache_parent}")
|
|
708
|
+
if not os.access(cache_parent, os.W_OK):
|
|
709
|
+
raise ValueError(f"cache_root parent is not writable: {cache_parent}")
|
|
710
|
+
|
|
711
|
+
def validate_scratch_path(self) -> None:
|
|
712
|
+
"""
|
|
713
|
+
Validate persistent scratch storage placement.
|
|
714
|
+
|
|
715
|
+
Rule:
|
|
716
|
+
- when scratch backend is persistent, scratch_path must be inside
|
|
717
|
+
runtime_data_root and outside workspace_root.
|
|
718
|
+
"""
|
|
719
|
+
if self.scratch_backend == "in_memory":
|
|
720
|
+
return
|
|
721
|
+
|
|
722
|
+
if not self.scratch_path:
|
|
723
|
+
raise ValueError("scratch_path is required when scratch_backend=sqlite")
|
|
724
|
+
|
|
725
|
+
scratch_path = self._resolve(self.scratch_path)
|
|
726
|
+
runtime_data_root = self._resolve(cast(str, self.runtime_data_root))
|
|
727
|
+
workspace_root = self._resolve(cast(str, self.workspace_root))
|
|
728
|
+
|
|
729
|
+
if (
|
|
730
|
+
runtime_data_root not in scratch_path.parents
|
|
731
|
+
and scratch_path != runtime_data_root
|
|
732
|
+
):
|
|
733
|
+
raise ValueError(
|
|
734
|
+
"scratch_path must be under runtime_data_root for persistent backends"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
if workspace_root in scratch_path.parents or scratch_path == workspace_root:
|
|
738
|
+
raise ValueError(
|
|
739
|
+
"scratch_path must not be under workspace_root for persistent backends"
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
def validate_artifacts_policy(self) -> None:
|
|
743
|
+
"""Validate runtime artifact policy fields and root path placement."""
|
|
744
|
+
self.resolve_artifacts_root_path(self.artifacts_root_dir)
|
|
745
|
+
|
|
746
|
+
def validate_transcript_path(self) -> None:
|
|
747
|
+
"""Validate transcript path placement when transcript persistence is enabled."""
|
|
748
|
+
if not self.transcript_enabled:
|
|
749
|
+
return
|
|
750
|
+
|
|
751
|
+
self.resolve_transcript_path(self.transcript_path)
|
|
752
|
+
|
|
753
|
+
def resolve_artifacts_root_path(self, path_value: str) -> Path:
|
|
754
|
+
"""
|
|
755
|
+
Resolve runtime artifact-root path under runtime_data_root.
|
|
756
|
+
"""
|
|
757
|
+
runtime_data_root = self._resolve(cast(str, self.runtime_data_root))
|
|
758
|
+
workspace_root = self._resolve(cast(str, self.workspace_root))
|
|
759
|
+
raw = Path(path_value).expanduser()
|
|
760
|
+
resolved = (
|
|
761
|
+
(runtime_data_root / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
|
762
|
+
)
|
|
763
|
+
if runtime_data_root not in resolved.parents and resolved != runtime_data_root:
|
|
764
|
+
raise ValueError("artifacts.root_dir must be under runtime_data_root")
|
|
765
|
+
if workspace_root in resolved.parents or resolved == workspace_root:
|
|
766
|
+
raise ValueError("artifacts.root_dir must not be under workspace_root")
|
|
767
|
+
return resolved
|
|
768
|
+
|
|
769
|
+
def resolve_transcript_path(self, path_value: str) -> Path:
|
|
770
|
+
"""Resolve transcript storage path under runtime_data_root."""
|
|
771
|
+
runtime_data_root = self._resolve(cast(str, self.runtime_data_root))
|
|
772
|
+
workspace_root = self._resolve(cast(str, self.workspace_root))
|
|
773
|
+
raw = Path(path_value).expanduser()
|
|
774
|
+
resolved = (
|
|
775
|
+
(runtime_data_root / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
|
776
|
+
)
|
|
777
|
+
if runtime_data_root not in resolved.parents and resolved != runtime_data_root:
|
|
778
|
+
raise ValueError("transcript.path must be under runtime_data_root")
|
|
779
|
+
if workspace_root in resolved.parents or resolved == workspace_root:
|
|
780
|
+
raise ValueError("transcript.path must not be under workspace_root")
|
|
781
|
+
return resolved
|
|
782
|
+
|
|
783
|
+
@classmethod
|
|
784
|
+
def from_env(cls) -> RuntimeConfig:
|
|
785
|
+
"""Create configuration from environment variables."""
|
|
786
|
+
return cls.from_sources()
|
|
787
|
+
|
|
788
|
+
@classmethod
|
|
789
|
+
def from_yaml(cls, path: str | Path) -> RuntimeConfig:
|
|
790
|
+
"""Create configuration from YAML with default precedence rules."""
|
|
791
|
+
return cls.from_sources(yaml_path=path)
|
|
792
|
+
|
|
793
|
+
@classmethod
|
|
794
|
+
def from_sources(
|
|
795
|
+
cls,
|
|
796
|
+
*,
|
|
797
|
+
yaml_path: str | Path | None = None,
|
|
798
|
+
env: Mapping[str, str] | None = None,
|
|
799
|
+
overrides: Mapping[str, Any] | None = None,
|
|
800
|
+
) -> RuntimeConfig:
|
|
801
|
+
"""
|
|
802
|
+
Build config using Phase 4 precedence:
|
|
803
|
+
defaults -> YAML -> env (if allowed or env-only) -> explicit overrides.
|
|
804
|
+
"""
|
|
805
|
+
env_map = env if env is not None else os.environ
|
|
806
|
+
merged = cls._default_source_data()
|
|
807
|
+
|
|
808
|
+
if yaml_path is not None:
|
|
809
|
+
yaml_data = cls._load_yaml(pathlib_path=Path(yaml_path))
|
|
810
|
+
cls._deep_merge(merged, yaml_data)
|
|
811
|
+
|
|
812
|
+
allow_env = yaml_path is None or bool(
|
|
813
|
+
merged["config_policy"]["allow_env_override"]
|
|
814
|
+
)
|
|
815
|
+
if allow_env:
|
|
816
|
+
cls._deep_merge(merged, cls._env_source_data(env_map))
|
|
817
|
+
|
|
818
|
+
if overrides:
|
|
819
|
+
cls._apply_flat_overrides(merged, overrides)
|
|
820
|
+
|
|
821
|
+
return cls(**cls._nested_to_flat_kwargs(merged))
|
|
822
|
+
|
|
823
|
+
@classmethod
|
|
824
|
+
def _default_source_data(cls) -> dict[str, Any]:
|
|
825
|
+
profile: Literal["local", "container", "cloud"] = "local"
|
|
826
|
+
defaults = cls.deployment_profile_defaults(profile)
|
|
827
|
+
return {
|
|
828
|
+
"deployment_profile": profile,
|
|
829
|
+
"config_policy": {
|
|
830
|
+
"allow_env_override": False,
|
|
831
|
+
"strict_agent_llm_binding": False,
|
|
832
|
+
"workflow_mode": "canonical",
|
|
833
|
+
"unknown_key_policy": "fail",
|
|
834
|
+
},
|
|
835
|
+
"llm": {
|
|
836
|
+
"provider": "openai",
|
|
837
|
+
"model": "gpt-4",
|
|
838
|
+
"api_key": None,
|
|
839
|
+
"api_base": None,
|
|
840
|
+
},
|
|
841
|
+
"llm_pool": {
|
|
842
|
+
"default_service": "default",
|
|
843
|
+
"services": {
|
|
844
|
+
"default": {
|
|
845
|
+
"provider": "openai",
|
|
846
|
+
"model": "gpt-4",
|
|
847
|
+
"api_key": None,
|
|
848
|
+
"api_base": None,
|
|
849
|
+
"resilience": {
|
|
850
|
+
"timeout_seconds": 60,
|
|
851
|
+
"max_retries": 2,
|
|
852
|
+
},
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
},
|
|
856
|
+
"embedding_pool": {
|
|
857
|
+
"default_service": "default",
|
|
858
|
+
"services": {},
|
|
859
|
+
},
|
|
860
|
+
"embedding": {
|
|
861
|
+
"model": "text-embedding-3-small",
|
|
862
|
+
},
|
|
863
|
+
"prompting": {
|
|
864
|
+
"base_system_prompt": None,
|
|
865
|
+
},
|
|
866
|
+
"memory": {
|
|
867
|
+
"mode": "single_agent",
|
|
868
|
+
"chroma_path": ".friday/chroma",
|
|
869
|
+
"chroma_collection": "friday_memory",
|
|
870
|
+
"recall": {
|
|
871
|
+
"semantic_retrieval_enabled": True,
|
|
872
|
+
},
|
|
873
|
+
"extraction": {
|
|
874
|
+
"enabled": True,
|
|
875
|
+
"llm_service": "default",
|
|
876
|
+
"embedding_service": None,
|
|
877
|
+
"max_tokens": None,
|
|
878
|
+
"rejection_log": None,
|
|
879
|
+
"user_name": "User",
|
|
880
|
+
"assistant_name": "Assistant",
|
|
881
|
+
"prompt": None,
|
|
882
|
+
"ontology": None,
|
|
883
|
+
},
|
|
884
|
+
},
|
|
885
|
+
"telemetry": {
|
|
886
|
+
"enabled": False,
|
|
887
|
+
"endpoint": None,
|
|
888
|
+
"debug_mode": False,
|
|
889
|
+
},
|
|
890
|
+
"agents": {
|
|
891
|
+
"dir": None,
|
|
892
|
+
"role_bindings": {},
|
|
893
|
+
},
|
|
894
|
+
"workflows": {
|
|
895
|
+
"dir": None,
|
|
896
|
+
},
|
|
897
|
+
"tools": {
|
|
898
|
+
"providers": {
|
|
899
|
+
"builtin": {
|
|
900
|
+
"enabled": True,
|
|
901
|
+
"mode": "all",
|
|
902
|
+
"include_categories": [],
|
|
903
|
+
"exclude_categories": [],
|
|
904
|
+
"include_tools": [],
|
|
905
|
+
"exclude_tools": [],
|
|
906
|
+
},
|
|
907
|
+
"friday_tools": {
|
|
908
|
+
"enabled": False,
|
|
909
|
+
"mode": "all",
|
|
910
|
+
"include_categories": [],
|
|
911
|
+
"exclude_categories": [],
|
|
912
|
+
"include_tools": [],
|
|
913
|
+
"exclude_tools": [],
|
|
914
|
+
},
|
|
915
|
+
"mcp": {
|
|
916
|
+
"enabled": False,
|
|
917
|
+
"mode": "all",
|
|
918
|
+
"include_categories": [],
|
|
919
|
+
"exclude_categories": [],
|
|
920
|
+
"include_tools": [],
|
|
921
|
+
"exclude_tools": [],
|
|
922
|
+
"config": {
|
|
923
|
+
"servers": [],
|
|
924
|
+
"server": None,
|
|
925
|
+
"auto_connect": True,
|
|
926
|
+
"tool_name_prefix": "mcp_",
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
"transcript": {
|
|
932
|
+
"enabled": False,
|
|
933
|
+
"backend": "sqlite",
|
|
934
|
+
"path": "transcript.db",
|
|
935
|
+
"capture_context_log": True,
|
|
936
|
+
},
|
|
937
|
+
"runtime_paths": {
|
|
938
|
+
"install_root": defaults["install_root"],
|
|
939
|
+
"runtime_data_root": defaults["runtime_data_root"],
|
|
940
|
+
"workspace_root": defaults["workspace_root"],
|
|
941
|
+
"cache_root": None,
|
|
942
|
+
},
|
|
943
|
+
"scratch": {
|
|
944
|
+
"backend": "in_memory",
|
|
945
|
+
"path": None,
|
|
946
|
+
},
|
|
947
|
+
"artifacts": {
|
|
948
|
+
"enabled": True,
|
|
949
|
+
"root_dir": "artifacts",
|
|
950
|
+
"lineage_required": True,
|
|
951
|
+
"publish_on_task_success": True,
|
|
952
|
+
},
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
@classmethod
|
|
956
|
+
def _env_source_data(cls, env: Mapping[str, str]) -> dict[str, Any]:
|
|
957
|
+
data: dict[str, Any] = {}
|
|
958
|
+
|
|
959
|
+
profile = env.get("FRIDAY_DEPLOYMENT_PROFILE")
|
|
960
|
+
if profile in {"local", "container", "cloud"}:
|
|
961
|
+
profile_defaults = cls.deployment_profile_defaults(
|
|
962
|
+
cast(Literal["local", "container", "cloud"], profile)
|
|
963
|
+
)
|
|
964
|
+
data["deployment_profile"] = profile
|
|
965
|
+
data["runtime_paths"] = {
|
|
966
|
+
"install_root": profile_defaults["install_root"],
|
|
967
|
+
"runtime_data_root": profile_defaults["runtime_data_root"],
|
|
968
|
+
"workspace_root": profile_defaults["workspace_root"],
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
mappings: tuple[tuple[str, tuple[str, ...], Any], ...] = (
|
|
972
|
+
("FRIDAY_ALLOW_ENV_OVERRIDE", ("config_policy", "allow_env_override"), _coerce_bool),
|
|
973
|
+
(
|
|
974
|
+
"FRIDAY_STRICT_AGENT_LLM_BINDING",
|
|
975
|
+
("config_policy", "strict_agent_llm_binding"),
|
|
976
|
+
_coerce_bool,
|
|
977
|
+
),
|
|
978
|
+
("FRIDAY_WORKFLOW_MODE", ("config_policy", "workflow_mode"), str),
|
|
979
|
+
("FRIDAY_UNKNOWN_KEY_POLICY", ("config_policy", "unknown_key_policy"), str),
|
|
980
|
+
("FRIDAY_LLM_PROVIDER", ("llm", "provider"), str),
|
|
981
|
+
("FRIDAY_LLM_MODEL", ("llm", "model"), str),
|
|
982
|
+
("OPENAI_API_KEY", ("llm", "api_key"), str),
|
|
983
|
+
("FRIDAY_LLM_API_BASE", ("llm", "api_base"), str),
|
|
984
|
+
("FRIDAY_EMBEDDING_MODEL", ("embedding", "model"), str),
|
|
985
|
+
("FRIDAY_BASE_SYSTEM_PROMPT", ("prompting", "base_system_prompt"), str),
|
|
986
|
+
("FRIDAY_CHROMA_PATH", ("memory", "chroma_path"), str),
|
|
987
|
+
("FRIDAY_CHROMA_COLLECTION", ("memory", "chroma_collection"), str),
|
|
988
|
+
("FRIDAY_MEMORY_MODE", ("memory", "mode"), str),
|
|
989
|
+
(
|
|
990
|
+
"FRIDAY_MEMORY_SEMANTIC_RETRIEVAL_ENABLED",
|
|
991
|
+
("memory", "recall", "semantic_retrieval_enabled"),
|
|
992
|
+
_coerce_bool,
|
|
993
|
+
),
|
|
994
|
+
(
|
|
995
|
+
"FRIDAY_MEMORY_EXTRACTION_ENABLED",
|
|
996
|
+
("memory", "extraction", "enabled"),
|
|
997
|
+
_coerce_bool,
|
|
998
|
+
),
|
|
999
|
+
(
|
|
1000
|
+
"FRIDAY_MEMORY_EXTRACTION_LLM_SERVICE",
|
|
1001
|
+
("memory", "extraction", "llm_service"),
|
|
1002
|
+
str,
|
|
1003
|
+
),
|
|
1004
|
+
(
|
|
1005
|
+
"FRIDAY_MEMORY_EXTRACTION_EMBEDDING_SERVICE",
|
|
1006
|
+
("memory", "extraction", "embedding_service"),
|
|
1007
|
+
str,
|
|
1008
|
+
),
|
|
1009
|
+
(
|
|
1010
|
+
"FRIDAY_MEMORY_EXTRACTION_MAX_TOKENS",
|
|
1011
|
+
("memory", "extraction", "max_tokens"),
|
|
1012
|
+
int,
|
|
1013
|
+
),
|
|
1014
|
+
(
|
|
1015
|
+
"FRIDAY_MEMORY_EXTRACTION_REJECTION_LOG",
|
|
1016
|
+
("memory", "extraction", "rejection_log"),
|
|
1017
|
+
str,
|
|
1018
|
+
),
|
|
1019
|
+
(
|
|
1020
|
+
"FRIDAY_MEMORY_EXTRACTION_USER_NAME",
|
|
1021
|
+
("memory", "extraction", "user_name"),
|
|
1022
|
+
str,
|
|
1023
|
+
),
|
|
1024
|
+
(
|
|
1025
|
+
"FRIDAY_MEMORY_EXTRACTION_ASSISTANT_NAME",
|
|
1026
|
+
("memory", "extraction", "assistant_name"),
|
|
1027
|
+
str,
|
|
1028
|
+
),
|
|
1029
|
+
("FRIDAY_TELEMETRY_ENABLED", ("telemetry", "enabled"), _coerce_bool),
|
|
1030
|
+
("FRIDAY_OTEL_ENDPOINT", ("telemetry", "endpoint"), str),
|
|
1031
|
+
("FRIDAY_DEBUG", ("telemetry", "debug_mode"), _coerce_bool),
|
|
1032
|
+
("FRIDAY_AGENTS_DIR", ("agents", "dir"), str),
|
|
1033
|
+
("FRIDAY_TRANSCRIPT_ENABLED", ("transcript", "enabled"), _coerce_bool),
|
|
1034
|
+
("FRIDAY_TRANSCRIPT_BACKEND", ("transcript", "backend"), str),
|
|
1035
|
+
("FRIDAY_TRANSCRIPT_PATH", ("transcript", "path"), str),
|
|
1036
|
+
(
|
|
1037
|
+
"FRIDAY_TRANSCRIPT_CAPTURE_CONTEXT_LOG",
|
|
1038
|
+
("transcript", "capture_context_log"),
|
|
1039
|
+
_coerce_bool,
|
|
1040
|
+
),
|
|
1041
|
+
("FRIDAY_INSTALL_ROOT", ("runtime_paths", "install_root"), str),
|
|
1042
|
+
("FRIDAY_RUNTIME_DATA_ROOT", ("runtime_paths", "runtime_data_root"), str),
|
|
1043
|
+
("FRIDAY_WORKSPACE_ROOT", ("runtime_paths", "workspace_root"), str),
|
|
1044
|
+
("FRIDAY_CACHE_ROOT", ("runtime_paths", "cache_root"), str),
|
|
1045
|
+
("FRIDAY_SCRATCH_BACKEND", ("scratch", "backend"), str),
|
|
1046
|
+
("FRIDAY_SCRATCH_PATH", ("scratch", "path"), str),
|
|
1047
|
+
("FRIDAY_ARTIFACTS_ENABLED", ("artifacts", "enabled"), _coerce_bool),
|
|
1048
|
+
("FRIDAY_ARTIFACTS_ROOT_DIR", ("artifacts", "root_dir"), str),
|
|
1049
|
+
(
|
|
1050
|
+
"FRIDAY_ARTIFACTS_LINEAGE_REQUIRED",
|
|
1051
|
+
("artifacts", "lineage_required"),
|
|
1052
|
+
_coerce_bool,
|
|
1053
|
+
),
|
|
1054
|
+
(
|
|
1055
|
+
"FRIDAY_ARTIFACTS_PUBLISH_ON_TASK_SUCCESS",
|
|
1056
|
+
("artifacts", "publish_on_task_success"),
|
|
1057
|
+
_coerce_bool,
|
|
1058
|
+
),
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
for env_name, path, coercer in mappings:
|
|
1062
|
+
raw_value = env.get(env_name)
|
|
1063
|
+
if raw_value is None:
|
|
1064
|
+
continue
|
|
1065
|
+
cls._set_nested(data, path, coercer(raw_value))
|
|
1066
|
+
|
|
1067
|
+
llm_section = cast(dict[str, Any], data.setdefault("llm", {}))
|
|
1068
|
+
llm_pool = cast(dict[str, Any], data.setdefault("llm_pool", {}))
|
|
1069
|
+
llm_pool.setdefault("default_service", "default")
|
|
1070
|
+
services = cast(dict[str, Any], llm_pool.setdefault("services", {}))
|
|
1071
|
+
default_service = cast(dict[str, Any], services.setdefault("default", {}))
|
|
1072
|
+
if "provider" in llm_section:
|
|
1073
|
+
default_service["provider"] = llm_section["provider"]
|
|
1074
|
+
if "model" in llm_section:
|
|
1075
|
+
default_service["model"] = llm_section["model"]
|
|
1076
|
+
if "api_key" in llm_section:
|
|
1077
|
+
default_service["api_key"] = llm_section["api_key"]
|
|
1078
|
+
if "api_base" in llm_section:
|
|
1079
|
+
default_service["api_base"] = llm_section["api_base"]
|
|
1080
|
+
default_service.setdefault(
|
|
1081
|
+
"resilience",
|
|
1082
|
+
{"timeout_seconds": 60, "max_retries": 2},
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
embedding_section = cast(dict[str, Any], data.setdefault("embedding", {}))
|
|
1086
|
+
embedding_pool = cast(dict[str, Any], data.setdefault("embedding_pool", {}))
|
|
1087
|
+
embedding_pool.setdefault("default_service", "default")
|
|
1088
|
+
embedding_services = cast(
|
|
1089
|
+
dict[str, Any], embedding_pool.setdefault("services", {})
|
|
1090
|
+
)
|
|
1091
|
+
default_embedding_service = cast(
|
|
1092
|
+
dict[str, Any], embedding_services.setdefault("default", {})
|
|
1093
|
+
)
|
|
1094
|
+
if "model" in embedding_section:
|
|
1095
|
+
default_embedding_service["model"] = embedding_section["model"]
|
|
1096
|
+
default_embedding_service.setdefault("provider", "openai")
|
|
1097
|
+
default_embedding_service.setdefault("api_key", llm_section.get("api_key"))
|
|
1098
|
+
default_embedding_service.setdefault("api_base", llm_section.get("api_base"))
|
|
1099
|
+
default_embedding_service.setdefault(
|
|
1100
|
+
"resilience",
|
|
1101
|
+
{"timeout_seconds": 60, "max_retries": 2},
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
return data
|
|
1105
|
+
|
|
1106
|
+
@classmethod
|
|
1107
|
+
def _load_yaml(cls, pathlib_path: Path) -> dict[str, Any]:
|
|
1108
|
+
try:
|
|
1109
|
+
with open(pathlib_path, encoding="utf-8") as handle:
|
|
1110
|
+
payload = yaml.safe_load(handle) or {}
|
|
1111
|
+
except yaml.YAMLError as exc:
|
|
1112
|
+
raise ValueError(f"Invalid runtime YAML: {exc}") from exc
|
|
1113
|
+
except OSError as exc:
|
|
1114
|
+
raise ValueError(f"Unable to read runtime YAML: {exc}") from exc
|
|
1115
|
+
|
|
1116
|
+
if not isinstance(payload, dict):
|
|
1117
|
+
raise ValueError("Runtime YAML must be a mapping")
|
|
1118
|
+
|
|
1119
|
+
cls._validate_yaml_keys(payload)
|
|
1120
|
+
return payload
|
|
1121
|
+
|
|
1122
|
+
@classmethod
|
|
1123
|
+
def _validate_yaml_keys(cls, payload: Mapping[str, Any]) -> None:
|
|
1124
|
+
allowed_top_level = {
|
|
1125
|
+
"llm_pool",
|
|
1126
|
+
"llm",
|
|
1127
|
+
"embedding_pool",
|
|
1128
|
+
"embedding",
|
|
1129
|
+
"prompting",
|
|
1130
|
+
"memory",
|
|
1131
|
+
"telemetry",
|
|
1132
|
+
"agents",
|
|
1133
|
+
"workflows",
|
|
1134
|
+
"tools",
|
|
1135
|
+
"transcript",
|
|
1136
|
+
"runtime_paths",
|
|
1137
|
+
"scratch",
|
|
1138
|
+
"artifacts",
|
|
1139
|
+
"config_policy",
|
|
1140
|
+
}
|
|
1141
|
+
unknown_top = set(payload) - allowed_top_level
|
|
1142
|
+
if unknown_top:
|
|
1143
|
+
raise ValueError(f"Unknown runtime config keys: {sorted(unknown_top)}")
|
|
1144
|
+
|
|
1145
|
+
section_keys: dict[str, set[str]] = {
|
|
1146
|
+
"llm": {"provider", "model", "api_key", "api_base"},
|
|
1147
|
+
"embedding": {"model"},
|
|
1148
|
+
"prompting": {"base_system_prompt"},
|
|
1149
|
+
"memory": {
|
|
1150
|
+
"mode",
|
|
1151
|
+
"chroma_path",
|
|
1152
|
+
"chroma_collection",
|
|
1153
|
+
"recall",
|
|
1154
|
+
"extraction",
|
|
1155
|
+
},
|
|
1156
|
+
"telemetry": {"enabled", "endpoint", "debug_mode"},
|
|
1157
|
+
"agents": {"dir", "role_bindings"},
|
|
1158
|
+
"workflows": {"dir"},
|
|
1159
|
+
"tools": {"providers"},
|
|
1160
|
+
"transcript": {"enabled", "backend", "path", "capture_context_log"},
|
|
1161
|
+
"runtime_paths": {
|
|
1162
|
+
"install_root",
|
|
1163
|
+
"runtime_data_root",
|
|
1164
|
+
"workspace_root",
|
|
1165
|
+
"cache_root",
|
|
1166
|
+
},
|
|
1167
|
+
"scratch": {"backend", "path"},
|
|
1168
|
+
"artifacts": {
|
|
1169
|
+
"enabled",
|
|
1170
|
+
"root_dir",
|
|
1171
|
+
"lineage_required",
|
|
1172
|
+
"publish_on_task_success",
|
|
1173
|
+
},
|
|
1174
|
+
"config_policy": {
|
|
1175
|
+
"allow_env_override",
|
|
1176
|
+
"strict_agent_llm_binding",
|
|
1177
|
+
"workflow_mode",
|
|
1178
|
+
"unknown_key_policy",
|
|
1179
|
+
},
|
|
1180
|
+
"llm_pool": {"default_service", "services"},
|
|
1181
|
+
"embedding_pool": {"default_service", "services"},
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
for section_name, allowed in section_keys.items():
|
|
1185
|
+
section = payload.get(section_name)
|
|
1186
|
+
if section is None:
|
|
1187
|
+
continue
|
|
1188
|
+
if not isinstance(section, dict):
|
|
1189
|
+
raise ValueError(f"Runtime config section '{section_name}' must be an object")
|
|
1190
|
+
unknown = set(section) - allowed
|
|
1191
|
+
if unknown:
|
|
1192
|
+
raise ValueError(
|
|
1193
|
+
f"Unknown keys in runtime config section '{section_name}': {sorted(unknown)}"
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
agents = payload.get("agents", {})
|
|
1197
|
+
if isinstance(agents, dict) and "role_bindings" in agents:
|
|
1198
|
+
role_bindings = agents["role_bindings"]
|
|
1199
|
+
if not isinstance(role_bindings, dict):
|
|
1200
|
+
raise ValueError("agents.role_bindings must be an object")
|
|
1201
|
+
|
|
1202
|
+
tools = payload.get("tools", {})
|
|
1203
|
+
if isinstance(tools, dict):
|
|
1204
|
+
providers = tools.get("providers")
|
|
1205
|
+
if providers is not None:
|
|
1206
|
+
if not isinstance(providers, dict):
|
|
1207
|
+
raise ValueError("tools.providers must be an object")
|
|
1208
|
+
allowed_provider_names = {"builtin", "friday_tools", "mcp"}
|
|
1209
|
+
unknown_provider_names = set(providers) - allowed_provider_names
|
|
1210
|
+
if unknown_provider_names:
|
|
1211
|
+
raise ValueError(
|
|
1212
|
+
"Unknown tool providers in runtime config: "
|
|
1213
|
+
f"{sorted(unknown_provider_names)}"
|
|
1214
|
+
)
|
|
1215
|
+
allowed_provider_keys = {
|
|
1216
|
+
"enabled",
|
|
1217
|
+
"mode",
|
|
1218
|
+
"include_categories",
|
|
1219
|
+
"exclude_categories",
|
|
1220
|
+
"include_tools",
|
|
1221
|
+
"exclude_tools",
|
|
1222
|
+
"config",
|
|
1223
|
+
}
|
|
1224
|
+
for provider_name, provider_config in providers.items():
|
|
1225
|
+
if not isinstance(provider_config, dict):
|
|
1226
|
+
raise ValueError(
|
|
1227
|
+
f"tools.providers.{provider_name} must be an object"
|
|
1228
|
+
)
|
|
1229
|
+
unknown_provider_keys = set(provider_config) - allowed_provider_keys
|
|
1230
|
+
if unknown_provider_keys:
|
|
1231
|
+
raise ValueError(
|
|
1232
|
+
"Unknown keys in runtime config section "
|
|
1233
|
+
f"'tools.providers.{provider_name}': "
|
|
1234
|
+
f"{sorted(unknown_provider_keys)}"
|
|
1235
|
+
)
|
|
1236
|
+
for list_key in (
|
|
1237
|
+
"include_categories",
|
|
1238
|
+
"exclude_categories",
|
|
1239
|
+
"include_tools",
|
|
1240
|
+
"exclude_tools",
|
|
1241
|
+
):
|
|
1242
|
+
list_value = provider_config.get(list_key)
|
|
1243
|
+
if list_value is not None and not isinstance(list_value, list):
|
|
1244
|
+
raise ValueError(
|
|
1245
|
+
f"tools.providers.{provider_name}.{list_key} "
|
|
1246
|
+
"must be a list"
|
|
1247
|
+
)
|
|
1248
|
+
config_value = provider_config.get("config")
|
|
1249
|
+
if config_value is not None and provider_name != "mcp":
|
|
1250
|
+
raise ValueError(
|
|
1251
|
+
f"tools.providers.{provider_name}.config is only valid for mcp"
|
|
1252
|
+
)
|
|
1253
|
+
if provider_name == "mcp" and config_value is not None and not isinstance(
|
|
1254
|
+
config_value, dict
|
|
1255
|
+
):
|
|
1256
|
+
raise ValueError("tools.providers.mcp.config must be an object")
|
|
1257
|
+
|
|
1258
|
+
transcript = payload.get("transcript", {})
|
|
1259
|
+
if isinstance(transcript, dict):
|
|
1260
|
+
backend = transcript.get("backend")
|
|
1261
|
+
if backend is not None and backend != "sqlite":
|
|
1262
|
+
raise ValueError("transcript.backend must be 'sqlite'")
|
|
1263
|
+
path_value = transcript.get("path")
|
|
1264
|
+
if path_value is not None and not isinstance(path_value, str):
|
|
1265
|
+
raise ValueError("transcript.path must be a string")
|
|
1266
|
+
|
|
1267
|
+
llm_pool = payload.get("llm_pool", {})
|
|
1268
|
+
if isinstance(llm_pool, dict) and "services" in llm_pool:
|
|
1269
|
+
services = llm_pool["services"]
|
|
1270
|
+
if not isinstance(services, dict):
|
|
1271
|
+
raise ValueError("llm_pool.services must be an object")
|
|
1272
|
+
allowed_service_keys = {"provider", "model", "api_key", "api_base", "resilience"}
|
|
1273
|
+
allowed_resilience_keys = {"timeout_seconds", "max_retries"}
|
|
1274
|
+
for service_name, config in services.items():
|
|
1275
|
+
if not isinstance(config, dict):
|
|
1276
|
+
raise ValueError(f"llm_pool.services.{service_name} must be an object")
|
|
1277
|
+
unknown_service = set(config) - allowed_service_keys
|
|
1278
|
+
if unknown_service:
|
|
1279
|
+
raise ValueError(
|
|
1280
|
+
f"Unknown keys in llm_pool.services.{service_name}: {sorted(unknown_service)}"
|
|
1281
|
+
)
|
|
1282
|
+
resilience = config.get("resilience", {})
|
|
1283
|
+
if resilience is not None:
|
|
1284
|
+
if not isinstance(resilience, dict):
|
|
1285
|
+
raise ValueError(
|
|
1286
|
+
f"llm_pool.services.{service_name}.resilience must be an object"
|
|
1287
|
+
)
|
|
1288
|
+
unknown_resilience = set(resilience) - allowed_resilience_keys
|
|
1289
|
+
if unknown_resilience:
|
|
1290
|
+
raise ValueError(
|
|
1291
|
+
"Unknown keys in "
|
|
1292
|
+
f"llm_pool.services.{service_name}.resilience: "
|
|
1293
|
+
f"{sorted(unknown_resilience)}"
|
|
1294
|
+
)
|
|
1295
|
+
|
|
1296
|
+
embedding_pool = payload.get("embedding_pool", {})
|
|
1297
|
+
if isinstance(embedding_pool, dict) and "services" in embedding_pool:
|
|
1298
|
+
services = embedding_pool["services"]
|
|
1299
|
+
if not isinstance(services, dict):
|
|
1300
|
+
raise ValueError("embedding_pool.services must be an object")
|
|
1301
|
+
allowed_service_keys = {"provider", "model", "api_key", "api_base", "resilience"}
|
|
1302
|
+
allowed_resilience_keys = {"timeout_seconds", "max_retries"}
|
|
1303
|
+
for service_name, config in services.items():
|
|
1304
|
+
if not isinstance(config, dict):
|
|
1305
|
+
raise ValueError(
|
|
1306
|
+
f"embedding_pool.services.{service_name} must be an object"
|
|
1307
|
+
)
|
|
1308
|
+
unknown_service = set(config) - allowed_service_keys
|
|
1309
|
+
if unknown_service:
|
|
1310
|
+
raise ValueError(
|
|
1311
|
+
"Unknown keys in "
|
|
1312
|
+
f"embedding_pool.services.{service_name}: {sorted(unknown_service)}"
|
|
1313
|
+
)
|
|
1314
|
+
resilience = config.get("resilience", {})
|
|
1315
|
+
if resilience is not None:
|
|
1316
|
+
if not isinstance(resilience, dict):
|
|
1317
|
+
raise ValueError(
|
|
1318
|
+
"embedding_pool.services."
|
|
1319
|
+
f"{service_name}.resilience must be an object"
|
|
1320
|
+
)
|
|
1321
|
+
unknown_resilience = set(resilience) - allowed_resilience_keys
|
|
1322
|
+
if unknown_resilience:
|
|
1323
|
+
raise ValueError(
|
|
1324
|
+
"Unknown keys in "
|
|
1325
|
+
f"embedding_pool.services.{service_name}.resilience: "
|
|
1326
|
+
f"{sorted(unknown_resilience)}"
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
memory = payload.get("memory", {})
|
|
1330
|
+
if isinstance(memory, dict):
|
|
1331
|
+
recall = memory.get("recall")
|
|
1332
|
+
if recall is not None:
|
|
1333
|
+
if not isinstance(recall, dict):
|
|
1334
|
+
raise ValueError("memory.recall must be an object")
|
|
1335
|
+
unknown_recall = set(recall) - {"semantic_retrieval_enabled"}
|
|
1336
|
+
if unknown_recall:
|
|
1337
|
+
raise ValueError(
|
|
1338
|
+
f"Unknown keys in runtime config section 'memory.recall': {sorted(unknown_recall)}"
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
extraction = memory.get("extraction")
|
|
1342
|
+
if extraction is not None:
|
|
1343
|
+
if not isinstance(extraction, dict):
|
|
1344
|
+
raise ValueError("memory.extraction must be an object")
|
|
1345
|
+
allowed_extraction = {
|
|
1346
|
+
"enabled",
|
|
1347
|
+
"llm_service",
|
|
1348
|
+
"embedding_service",
|
|
1349
|
+
"max_tokens",
|
|
1350
|
+
"rejection_log",
|
|
1351
|
+
"user_name",
|
|
1352
|
+
"assistant_name",
|
|
1353
|
+
"prompt",
|
|
1354
|
+
"ontology",
|
|
1355
|
+
}
|
|
1356
|
+
unknown_extraction = set(extraction) - allowed_extraction
|
|
1357
|
+
if unknown_extraction:
|
|
1358
|
+
raise ValueError(
|
|
1359
|
+
"Unknown keys in runtime config section 'memory.extraction': "
|
|
1360
|
+
f"{sorted(unknown_extraction)}"
|
|
1361
|
+
)
|
|
1362
|
+
ontology = extraction.get("ontology")
|
|
1363
|
+
if ontology is not None and not isinstance(ontology, dict):
|
|
1364
|
+
raise ValueError("memory.extraction.ontology must be an object")
|
|
1365
|
+
|
|
1366
|
+
@classmethod
|
|
1367
|
+
def _set_nested(
|
|
1368
|
+
cls,
|
|
1369
|
+
target: dict[str, Any],
|
|
1370
|
+
path: tuple[str, ...],
|
|
1371
|
+
value: Any,
|
|
1372
|
+
) -> None:
|
|
1373
|
+
current = target
|
|
1374
|
+
for key in path[:-1]:
|
|
1375
|
+
current = cast(dict[str, Any], current.setdefault(key, {}))
|
|
1376
|
+
current[path[-1]] = value
|
|
1377
|
+
|
|
1378
|
+
@classmethod
|
|
1379
|
+
def _deep_merge(cls, target: dict[str, Any], source: Mapping[str, Any]) -> None:
|
|
1380
|
+
for key, value in source.items():
|
|
1381
|
+
if (
|
|
1382
|
+
key in target
|
|
1383
|
+
and isinstance(target[key], dict)
|
|
1384
|
+
and isinstance(value, Mapping)
|
|
1385
|
+
):
|
|
1386
|
+
cls._deep_merge(cast(dict[str, Any], target[key]), value)
|
|
1387
|
+
else:
|
|
1388
|
+
target[key] = value
|
|
1389
|
+
|
|
1390
|
+
@classmethod
|
|
1391
|
+
def _apply_flat_overrides(
|
|
1392
|
+
cls,
|
|
1393
|
+
merged: dict[str, Any],
|
|
1394
|
+
overrides: Mapping[str, Any],
|
|
1395
|
+
) -> None:
|
|
1396
|
+
mapping: dict[str, tuple[str, ...]] = {
|
|
1397
|
+
"allow_env_override": ("config_policy", "allow_env_override"),
|
|
1398
|
+
"strict_agent_llm_binding": ("config_policy", "strict_agent_llm_binding"),
|
|
1399
|
+
"workflow_mode": ("config_policy", "workflow_mode"),
|
|
1400
|
+
"unknown_key_policy": ("config_policy", "unknown_key_policy"),
|
|
1401
|
+
"llm_provider": ("llm", "provider"),
|
|
1402
|
+
"llm_model": ("llm", "model"),
|
|
1403
|
+
"llm_api_key": ("llm", "api_key"),
|
|
1404
|
+
"llm_api_base": ("llm", "api_base"),
|
|
1405
|
+
"llm_pool_default_service": ("llm_pool", "default_service"),
|
|
1406
|
+
"llm_pool_services": ("llm_pool", "services"),
|
|
1407
|
+
"embedding_pool_default_service": ("embedding_pool", "default_service"),
|
|
1408
|
+
"embedding_pool_services": ("embedding_pool", "services"),
|
|
1409
|
+
"embedding_model": ("embedding", "model"),
|
|
1410
|
+
"prompting_base_system_prompt": ("prompting", "base_system_prompt"),
|
|
1411
|
+
"chroma_path": ("memory", "chroma_path"),
|
|
1412
|
+
"chroma_collection": ("memory", "chroma_collection"),
|
|
1413
|
+
"memory_mode": ("memory", "mode"),
|
|
1414
|
+
"memory_recall_semantic_retrieval_enabled": (
|
|
1415
|
+
"memory",
|
|
1416
|
+
"recall",
|
|
1417
|
+
"semantic_retrieval_enabled",
|
|
1418
|
+
),
|
|
1419
|
+
"memory_extraction_enabled": ("memory", "extraction", "enabled"),
|
|
1420
|
+
"memory_extraction_llm_service": (
|
|
1421
|
+
"memory",
|
|
1422
|
+
"extraction",
|
|
1423
|
+
"llm_service",
|
|
1424
|
+
),
|
|
1425
|
+
"memory_extraction_embedding_service": (
|
|
1426
|
+
"memory",
|
|
1427
|
+
"extraction",
|
|
1428
|
+
"embedding_service",
|
|
1429
|
+
),
|
|
1430
|
+
"memory_extraction_max_tokens": (
|
|
1431
|
+
"memory",
|
|
1432
|
+
"extraction",
|
|
1433
|
+
"max_tokens",
|
|
1434
|
+
),
|
|
1435
|
+
"memory_extraction_rejection_log": (
|
|
1436
|
+
"memory",
|
|
1437
|
+
"extraction",
|
|
1438
|
+
"rejection_log",
|
|
1439
|
+
),
|
|
1440
|
+
"memory_extraction_user_name": (
|
|
1441
|
+
"memory",
|
|
1442
|
+
"extraction",
|
|
1443
|
+
"user_name",
|
|
1444
|
+
),
|
|
1445
|
+
"memory_extraction_assistant_name": (
|
|
1446
|
+
"memory",
|
|
1447
|
+
"extraction",
|
|
1448
|
+
"assistant_name",
|
|
1449
|
+
),
|
|
1450
|
+
"memory_extraction_prompt": ("memory", "extraction", "prompt"),
|
|
1451
|
+
"memory_extraction_ontology": ("memory", "extraction", "ontology"),
|
|
1452
|
+
"telemetry_enabled": ("telemetry", "enabled"),
|
|
1453
|
+
"telemetry_endpoint": ("telemetry", "endpoint"),
|
|
1454
|
+
"debug_mode": ("telemetry", "debug_mode"),
|
|
1455
|
+
"agents_dir": ("agents", "dir"),
|
|
1456
|
+
"agents_role_bindings": ("agents", "role_bindings"),
|
|
1457
|
+
"workflows_dir": ("workflows", "dir"),
|
|
1458
|
+
"tools_config": ("tools",),
|
|
1459
|
+
"transcript_enabled": ("transcript", "enabled"),
|
|
1460
|
+
"transcript_backend": ("transcript", "backend"),
|
|
1461
|
+
"transcript_path": ("transcript", "path"),
|
|
1462
|
+
"transcript_capture_context_log": (
|
|
1463
|
+
"transcript",
|
|
1464
|
+
"capture_context_log",
|
|
1465
|
+
),
|
|
1466
|
+
"deployment_profile": ("deployment_profile",),
|
|
1467
|
+
"install_root": ("runtime_paths", "install_root"),
|
|
1468
|
+
"runtime_data_root": ("runtime_paths", "runtime_data_root"),
|
|
1469
|
+
"workspace_root": ("runtime_paths", "workspace_root"),
|
|
1470
|
+
"cache_root": ("runtime_paths", "cache_root"),
|
|
1471
|
+
"scratch_backend": ("scratch", "backend"),
|
|
1472
|
+
"scratch_path": ("scratch", "path"),
|
|
1473
|
+
"artifacts_enabled": ("artifacts", "enabled"),
|
|
1474
|
+
"artifacts_root_dir": ("artifacts", "root_dir"),
|
|
1475
|
+
"artifacts_lineage_required": ("artifacts", "lineage_required"),
|
|
1476
|
+
"artifacts_publish_on_task_success": (
|
|
1477
|
+
"artifacts",
|
|
1478
|
+
"publish_on_task_success",
|
|
1479
|
+
),
|
|
1480
|
+
}
|
|
1481
|
+
for key, value in overrides.items():
|
|
1482
|
+
path = mapping.get(key)
|
|
1483
|
+
if path is None:
|
|
1484
|
+
raise ValueError(f"Unknown RuntimeConfig override: {key}")
|
|
1485
|
+
cls._set_nested(merged, path, value)
|
|
1486
|
+
|
|
1487
|
+
@classmethod
|
|
1488
|
+
def _nested_to_flat_kwargs(cls, data: Mapping[str, Any]) -> dict[str, Any]:
|
|
1489
|
+
llm_pool = cast(Mapping[str, Any], data["llm_pool"])
|
|
1490
|
+
llm_services = cast(Mapping[str, Any], llm_pool["services"])
|
|
1491
|
+
default_service_name = cast(str, llm_pool["default_service"])
|
|
1492
|
+
default_service_config = dict(
|
|
1493
|
+
cast(Mapping[str, Any], llm_services.get(default_service_name, {}))
|
|
1494
|
+
)
|
|
1495
|
+
default_templates = cls._default_source_data()
|
|
1496
|
+
default_pool_service_template = cast(
|
|
1497
|
+
Mapping[str, Any],
|
|
1498
|
+
default_templates["llm_pool"]["services"]["default"],
|
|
1499
|
+
)
|
|
1500
|
+
legacy_llm = cast(Mapping[str, Any], data["llm"])
|
|
1501
|
+
if (
|
|
1502
|
+
default_service_name == "default"
|
|
1503
|
+
and default_service_config == dict(default_pool_service_template)
|
|
1504
|
+
):
|
|
1505
|
+
default_service_config.update(
|
|
1506
|
+
{
|
|
1507
|
+
"provider": legacy_llm["provider"],
|
|
1508
|
+
"model": legacy_llm["model"],
|
|
1509
|
+
"api_key": legacy_llm.get("api_key"),
|
|
1510
|
+
"api_base": legacy_llm.get("api_base"),
|
|
1511
|
+
}
|
|
1512
|
+
)
|
|
1513
|
+
llm_services = dict(llm_services)
|
|
1514
|
+
cast(dict[str, Any], llm_services)["default"] = default_service_config
|
|
1515
|
+
|
|
1516
|
+
embedding_pool_data = cast(
|
|
1517
|
+
Mapping[str, Any],
|
|
1518
|
+
data.get(
|
|
1519
|
+
"embedding_pool",
|
|
1520
|
+
{
|
|
1521
|
+
"default_service": "default",
|
|
1522
|
+
"services": {},
|
|
1523
|
+
},
|
|
1524
|
+
),
|
|
1525
|
+
)
|
|
1526
|
+
embedding_services = cast(
|
|
1527
|
+
Mapping[str, Any], embedding_pool_data.get("services", {})
|
|
1528
|
+
)
|
|
1529
|
+
prompting = cast(Mapping[str, Any], data.get("prompting", {}))
|
|
1530
|
+
memory = cast(Mapping[str, Any], data["memory"])
|
|
1531
|
+
recall = cast(Mapping[str, Any], memory.get("recall", {}))
|
|
1532
|
+
extraction = cast(Mapping[str, Any], memory.get("extraction", {}))
|
|
1533
|
+
transcript = cast(Mapping[str, Any], data.get("transcript", {}))
|
|
1534
|
+
tools = cast(Mapping[str, Any], data.get("tools", {}))
|
|
1535
|
+
return {
|
|
1536
|
+
"allow_env_override": data["config_policy"]["allow_env_override"],
|
|
1537
|
+
"strict_agent_llm_binding": data["config_policy"][
|
|
1538
|
+
"strict_agent_llm_binding"
|
|
1539
|
+
],
|
|
1540
|
+
"workflow_mode": data["config_policy"]["workflow_mode"],
|
|
1541
|
+
"unknown_key_policy": data["config_policy"]["unknown_key_policy"],
|
|
1542
|
+
"llm_provider": data["llm"]["provider"],
|
|
1543
|
+
"llm_model": data["llm"]["model"],
|
|
1544
|
+
"llm_api_key": data["llm"].get("api_key"),
|
|
1545
|
+
"llm_api_base": data["llm"].get("api_base"),
|
|
1546
|
+
"llm_pool_default_service": default_service_name,
|
|
1547
|
+
"llm_pool_services": {
|
|
1548
|
+
name: LLMServiceConfig.from_dict(cast(Mapping[str, Any], config))
|
|
1549
|
+
for name, config in llm_services.items()
|
|
1550
|
+
},
|
|
1551
|
+
"embedding_pool_default_service": embedding_pool_data.get(
|
|
1552
|
+
"default_service", "default"
|
|
1553
|
+
),
|
|
1554
|
+
"embedding_pool_services": {
|
|
1555
|
+
name: EmbeddingServiceConfig.from_dict(cast(Mapping[str, Any], config))
|
|
1556
|
+
for name, config in embedding_services.items()
|
|
1557
|
+
},
|
|
1558
|
+
"embedding_model": data["embedding"]["model"],
|
|
1559
|
+
"prompting_base_system_prompt": prompting.get("base_system_prompt"),
|
|
1560
|
+
"chroma_path": memory["chroma_path"],
|
|
1561
|
+
"chroma_collection": memory["chroma_collection"],
|
|
1562
|
+
"memory_mode": memory["mode"],
|
|
1563
|
+
"memory_recall_semantic_retrieval_enabled": recall.get(
|
|
1564
|
+
"semantic_retrieval_enabled", True
|
|
1565
|
+
),
|
|
1566
|
+
"memory_extraction_enabled": extraction.get("enabled", True),
|
|
1567
|
+
"memory_extraction_llm_service": extraction.get("llm_service"),
|
|
1568
|
+
"memory_extraction_embedding_service": extraction.get(
|
|
1569
|
+
"embedding_service"
|
|
1570
|
+
),
|
|
1571
|
+
"memory_extraction_max_tokens": extraction.get("max_tokens"),
|
|
1572
|
+
"memory_extraction_rejection_log": extraction.get("rejection_log"),
|
|
1573
|
+
"memory_extraction_user_name": extraction.get("user_name", "User"),
|
|
1574
|
+
"memory_extraction_assistant_name": extraction.get(
|
|
1575
|
+
"assistant_name", "Assistant"
|
|
1576
|
+
),
|
|
1577
|
+
"memory_extraction_prompt": extraction.get("prompt"),
|
|
1578
|
+
"memory_extraction_ontology": extraction.get("ontology"),
|
|
1579
|
+
"telemetry_enabled": data["telemetry"]["enabled"],
|
|
1580
|
+
"telemetry_endpoint": data["telemetry"].get("endpoint"),
|
|
1581
|
+
"debug_mode": data["telemetry"].get("debug_mode", False),
|
|
1582
|
+
"agents_dir": data["agents"].get("dir"),
|
|
1583
|
+
"agents_role_bindings": dict(data["agents"].get("role_bindings", {})),
|
|
1584
|
+
"workflows_dir": data.get("workflows", {}).get("dir"),
|
|
1585
|
+
"tools_config": ToolsConfig.from_dict(tools),
|
|
1586
|
+
"transcript_enabled": transcript.get("enabled", False),
|
|
1587
|
+
"transcript_backend": transcript.get("backend", "sqlite"),
|
|
1588
|
+
"transcript_path": transcript.get("path", "transcript.db"),
|
|
1589
|
+
"transcript_capture_context_log": transcript.get(
|
|
1590
|
+
"capture_context_log", True
|
|
1591
|
+
),
|
|
1592
|
+
"deployment_profile": data["deployment_profile"],
|
|
1593
|
+
"install_root": data["runtime_paths"]["install_root"],
|
|
1594
|
+
"runtime_data_root": data["runtime_paths"]["runtime_data_root"],
|
|
1595
|
+
"workspace_root": data["runtime_paths"]["workspace_root"],
|
|
1596
|
+
"cache_root": data["runtime_paths"].get("cache_root"),
|
|
1597
|
+
"scratch_backend": data["scratch"]["backend"],
|
|
1598
|
+
"scratch_path": data["scratch"].get("path"),
|
|
1599
|
+
"artifacts_enabled": data["artifacts"]["enabled"],
|
|
1600
|
+
"artifacts_root_dir": data["artifacts"]["root_dir"],
|
|
1601
|
+
"artifacts_lineage_required": data["artifacts"]["lineage_required"],
|
|
1602
|
+
"artifacts_publish_on_task_success": data["artifacts"][
|
|
1603
|
+
"publish_on_task_success"
|
|
1604
|
+
],
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
@classmethod
|
|
1608
|
+
def deployment_profile_defaults(
|
|
1609
|
+
cls,
|
|
1610
|
+
profile: Literal["local", "container", "cloud"],
|
|
1611
|
+
*,
|
|
1612
|
+
cwd: Path | None = None,
|
|
1613
|
+
) -> dict[str, str]:
|
|
1614
|
+
"""
|
|
1615
|
+
Return default path topology for deployment profiles.
|
|
1616
|
+
"""
|
|
1617
|
+
root = (cwd or Path.cwd()).resolve()
|
|
1618
|
+
defaults = {
|
|
1619
|
+
"local": {
|
|
1620
|
+
"install_root": str(root),
|
|
1621
|
+
"runtime_data_root": str(Path.home() / ".friday" / "runtime"),
|
|
1622
|
+
"workspace_root": str(root),
|
|
1623
|
+
},
|
|
1624
|
+
"container": {
|
|
1625
|
+
"install_root": "/opt/friday",
|
|
1626
|
+
"runtime_data_root": "/var/lib/friday/runtime",
|
|
1627
|
+
"workspace_root": "/workspace",
|
|
1628
|
+
},
|
|
1629
|
+
"cloud": {
|
|
1630
|
+
"install_root": "/srv/friday/install",
|
|
1631
|
+
"runtime_data_root": "/srv/friday/runtime",
|
|
1632
|
+
"workspace_root": "/srv/friday/workspace",
|
|
1633
|
+
},
|
|
1634
|
+
}
|
|
1635
|
+
return defaults[profile]
|
|
1636
|
+
|
|
1637
|
+
@classmethod
|
|
1638
|
+
def for_testing(cls) -> RuntimeConfig:
|
|
1639
|
+
"""
|
|
1640
|
+
Create configuration suitable for testing.
|
|
1641
|
+
|
|
1642
|
+
Uses cheaper LLM model, disables telemetry, and uses
|
|
1643
|
+
a separate ChromaDB path to avoid polluting production data.
|
|
1644
|
+
"""
|
|
1645
|
+
return cls(
|
|
1646
|
+
llm_model="gpt-3.5-turbo",
|
|
1647
|
+
debug_mode=True,
|
|
1648
|
+
telemetry_enabled=False,
|
|
1649
|
+
chroma_path=".friday/test_chroma",
|
|
1650
|
+
)
|
|
1651
|
+
|
|
1652
|
+
def resolve_workspace_artifact_path(self, path_value: str) -> Path:
|
|
1653
|
+
"""
|
|
1654
|
+
Resolve artifact output path and enforce workspace-root containment.
|
|
1655
|
+
"""
|
|
1656
|
+
workspace_root = self._resolve(cast(str, self.workspace_root))
|
|
1657
|
+
raw = Path(path_value).expanduser()
|
|
1658
|
+
resolved = (
|
|
1659
|
+
(workspace_root / raw).resolve() if not raw.is_absolute() else raw.resolve()
|
|
1660
|
+
)
|
|
1661
|
+
if workspace_root not in resolved.parents and resolved != workspace_root:
|
|
1662
|
+
raise ValueError("artifact path must be under workspace_root")
|
|
1663
|
+
return resolved
|
|
1664
|
+
|
|
1665
|
+
def resolve_runtime_state_path(self, path_value: str) -> Path:
|
|
1666
|
+
"""
|
|
1667
|
+
Resolve runtime-state path and enforce runtime-data-root containment.
|
|
1668
|
+
"""
|
|
1669
|
+
runtime_data_root = self._resolve(cast(str, self.runtime_data_root))
|
|
1670
|
+
workspace_root = self._resolve(cast(str, self.workspace_root))
|
|
1671
|
+
raw = Path(path_value).expanduser()
|
|
1672
|
+
resolved = (
|
|
1673
|
+
(runtime_data_root / raw).resolve()
|
|
1674
|
+
if not raw.is_absolute()
|
|
1675
|
+
else raw.resolve()
|
|
1676
|
+
)
|
|
1677
|
+
if runtime_data_root not in resolved.parents and resolved != runtime_data_root:
|
|
1678
|
+
raise ValueError("runtime state path must be under runtime_data_root")
|
|
1679
|
+
if workspace_root in resolved.parents or resolved == workspace_root:
|
|
1680
|
+
raise ValueError("runtime state path must not be under workspace_root")
|
|
1681
|
+
return resolved
|