1pub mod config;
7pub mod health;
8pub mod mcp;
9pub mod oauth;
10pub mod provider;
11pub mod skills;
12
13pub use config::{parse_vault_args, resolve_config_path};
14pub use health::{health_check, warmup_provider};
15pub use mcp::{
16 create_mcp_manager, create_mcp_manager_with_vault, create_mcp_registry, wire_trust_calibration,
17};
18pub use oauth::VaultCredentialStore;
19#[cfg(feature = "candle")]
20pub use provider::select_device;
21pub use provider::{
22 BootstrapError, build_provider_for_switch, build_provider_from_entry, create_named_provider,
23 create_provider, create_summary_provider,
24};
25pub use skills::{
26 create_embedding_provider, create_skill_matcher, effective_embedding_model, managed_skills_dir,
27};
28
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use tokio::sync::{RwLock, mpsc, watch};
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::LlmProvider;
35use zeph_memory::GraphStore;
36use zeph_memory::QdrantOps;
37use zeph_memory::semantic::SemanticMemory;
38use zeph_skills::loader::SkillMeta;
39use zeph_skills::matcher::SkillMatcherBackend;
40use zeph_skills::registry::SkillRegistry;
41use zeph_skills::watcher::{SkillEvent, SkillWatcher};
42
43use crate::config::{Config, SecretResolver};
44use crate::config_watcher::{ConfigEvent, ConfigWatcher};
45use crate::vault::AgeVaultProvider;
46use crate::vault::{EnvVaultProvider, VaultProvider};
47
48pub struct AppBuilder {
49 config: Config,
50 config_path: PathBuf,
51 vault: Box<dyn VaultProvider>,
52 age_vault: Option<Arc<RwLock<AgeVaultProvider>>>,
55 qdrant_ops: Option<QdrantOps>,
56}
57
58pub struct VaultArgs {
59 pub backend: String,
60 pub key_path: Option<String>,
61 pub vault_path: Option<String>,
62}
63
64pub struct WatcherBundle {
65 pub skill_watcher: Option<SkillWatcher>,
66 pub skill_reload_rx: mpsc::Receiver<SkillEvent>,
67 pub config_watcher: Option<ConfigWatcher>,
68 pub config_reload_rx: mpsc::Receiver<ConfigEvent>,
69}
70
71impl AppBuilder {
72 pub async fn new(
81 config_override: Option<&Path>,
82 vault_override: Option<&str>,
83 vault_key_override: Option<&Path>,
84 vault_path_override: Option<&Path>,
85 ) -> Result<Self, BootstrapError> {
86 let config_path = resolve_config_path(config_override);
87 let mut config = Config::load(&config_path)?;
88 config.validate()?;
89 config.llm.check_legacy_format()?;
90
91 let vault_args = parse_vault_args(
92 &config,
93 vault_override,
94 vault_key_override,
95 vault_path_override,
96 );
97 let (vault, age_vault): (
98 Box<dyn VaultProvider>,
99 Option<Arc<RwLock<AgeVaultProvider>>>,
100 ) = match vault_args.backend.as_str() {
101 "env" => (Box::new(EnvVaultProvider), None),
102 "age" => {
103 let key = vault_args.key_path.ok_or_else(|| {
104 BootstrapError::Provider("--vault-key required for age backend".into())
105 })?;
106 let path = vault_args.vault_path.ok_or_else(|| {
107 BootstrapError::Provider("--vault-path required for age backend".into())
108 })?;
109 let provider = AgeVaultProvider::new(Path::new(&key), Path::new(&path))
110 .map_err(BootstrapError::VaultInit)?;
111 let arc = Arc::new(RwLock::new(provider));
112 let boxed: Box<dyn VaultProvider> =
113 Box::new(crate::vault::ArcAgeVaultProvider(Arc::clone(&arc)));
114 (boxed, Some(arc))
115 }
116 other => {
117 return Err(BootstrapError::Provider(format!(
118 "unknown vault backend: {other}"
119 )));
120 }
121 };
122
123 config.resolve_secrets(vault.as_ref()).await?;
124
125 let qdrant_ops = match config.memory.vector_backend {
126 crate::config::VectorBackend::Qdrant => {
127 let ops = QdrantOps::new(&config.memory.qdrant_url).map_err(|e| {
128 BootstrapError::Provider(format!(
129 "invalid qdrant_url '{}': {e}",
130 config.memory.qdrant_url
131 ))
132 })?;
133 Some(ops)
134 }
135 crate::config::VectorBackend::Sqlite => None,
136 };
137
138 Ok(Self {
139 config,
140 config_path,
141 vault,
142 age_vault,
143 qdrant_ops,
144 })
145 }
146
147 pub fn qdrant_ops(&self) -> Option<&QdrantOps> {
148 self.qdrant_ops.as_ref()
149 }
150
151 pub fn config(&self) -> &Config {
152 &self.config
153 }
154
155 pub fn config_mut(&mut self) -> &mut Config {
156 &mut self.config
157 }
158
159 pub fn config_path(&self) -> &Path {
160 &self.config_path
161 }
162
163 pub fn vault(&self) -> &dyn VaultProvider {
168 self.vault.as_ref()
169 }
170
171 pub fn age_vault_arc(&self) -> Option<&Arc<RwLock<AgeVaultProvider>>> {
176 self.age_vault.as_ref()
177 }
178
179 pub async fn build_provider(
183 &self,
184 ) -> Result<
185 (
186 AnyProvider,
187 tokio::sync::mpsc::UnboundedSender<String>,
188 tokio::sync::mpsc::UnboundedReceiver<String>,
189 ),
190 BootstrapError,
191 > {
192 let mut provider = create_provider(&self.config)?;
193
194 let (status_tx, status_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
195 let status_tx_clone = status_tx.clone();
196 provider.set_status_tx(status_tx);
197
198 health_check(&provider).await;
199
200 if let AnyProvider::Ollama(ref mut ollama) = provider
201 && let Ok(info) = ollama.fetch_model_info().await
202 && let Some(ctx) = info.context_length
203 {
204 ollama.set_context_window(ctx);
205 tracing::info!(context_window = ctx, "detected Ollama model context window");
206 }
207
208 if let Some(ctx) = provider.context_window()
209 && !matches!(provider, AnyProvider::Ollama(_))
210 {
211 tracing::info!(context_window = ctx, "detected provider context window");
212 }
213
214 Ok((provider, status_tx_clone, status_rx))
215 }
216
217 pub fn auto_budget_tokens(&self, provider: &AnyProvider) -> usize {
218 let tokens =
219 if self.config.memory.auto_budget && self.config.memory.context_budget_tokens == 0 {
220 if let Some(ctx_size) = provider.context_window() {
221 tracing::info!(model_context = ctx_size, "auto-configured context budget");
222 ctx_size
223 } else {
224 0
225 }
226 } else {
227 self.config.memory.context_budget_tokens
228 };
229 if tokens == 0 {
230 tracing::warn!(
231 "context_budget_tokens resolved to 0 — using fallback of 128000 tokens to ensure compaction runs"
232 );
233 128_000
234 } else {
235 tokens
236 }
237 }
238
239 pub async fn build_memory(
245 &self,
246 provider: &AnyProvider,
247 ) -> Result<SemanticMemory, BootstrapError> {
248 let embed_model = self.embedding_model();
249 let db_path: &str = self
251 .config
252 .memory
253 .database_url
254 .as_deref()
255 .unwrap_or(&self.config.memory.sqlite_path);
256
257 if zeph_db::is_postgres_url(db_path) {
258 return Err(BootstrapError::Memory(
259 "database_url points to PostgreSQL but binary was compiled with the \
260 sqlite feature. Recompile with --features postgres."
261 .to_string(),
262 ));
263 }
264
265 let mut memory = match self.config.memory.vector_backend {
266 crate::config::VectorBackend::Sqlite => {
267 SemanticMemory::with_sqlite_backend_and_pool_size(
268 db_path,
269 provider.clone(),
270 &embed_model,
271 self.config.memory.semantic.vector_weight,
272 self.config.memory.semantic.keyword_weight,
273 self.config.memory.sqlite_pool_size,
274 )
275 .await
276 .map_err(|e| BootstrapError::Memory(e.to_string()))?
277 }
278 crate::config::VectorBackend::Qdrant => {
279 let ops = self
280 .qdrant_ops
281 .as_ref()
282 .ok_or_else(|| {
283 BootstrapError::Memory(
284 "qdrant_ops must be Some when vector_backend = Qdrant".into(),
285 )
286 })?
287 .clone();
288 SemanticMemory::with_qdrant_ops(
289 db_path,
290 ops,
291 provider.clone(),
292 &embed_model,
293 self.config.memory.semantic.vector_weight,
294 self.config.memory.semantic.keyword_weight,
295 self.config.memory.sqlite_pool_size,
296 )
297 .await
298 .map_err(|e| BootstrapError::Memory(e.to_string()))?
299 }
300 };
301
302 memory = memory.with_ranking_options(
303 self.config.memory.semantic.temporal_decay_enabled,
304 self.config.memory.semantic.temporal_decay_half_life_days,
305 self.config.memory.semantic.mmr_enabled,
306 self.config.memory.semantic.mmr_lambda,
307 );
308
309 memory = memory.with_importance_options(
310 self.config.memory.semantic.importance_enabled,
311 self.config.memory.semantic.importance_weight,
312 );
313
314 if self.config.memory.semantic.enabled && memory.is_vector_store_connected().await {
315 tracing::info!("semantic memory enabled, vector store connected");
316 }
317
318 if self.config.memory.graph.enabled {
319 let graph_pool = zeph_db::DbConfig {
323 url: db_path.to_string(),
324 max_connections: self.config.memory.graph.pool_size,
325 pool_size: self.config.memory.graph.pool_size,
326 }
327 .connect()
328 .await
329 .map_err(|e| BootstrapError::Memory(e.to_string()))?;
330 let store = Arc::new(GraphStore::new(graph_pool));
331 memory = memory.with_graph_store(store);
332 tracing::info!(
333 pool_size = self.config.memory.graph.pool_size,
334 "graph memory enabled, GraphStore attached with dedicated pool"
335 );
336 }
337
338 if self.config.memory.admission.enabled {
339 memory = memory.with_admission_control(self.build_admission_control(provider));
340 }
341
342 if let Some(ep) = self.build_memory_embed_provider() {
343 memory = memory.with_embed_provider(ep);
344 }
345
346 memory =
347 memory.with_key_facts_dedup_threshold(self.config.memory.key_facts_dedup_threshold);
348
349 Ok(memory)
350 }
351
352 fn build_memory_embed_provider(&self) -> Option<AnyProvider> {
353 let name = self
354 .config
355 .memory
356 .semantic
357 .embed_provider
358 .as_deref()
359 .filter(|s| !s.is_empty())?;
360
361 match create_named_provider(name, &self.config) {
362 Ok(ep) => {
363 tracing::info!(provider = %name, "Using dedicated embed provider for memory backfill");
364 Some(ep)
365 }
366 Err(e) => {
367 tracing::warn!(
368 provider = %name,
369 error = %e,
370 "Memory embed_provider resolution failed — main provider will be used"
371 );
372 None
373 }
374 }
375 }
376}
377
378pub fn spawn_embed_backfill(
387 memory: Arc<SemanticMemory>,
388 timeout_secs: u64,
389 progress_tx: Option<
390 tokio::sync::watch::Sender<Option<zeph_memory::semantic::BackfillProgress>>,
391 >,
392) -> tokio::task::JoinHandle<()> {
393 tokio::spawn(async move {
394 let result = tokio::time::timeout(
395 std::time::Duration::from_secs(timeout_secs),
396 memory.embed_missing(progress_tx.clone()),
397 )
398 .await;
399 match result {
400 Ok(Ok(n)) if n > 0 => tracing::info!("backfilled {n} missing embedding(s)"),
401 Ok(Ok(_)) => {}
402 Ok(Err(e)) => tracing::warn!("embed_missing failed: {e:#}"),
403 Err(_) => tracing::warn!("embed_missing timed out after {timeout_secs}s"),
404 }
405 if let Some(tx) = progress_tx {
407 let _ = tx.send(None);
408 }
409 })
410}
411
412impl AppBuilder {
413 fn build_admission_control(
414 &self,
415 fallback_provider: &AnyProvider,
416 ) -> zeph_memory::AdmissionControl {
417 let admission_provider = if self.config.memory.admission.admission_provider.is_empty() {
418 fallback_provider.clone()
419 } else {
420 match create_named_provider(
421 &self.config.memory.admission.admission_provider,
422 &self.config,
423 ) {
424 Ok(p) => {
425 tracing::info!(
426 provider = %self.config.memory.admission.admission_provider,
427 "A-MAC admission provider configured"
428 );
429 p
430 }
431 Err(e) => {
432 tracing::warn!(
433 provider = %self.config.memory.admission.admission_provider,
434 error = %e,
435 "A-MAC admission provider resolution failed — primary provider will be used"
436 );
437 fallback_provider.clone()
438 }
439 }
440 };
441 let w = &self.config.memory.admission.weights;
442 let weights = zeph_memory::AdmissionWeights {
443 future_utility: w.future_utility,
444 factual_confidence: w.factual_confidence,
445 semantic_novelty: w.semantic_novelty,
446 temporal_recency: w.temporal_recency,
447 content_type_prior: w.content_type_prior,
448 goal_utility: w.goal_utility,
449 };
450 let mut control = zeph_memory::AdmissionControl::new(
451 self.config.memory.admission.threshold,
452 self.config.memory.admission.fast_path_margin,
453 weights,
454 )
455 .with_provider(admission_provider);
456
457 if self.config.memory.admission.goal_conditioned_write {
458 let goal_provider = if self
459 .config
460 .memory
461 .admission
462 .goal_utility_provider
463 .is_empty()
464 {
465 None
466 } else {
467 match create_named_provider(
468 &self.config.memory.admission.goal_utility_provider,
469 &self.config,
470 ) {
471 Ok(p) => Some(p),
472 Err(e) => {
473 tracing::warn!(
474 provider = %self.config.memory.admission.goal_utility_provider,
475 error = %e,
476 "goal_utility_provider not found, LLM refinement disabled"
477 );
478 None
479 }
480 }
481 };
482 control = control.with_goal_gate(zeph_memory::GoalGateConfig {
483 threshold: self.config.memory.admission.goal_utility_threshold,
484 provider: goal_provider,
485 weight: self.config.memory.admission.goal_utility_weight,
486 });
487 tracing::info!(
488 threshold = self.config.memory.admission.goal_utility_threshold,
489 weight = self.config.memory.admission.goal_utility_weight,
490 "A-MAC: goal-conditioned write gate enabled"
491 );
492 }
493
494 if self.config.memory.admission.admission_strategy == zeph_config::AdmissionStrategy::Rl {
495 tracing::warn!(
496 "admission_strategy = \"rl\" is configured but the RL model is not yet wired \
497 into the admission path — falling back to heuristic. See #2416."
498 );
499 }
500
501 tracing::info!(
502 threshold = self.config.memory.admission.threshold,
503 "A-MAC admission control enabled"
504 );
505 control
506 }
507
508 pub async fn build_skill_matcher(
509 &self,
510 provider: &AnyProvider,
511 meta: &[&SkillMeta],
512 memory: &SemanticMemory,
513 ) -> Option<SkillMatcherBackend> {
514 let embed_model = self.embedding_model();
515 create_skill_matcher(
516 &self.config,
517 provider,
518 meta,
519 memory,
520 &embed_model,
521 self.qdrant_ops.as_ref(),
522 )
523 .await
524 }
525
526 pub fn build_registry(&self) -> SkillRegistry {
527 {
528 let managed = managed_skills_dir();
529 match zeph_skills::bundled::provision_bundled_skills(&managed) {
530 Ok(report) => {
531 if !report.installed.is_empty() {
532 tracing::info!(
533 skills = ?report.installed,
534 "provisioned new bundled skills"
535 );
536 }
537 if !report.updated.is_empty() {
538 tracing::info!(
539 skills = ?report.updated,
540 "updated bundled skills"
541 );
542 }
543 for (name, err) in &report.failed {
544 tracing::warn!(skill = %name, error = %err, "failed to provision bundled skill");
545 }
546 }
547 Err(e) => {
548 tracing::warn!(error = %e, "bundled skill provisioning failed");
549 }
550 }
551 }
552
553 let skill_paths = self.skill_paths();
554 let registry = SkillRegistry::load(&skill_paths);
555
556 if self.config.skills.trust.scan_on_load {
557 let findings = registry.scan_loaded();
558 if findings.is_empty() {
559 tracing::debug!("skill content scan: no injection patterns found");
560 } else {
561 tracing::warn!(
562 count = findings.len(),
563 "skill content scan complete: {} skill(s) with potential injection patterns",
564 findings.len()
565 );
566 }
567 }
568
569 if self.config.skills.trust.scanner.capability_escalation_check {
570 let default_level = self.config.skills.trust.default_level;
573 let trust_levels: Vec<(String, zeph_tools::SkillTrustLevel)> = registry
574 .all_meta()
575 .iter()
576 .map(|meta| (meta.name.clone(), default_level))
577 .collect();
578
579 let violations = registry.check_escalations(&trust_levels);
580 for v in &violations {
581 tracing::warn!(
582 skill = %v.skill_name,
583 denied_tools = ?v.denied_tools,
584 "capability escalation: skill declares tools exceeding its trust level"
585 );
586 }
587 if violations.is_empty() {
588 tracing::debug!("capability escalation check: no violations found");
589 }
590 }
591
592 registry
593 }
594
595 pub fn skill_paths(&self) -> Vec<PathBuf> {
596 let mut paths: Vec<PathBuf> = self.config.skills.paths.iter().map(PathBuf::from).collect();
597 let managed_dir = managed_skills_dir();
598 if !paths.contains(&managed_dir) {
599 paths.push(managed_dir);
600 }
601 paths
602 }
603
604 pub fn managed_skills_dir() -> PathBuf {
605 managed_skills_dir()
606 }
607
608 pub fn build_watchers(&self) -> WatcherBundle {
609 let skill_paths = self.skill_paths();
610 let (reload_tx, skill_reload_rx) = mpsc::channel(4);
611 let skill_watcher = match SkillWatcher::start(&skill_paths, reload_tx) {
612 Ok(w) => {
613 tracing::info!("skill watcher started");
614 Some(w)
615 }
616 Err(e) => {
617 tracing::warn!("skill watcher unavailable: {e:#}");
618 None
619 }
620 };
621
622 let (config_reload_tx, config_reload_rx) = mpsc::channel(4);
623 let config_watcher = match ConfigWatcher::start(&self.config_path, config_reload_tx) {
624 Ok(w) => {
625 tracing::info!("config watcher started");
626 Some(w)
627 }
628 Err(e) => {
629 tracing::warn!("config watcher unavailable: {e:#}");
630 None
631 }
632 };
633
634 WatcherBundle {
635 skill_watcher,
636 skill_reload_rx,
637 config_watcher,
638 config_reload_rx,
639 }
640 }
641
642 pub fn build_shutdown() -> (watch::Sender<bool>, watch::Receiver<bool>) {
643 watch::channel(false)
644 }
645
646 pub fn embedding_model(&self) -> String {
647 effective_embedding_model(&self.config)
648 }
649
650 pub fn build_summary_provider(&self) -> Option<AnyProvider> {
651 if let Some(ref entry) = self.config.llm.summary_provider {
653 return match build_provider_from_entry(entry, &self.config) {
654 Ok(sp) => {
655 tracing::info!(
656 provider_type = ?entry.provider_type,
657 model = ?entry.model,
658 "summary provider configured via [llm.summary_provider]"
659 );
660 Some(sp)
661 }
662 Err(e) => {
663 tracing::warn!("failed to create summary provider: {e:#}, using primary");
664 None
665 }
666 };
667 }
668 self.config.llm.summary_model.as_ref().and_then(
669 |model_spec| match create_summary_provider(model_spec, &self.config) {
670 Ok(sp) => {
671 tracing::info!(model = %model_spec, "summary provider configured via llm.summary_model");
672 Some(sp)
673 }
674 Err(e) => {
675 tracing::warn!("failed to create summary provider: {e:#}, using primary");
676 None
677 }
678 },
679 )
680 }
681
682 pub fn build_quarantine_provider(
687 &self,
688 ) -> Option<(AnyProvider, zeph_sanitizer::QuarantineConfig)> {
689 let ci = &self.config.security.content_isolation;
690 let qc = &ci.quarantine;
691 if !qc.enabled {
692 if ci.mcp_to_acp_boundary {
693 tracing::warn!(
694 "mcp_to_acp_boundary is enabled but quarantine is disabled — \
695 cross-boundary MCP tool results in ACP sessions will be \
696 spotlighted but NOT quarantine-summarized; enable \
697 [security.content_isolation.quarantine] for full protection"
698 );
699 }
700 return None;
701 }
702 match create_named_provider(&qc.model, &self.config) {
703 Ok(p) => {
704 tracing::info!(model = %qc.model, "quarantine provider configured");
705 Some((p, qc.clone()))
706 }
707 Err(e) => {
708 tracing::warn!(
709 model = %qc.model,
710 error = %e,
711 "quarantine provider resolution failed, quarantine disabled"
712 );
713 None
714 }
715 }
716 }
717
718 pub fn build_guardrail_filter(&self) -> Option<zeph_sanitizer::guardrail::GuardrailFilter> {
723 let (provider, config) = self.build_guardrail_provider()?;
724 match zeph_sanitizer::guardrail::GuardrailFilter::new(provider, &config) {
725 Ok(filter) => Some(filter),
726 Err(e) => {
727 tracing::warn!(error = %e, "guardrail filter construction failed, guardrail disabled");
728 None
729 }
730 }
731 }
732
733 pub fn build_guardrail_provider(
737 &self,
738 ) -> Option<(AnyProvider, zeph_sanitizer::guardrail::GuardrailConfig)> {
739 let gc = &self.config.security.guardrail;
740 if !gc.enabled {
741 return None;
742 }
743 let provider_name = gc.provider.as_deref().unwrap_or("ollama");
744 match create_named_provider(provider_name, &self.config) {
745 Ok(p) => {
746 tracing::info!(
747 provider = %provider_name,
748 model = ?gc.model,
749 "guardrail provider configured"
750 );
751 Some((p, gc.clone()))
752 }
753 Err(e) => {
754 tracing::warn!(
755 provider = %provider_name,
756 error = %e,
757 "guardrail provider resolution failed, guardrail disabled"
758 );
759 None
760 }
761 }
762 }
763
764 pub fn build_judge_provider(&self) -> Option<AnyProvider> {
769 use crate::config::DetectorMode;
770 let learning = &self.config.skills.learning;
771 if learning.detector_mode != DetectorMode::Judge {
772 return None;
773 }
774 if learning.judge_model.is_empty() {
775 tracing::warn!(
776 "detector_mode=judge but judge_model is empty — primary provider will be used for judging"
777 );
778 return None;
779 }
780 match create_named_provider(&learning.judge_model, &self.config) {
781 Ok(jp) => {
782 tracing::info!(model = %learning.judge_model, "judge provider configured");
783 Some(jp)
784 }
785 Err(e) => {
786 tracing::warn!("failed to create judge provider: {e:#}, using primary");
787 None
788 }
789 }
790 }
791
792 pub fn build_feedback_classifier(
798 &self,
799 primary: &AnyProvider,
800 ) -> Option<zeph_llm::classifier::llm::LlmClassifier> {
801 use crate::config::DetectorMode;
802 let learning = &self.config.skills.learning;
803 if learning.detector_mode != DetectorMode::Model {
804 return None;
805 }
806 let provider = if learning.feedback_provider.is_empty() {
807 tracing::debug!("feedback_provider empty — using primary provider for LlmClassifier");
808 Some(primary.clone())
809 } else {
810 match crate::bootstrap::provider::create_named_provider(
811 &learning.feedback_provider,
812 &self.config,
813 ) {
814 Ok(p) => {
815 tracing::info!(
816 provider = %learning.feedback_provider,
817 "LlmClassifier feedback provider configured"
818 );
819 Some(p)
820 }
821 Err(e) => {
822 tracing::warn!(
823 provider = %learning.feedback_provider,
824 error = %e,
825 "feedback_provider not found in registry, degrading to regex-only"
826 );
827 None
828 }
829 }
830 };
831 if let Some(p) = provider {
832 Some(zeph_llm::classifier::llm::LlmClassifier::new(
833 std::sync::Arc::new(p),
834 ))
835 } else {
836 tracing::warn!(
837 "detector_mode=model but no provider available, degrading to regex-only"
838 );
839 None
840 }
841 }
842
843 pub fn build_probe_provider(&self) -> Option<AnyProvider> {
848 let name = &self.config.memory.compression.probe.probe_provider;
849 if name.is_empty() {
850 return None;
851 }
852 match create_named_provider(name, &self.config) {
853 Ok(p) => {
854 tracing::info!(provider = %name, "compaction probe provider configured");
855 Some(p)
856 }
857 Err(e) => {
858 tracing::warn!(
859 provider = %name,
860 error = %e,
861 "probe provider resolution failed — summary/primary provider will be used"
862 );
863 None
864 }
865 }
866 }
867
868 pub fn build_compress_provider(&self) -> Option<AnyProvider> {
873 let name = &self.config.memory.compression.compress_provider;
874 if name.is_empty() {
875 return None;
876 }
877 match create_named_provider(name, &self.config) {
878 Ok(p) => {
879 tracing::info!(provider = %name, "compress_context provider configured");
880 Some(p)
881 }
882 Err(e) => {
883 tracing::warn!(
884 provider = %name,
885 error = %e,
886 "compress_context provider resolution failed — primary provider will be used"
887 );
888 None
889 }
890 }
891 }
892
893 pub fn build_guidelines_provider(&self) -> Option<AnyProvider> {
901 let name = &self
902 .config
903 .memory
904 .compression_guidelines
905 .guidelines_provider;
906 if name.is_empty() {
907 return None;
908 }
909 match create_named_provider(name, &self.config) {
910 Ok(p) => {
911 tracing::info!(provider = %name, "compression guidelines provider configured");
912 Some(p)
913 }
914 Err(e) => {
915 tracing::warn!(
916 provider = %name,
917 error = %e,
918 "guidelines provider resolution failed — primary provider will be used"
919 );
920 None
921 }
922 }
923 }
924
925 pub fn build_consolidation_provider(&self) -> Option<AnyProvider> {
930 let name = &self.config.memory.consolidation.consolidation_provider;
931 if name.is_empty() {
932 return None;
933 }
934 match create_named_provider(name, &self.config) {
935 Ok(p) => {
936 tracing::info!(provider = %name, "consolidation provider configured");
937 Some(p)
938 }
939 Err(e) => {
940 tracing::warn!(
941 provider = %name,
942 error = %e,
943 "consolidation provider resolution failed — primary provider will be used"
944 );
945 None
946 }
947 }
948 }
949
950 pub fn build_tree_consolidation_provider(&self) -> Option<AnyProvider> {
954 let name = &self.config.memory.tree.consolidation_provider;
955 if name.is_empty() {
956 return None;
957 }
958 match create_named_provider(name, &self.config) {
959 Ok(p) => {
960 tracing::info!(provider = %name, "tree consolidation provider configured");
961 Some(p)
962 }
963 Err(e) => {
964 tracing::warn!(
965 provider = %name,
966 error = %e,
967 "tree consolidation provider resolution failed — primary provider will be used"
968 );
969 None
970 }
971 }
972 }
973
974 pub fn build_planner_provider(&self) -> Option<AnyProvider> {
982 let name = &self.config.orchestration.planner_provider;
983 if name.is_empty() {
984 return None;
985 }
986 match create_named_provider(name, &self.config) {
987 Ok(p) => {
988 tracing::info!(provider = %name, "planner provider configured");
989 Some(p)
990 }
991 Err(e) => {
992 tracing::warn!(
993 provider = %name,
994 error = %e,
995 "planner provider resolution failed — primary provider will be used"
996 );
997 None
998 }
999 }
1000 }
1001
1002 pub fn build_verify_provider(&self) -> Option<AnyProvider> {
1007 let name = &self.config.orchestration.verify_provider;
1008 if name.is_empty() {
1009 return None;
1010 }
1011 match create_named_provider(name, &self.config) {
1012 Ok(p) => {
1013 tracing::info!(provider = %name, "verify provider configured");
1014 Some(p)
1015 }
1016 Err(e) => {
1017 tracing::warn!(
1018 provider = %name,
1019 error = %e,
1020 "verify provider resolution failed — primary provider will be used"
1021 );
1022 None
1023 }
1024 }
1025 }
1026 pub fn build_eval_provider(&self) -> Option<AnyProvider> {
1027 let model_spec = self.config.experiments.eval_model.as_deref()?;
1028 match create_summary_provider(model_spec, &self.config) {
1029 Ok(p) => {
1030 tracing::info!(eval_model = %model_spec, "experiment eval provider configured");
1031 Some(p)
1032 }
1033 Err(e) => {
1034 tracing::warn!(
1035 eval_model = %model_spec,
1036 error = %e,
1037 "failed to create eval provider — primary provider will be used as judge"
1038 );
1039 None
1040 }
1041 }
1042 }
1043
1044 pub fn build_scene_provider(&self) -> Option<AnyProvider> {
1049 let name = &self.config.memory.tiers.scene_provider;
1050 if name.is_empty() {
1051 return None;
1052 }
1053 match create_named_provider(name, &self.config) {
1054 Ok(p) => {
1055 tracing::info!(provider = %name, "scene consolidation provider configured");
1056 Some(p)
1057 }
1058 Err(e) => {
1059 tracing::warn!(
1060 provider = %name,
1061 error = %e,
1062 "scene provider resolution failed — primary provider will be used"
1063 );
1064 None
1065 }
1066 }
1067 }
1068
1069 #[cfg(test)]
1070 pub fn for_test(config: crate::config::Config) -> Self {
1071 Self {
1072 config,
1073 config_path: std::path::PathBuf::new(),
1074 vault: Box::new(crate::vault::EnvVaultProvider),
1075 age_vault: None,
1076 qdrant_ops: None,
1077 }
1078 }
1079}
1080
1081#[cfg(test)]
1082mod tests;