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 provider;
10pub mod skills;
11
12pub use config::{parse_vault_args, resolve_config_path};
13pub use health::{health_check, warmup_provider};
14pub use mcp::{create_mcp_manager, create_mcp_registry};
15#[cfg(feature = "candle")]
16pub use provider::select_device;
17pub use provider::{
18    BootstrapError, build_orchestrator, create_named_provider, create_provider,
19    create_provider_from_config, create_summary_provider,
20};
21pub use skills::{create_skill_matcher, effective_embedding_model, managed_skills_dir};
22
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use tokio::sync::{mpsc, watch};
27use zeph_llm::any::AnyProvider;
28use zeph_llm::provider::LlmProvider;
29use zeph_memory::GraphStore;
30use zeph_memory::QdrantOps;
31use zeph_memory::semantic::SemanticMemory;
32use zeph_skills::loader::SkillMeta;
33use zeph_skills::matcher::SkillMatcherBackend;
34use zeph_skills::registry::SkillRegistry;
35use zeph_skills::watcher::{SkillEvent, SkillWatcher};
36
37use crate::config::Config;
38use crate::config_watcher::{ConfigEvent, ConfigWatcher};
39use crate::vault::AgeVaultProvider;
40use crate::vault::{EnvVaultProvider, VaultProvider};
41
42pub struct AppBuilder {
43    config: Config,
44    config_path: PathBuf,
45    vault: Box<dyn VaultProvider>,
46    qdrant_ops: Option<QdrantOps>,
47}
48
49pub struct VaultArgs {
50    pub backend: String,
51    pub key_path: Option<String>,
52    pub vault_path: Option<String>,
53}
54
55pub struct WatcherBundle {
56    pub skill_watcher: Option<SkillWatcher>,
57    pub skill_reload_rx: mpsc::Receiver<SkillEvent>,
58    pub config_watcher: Option<ConfigWatcher>,
59    pub config_reload_rx: mpsc::Receiver<ConfigEvent>,
60}
61
62impl AppBuilder {
63    /// Resolve config, load it, create vault, resolve secrets.
64    ///
65    /// CLI-provided overrides take priority over environment variables and config.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`BootstrapError`] if config loading, validation, vault construction,
70    /// secret resolution, or Qdrant URL parsing fails.
71    pub async fn new(
72        config_override: Option<&Path>,
73        vault_override: Option<&str>,
74        vault_key_override: Option<&Path>,
75        vault_path_override: Option<&Path>,
76    ) -> Result<Self, BootstrapError> {
77        let config_path = resolve_config_path(config_override);
78        let mut config = Config::load(&config_path)?;
79        config.validate()?;
80
81        let vault_args = parse_vault_args(
82            &config,
83            vault_override,
84            vault_key_override,
85            vault_path_override,
86        );
87        let vault: Box<dyn VaultProvider> = match vault_args.backend.as_str() {
88            "env" => Box::new(EnvVaultProvider),
89            "age" => {
90                let key = vault_args.key_path.ok_or_else(|| {
91                    BootstrapError::Provider("--vault-key required for age backend".into())
92                })?;
93                let path = vault_args.vault_path.ok_or_else(|| {
94                    BootstrapError::Provider("--vault-path required for age backend".into())
95                })?;
96                Box::new(
97                    AgeVaultProvider::new(Path::new(&key), Path::new(&path))
98                        .map_err(BootstrapError::VaultInit)?,
99                )
100            }
101            other => {
102                return Err(BootstrapError::Provider(format!(
103                    "unknown vault backend: {other}"
104                )));
105            }
106        };
107
108        config.resolve_secrets(vault.as_ref()).await?;
109
110        let qdrant_ops = match config.memory.vector_backend {
111            crate::config::VectorBackend::Qdrant => {
112                let ops = QdrantOps::new(&config.memory.qdrant_url).map_err(|e| {
113                    BootstrapError::Provider(format!(
114                        "invalid qdrant_url '{}': {e}",
115                        config.memory.qdrant_url
116                    ))
117                })?;
118                Some(ops)
119            }
120            crate::config::VectorBackend::Sqlite => None,
121        };
122
123        Ok(Self {
124            config,
125            config_path,
126            vault,
127            qdrant_ops,
128        })
129    }
130
131    pub fn qdrant_ops(&self) -> Option<&QdrantOps> {
132        self.qdrant_ops.as_ref()
133    }
134
135    pub fn config(&self) -> &Config {
136        &self.config
137    }
138
139    pub fn config_mut(&mut self) -> &mut Config {
140        &mut self.config
141    }
142
143    pub fn config_path(&self) -> &Path {
144        &self.config_path
145    }
146
147    /// Returns the vault provider used for secret resolution.
148    ///
149    /// Retained as part of the public `Bootstrap` API for external callers
150    /// that may inspect or override vault behavior at runtime.
151    pub fn vault(&self) -> &dyn VaultProvider {
152        self.vault.as_ref()
153    }
154
155    /// # Errors
156    ///
157    /// Returns [`BootstrapError`] if provider creation or health check fails.
158    pub async fn build_provider(
159        &self,
160    ) -> Result<(AnyProvider, tokio::sync::mpsc::UnboundedReceiver<String>), BootstrapError> {
161        let mut provider = create_provider(&self.config)?;
162
163        let (status_tx, status_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
164        provider.set_status_tx(status_tx);
165
166        health_check(&provider).await;
167
168        if let AnyProvider::Ollama(ref mut ollama) = provider
169            && let Ok(info) = ollama.fetch_model_info().await
170            && let Some(ctx) = info.context_length
171        {
172            ollama.set_context_window(ctx);
173            tracing::info!(context_window = ctx, "detected Ollama model context window");
174        }
175
176        if let AnyProvider::Orchestrator(ref mut orch) = provider {
177            orch.auto_detect_context_window().await;
178        }
179        if let Some(ctx) = provider.context_window()
180            && !matches!(provider, AnyProvider::Ollama(_))
181        {
182            tracing::info!(context_window = ctx, "detected orchestrator context window");
183        }
184
185        Ok((provider, status_rx))
186    }
187
188    pub fn auto_budget_tokens(&self, provider: &AnyProvider) -> usize {
189        if self.config.memory.auto_budget && self.config.memory.context_budget_tokens == 0 {
190            if let Some(ctx_size) = provider.context_window() {
191                tracing::info!(model_context = ctx_size, "auto-configured context budget");
192                ctx_size
193            } else {
194                0
195            }
196        } else {
197            self.config.memory.context_budget_tokens
198        }
199    }
200
201    /// # Errors
202    ///
203    /// Returns [`BootstrapError`] if `SQLite` cannot be initialized or if `vector_backend = "Qdrant"`
204    /// but `qdrant_ops` is `None` (invariant violation — should not happen if `AppBuilder::new`
205    /// succeeded).
206    pub async fn build_memory(
207        &self,
208        provider: &AnyProvider,
209    ) -> Result<SemanticMemory, BootstrapError> {
210        let embed_model = self.embedding_model();
211        let mut memory = match self.config.memory.vector_backend {
212            crate::config::VectorBackend::Sqlite => {
213                SemanticMemory::with_sqlite_backend_and_pool_size(
214                    &self.config.memory.sqlite_path,
215                    provider.clone(),
216                    &embed_model,
217                    self.config.memory.semantic.vector_weight,
218                    self.config.memory.semantic.keyword_weight,
219                    self.config.memory.sqlite_pool_size,
220                )
221                .await
222                .map_err(|e| BootstrapError::Memory(e.to_string()))?
223            }
224            crate::config::VectorBackend::Qdrant => {
225                let ops = self
226                    .qdrant_ops
227                    .as_ref()
228                    .ok_or_else(|| {
229                        BootstrapError::Memory(
230                            "qdrant_ops must be Some when vector_backend = Qdrant".into(),
231                        )
232                    })?
233                    .clone();
234                SemanticMemory::with_qdrant_ops(
235                    &self.config.memory.sqlite_path,
236                    ops,
237                    provider.clone(),
238                    &embed_model,
239                    self.config.memory.semantic.vector_weight,
240                    self.config.memory.semantic.keyword_weight,
241                    self.config.memory.sqlite_pool_size,
242                )
243                .await
244                .map_err(|e| BootstrapError::Memory(e.to_string()))?
245            }
246        };
247
248        memory = memory.with_ranking_options(
249            self.config.memory.semantic.temporal_decay_enabled,
250            self.config.memory.semantic.temporal_decay_half_life_days,
251            self.config.memory.semantic.mmr_enabled,
252            self.config.memory.semantic.mmr_lambda,
253        );
254
255        if self.config.memory.semantic.enabled && memory.is_vector_store_connected().await {
256            tracing::info!("semantic memory enabled, vector store connected");
257            match memory.embed_missing().await {
258                Ok(n) if n > 0 => tracing::info!("backfilled {n} missing embedding(s)"),
259                Ok(_) => {}
260                Err(e) => tracing::warn!("embed_missing failed: {e:#}"),
261            }
262        }
263
264        if self.config.memory.graph.enabled {
265            let pool = memory.sqlite().pool().clone();
266            let store = Arc::new(GraphStore::new(pool));
267            memory = memory.with_graph_store(store);
268            tracing::info!("graph memory enabled, GraphStore attached");
269        }
270
271        Ok(memory)
272    }
273
274    pub async fn build_skill_matcher(
275        &self,
276        provider: &AnyProvider,
277        meta: &[&SkillMeta],
278        memory: &SemanticMemory,
279    ) -> Option<SkillMatcherBackend> {
280        let embed_model = self.embedding_model();
281        create_skill_matcher(
282            &self.config,
283            provider,
284            meta,
285            memory,
286            &embed_model,
287            self.qdrant_ops.as_ref(),
288        )
289        .await
290    }
291
292    pub fn build_registry(&self) -> SkillRegistry {
293        let skill_paths: Vec<PathBuf> =
294            self.config.skills.paths.iter().map(PathBuf::from).collect();
295        SkillRegistry::load(&skill_paths)
296    }
297
298    pub fn skill_paths(&self) -> Vec<PathBuf> {
299        let mut paths: Vec<PathBuf> = self.config.skills.paths.iter().map(PathBuf::from).collect();
300        let managed_dir = managed_skills_dir();
301        if !paths.contains(&managed_dir) {
302            paths.push(managed_dir);
303        }
304        paths
305    }
306
307    pub fn managed_skills_dir() -> PathBuf {
308        managed_skills_dir()
309    }
310
311    pub fn build_watchers(&self) -> WatcherBundle {
312        let skill_paths = self.skill_paths();
313        let (reload_tx, skill_reload_rx) = mpsc::channel(4);
314        let skill_watcher = match SkillWatcher::start(&skill_paths, reload_tx) {
315            Ok(w) => {
316                tracing::info!("skill watcher started");
317                Some(w)
318            }
319            Err(e) => {
320                tracing::warn!("skill watcher unavailable: {e:#}");
321                None
322            }
323        };
324
325        let (config_reload_tx, config_reload_rx) = mpsc::channel(4);
326        let config_watcher = match ConfigWatcher::start(&self.config_path, config_reload_tx) {
327            Ok(w) => {
328                tracing::info!("config watcher started");
329                Some(w)
330            }
331            Err(e) => {
332                tracing::warn!("config watcher unavailable: {e:#}");
333                None
334            }
335        };
336
337        WatcherBundle {
338            skill_watcher,
339            skill_reload_rx,
340            config_watcher,
341            config_reload_rx,
342        }
343    }
344
345    pub fn build_shutdown() -> (watch::Sender<bool>, watch::Receiver<bool>) {
346        watch::channel(false)
347    }
348
349    pub fn embedding_model(&self) -> String {
350        effective_embedding_model(&self.config)
351    }
352
353    pub fn build_summary_provider(&self) -> Option<AnyProvider> {
354        // Structured config takes precedence over the string-based summary_model.
355        if let Some(ref pcfg) = self.config.llm.summary_provider {
356            return match create_provider_from_config(pcfg, &self.config) {
357                Ok(sp) => {
358                    tracing::info!(
359                        provider_type = %pcfg.provider_type,
360                        model = ?pcfg.model,
361                        "summary provider configured via [llm.summary_provider]"
362                    );
363                    Some(sp)
364                }
365                Err(e) => {
366                    tracing::warn!("failed to create summary provider: {e:#}, using primary");
367                    None
368                }
369            };
370        }
371        self.config.llm.summary_model.as_ref().and_then(
372            |model_spec| match create_summary_provider(model_spec, &self.config) {
373                Ok(sp) => {
374                    tracing::info!(model = %model_spec, "summary provider configured via llm.summary_model");
375                    Some(sp)
376                }
377                Err(e) => {
378                    tracing::warn!("failed to create summary provider: {e:#}, using primary");
379                    None
380                }
381            },
382        )
383    }
384
385    /// Build the quarantine summarizer provider when `security.content_isolation.quarantine.enabled = true`.
386    ///
387    /// Returns `None` when quarantine is disabled or provider resolution fails.
388    /// Emits a `tracing::warn` on resolution failure (quarantine silently disabled).
389    pub fn build_quarantine_provider(
390        &self,
391    ) -> Option<(AnyProvider, crate::sanitizer::QuarantineConfig)> {
392        let qc = &self.config.security.content_isolation.quarantine;
393        if !qc.enabled {
394            return None;
395        }
396        match create_named_provider(&qc.model, &self.config) {
397            Ok(p) => {
398                tracing::info!(model = %qc.model, "quarantine provider configured");
399                Some((p, qc.clone()))
400            }
401            Err(e) => {
402                tracing::warn!(
403                    model = %qc.model,
404                    error = %e,
405                    "quarantine provider resolution failed, quarantine disabled"
406                );
407                None
408            }
409        }
410    }
411
412    /// Build a dedicated provider for the judge detector when `detector_mode = judge`.
413    ///
414    /// Returns `None` when mode is `Regex` or `judge_model` is empty (primary provider used).
415    /// Emits a `tracing::warn` when mode is `Judge` but no model is specified.
416    pub fn build_judge_provider(&self) -> Option<AnyProvider> {
417        use crate::config::DetectorMode;
418        let learning = &self.config.skills.learning;
419        if learning.detector_mode != DetectorMode::Judge {
420            return None;
421        }
422        if learning.judge_model.is_empty() {
423            tracing::warn!(
424                provider = ?self.config.llm.provider,
425                "detector_mode=judge but judge_model is empty — primary provider will be used for judging"
426            );
427            return None;
428        }
429        match create_named_provider(&learning.judge_model, &self.config) {
430            Ok(jp) => {
431                tracing::info!(model = %learning.judge_model, "judge provider configured");
432                Some(jp)
433            }
434            Err(e) => {
435                tracing::warn!("failed to create judge provider: {e:#}, using primary");
436                None
437            }
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests;