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.
@@ -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