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