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