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