Skip to main content

zeph_core/bootstrap/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Application bootstrap: config resolution, provider/memory/tool construction.
5
6pub 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    /// Present when the vault backend is `age`. Used to pass to `create_mcp_manager_with_vault`
53    /// for OAuth credential persistence across sessions.
54    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    /// Resolve config, load it, create vault, resolve secrets.
73    ///
74    /// CLI-provided overrides take priority over environment variables and config.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`BootstrapError`] if config loading, validation, vault construction,
79    /// secret resolution, or Qdrant URL parsing fails.
80    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    /// Returns the vault provider used for secret resolution.
164    ///
165    /// Retained as part of the public `Bootstrap` API for external callers
166    /// that may inspect or override vault behavior at runtime.
167    pub fn vault(&self) -> &dyn VaultProvider {
168        self.vault.as_ref()
169    }
170
171    /// Returns the shared age vault, if the backend is `age`.
172    ///
173    /// Pass this to `create_mcp_manager_with_vault` so OAuth tokens are persisted
174    /// across sessions.
175    pub fn age_vault_arc(&self) -> Option<&Arc<RwLock<AgeVaultProvider>>> {
176        self.age_vault.as_ref()
177    }
178
179    /// # Errors
180    ///
181    /// Returns [`BootstrapError`] if provider creation or health check fails.
182    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    /// # Errors
240    ///
241    /// Returns [`BootstrapError`] if `SQLite` cannot be initialized or if `vector_backend = "Qdrant"`
242    /// but `qdrant_ops` is `None` (invariant violation — should not happen if `AppBuilder::new`
243    /// succeeded).
244    pub async fn build_memory(
245        &self,
246        provider: &AnyProvider,
247    ) -> Result<SemanticMemory, BootstrapError> {
248        let embed_model = self.embedding_model();
249        // Resolve the database path: prefer database_url (PostgreSQL) over sqlite_path.
250        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            // Open a dedicated pool for graph operations to prevent pool starvation.
320            // Community detection and spreading activation can saturate the shared message pool
321            // (pool_size=5), causing pool.acquire() cancellation and semaphore drift in sqlx 0.8.
322            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
378/// Spawn a background task that backfills missing embeddings.
379///
380/// Fire-and-forget: the caller does not need to await the returned handle.
381/// The task runs for at most `timeout_secs` seconds.
382///
383/// # Errors
384///
385/// The returned `JoinHandle` resolves to `()` — errors are logged internally.
386pub 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        // Ensure progress signals done on timeout/error.
406        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            // Build a trust-level mapping from all loaded skill metas.
571            // Skills without a trust record default to the configured default_level.
572            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        // Structured config takes precedence over the string-based summary_model.
652        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    /// Build the quarantine summarizer provider when `security.content_isolation.quarantine.enabled = true`.
683    ///
684    /// Returns `None` when quarantine is disabled or provider resolution fails.
685    /// Emits a `tracing::warn` on resolution failure (quarantine silently disabled).
686    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    /// Build the guardrail filter when `security.guardrail.enabled = true`.
719    ///
720    /// Returns `None` when guardrail is disabled or provider resolution fails.
721    /// Emits a `tracing::warn` on resolution failure (guardrail silently disabled).
722    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    /// Build the guardrail provider and config pair for use in multi-session contexts.
734    ///
735    /// Returns `None` when guardrail is disabled or provider resolution fails.
736    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    /// Build a dedicated provider for the judge detector when `detector_mode = judge`.
765    ///
766    /// Returns `None` when mode is `Regex` or `judge_model` is empty (primary provider used).
767    /// Emits a `tracing::warn` when mode is `Judge` but no model is specified.
768    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    /// Build an `LlmClassifier` for `detector_mode = "model"` feedback detection.
793    ///
794    /// Resolves `feedback_provider` from `[[llm.providers]]` registry.
795    /// Pass the session's primary provider as `primary` for fallback when `feedback_provider`
796    /// is empty. Returns `None` with a warning on resolution failure — never fails startup.
797    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    /// Build a dedicated provider for compaction probe LLM calls.
844    ///
845    /// Returns `None` when `probe_provider` is empty (falls back to summary provider at call site).
846    /// Emits a `tracing::warn` on resolution failure (summary/primary provider used as fallback).
847    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    /// Build a dedicated provider for `compress_context` LLM calls (#2356).
869    ///
870    /// Returns `None` when `compress_provider` is empty (falls back to primary provider at call site).
871    /// Emits a `tracing::warn` on resolution failure (primary provider used as fallback).
872    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    /// Build a dedicated provider for ACON compression guidelines LLM calls.
894    ///
895    /// Returns `None` when `guidelines_provider` is empty (falls back to primary provider at call site).
896    ///
897    /// # Errors (logged, not propagated)
898    ///
899    /// Emits a `tracing::warn` on resolution failure; primary provider is used as fallback.
900    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    /// Build a dedicated provider for All-Mem consolidation LLM calls.
926    ///
927    /// Returns `None` when `consolidation_provider` is empty (falls back to primary provider at
928    /// call site) or when provider resolution fails (logs a warning, fails open).
929    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    /// Build a dedicated provider for `TiMem` tree consolidation LLM calls (#2262).
951    ///
952    /// Returns `None` when `consolidation_provider` is empty or resolution fails.
953    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    /// Build a dedicated provider for orchestration planner LLM calls.
975    ///
976    /// Returns `None` when `planner_provider` is empty (falls back to primary provider at call site).
977    ///
978    /// # Errors (logged, not propagated)
979    ///
980    /// Emits a `tracing::warn` on resolution failure; primary provider is used as fallback.
981    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    /// Build the `PlanVerifier` provider from `[orchestration] verify_provider`.
1003    ///
1004    /// Returns `None` when `verify_provider` is empty (falls back to the primary provider at
1005    /// runtime) or when provider resolution fails (logs a warning, fails open).
1006    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    /// Build a dedicated provider for `MemScene` label/profile LLM generation.
1045    ///
1046    /// Returns `None` when `tiers.scene_provider` is empty (caller falls back to primary provider).
1047    /// Emits a `tracing::warn` on resolution failure; primary provider is used as fallback.
1048    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;