diff --git a/CHANGELOG.md b/CHANGELOG.md index a51bde69..b12431bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `full` feature flag enabling all optional features ### Changed +- Extract bootstrap logic from main.rs into `zeph-core::bootstrap::AppBuilder` (#393): main.rs reduced from 2313 to 978 lines +- `SecurityConfig` and `TimeoutConfig` gain `Clone + Copy` - `AnyChannel` moved from main.rs to zeph-channels crate - Default features reduced to minimal set (qdrant, self-learning, vault-age, compatible, index) - Skill matcher concurrency reduced from 50 to 20 diff --git a/Cargo.toml b/Cargo.toml index 7d0de570..2772e59a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,15 +106,15 @@ repository.workspace = true default = ["compatible", "openai", "qdrant", "self-learning", "vault-age"] full = ["a2a", "compatible", "discord", "gateway", "index", "mcp", "openai", "orchestrator", "qdrant", "router", "self-learning", "slack", "tui", "vault-age"] a2a = ["dep:zeph-a2a", "zeph-a2a?/server"] -compatible = ["zeph-llm/compatible"] +compatible = ["zeph-llm/compatible", "zeph-core/compatible"] mcp = ["dep:zeph-mcp", "zeph-core/mcp"] -qdrant = ["zeph-skills/qdrant", "zeph-mcp?/qdrant"] -candle = ["zeph-llm/candle"] -metal = ["zeph-llm/metal"] -cuda = ["zeph-llm/cuda"] -openai = ["zeph-llm/openai"] -orchestrator = ["zeph-llm/orchestrator"] -router = ["zeph-llm/router"] +qdrant = ["zeph-skills/qdrant", "zeph-mcp?/qdrant", "zeph-core/qdrant"] +candle = ["zeph-llm/candle", "zeph-core/candle"] +metal = ["zeph-llm/metal", "zeph-core/metal"] +cuda = ["zeph-llm/cuda", "zeph-core/cuda"] +openai = ["zeph-llm/openai", "zeph-core/openai"] +orchestrator = ["zeph-llm/orchestrator", "zeph-core/orchestrator"] +router = ["zeph-llm/router", "zeph-core/router"] self-learning = ["zeph-core/self-learning"] tui = ["dep:zeph-tui"] discord = ["zeph-channels/discord"] diff --git a/README.md b/README.md index 3d668c53..07d75367 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,9 @@ cargo build --release --features tui ## Architecture ``` -zeph (binary) — bootstrap, vault resolution (anyhow for top-level errors) -├── zeph-core — Agent split into 7 submodules (context, streaming, persistence, -│ learning, mcp, index), daemon supervisor, typed AgentError/ChannelError, config hot-reload +zeph (binary) — thin CLI/channel dispatch (anyhow for top-level errors) +├── zeph-core — bootstrap/AppBuilder, Agent split into 7 submodules (context, streaming, +│ persistence, learning, mcp, index), daemon supervisor, typed AgentError/ChannelError, config hot-reload ├── zeph-llm — LlmProvider: Ollama, Claude, OpenAI, Candle, Compatible, orchestrator, │ RouterProvider, native tool_use (Claude/OpenAI), typed LlmError ├── zeph-skills — SKILL.md parser, embedding matcher, hot-reload, self-learning, typed SkillError diff --git a/crates/zeph-core/Cargo.toml b/crates/zeph-core/Cargo.toml index 1d297f07..b35c958b 100644 --- a/crates/zeph-core/Cargo.toml +++ b/crates/zeph-core/Cargo.toml @@ -8,9 +8,17 @@ repository.workspace = true [features] default = [] +candle = ["zeph-llm/candle"] +compatible = ["zeph-llm/compatible"] +cuda = ["zeph-llm/cuda"] +daemon = [] index = ["dep:zeph-index"] mcp = ["dep:zeph-mcp"] -daemon = [] +metal = ["zeph-llm/metal"] +openai = ["zeph-llm/openai"] +orchestrator = ["zeph-llm/orchestrator"] +qdrant = ["zeph-skills/qdrant", "zeph-mcp?/qdrant"] +router = ["zeph-llm/router"] self-learning = ["zeph-skills/self-learning"] vault-age = ["dep:age"] diff --git a/crates/zeph-core/src/bootstrap.rs b/crates/zeph-core/src/bootstrap.rs new file mode 100644 index 00000000..54a43880 --- /dev/null +++ b/crates/zeph-core/src/bootstrap.rs @@ -0,0 +1,1579 @@ +//! Application bootstrap: config resolution, provider/memory/tool construction. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, bail}; +use tokio::sync::{mpsc, watch}; +use zeph_llm::any::AnyProvider; +use zeph_llm::claude::ClaudeProvider; +use zeph_llm::ollama::OllamaProvider; +use zeph_llm::provider::LlmProvider; +use zeph_memory::semantic::SemanticMemory; +use zeph_skills::loader::SkillMeta; +use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend}; +use zeph_skills::registry::SkillRegistry; +use zeph_skills::watcher::{SkillEvent, SkillWatcher}; +use zeph_tools::{CompositeExecutor, FileExecutor, ShellExecutor, WebScrapeExecutor}; + +use crate::config::{Config, ProviderKind}; +use crate::config_watcher::{ConfigEvent, ConfigWatcher}; +#[cfg(feature = "vault-age")] +use crate::vault::AgeVaultProvider; +use crate::vault::{EnvVaultProvider, VaultProvider}; + +#[cfg(feature = "compatible")] +use zeph_llm::compatible::CompatibleProvider; +#[cfg(feature = "openai")] +use zeph_llm::openai::OpenAiProvider; +#[cfg(feature = "qdrant")] +use zeph_skills::qdrant_matcher::QdrantSkillMatcher; + +pub struct AppBuilder { + config: Config, + config_path: PathBuf, + vault: Box, +} + +#[cfg_attr(not(feature = "vault-age"), allow(dead_code))] +pub struct VaultArgs { + pub backend: String, + pub key_path: Option, + pub vault_path: Option, +} + +pub struct WatcherBundle { + pub skill_watcher: Option, + pub skill_reload_rx: mpsc::Receiver, + pub config_watcher: Option, + pub config_reload_rx: mpsc::Receiver, +} + +#[cfg(feature = "mcp")] +pub struct ToolExecutorBundle { + pub executor: CompositeExecutor< + CompositeExecutor>, + zeph_mcp::McpToolExecutor, + >, + pub mcp_tools: Vec, + pub mcp_manager: std::sync::Arc, +} + +#[cfg(not(feature = "mcp"))] +pub struct ToolExecutorBundleNoMcp { + pub executor: + CompositeExecutor>, +} + +impl AppBuilder { + /// Resolve config, load it, create vault, resolve secrets. + pub async fn from_env() -> anyhow::Result { + let config_path = resolve_config_path(); + let mut config = Config::load(&config_path)?; + config.validate()?; + + let vault_args = parse_vault_args(&config); + let vault: Box = match vault_args.backend.as_str() { + "env" => Box::new(EnvVaultProvider), + #[cfg(feature = "vault-age")] + "age" => { + let key = vault_args + .key_path + .context("--vault-key required for age backend")?; + let path = vault_args + .vault_path + .context("--vault-path required for age backend")?; + Box::new(AgeVaultProvider::new(Path::new(&key), Path::new(&path))?) + } + other => bail!("unknown vault backend: {other}"), + }; + + config.resolve_secrets(vault.as_ref()).await?; + + Ok(Self { + config, + config_path, + vault, + }) + } + + pub fn config(&self) -> &Config { + &self.config + } + + pub fn config_mut(&mut self) -> &mut Config { + &mut self.config + } + + pub fn config_path(&self) -> &Path { + &self.config_path + } + + #[allow(dead_code)] + pub fn vault(&self) -> &dyn VaultProvider { + self.vault.as_ref() + } + + pub async fn build_provider( + &self, + ) -> anyhow::Result<(AnyProvider, tokio::sync::mpsc::UnboundedReceiver)> { + let mut provider = create_provider(&self.config)?; + + let (status_tx, status_rx) = tokio::sync::mpsc::unbounded_channel::(); + provider.set_status_tx(status_tx); + + health_check(&provider).await; + + if let AnyProvider::Ollama(ref mut ollama) = provider + && let Ok(info) = ollama.fetch_model_info().await + && let Some(ctx) = info.context_length + { + ollama.set_context_window(ctx); + tracing::info!(context_window = ctx, "detected Ollama model context window"); + } + + Ok((provider, status_rx)) + } + + pub fn auto_budget_tokens(&self, provider: &AnyProvider) -> usize { + if self.config.memory.auto_budget && self.config.memory.context_budget_tokens == 0 { + if let Some(ctx_size) = provider.context_window() { + tracing::info!(model_context = ctx_size, "auto-configured context budget"); + ctx_size + } else { + 0 + } + } else { + self.config.memory.context_budget_tokens + } + } + + pub async fn build_memory(&self, provider: &AnyProvider) -> anyhow::Result { + let embed_model = self.embedding_model(); + let memory = SemanticMemory::with_weights( + &self.config.memory.sqlite_path, + &self.config.memory.qdrant_url, + provider.clone(), + &embed_model, + self.config.memory.semantic.vector_weight, + self.config.memory.semantic.keyword_weight, + ) + .await?; + + if self.config.memory.semantic.enabled && memory.has_qdrant() { + tracing::info!("semantic memory enabled, Qdrant connected"); + match memory.embed_missing().await { + Ok(n) if n > 0 => tracing::info!("backfilled {n} missing embedding(s)"), + Ok(_) => {} + Err(e) => tracing::warn!("embed_missing failed: {e:#}"), + } + } + + Ok(memory) + } + + pub async fn build_skill_matcher( + &self, + provider: &AnyProvider, + meta: &[&SkillMeta], + memory: &SemanticMemory, + ) -> Option { + let embed_model = self.embedding_model(); + create_skill_matcher(&self.config, provider, meta, memory, &embed_model).await + } + + pub fn build_registry(&self) -> SkillRegistry { + let skill_paths: Vec = + self.config.skills.paths.iter().map(PathBuf::from).collect(); + SkillRegistry::load(&skill_paths) + } + + pub fn skill_paths(&self) -> Vec { + self.config.skills.paths.iter().map(PathBuf::from).collect() + } + + #[cfg(feature = "mcp")] + pub async fn build_tool_executor(&self) -> anyhow::Result { + let permission_policy = self + .config + .tools + .permission_policy(self.config.security.autonomy_level); + let mut shell_executor = + ShellExecutor::new(&self.config.tools.shell).with_permissions(permission_policy); + if self.config.tools.audit.enabled + && let Ok(logger) = zeph_tools::AuditLogger::from_config(&self.config.tools.audit).await + { + shell_executor = shell_executor.with_audit(logger); + } + + let scrape_executor = WebScrapeExecutor::new(&self.config.tools.scrape); + let file_executor = FileExecutor::new( + self.config + .tools + .shell + .allowed_paths + .iter() + .map(PathBuf::from) + .collect(), + ); + + let mcp_manager = std::sync::Arc::new(create_mcp_manager(&self.config)); + let mcp_tools = mcp_manager.connect_all().await; + tracing::info!("discovered {} MCP tool(s)", mcp_tools.len()); + + let mcp_executor = zeph_mcp::McpToolExecutor::new(mcp_manager.clone()); + let base_executor = CompositeExecutor::new( + file_executor, + CompositeExecutor::new(shell_executor, scrape_executor), + ); + let executor = CompositeExecutor::new(base_executor, mcp_executor); + + Ok(ToolExecutorBundle { + executor, + mcp_tools, + mcp_manager, + }) + } + + #[cfg(not(feature = "mcp"))] + pub async fn build_tool_executor(&self) -> anyhow::Result { + let permission_policy = self + .config + .tools + .permission_policy(self.config.security.autonomy_level); + let mut shell_executor = + ShellExecutor::new(&self.config.tools.shell).with_permissions(permission_policy); + if self.config.tools.audit.enabled + && let Ok(logger) = zeph_tools::AuditLogger::from_config(&self.config.tools.audit).await + { + shell_executor = shell_executor.with_audit(logger); + } + + let scrape_executor = WebScrapeExecutor::new(&self.config.tools.scrape); + let file_executor = FileExecutor::new( + self.config + .tools + .shell + .allowed_paths + .iter() + .map(PathBuf::from) + .collect(), + ); + + let executor = CompositeExecutor::new( + file_executor, + CompositeExecutor::new(shell_executor, scrape_executor), + ); + + Ok(ToolExecutorBundleNoMcp { executor }) + } + + pub fn build_watchers(&self) -> WatcherBundle { + let skill_paths = self.skill_paths(); + let (reload_tx, skill_reload_rx) = mpsc::channel(4); + let skill_watcher = match SkillWatcher::start(&skill_paths, reload_tx) { + Ok(w) => { + tracing::info!("skill watcher started"); + Some(w) + } + Err(e) => { + tracing::warn!("skill watcher unavailable: {e:#}"); + None + } + }; + + let (config_reload_tx, config_reload_rx) = mpsc::channel(4); + let config_watcher = match ConfigWatcher::start(&self.config_path, config_reload_tx) { + Ok(w) => { + tracing::info!("config watcher started"); + Some(w) + } + Err(e) => { + tracing::warn!("config watcher unavailable: {e:#}"); + None + } + }; + + WatcherBundle { + skill_watcher, + skill_reload_rx, + config_watcher, + config_reload_rx, + } + } + + pub fn build_shutdown() -> (watch::Sender, watch::Receiver) { + watch::channel(false) + } + + pub fn embedding_model(&self) -> String { + effective_embedding_model(&self.config) + } + + pub fn build_summary_provider(&self) -> Option { + self.config.agent.summary_model.as_ref().and_then( + |model_spec| match create_summary_provider(model_spec, &self.config) { + Ok(sp) => { + tracing::info!(model = %model_spec, "summary provider configured"); + Some(sp) + } + Err(e) => { + tracing::warn!("failed to create summary provider: {e:#}, using primary"); + None + } + }, + ) + } +} + +// --- Free functions moved from main.rs --- + +pub fn resolve_config_path() -> PathBuf { + let args: Vec = std::env::args().collect(); + if let Some(path) = args.windows(2).find(|w| w[0] == "--config").map(|w| &w[1]) { + return PathBuf::from(path); + } + if let Ok(path) = std::env::var("ZEPH_CONFIG") { + return PathBuf::from(path); + } + PathBuf::from("config/default.toml") +} + +/// Priority: CLI --vault > `ZEPH_VAULT_BACKEND` env > config.vault.backend > "env" +pub fn parse_vault_args(config: &Config) -> VaultArgs { + let args: Vec = std::env::args().collect(); + let cli_backend = args + .windows(2) + .find(|w| w[0] == "--vault") + .map(|w| w[1].clone()); + let env_backend = std::env::var("ZEPH_VAULT_BACKEND").ok(); + let backend = cli_backend + .or(env_backend) + .unwrap_or_else(|| config.vault.backend.clone()); + let key_path = args + .windows(2) + .find(|w| w[0] == "--vault-key") + .map(|w| w[1].clone()); + let vault_path = args + .windows(2) + .find(|w| w[0] == "--vault-path") + .map(|w| w[1].clone()); + VaultArgs { + backend, + key_path, + vault_path, + } +} + +pub async fn health_check(provider: &AnyProvider) { + match provider { + AnyProvider::Ollama(ollama) => match ollama.health_check().await { + Ok(()) => tracing::info!("ollama health check passed"), + Err(e) => tracing::warn!("ollama health check failed: {e:#}"), + }, + #[cfg(feature = "candle")] + AnyProvider::Candle(candle) => { + tracing::info!("candle provider loaded, device: {}", candle.device_name()); + } + #[cfg(feature = "orchestrator")] + AnyProvider::Orchestrator(orch) => { + for (name, p) in orch.providers() { + tracing::info!( + "orchestrator sub-provider '{name}': {}", + zeph_llm::provider::LlmProvider::name(p) + ); + } + } + _ => {} + } +} + +pub async fn warmup_provider(provider: &AnyProvider) { + match provider { + AnyProvider::Ollama(ollama) => { + let start = std::time::Instant::now(); + match ollama.warmup().await { + Ok(()) => { + tracing::info!("ollama model ready ({:.1}s)", start.elapsed().as_secs_f64()); + } + Err(e) => tracing::warn!("ollama warmup failed: {e:#}"), + } + } + #[cfg(feature = "orchestrator")] + AnyProvider::Orchestrator(orch) => { + for (name, p) in orch.providers() { + if let zeph_llm::orchestrator::SubProvider::Ollama(ollama) = p { + let start = std::time::Instant::now(); + match ollama.warmup().await { + Ok(()) => tracing::info!( + "ollama '{name}' ready ({:.1}s)", + start.elapsed().as_secs_f64() + ), + Err(e) => tracing::warn!("ollama '{name}' warmup failed: {e:#}"), + } + } + } + } + _ => {} + } +} + +#[allow(unused_variables)] +pub async fn create_skill_matcher( + config: &Config, + provider: &AnyProvider, + meta: &[&SkillMeta], + memory: &SemanticMemory, + embedding_model: &str, +) -> Option { + let embed_fn = provider.embed_fn(); + + #[cfg(feature = "qdrant")] + if config.memory.semantic.enabled && memory.has_qdrant() { + match QdrantSkillMatcher::new(&config.memory.qdrant_url) { + Ok(mut qm) => match qm.sync(meta, embedding_model, &embed_fn).await { + Ok(_) => return Some(SkillMatcherBackend::Qdrant(qm)), + Err(e) => { + tracing::warn!("Qdrant skill sync failed, falling back to in-memory: {e:#}"); + } + }, + Err(e) => { + tracing::warn!("Qdrant client creation failed, falling back to in-memory: {e:#}"); + } + } + } + + SkillMatcher::new(meta, &embed_fn) + .await + .map(SkillMatcherBackend::InMemory) +} + +pub fn effective_embedding_model(config: &Config) -> String { + match config.llm.provider { + #[cfg(feature = "openai")] + ProviderKind::OpenAi => { + if let Some(m) = config + .llm + .openai + .as_ref() + .and_then(|o| o.embedding_model.clone()) + { + return m; + } + } + #[cfg(feature = "orchestrator")] + ProviderKind::Orchestrator => { + if let Some(orch) = &config.llm.orchestrator + && let Some(pcfg) = orch.providers.get(&orch.embed) + { + #[cfg(feature = "openai")] + if pcfg.provider_type == "openai" + && let Some(m) = config + .llm + .openai + .as_ref() + .and_then(|o| o.embedding_model.clone()) + { + return m; + } + } + } + ProviderKind::Compatible => { + if let Some(entries) = &config.llm.compatible + && let Some(entry) = entries.first() + && let Some(ref m) = entry.embedding_model + { + return m.clone(); + } + } + _ => {} + } + config.llm.embedding_model.clone() +} + +#[allow(clippy::too_many_lines)] +pub fn create_provider(config: &Config) -> anyhow::Result { + match config.llm.provider { + ProviderKind::Ollama | ProviderKind::Claude => { + create_named_provider(config.llm.provider.as_str(), config) + } + #[cfg(feature = "openai")] + ProviderKind::OpenAi => create_named_provider("openai", config), + #[cfg(feature = "compatible")] + ProviderKind::Compatible => create_named_provider("compatible", config), + #[cfg(feature = "candle")] + ProviderKind::Candle => { + let candle_cfg = config + .llm + .candle + .as_ref() + .context("llm.candle config section required for candle provider")?; + + let source = match candle_cfg.source.as_str() { + "local" => zeph_llm::candle_provider::loader::ModelSource::Local { + path: std::path::PathBuf::from(&candle_cfg.local_path), + }, + _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace { + repo_id: config.llm.model.clone(), + filename: candle_cfg.filename.clone(), + }, + }; + + let template = zeph_llm::candle_provider::template::ChatTemplate::parse_str( + &candle_cfg.chat_template, + ); + let gen_config = zeph_llm::candle_provider::generate::GenerationConfig { + temperature: candle_cfg.generation.temperature, + top_p: candle_cfg.generation.top_p, + top_k: candle_cfg.generation.top_k, + max_tokens: candle_cfg.generation.capped_max_tokens(), + seed: candle_cfg.generation.seed, + repeat_penalty: candle_cfg.generation.repeat_penalty, + repeat_last_n: candle_cfg.generation.repeat_last_n, + }; + + let device = select_device(&candle_cfg.device)?; + + let provider = zeph_llm::candle_provider::CandleProvider::new( + &source, + template, + gen_config, + candle_cfg.embedding_repo.as_deref(), + device, + )?; + Ok(AnyProvider::Candle(provider)) + } + #[cfg(feature = "orchestrator")] + ProviderKind::Orchestrator => { + let orch = build_orchestrator(config)?; + Ok(AnyProvider::Orchestrator(Box::new(orch))) + } + #[cfg(feature = "router")] + ProviderKind::Router => { + use zeph_llm::router::RouterProvider; + + let router_cfg = config + .llm + .router + .as_ref() + .context("llm.router config section required for router provider")?; + + let mut providers = Vec::new(); + for name in &router_cfg.chain { + let p = create_named_provider(name, config)?; + providers.push(p); + } + if providers.is_empty() { + bail!("router chain is empty"); + } + Ok(AnyProvider::Router(Box::new(RouterProvider::new( + providers, + )))) + } + #[allow(unreachable_patterns)] + other => bail!("LLM provider {other} not available (feature not enabled)"), + } +} + +pub fn create_named_provider(name: &str, config: &Config) -> anyhow::Result { + match name { + "ollama" => { + let provider = OllamaProvider::new( + &config.llm.base_url, + config.llm.model.clone(), + config.llm.embedding_model.clone(), + ); + Ok(AnyProvider::Ollama(provider)) + } + "claude" => { + let cloud = config + .llm + .cloud + .as_ref() + .context("llm.cloud config section required for Claude provider")?; + let api_key = config + .secrets + .claude_api_key + .as_ref() + .context("ZEPH_CLAUDE_API_KEY not found in vault")? + .expose() + .to_owned(); + Ok(AnyProvider::Claude(ClaudeProvider::new( + api_key, + cloud.model.clone(), + cloud.max_tokens, + ))) + } + #[cfg(feature = "openai")] + "openai" => { + let openai_cfg = config + .llm + .openai + .as_ref() + .context("llm.openai config section required for OpenAI provider")?; + let api_key = config + .secrets + .openai_api_key + .as_ref() + .context("ZEPH_OPENAI_API_KEY not found in vault")? + .expose() + .to_owned(); + Ok(AnyProvider::OpenAi(OpenAiProvider::new( + api_key, + openai_cfg.base_url.clone(), + openai_cfg.model.clone(), + openai_cfg.max_tokens, + openai_cfg.embedding_model.clone(), + openai_cfg.reasoning_effort.clone(), + ))) + } + other => { + #[cfg(feature = "compatible")] + if let Some(entries) = &config.llm.compatible { + let entry = if other == "compatible" { + entries.first() + } else { + entries.iter().find(|e| e.name == other) + }; + if let Some(entry) = entry { + let api_key = config + .secrets + .compatible_api_keys + .get(&entry.name) + .with_context(|| { + format!( + "ZEPH_COMPATIBLE_{}_API_KEY required for {}", + entry.name.to_uppercase(), + entry.name + ) + })? + .expose() + .to_owned(); + return Ok(AnyProvider::Compatible(CompatibleProvider::new( + entry.name.clone(), + api_key, + entry.base_url.clone(), + entry.model.clone(), + entry.max_tokens, + entry.embedding_model.clone(), + ))); + } + } + bail!("unknown provider: {other}") + } + } +} + +pub fn create_summary_provider(model_spec: &str, config: &Config) -> anyhow::Result { + if let Some(model) = model_spec.strip_prefix("ollama/") { + let base_url = &config.llm.base_url; + let provider = OllamaProvider::new(base_url, model.to_owned(), String::new()); + Ok(AnyProvider::Ollama(provider)) + } else { + bail!("unsupported summary_model format: {model_spec} (expected 'ollama/')") + } +} + +#[cfg(feature = "candle")] +pub fn select_device(preference: &str) -> anyhow::Result { + match preference { + "metal" => { + #[cfg(feature = "metal")] + return Ok(zeph_llm::candle_provider::Device::new_metal(0)?); + #[cfg(not(feature = "metal"))] + bail!("candle compiled without metal feature"); + } + "cuda" => { + #[cfg(feature = "cuda")] + return Ok(zeph_llm::candle_provider::Device::new_cuda(0)?); + #[cfg(not(feature = "cuda"))] + bail!("candle compiled without cuda feature"); + } + "auto" => { + #[cfg(feature = "metal")] + if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) { + return Ok(device); + } + #[cfg(feature = "cuda")] + if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) { + return Ok(device); + } + Ok(zeph_llm::candle_provider::Device::Cpu) + } + _ => Ok(zeph_llm::candle_provider::Device::Cpu), + } +} + +#[cfg(feature = "orchestrator")] +#[allow(clippy::too_many_lines)] +pub fn build_orchestrator( + config: &Config, +) -> anyhow::Result { + use std::collections::HashMap; + use zeph_llm::orchestrator::{ModelOrchestrator, SubProvider, TaskType}; + + let orch_cfg = config + .llm + .orchestrator + .as_ref() + .context("llm.orchestrator config section required for orchestrator provider")?; + + let mut providers = HashMap::new(); + for (name, pcfg) in &orch_cfg.providers { + let provider = match pcfg.provider_type.as_str() { + "ollama" => { + let model = pcfg.model.as_deref().unwrap_or(&config.llm.model); + SubProvider::Ollama(OllamaProvider::new( + &config.llm.base_url, + model.to_owned(), + config.llm.embedding_model.clone(), + )) + } + "claude" => { + let cloud = config + .llm + .cloud + .as_ref() + .context("llm.cloud config required for claude sub-provider")?; + let api_key = config + .secrets + .claude_api_key + .as_ref() + .context("ZEPH_CLAUDE_API_KEY required for claude sub-provider")? + .expose() + .to_owned(); + let model = pcfg.model.as_deref().unwrap_or(&cloud.model); + SubProvider::Claude(ClaudeProvider::new( + api_key, + model.to_owned(), + cloud.max_tokens, + )) + } + #[cfg(feature = "openai")] + "openai" => { + let openai_cfg = config + .llm + .openai + .as_ref() + .context("llm.openai config required for openai sub-provider")?; + let api_key = config + .secrets + .openai_api_key + .as_ref() + .context("ZEPH_OPENAI_API_KEY required for openai sub-provider")? + .expose() + .to_owned(); + let model = pcfg.model.as_deref().unwrap_or(&openai_cfg.model); + SubProvider::OpenAi(OpenAiProvider::new( + api_key, + openai_cfg.base_url.clone(), + model.to_owned(), + openai_cfg.max_tokens, + openai_cfg.embedding_model.clone(), + openai_cfg.reasoning_effort.clone(), + )) + } + #[cfg(feature = "candle")] + "candle" => { + let candle_cfg = config + .llm + .candle + .as_ref() + .context("llm.candle config required for candle sub-provider")?; + let source = match candle_cfg.source.as_str() { + "local" => zeph_llm::candle_provider::loader::ModelSource::Local { + path: std::path::PathBuf::from(&candle_cfg.local_path), + }, + _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace { + repo_id: pcfg + .model + .clone() + .unwrap_or_else(|| config.llm.model.clone()), + filename: candle_cfg.filename.clone(), + }, + }; + let template = zeph_llm::candle_provider::template::ChatTemplate::parse_str( + &candle_cfg.chat_template, + ); + let device_pref = pcfg.device.as_deref().unwrap_or(&candle_cfg.device); + let device = select_device(device_pref)?; + let gen_config = zeph_llm::candle_provider::generate::GenerationConfig { + temperature: candle_cfg.generation.temperature, + top_p: candle_cfg.generation.top_p, + top_k: candle_cfg.generation.top_k, + max_tokens: candle_cfg.generation.capped_max_tokens(), + seed: candle_cfg.generation.seed, + repeat_penalty: candle_cfg.generation.repeat_penalty, + repeat_last_n: candle_cfg.generation.repeat_last_n, + }; + let candle_provider = zeph_llm::candle_provider::CandleProvider::new( + &source, + template, + gen_config, + candle_cfg.embedding_repo.as_deref(), + device, + )?; + SubProvider::Candle(candle_provider) + } + other => bail!("unknown orchestrator sub-provider type: {other}"), + }; + providers.insert(name.clone(), provider); + } + + let mut routes = HashMap::new(); + for (task_str, chain) in &orch_cfg.routes { + let task = TaskType::parse_str(task_str); + routes.insert(task, chain.clone()); + } + + Ok(ModelOrchestrator::new( + routes, + providers, + orch_cfg.default.clone(), + orch_cfg.embed.clone(), + )?) +} + +#[cfg(feature = "mcp")] +pub fn create_mcp_manager(config: &Config) -> zeph_mcp::McpManager { + let entries: Vec = config + .mcp + .servers + .iter() + .map(|s| { + let transport = if let Some(ref url) = s.url { + zeph_mcp::McpTransport::Http { url: url.clone() } + } else { + zeph_mcp::McpTransport::Stdio { + command: s.command.clone().unwrap_or_default(), + args: s.args.clone(), + env: s.env.clone(), + } + }; + zeph_mcp::ServerEntry { + id: s.id.clone(), + transport, + timeout: std::time::Duration::from_secs(s.timeout), + } + }) + .collect(); + zeph_mcp::McpManager::new(entries) +} + +#[cfg(feature = "mcp")] +pub async fn create_mcp_registry( + config: &Config, + provider: &AnyProvider, + mcp_tools: &[zeph_mcp::McpTool], + embedding_model: &str, +) -> Option { + if !config.memory.semantic.enabled { + return None; + } + match zeph_mcp::McpToolRegistry::new(&config.memory.qdrant_url) { + Ok(mut reg) => { + let embed_fn = provider.embed_fn(); + if let Err(e) = reg.sync(mcp_tools, embedding_model, &embed_fn).await { + tracing::warn!("MCP tool embedding sync failed: {e:#}"); + } + Some(reg) + } + Err(e) => { + tracing::warn!("MCP tool registry unavailable: {e:#}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vault_args_defaults_in_test_context() { + let config = Config::load(Path::new("/nonexistent")).unwrap(); + let args = parse_vault_args(&config); + assert_eq!(args.backend, "env"); + assert!(args.key_path.is_none()); + assert!(args.vault_path.is_none()); + } + + #[test] + fn vault_args_uses_config_backend_as_fallback() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.vault.backend = "age".into(); + let args = parse_vault_args(&config); + assert_eq!(args.backend, "age"); + } + + #[test] + fn vault_args_env_overrides_config() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.vault.backend = "age".into(); + unsafe { std::env::set_var("ZEPH_VAULT_BACKEND", "env") }; + let args = parse_vault_args(&config); + unsafe { std::env::remove_var("ZEPH_VAULT_BACKEND") }; + assert_eq!(args.backend, "env"); + } + + #[test] + fn vault_args_struct_construction() { + let args = VaultArgs { + backend: "age".into(), + key_path: Some("/tmp/key".into()), + vault_path: Some("/tmp/vault".into()), + }; + assert_eq!(args.backend, "age"); + assert_eq!(args.key_path.as_deref(), Some("/tmp/key")); + assert_eq!(args.vault_path.as_deref(), Some("/tmp/vault")); + } + + #[test] + fn vault_args_struct_env_backend() { + let args = VaultArgs { + backend: "env".into(), + key_path: None, + vault_path: None, + }; + assert_eq!(args.backend, "env"); + assert!(args.key_path.is_none()); + assert!(args.vault_path.is_none()); + } + + #[test] + fn create_provider_ollama() { + let config = Config::load(Path::new("/nonexistent")).unwrap(); + let provider = create_provider(&config).unwrap(); + assert!(matches!(provider, AnyProvider::Ollama(_))); + assert_eq!(provider.name(), "ollama"); + } + + #[test] + fn create_provider_claude_without_cloud_config_errors() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Claude; + config.llm.cloud = None; + let result = create_provider(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("llm.cloud config section required") + ); + } + + #[test] + fn create_provider_claude_without_api_key_errors() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Claude; + config.llm.cloud = Some(crate::config::CloudLlmConfig { + model: "claude-3-opus".into(), + max_tokens: 4096, + }); + config.secrets.claude_api_key = None; + + let result = create_provider(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("ZEPH_CLAUDE_API_KEY not found") + ); + } + + #[tokio::test] + async fn health_check_ollama_unreachable() { + let provider = AnyProvider::Ollama(OllamaProvider::new( + "http://127.0.0.1:1", + "test".into(), + "embed".into(), + )); + health_check(&provider).await; + } + + #[tokio::test] + async fn health_check_claude_noop() { + let provider = AnyProvider::Claude(ClaudeProvider::new("key".into(), "model".into(), 1024)); + health_check(&provider).await; + } + + #[test] + fn effective_embedding_model_defaults_to_llm() { + let config = Config::load(Path::new("/nonexistent")).unwrap(); + assert_eq!(effective_embedding_model(&config), "qwen3-embedding"); + } + + #[cfg(feature = "openai")] + #[test] + fn effective_embedding_model_uses_openai_when_set() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::OpenAi; + config.llm.openai = Some(crate::config::OpenAiConfig { + base_url: "https://api.openai.com/v1".into(), + model: "gpt-5.2".into(), + max_tokens: 4096, + embedding_model: Some("text-embedding-3-small".into()), + reasoning_effort: None, + }); + assert_eq!(effective_embedding_model(&config), "text-embedding-3-small"); + } + + #[cfg(feature = "openai")] + #[test] + fn effective_embedding_model_falls_back_when_openai_embed_missing() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::OpenAi; + config.llm.openai = Some(crate::config::OpenAiConfig { + base_url: "https://api.openai.com/v1".into(), + model: "gpt-5.2".into(), + max_tokens: 4096, + embedding_model: None, + reasoning_effort: None, + }); + assert_eq!(effective_embedding_model(&config), "qwen3-embedding"); + } + + #[cfg(feature = "openai")] + #[test] + fn create_provider_openai_missing_config_errors() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::OpenAi; + config.llm.openai = None; + let result = create_provider(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("llm.openai config section required") + ); + } + + #[cfg(feature = "openai")] + #[test] + fn create_provider_openai_missing_api_key_errors() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::OpenAi; + config.llm.openai = Some(crate::config::OpenAiConfig { + base_url: "https://api.openai.com/v1".into(), + model: "gpt-4o".into(), + max_tokens: 4096, + embedding_model: None, + reasoning_effort: None, + }); + config.secrets.openai_api_key = None; + let result = create_provider(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("ZEPH_OPENAI_API_KEY not found") + ); + } + + #[cfg(feature = "candle")] + #[test] + fn select_device_cpu_default() { + let device = select_device("cpu").unwrap(); + assert!(matches!(device, zeph_llm::candle_provider::Device::Cpu)); + } + + #[cfg(feature = "candle")] + #[test] + fn select_device_unknown_defaults_to_cpu() { + let device = select_device("unknown").unwrap(); + assert!(matches!(device, zeph_llm::candle_provider::Device::Cpu)); + } + + #[cfg(all(feature = "candle", not(feature = "metal")))] + #[test] + fn select_device_metal_without_feature_errors() { + let result = select_device("metal"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("metal feature")); + } + + #[cfg(all(feature = "candle", not(feature = "cuda")))] + #[test] + fn select_device_cuda_without_feature_errors() { + let result = select_device("cuda"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cuda feature")); + } + + #[cfg(feature = "candle")] + #[test] + fn select_device_auto_fallback() { + let device = select_device("auto").unwrap(); + assert!(matches!( + device, + zeph_llm::candle_provider::Device::Cpu + | zeph_llm::candle_provider::Device::Cuda(_) + | zeph_llm::candle_provider::Device::Metal(_) + )); + } + + #[cfg(feature = "candle")] + #[test] + fn create_provider_candle_without_config_errors() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Candle; + config.llm.candle = None; + let result = create_provider(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("llm.candle config section required") + ); + } + + #[cfg(feature = "orchestrator")] + #[test] + fn create_provider_orchestrator_without_config_errors() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + config.llm.orchestrator = None; + let result = create_provider(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("llm.orchestrator config section required") + ); + } + + #[cfg(feature = "orchestrator")] + #[test] + fn build_orchestrator_with_unknown_provider_errors() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + + let mut providers = HashMap::new(); + providers.insert( + "test".to_string(), + OrchestratorProviderConfig { + provider_type: "unknown_type".to_string(), + model: None, + filename: None, + device: None, + }, + ); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes: HashMap::new(), + default: "test".to_string(), + embed: "test".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("unknown orchestrator sub-provider type") + ); + } + + #[cfg(feature = "orchestrator")] + #[test] + fn build_orchestrator_claude_without_cloud_config_errors() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + config.llm.cloud = None; + + let mut providers = HashMap::new(); + providers.insert( + "claude_sub".to_string(), + OrchestratorProviderConfig { + provider_type: "claude".to_string(), + model: None, + filename: None, + device: None, + }, + ); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes: HashMap::new(), + default: "claude_sub".to_string(), + embed: "claude_sub".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("llm.cloud config required") + ); + } + + #[cfg(feature = "orchestrator")] + #[test] + fn build_orchestrator_claude_sub_without_api_key_errors() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + config.llm.cloud = Some(crate::config::CloudLlmConfig { + model: "claude-3".into(), + max_tokens: 4096, + }); + config.secrets.claude_api_key = None; + + let mut providers = HashMap::new(); + providers.insert( + "claude_sub".to_string(), + OrchestratorProviderConfig { + provider_type: "claude".to_string(), + model: None, + filename: None, + device: None, + }, + ); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes: HashMap::new(), + default: "claude_sub".to_string(), + embed: "claude_sub".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("ZEPH_CLAUDE_API_KEY required") + ); + } + + #[cfg(all(feature = "orchestrator", feature = "candle"))] + #[test] + fn build_orchestrator_candle_without_config_errors() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + config.llm.candle = None; + + let mut providers = HashMap::new(); + providers.insert( + "candle_sub".to_string(), + OrchestratorProviderConfig { + provider_type: "candle".to_string(), + model: None, + filename: None, + device: None, + }, + ); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes: HashMap::new(), + default: "candle_sub".to_string(), + embed: "candle_sub".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("llm.candle config required") + ); + } + + #[cfg(feature = "orchestrator")] + #[test] + fn build_orchestrator_with_ollama_sub_provider() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + + let mut providers = HashMap::new(); + providers.insert( + "ollama_sub".to_string(), + OrchestratorProviderConfig { + provider_type: "ollama".to_string(), + model: Some("llama2".to_string()), + filename: None, + device: None, + }, + ); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes: HashMap::new(), + default: "ollama_sub".to_string(), + embed: "ollama_sub".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_ok()); + } + + #[cfg(feature = "orchestrator")] + #[test] + fn build_orchestrator_routes_parsing() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + + let mut providers = HashMap::new(); + providers.insert( + "ollama_sub".to_string(), + OrchestratorProviderConfig { + provider_type: "ollama".to_string(), + model: None, + filename: None, + device: None, + }, + ); + + let mut routes = HashMap::new(); + routes.insert("chat".to_string(), vec!["ollama_sub".to_string()]); + routes.insert("embed".to_string(), vec!["ollama_sub".to_string()]); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes, + default: "ollama_sub".to_string(), + embed: "ollama_sub".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_ok()); + } + + #[cfg(all(feature = "orchestrator", feature = "candle"))] + #[test] + fn build_orchestrator_with_candle_local_source() { + use crate::config::OrchestratorProviderConfig; + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.llm.provider = ProviderKind::Orchestrator; + config.llm.candle = Some(crate::config::CandleConfig { + source: "local".into(), + local_path: "/tmp/model.gguf".into(), + filename: Some("model.gguf".to_string()), + chat_template: "{{ messages[0].content }}".into(), + device: "cpu".into(), + embedding_repo: Some("embed/model".into()), + generation: crate::config::GenerationParams { + temperature: 0.7, + top_p: Some(0.9), + top_k: Some(50), + max_tokens: 512, + seed: 42, + repeat_penalty: 1.1, + repeat_last_n: 64, + }, + }); + + let mut providers = HashMap::new(); + providers.insert( + "candle_local".to_string(), + OrchestratorProviderConfig { + provider_type: "candle".to_string(), + model: Some("local-model".to_string()), + filename: None, + device: Some("cpu".to_string()), + }, + ); + + config.llm.orchestrator = Some(crate::config::OrchestratorConfig { + providers, + routes: HashMap::new(), + default: "candle_local".to_string(), + embed: "candle_local".to_string(), + }); + + let result = build_orchestrator(&config); + assert!(result.is_err(), "expected error loading nonexistent model"); + } + + #[cfg(feature = "candle")] + #[tokio::test] + async fn health_check_candle_logs_device() { + use zeph_llm::candle_provider::CandleProvider; + + let source = zeph_llm::candle_provider::loader::ModelSource::HuggingFace { + repo_id: "test/model".to_string(), + filename: Some("model.gguf".to_string()), + }; + let template = zeph_llm::candle_provider::template::ChatTemplate::parse_str( + "{{ bos_token }}{{ messages[0].content }}", + ); + let gen_config = zeph_llm::candle_provider::generate::GenerationConfig { + temperature: 0.7, + top_p: Some(0.9), + top_k: Some(50), + max_tokens: 512, + seed: 42, + repeat_penalty: 1.1, + repeat_last_n: 64, + }; + let device = zeph_llm::candle_provider::Device::Cpu; + + let candle_result = + CandleProvider::new(&source, template, gen_config, Some("embed/model"), device); + + if let Ok(candle) = candle_result { + let provider = AnyProvider::Candle(candle); + health_check(&provider).await; + } + } + + #[cfg(feature = "orchestrator")] + #[tokio::test] + async fn health_check_orchestrator_logs_providers() { + use std::collections::HashMap; + use zeph_llm::orchestrator::{ModelOrchestrator, SubProvider}; + + let mut providers = HashMap::new(); + providers.insert( + "ollama_local".to_string(), + SubProvider::Ollama(OllamaProvider::new( + "http://localhost:11434", + "test".into(), + "embed".into(), + )), + ); + + let routes = HashMap::new(); + let orch = ModelOrchestrator::new( + routes, + providers, + "ollama_local".to_string(), + "ollama_local".to_string(), + ) + .unwrap(); + + let provider = AnyProvider::Orchestrator(Box::new(orch)); + health_check(&provider).await; + } + + #[cfg(feature = "mcp")] + #[test] + fn create_mcp_manager_with_http_transport() { + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.mcp.servers = vec![crate::config::McpServerConfig { + id: "test".into(), + url: Some("http://localhost:3000".into()), + command: None, + args: vec![], + env: HashMap::new(), + timeout: 30, + }]; + + let manager = create_mcp_manager(&config); + let debug = format!("{manager:?}"); + assert!(debug.contains("server_count: 1")); + } + + #[cfg(feature = "mcp")] + #[test] + fn create_mcp_manager_with_stdio_transport() { + use std::collections::HashMap; + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.mcp.servers = vec![crate::config::McpServerConfig { + id: "test".into(), + url: None, + command: Some("node".into()), + args: vec!["server.js".into()], + env: HashMap::new(), + timeout: 30, + }]; + + let manager = create_mcp_manager(&config); + let debug = format!("{manager:?}"); + assert!(debug.contains("server_count: 1")); + } + + #[cfg(feature = "mcp")] + #[test] + fn create_mcp_manager_empty_servers() { + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.mcp.servers = vec![]; + + let manager = create_mcp_manager(&config); + let debug = format!("{manager:?}"); + assert!(debug.contains("server_count: 0")); + } + + #[cfg(feature = "mcp")] + #[tokio::test] + async fn create_mcp_registry_when_semantic_disabled() { + let config_path = Path::new("/nonexistent"); + let mut config = Config::load(config_path).unwrap(); + config.memory.semantic.enabled = false; + + let provider = AnyProvider::Ollama(OllamaProvider::new( + "http://localhost:11434", + "test".into(), + "embed".into(), + )); + + let mcp_tools = vec![]; + let registry = create_mcp_registry(&config, &provider, &mcp_tools, "test-model").await; + assert!(registry.is_none()); + } + + #[tokio::test] + async fn create_skill_matcher_when_semantic_disabled() { + let tmp = std::env::temp_dir().join("zeph_test_skill_matcher_bootstrap.db"); + let tmp_path = tmp.to_string_lossy().to_string(); + + let mut config = Config::load(Path::new("/nonexistent")).unwrap(); + config.memory.semantic.enabled = false; + config.memory.sqlite_path = tmp_path.clone(); + + let provider = AnyProvider::Ollama(OllamaProvider::new( + "http://localhost:11434", + "test".into(), + "embed".into(), + )); + + let memory = SemanticMemory::new( + &tmp_path, + &config.memory.qdrant_url, + provider.clone(), + &config.llm.embedding_model, + ) + .await + .unwrap(); + + let meta: Vec<&SkillMeta> = vec![]; + let result = create_skill_matcher(&config, &provider, &meta, &memory, "test-model").await; + assert!(result.is_none()); + + let _ = std::fs::remove_file(&tmp); + } +} diff --git a/crates/zeph-core/src/config/types.rs b/crates/zeph-core/src/config/types.rs index 8a2ed0da..eb75ff63 100644 --- a/crates/zeph-core/src/config/types.rs +++ b/crates/zeph-core/src/config/types.rs @@ -674,7 +674,7 @@ fn default_a2a_timeout() -> u64 { 30 } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize)] pub struct SecurityConfig { #[serde(default = "default_true")] pub redact_secrets: bool, @@ -691,7 +691,7 @@ impl Default for SecurityConfig { } } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Copy, Deserialize)] pub struct TimeoutConfig { #[serde(default = "default_llm_timeout")] pub llm_seconds: u64, diff --git a/crates/zeph-core/src/lib.rs b/crates/zeph-core/src/lib.rs index 090e289b..ba5ae427 100644 --- a/crates/zeph-core/src/lib.rs +++ b/crates/zeph-core/src/lib.rs @@ -1,6 +1,8 @@ //! Agent loop, configuration loading, and context builder. pub mod agent; +#[allow(clippy::missing_errors_doc, clippy::must_use_candidate)] +pub mod bootstrap; pub mod channel; pub mod config; pub mod config_watcher; diff --git a/docs/src/architecture/crates.md b/docs/src/architecture/crates.md index 0a07534f..9f4c3ded 100644 --- a/docs/src/architecture/crates.md +++ b/docs/src/architecture/crates.md @@ -4,8 +4,9 @@ Each workspace crate has a focused responsibility. All leaf crates are independe ## zeph-core -Agent loop, configuration loading, and context builder. +Agent loop, bootstrap orchestration, configuration loading, and context builder. +- `AppBuilder` — bootstrap orchestrator in `zeph-core::bootstrap`: `from_env()` config/vault resolution, `build_provider()` with health check, `build_memory()`, `build_skill_matcher()`, `build_registry()`, `build_tool_executor()`, `build_watchers()`, `build_shutdown()`, `build_summary_provider()` - `Agent` — main agent loop with streaming support, message queue drain, configurable `max_tool_iterations` (default 10), doom-loop detection, and context budget check (stops at 80% threshold). Internal state is grouped into five domain structs (`MemoryState`, `SkillState`, `ContextState`, `McpState`, `IndexState`); logic is decomposed into `streaming.rs` and `persistence.rs` submodules - `AgentError` — typed error enum covering LLM, memory, channel, tool, context, and I/O failures (replaces prior `anyhow` usage) - `Config` — TOML config loading with env var overrides diff --git a/docs/src/architecture/overview.md b/docs/src/architecture/overview.md index 4da17047..a1eb9bcd 100644 --- a/docs/src/architecture/overview.md +++ b/docs/src/architecture/overview.md @@ -7,8 +7,8 @@ Requires Rust 1.88+. Native async traits are used throughout — no `async-trait ## Workspace Layout ```text -zeph (binary) — thin bootstrap glue -├── zeph-core Agent loop, config, config hot-reload, channel trait, context builder +zeph (binary) — thin CLI/channel dispatch, delegates to AppBuilder +├── zeph-core Agent loop, bootstrap/AppBuilder, config, config hot-reload, channel trait, context builder ├── zeph-llm LlmProvider trait, Ollama + Claude + OpenAI + Candle backends, orchestrator, embeddings ├── zeph-skills SKILL.md parser, registry with lazy body loading, embedding matcher, resource resolver, hot-reload ├── zeph-memory SQLite + Qdrant, SemanticMemory orchestrator, summarization @@ -54,6 +54,7 @@ Queued messages are processed sequentially with full context rebuilding between - **Generic Agent:** `Agent` — fully generic over provider, channel, and tool executor. Internal state is grouped into five domain structs (`MemoryState`, `SkillState`, `ContextState`, `McpState`, `IndexState`) with logic decomposed into `streaming.rs` and `persistence.rs` submodules - **TLS:** rustls everywhere (no openssl-sys) +- **Bootstrap:** `AppBuilder` in `zeph-core::bootstrap` handles config/vault resolution, provider creation, memory setup, skill matching, tool executor composition, and graceful shutdown wiring. `main.rs` is a thin dispatcher over `AnyChannel` - **Errors:** `thiserror` for all crates with typed error enums (`ChannelError`, `AgentError`, `LlmError`, etc.); `anyhow` only for top-level orchestration in `main.rs` - **Lints:** workspace-level `clippy::all` + `clippy::pedantic` + `clippy::nursery`; `unsafe_code = "deny"` - **Dependencies:** versions only in root `[workspace.dependencies]`; crates inherit via `workspace = true` diff --git a/src/main.rs b/src/main.rs index 507a10d3..3a9c6806 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; #[cfg(feature = "tui")] use std::time::Duration; -use anyhow::{Context, bail}; +#[cfg(any(feature = "a2a", feature = "tui"))] use tokio::sync::watch; +use zeph_channels::AnyChannel; use zeph_channels::CliChannel; #[cfg(feature = "discord")] use zeph_channels::discord::DiscordChannel; @@ -11,14 +12,13 @@ use zeph_channels::discord::DiscordChannel; use zeph_channels::slack::SlackChannel; use zeph_channels::telegram::TelegramChannel; use zeph_core::agent::Agent; +#[cfg(feature = "mcp")] +use zeph_core::bootstrap::create_mcp_registry; +use zeph_core::bootstrap::{self, AppBuilder, warmup_provider}; #[cfg(feature = "tui")] use zeph_core::channel::{Channel, ChannelError, ChannelMessage}; -use zeph_core::config::{Config, ProviderKind}; -use zeph_core::config_watcher::ConfigWatcher; +use zeph_core::config::Config; use zeph_core::cost::CostTracker; -#[cfg(feature = "vault-age")] -use zeph_core::vault::AgeVaultProvider; -use zeph_core::vault::{EnvVaultProvider, VaultProvider}; #[cfg(feature = "index")] use zeph_index::{ indexer::{CodeIndexer, IndexerConfig}, @@ -26,29 +26,13 @@ use zeph_index::{ store::CodeStore, watcher::IndexWatcher, }; +#[cfg(any(feature = "a2a", feature = "tui"))] use zeph_llm::any::AnyProvider; -use zeph_llm::claude::ClaudeProvider; -#[cfg(feature = "compatible")] -use zeph_llm::compatible::CompatibleProvider; -use zeph_llm::ollama::OllamaProvider; -#[cfg(feature = "openai")] -use zeph_llm::openai::OpenAiProvider; +#[cfg(feature = "index")] use zeph_llm::provider::LlmProvider; -#[cfg(feature = "router")] -use zeph_llm::router::RouterProvider; -use zeph_memory::semantic::SemanticMemory; -use zeph_skills::loader::SkillMeta; -use zeph_skills::matcher::{SkillMatcher, SkillMatcherBackend}; -#[cfg(feature = "qdrant")] -use zeph_skills::qdrant_matcher::QdrantSkillMatcher; -use zeph_skills::registry::SkillRegistry; -use zeph_skills::watcher::SkillWatcher; -use zeph_tools::{CompositeExecutor, FileExecutor, ShellExecutor, WebScrapeExecutor}; #[cfg(feature = "tui")] use zeph_tui::{App, EventReader, TuiChannel}; -use zeph_channels::AnyChannel; - #[cfg(feature = "tui")] #[derive(Debug)] enum AppChannel { @@ -103,7 +87,6 @@ impl Channel for AppChannel { #[tokio::main] #[allow(clippy::too_many_lines)] async fn main() -> anyhow::Result<()> { - // When TUI is active, redirect tracing to a file to avoid corrupting the terminal #[cfg(feature = "tui")] let tui_active = is_tui_requested(); #[cfg(feature = "tui")] @@ -123,82 +106,18 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt::init(); } #[cfg(not(feature = "tui"))] - init_subscriber(&resolve_config_path()); - - let config_path = resolve_config_path(); - let mut config = Config::load(&config_path)?; - config.validate()?; - - let vault_args = parse_vault_args(&config); - let vault: Box = match vault_args.backend.as_str() { - "env" => Box::new(EnvVaultProvider), - #[cfg(feature = "vault-age")] - "age" => { - let key = vault_args - .key_path - .context("--vault-key required for age backend")?; - let path = vault_args - .vault_path - .context("--vault-path required for age backend")?; - Box::new(AgeVaultProvider::new(Path::new(&key), Path::new(&path))?) - } - other => bail!("unknown vault backend: {other}"), - }; - - config.resolve_secrets(vault.as_ref()).await?; - - let mut provider = create_provider(&config)?; - let embed_model = effective_embedding_model(&config); - - let (status_tx, status_rx) = tokio::sync::mpsc::unbounded_channel::(); - provider.set_status_tx(status_tx); - - health_check(&provider).await; - - // Auto-detect context window for Ollama models - if let AnyProvider::Ollama(ref mut ollama) = provider - && let Ok(info) = ollama.fetch_model_info().await - && let Some(ctx) = info.context_length - { - ollama.set_context_window(ctx); - tracing::info!(context_window = ctx, "detected Ollama model context window"); - } - - let budget_tokens = if config.memory.auto_budget && config.memory.context_budget_tokens == 0 { - if let Some(ctx_size) = provider.context_window() { - tracing::info!(model_context = ctx_size, "auto-configured context budget"); - ctx_size - } else { - 0 - } - } else { - config.memory.context_budget_tokens - }; + init_subscriber(&bootstrap::resolve_config_path()); - let skill_paths: Vec = config.skills.paths.iter().map(PathBuf::from).collect(); - let registry = SkillRegistry::load(&skill_paths); + let app = AppBuilder::from_env().await?; + let (provider, status_rx) = app.build_provider().await?; + let embed_model = app.embedding_model(); + let budget_tokens = app.auto_budget_tokens(&provider); - let memory = SemanticMemory::with_weights( - &config.memory.sqlite_path, - &config.memory.qdrant_url, - provider.clone(), - &embed_model, - config.memory.semantic.vector_weight, - config.memory.semantic.keyword_weight, - ) - .await?; - - if config.memory.semantic.enabled && memory.has_qdrant() { - tracing::info!("semantic memory enabled, Qdrant connected"); - match memory.embed_missing().await { - Ok(n) if n > 0 => tracing::info!("backfilled {n} missing embedding(s)"), - Ok(_) => {} - Err(e) => tracing::warn!("embed_missing failed: {e:#}"), - } - } + let registry = app.build_registry(); + let memory = app.build_memory(&provider).await?; let all_meta = registry.all_meta(); - let matcher = create_skill_matcher(&config, &provider, &all_meta, &memory, &embed_model).await; + let matcher = app.build_skill_matcher(&provider, &all_meta, &memory).await; let skill_count = all_meta.len(); if matcher.is_some() { tracing::info!("skill matcher initialized for {skill_count} skill(s)"); @@ -207,9 +126,9 @@ async fn main() -> anyhow::Result<()> { } #[cfg(feature = "tui")] - let (channel, tui_handle) = create_channel_with_tui(&config).await?; + let (channel, tui_handle) = create_channel_with_tui(app.config()).await?; #[cfg(not(feature = "tui"))] - let channel = create_channel(&config).await?; + let channel = create_channel(app.config()).await?; #[cfg(feature = "tui")] let is_cli = matches!(channel, AppChannel::Standard(AnyChannel::Cli(_))); @@ -223,11 +142,9 @@ async fn main() -> anyhow::Result<()> { Some(id) => id, None => memory.sqlite().create_conversation().await?, }; - tracing::info!("conversation id: {conversation_id}"); - let (shutdown_tx, shutdown_rx) = watch::channel(false); - + let (shutdown_tx, shutdown_rx) = AppBuilder::build_shutdown(); tokio::spawn(async move { if let Err(e) = tokio::signal::ctrl_c().await { tracing::error!("failed to listen for ctrl-c: {e:#}"); @@ -241,81 +158,92 @@ async fn main() -> anyhow::Result<()> { zeph_tools::cleanup_overflow_files(std::time::Duration::from_secs(86_400)); }); + let config = app.config(); let permission_policy = config .tools .permission_policy(config.security.autonomy_level); - let mut shell_executor = - ShellExecutor::new(&config.tools.shell).with_permissions(permission_policy.clone()); - if config.tools.audit.enabled - && let Ok(logger) = zeph_tools::AuditLogger::from_config(&config.tools.audit).await - { - shell_executor = shell_executor.with_audit(logger); - } - - #[cfg(feature = "tui")] - let tool_event_rx = if tui_handle.is_some() { - let (tool_tx, tool_rx) = tokio::sync::mpsc::unbounded_channel::(); - shell_executor = shell_executor.with_tool_event_tx(tool_tx); - Some(tool_rx) - } else { - None - }; - let scrape_executor = WebScrapeExecutor::new(&config.tools.scrape); - let file_executor = FileExecutor::new( - config - .tools - .shell - .allowed_paths - .iter() - .map(PathBuf::from) - .collect(), - ); + let skill_paths = app.skill_paths(); #[cfg(feature = "mcp")] - let (tool_executor, mcp_tools, mcp_manager) = { - let mcp_manager = std::sync::Arc::new(create_mcp_manager(&config)); + let (tool_executor, mcp_tools, mcp_manager, shell_executor_for_tui) = { + let mut shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell) + .with_permissions(permission_policy.clone()); + if config.tools.audit.enabled + && let Ok(logger) = zeph_tools::AuditLogger::from_config(&config.tools.audit).await + { + shell_executor = shell_executor.with_audit(logger); + } + + #[cfg(feature = "tui")] + let tool_event_rx = if tui_handle.is_some() { + let (tool_tx, tool_rx) = + tokio::sync::mpsc::unbounded_channel::(); + shell_executor = shell_executor.with_tool_event_tx(tool_tx); + Some(tool_rx) + } else { + None + }; + + let scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape); + let file_executor = zeph_tools::FileExecutor::new( + config + .tools + .shell + .allowed_paths + .iter() + .map(PathBuf::from) + .collect(), + ); + + let mcp_manager = std::sync::Arc::new(bootstrap::create_mcp_manager(config)); let mcp_tools = mcp_manager.connect_all().await; tracing::info!("discovered {} MCP tool(s)", mcp_tools.len()); let mcp_executor = zeph_mcp::McpToolExecutor::new(mcp_manager.clone()); - let base_executor = CompositeExecutor::new( + let base_executor = zeph_tools::CompositeExecutor::new( file_executor, - CompositeExecutor::new(shell_executor, scrape_executor), + zeph_tools::CompositeExecutor::new(shell_executor, scrape_executor), ); - let executor = CompositeExecutor::new(base_executor, mcp_executor); + let executor = zeph_tools::CompositeExecutor::new(base_executor, mcp_executor); - (executor, mcp_tools, mcp_manager) + #[cfg(feature = "tui")] + let shell_for_tui = tool_event_rx; + #[cfg(not(feature = "tui"))] + let shell_for_tui = (); + + (executor, mcp_tools, mcp_manager, shell_for_tui) }; #[cfg(not(feature = "mcp"))] - let tool_executor = CompositeExecutor::new( - file_executor, - CompositeExecutor::new(shell_executor, scrape_executor), - ); - - let (reload_tx, reload_rx) = tokio::sync::mpsc::channel(4); - let _watcher = match SkillWatcher::start(&skill_paths, reload_tx) { - Ok(w) => { - tracing::info!("skill watcher started"); - Some(w) - } - Err(e) => { - tracing::warn!("skill watcher unavailable: {e:#}"); - None - } + let tool_executor = { + let mut shell_executor = zeph_tools::ShellExecutor::new(&config.tools.shell) + .with_permissions(permission_policy.clone()); + if config.tools.audit.enabled + && let Ok(logger) = zeph_tools::AuditLogger::from_config(&config.tools.audit).await + { + shell_executor = shell_executor.with_audit(logger); + } + let scrape_executor = zeph_tools::WebScrapeExecutor::new(&config.tools.scrape); + let file_executor = zeph_tools::FileExecutor::new( + config + .tools + .shell + .allowed_paths + .iter() + .map(PathBuf::from) + .collect(), + ); + zeph_tools::CompositeExecutor::new( + file_executor, + zeph_tools::CompositeExecutor::new(shell_executor, scrape_executor), + ) }; - let (config_reload_tx, config_reload_rx) = tokio::sync::mpsc::channel(4); - let _config_watcher = match ConfigWatcher::start(&config_path, config_reload_tx) { - Ok(w) => { - tracing::info!("config watcher started"); - Some(w) - } - Err(e) => { - tracing::warn!("config watcher unavailable: {e:#}"); - None - } - }; + let watchers = app.build_watchers(); + let _skill_watcher = watchers.skill_watcher; + let reload_rx = watchers.skill_reload_rx; + let _config_watcher = watchers.config_watcher; + let config_reload_rx = watchers.config_reload_rx; #[cfg(feature = "a2a")] if config.a2a.enabled { @@ -326,16 +254,11 @@ async fn main() -> anyhow::Result<()> { config.agent.name, skill_names.join(", ") ); - spawn_a2a_server( - &config, - shutdown_rx.clone(), - a2a_provider, - a2a_system_prompt, - ); + spawn_a2a_server(config, shutdown_rx.clone(), a2a_provider, a2a_system_prompt); } #[cfg(feature = "mcp")] - let mcp_registry = create_mcp_registry(&config, &provider, &mcp_tools, &embed_model).await; + let mcp_registry = create_mcp_registry(config, &provider, &mcp_tools, &embed_model).await; #[cfg(feature = "index")] let index_pool = memory.sqlite().pool().clone(); @@ -345,18 +268,9 @@ async fn main() -> anyhow::Result<()> { let provider_has_tools = provider.supports_tool_use(); let warmup_provider_clone = provider.clone(); - let summary_provider = config.agent.summary_model.as_ref().and_then(|model_spec| { - match create_summary_provider(model_spec, &config) { - Ok(sp) => { - tracing::info!(model = %model_spec, "summary provider configured"); - Some(sp) - } - Err(e) => { - tracing::warn!("failed to create summary provider: {e:#}, using primary"); - None - } - } - }); + let summary_provider = app.build_summary_provider(); + let config = app.config(); + let config_path = app.config_path().to_owned(); let agent = Agent::new( provider, @@ -369,7 +283,7 @@ async fn main() -> anyhow::Result<()> { .with_max_tool_iterations(config.agent.max_tool_iterations) .with_model_name(config.llm.model.clone()) .with_embedding_model(embed_model.clone()) - .with_skill_reload(skill_paths, reload_rx) + .with_skill_reload(skill_paths.clone(), reload_rx) .with_memory( memory, conversation_id, @@ -388,7 +302,7 @@ async fn main() -> anyhow::Result<()> { .with_security(config.security, config.timeouts) .with_tool_summarization(config.tools.summarize_output) .with_permission_policy(permission_policy.clone()) - .with_config_reload(config_path.clone(), config_reload_rx); + .with_config_reload(config_path, config_reload_rx); let agent = if config.cost.enabled { let tracker = CostTracker::new(true, f64::from(config.cost.max_daily_cents)); @@ -476,7 +390,7 @@ async fn main() -> anyhow::Result<()> { let agent = agent.with_mcp(mcp_tools, mcp_registry, Some(mcp_manager), &config.mcp); #[cfg(feature = "self-learning")] - let agent = agent.with_learning(config.skills.learning); + let agent = agent.with_learning(config.skills.learning.clone()); #[cfg(feature = "tui")] let tui_metrics_rx; @@ -504,7 +418,7 @@ async fn main() -> anyhow::Result<()> { let reader = EventReader::new(event_tx, Duration::from_millis(100)); std::thread::spawn(move || reader.run()); - let mut app = App::new(tui_handle.user_tx, tui_handle.agent_rx); + let mut tui_app = App::new(tui_handle.user_tx, tui_handle.agent_rx); let history: Vec<(&str, &str)> = agent .context_messages() @@ -518,16 +432,16 @@ async fn main() -> anyhow::Result<()> { (role, m.content.as_str()) }) .collect(); - app.load_history(&history); + tui_app.load_history(&history); if let Some(rx) = tui_metrics_rx { - app = app.with_metrics_rx(rx); + tui_app = tui_app.with_metrics_rx(rx); } let agent_tx = tui_handle.agent_tx; tokio::spawn(forward_status_to_tui(status_rx, agent_tx.clone())); - if let Some(tool_rx) = tool_event_rx { + if let Some(tool_rx) = shell_executor_for_tui { tokio::spawn(forward_tool_events_to_tui(tool_rx, agent_tx.clone())); } @@ -546,8 +460,7 @@ async fn main() -> anyhow::Result<()> { let mut agent = agent.with_warmup_ready(warmup_rx); - let tui_task = tokio::spawn(zeph_tui::run_tui(app, event_rx)); - // No Box::pin here: TUI branch already spawns tasks, no large_futures lint + let tui_task = tokio::spawn(zeph_tui::run_tui(tui_app, event_rx)); let agent_future = agent.run(); tokio::select! { @@ -564,7 +477,6 @@ async fn main() -> anyhow::Result<()> { warmup_provider(&warmup_provider_clone).await; tokio::spawn(forward_status_to_stderr(status_rx)); - // Box::pin avoids clippy::large_futures on non-TUI path let result = Box::pin(agent.run()).await; agent.shutdown().await; result @@ -625,313 +537,6 @@ async fn forward_tool_events_to_tui( } } -async fn health_check(provider: &AnyProvider) { - match provider { - AnyProvider::Ollama(ollama) => match ollama.health_check().await { - Ok(()) => tracing::info!("ollama health check passed"), - Err(e) => tracing::warn!("ollama health check failed: {e:#}"), - }, - #[cfg(feature = "candle")] - AnyProvider::Candle(candle) => { - tracing::info!("candle provider loaded, device: {}", candle.device_name()); - } - #[cfg(feature = "orchestrator")] - AnyProvider::Orchestrator(orch) => { - for (name, p) in orch.providers() { - tracing::info!( - "orchestrator sub-provider '{name}': {}", - zeph_llm::provider::LlmProvider::name(p) - ); - } - } - _ => {} - } -} - -async fn warmup_provider(provider: &AnyProvider) { - match provider { - AnyProvider::Ollama(ollama) => { - let start = std::time::Instant::now(); - match ollama.warmup().await { - Ok(()) => { - tracing::info!("ollama model ready ({:.1}s)", start.elapsed().as_secs_f64()); - } - Err(e) => tracing::warn!("ollama warmup failed: {e:#}"), - } - } - #[cfg(feature = "orchestrator")] - AnyProvider::Orchestrator(orch) => { - for (name, p) in orch.providers() { - if let zeph_llm::orchestrator::SubProvider::Ollama(ollama) = p { - let start = std::time::Instant::now(); - match ollama.warmup().await { - Ok(()) => tracing::info!( - "ollama '{name}' ready ({:.1}s)", - start.elapsed().as_secs_f64() - ), - Err(e) => tracing::warn!("ollama '{name}' warmup failed: {e:#}"), - } - } - } - } - _ => {} - } -} - -#[allow(unused_variables)] -async fn create_skill_matcher( - config: &Config, - provider: &AnyProvider, - meta: &[&SkillMeta], - memory: &SemanticMemory, - embedding_model: &str, -) -> Option { - let embed_fn = provider.embed_fn(); - - #[cfg(feature = "qdrant")] - if config.memory.semantic.enabled && memory.has_qdrant() { - match QdrantSkillMatcher::new(&config.memory.qdrant_url) { - Ok(mut qm) => match qm.sync(meta, embedding_model, &embed_fn).await { - Ok(_) => return Some(SkillMatcherBackend::Qdrant(qm)), - Err(e) => { - tracing::warn!("Qdrant skill sync failed, falling back to in-memory: {e:#}"); - } - }, - Err(e) => { - tracing::warn!("Qdrant client creation failed, falling back to in-memory: {e:#}"); - } - } - } - - SkillMatcher::new(meta, &embed_fn) - .await - .map(SkillMatcherBackend::InMemory) -} - -fn effective_embedding_model(config: &Config) -> String { - match config.llm.provider { - #[cfg(feature = "openai")] - ProviderKind::OpenAi => { - if let Some(m) = config - .llm - .openai - .as_ref() - .and_then(|o| o.embedding_model.clone()) - { - return m; - } - } - #[cfg(feature = "orchestrator")] - ProviderKind::Orchestrator => { - if let Some(orch) = &config.llm.orchestrator - && let Some(pcfg) = orch.providers.get(&orch.embed) - { - #[cfg(feature = "openai")] - if pcfg.provider_type == "openai" - && let Some(m) = config - .llm - .openai - .as_ref() - .and_then(|o| o.embedding_model.clone()) - { - return m; - } - } - } - ProviderKind::Compatible => { - if let Some(entries) = &config.llm.compatible - && let Some(entry) = entries.first() - && let Some(ref m) = entry.embedding_model - { - return m.clone(); - } - } - _ => {} - } - config.llm.embedding_model.clone() -} - -#[allow(clippy::too_many_lines)] -fn create_provider(config: &Config) -> anyhow::Result { - match config.llm.provider { - ProviderKind::Ollama | ProviderKind::Claude => { - create_named_provider(config.llm.provider.as_str(), config) - } - #[cfg(feature = "openai")] - ProviderKind::OpenAi => create_named_provider("openai", config), - #[cfg(feature = "compatible")] - ProviderKind::Compatible => create_named_provider("compatible", config), - #[cfg(feature = "candle")] - ProviderKind::Candle => { - let candle_cfg = config - .llm - .candle - .as_ref() - .context("llm.candle config section required for candle provider")?; - - let source = match candle_cfg.source.as_str() { - "local" => zeph_llm::candle_provider::loader::ModelSource::Local { - path: std::path::PathBuf::from(&candle_cfg.local_path), - }, - _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace { - repo_id: config.llm.model.clone(), - filename: candle_cfg.filename.clone(), - }, - }; - - let template = zeph_llm::candle_provider::template::ChatTemplate::parse_str( - &candle_cfg.chat_template, - ); - let gen_config = zeph_llm::candle_provider::generate::GenerationConfig { - temperature: candle_cfg.generation.temperature, - top_p: candle_cfg.generation.top_p, - top_k: candle_cfg.generation.top_k, - max_tokens: candle_cfg.generation.capped_max_tokens(), - seed: candle_cfg.generation.seed, - repeat_penalty: candle_cfg.generation.repeat_penalty, - repeat_last_n: candle_cfg.generation.repeat_last_n, - }; - - let device = select_device(&candle_cfg.device)?; - - let provider = zeph_llm::candle_provider::CandleProvider::new( - &source, - template, - gen_config, - candle_cfg.embedding_repo.as_deref(), - device, - )?; - Ok(AnyProvider::Candle(provider)) - } - #[cfg(feature = "orchestrator")] - ProviderKind::Orchestrator => { - let orch = build_orchestrator(config)?; - Ok(AnyProvider::Orchestrator(Box::new(orch))) - } - #[cfg(feature = "router")] - ProviderKind::Router => { - let router_cfg = config - .llm - .router - .as_ref() - .context("llm.router config section required for router provider")?; - - let mut providers = Vec::new(); - for name in &router_cfg.chain { - let p = create_named_provider(name, config)?; - providers.push(p); - } - if providers.is_empty() { - bail!("router chain is empty"); - } - Ok(AnyProvider::Router(Box::new(RouterProvider::new( - providers, - )))) - } - #[allow(unreachable_patterns)] - other => bail!("LLM provider {other} not available (feature not enabled)"), - } -} - -fn create_named_provider(name: &str, config: &Config) -> anyhow::Result { - match name { - "ollama" => { - let provider = OllamaProvider::new( - &config.llm.base_url, - config.llm.model.clone(), - config.llm.embedding_model.clone(), - ); - Ok(AnyProvider::Ollama(provider)) - } - "claude" => { - let cloud = config - .llm - .cloud - .as_ref() - .context("llm.cloud config section required for Claude provider")?; - let api_key = config - .secrets - .claude_api_key - .as_ref() - .context("ZEPH_CLAUDE_API_KEY not found in vault")? - .expose() - .to_owned(); - Ok(AnyProvider::Claude(ClaudeProvider::new( - api_key, - cloud.model.clone(), - cloud.max_tokens, - ))) - } - #[cfg(feature = "openai")] - "openai" => { - let openai_cfg = config - .llm - .openai - .as_ref() - .context("llm.openai config section required for OpenAI provider")?; - let api_key = config - .secrets - .openai_api_key - .as_ref() - .context("ZEPH_OPENAI_API_KEY not found in vault")? - .expose() - .to_owned(); - Ok(AnyProvider::OpenAi(OpenAiProvider::new( - api_key, - openai_cfg.base_url.clone(), - openai_cfg.model.clone(), - openai_cfg.max_tokens, - openai_cfg.embedding_model.clone(), - openai_cfg.reasoning_effort.clone(), - ))) - } - other => { - #[cfg(feature = "compatible")] - if let Some(entries) = &config.llm.compatible { - let entry = if other == "compatible" { - entries.first() - } else { - entries.iter().find(|e| e.name == other) - }; - if let Some(entry) = entry { - let api_key = config - .secrets - .compatible_api_keys - .get(&entry.name) - .with_context(|| { - format!( - "ZEPH_COMPATIBLE_{}_API_KEY required for {}", - entry.name.to_uppercase(), - entry.name - ) - })? - .expose() - .to_owned(); - return Ok(AnyProvider::Compatible(CompatibleProvider::new( - entry.name.clone(), - api_key, - entry.base_url.clone(), - entry.model.clone(), - entry.max_tokens, - entry.embedding_model.clone(), - ))); - } - } - bail!("unknown provider: {other}") - } - } -} - -fn create_summary_provider(model_spec: &str, config: &Config) -> anyhow::Result { - if let Some(model) = model_spec.strip_prefix("ollama/") { - let base_url = &config.llm.base_url; - let provider = OllamaProvider::new(base_url, model.to_owned(), String::new()); - Ok(AnyProvider::Ollama(provider)) - } else { - bail!("unsupported summary_model format: {model_spec} (expected 'ollama/')") - } -} - #[cfg(feature = "a2a")] fn spawn_a2a_server( config: &Config, @@ -1034,301 +639,6 @@ impl zeph_a2a::TaskProcessor for AgentTaskProcessor { } } -#[cfg(feature = "mcp")] -fn create_mcp_manager(config: &Config) -> zeph_mcp::McpManager { - let entries: Vec = config - .mcp - .servers - .iter() - .map(|s| { - let transport = if let Some(ref url) = s.url { - zeph_mcp::McpTransport::Http { url: url.clone() } - } else { - zeph_mcp::McpTransport::Stdio { - command: s.command.clone().unwrap_or_default(), - args: s.args.clone(), - env: s.env.clone(), - } - }; - zeph_mcp::ServerEntry { - id: s.id.clone(), - transport, - timeout: std::time::Duration::from_secs(s.timeout), - } - }) - .collect(); - zeph_mcp::McpManager::new(entries) -} - -#[cfg(feature = "mcp")] -async fn create_mcp_registry( - config: &Config, - provider: &AnyProvider, - mcp_tools: &[zeph_mcp::McpTool], - embedding_model: &str, -) -> Option { - if !config.memory.semantic.enabled { - return None; - } - match zeph_mcp::McpToolRegistry::new(&config.memory.qdrant_url) { - Ok(mut reg) => { - let embed_fn = provider.embed_fn(); - if let Err(e) = reg.sync(mcp_tools, embedding_model, &embed_fn).await { - tracing::warn!("MCP tool embedding sync failed: {e:#}"); - } - Some(reg) - } - Err(e) => { - tracing::warn!("MCP tool registry unavailable: {e:#}"); - None - } - } -} - -#[cfg(feature = "candle")] -fn select_device(preference: &str) -> anyhow::Result { - match preference { - "metal" => { - #[cfg(feature = "metal")] - return Ok(zeph_llm::candle_provider::Device::new_metal(0)?); - #[cfg(not(feature = "metal"))] - bail!("candle compiled without metal feature"); - } - "cuda" => { - #[cfg(feature = "cuda")] - return Ok(zeph_llm::candle_provider::Device::new_cuda(0)?); - #[cfg(not(feature = "cuda"))] - bail!("candle compiled without cuda feature"); - } - "auto" => { - #[cfg(feature = "metal")] - if let Ok(device) = zeph_llm::candle_provider::Device::new_metal(0) { - return Ok(device); - } - #[cfg(feature = "cuda")] - if let Ok(device) = zeph_llm::candle_provider::Device::new_cuda(0) { - return Ok(device); - } - Ok(zeph_llm::candle_provider::Device::Cpu) - } - _ => Ok(zeph_llm::candle_provider::Device::Cpu), - } -} - -#[cfg(feature = "orchestrator")] -#[allow(clippy::too_many_lines)] -fn build_orchestrator( - config: &Config, -) -> anyhow::Result { - use std::collections::HashMap; - use zeph_llm::orchestrator::{ModelOrchestrator, SubProvider, TaskType}; - - let orch_cfg = config - .llm - .orchestrator - .as_ref() - .context("llm.orchestrator config section required for orchestrator provider")?; - - let mut providers = HashMap::new(); - for (name, pcfg) in &orch_cfg.providers { - let provider = match pcfg.provider_type.as_str() { - "ollama" => { - let model = pcfg.model.as_deref().unwrap_or(&config.llm.model); - SubProvider::Ollama(OllamaProvider::new( - &config.llm.base_url, - model.to_owned(), - config.llm.embedding_model.clone(), - )) - } - "claude" => { - let cloud = config - .llm - .cloud - .as_ref() - .context("llm.cloud config required for claude sub-provider")?; - let api_key = config - .secrets - .claude_api_key - .as_ref() - .context("ZEPH_CLAUDE_API_KEY required for claude sub-provider")? - .expose() - .to_owned(); - let model = pcfg.model.as_deref().unwrap_or(&cloud.model); - SubProvider::Claude(ClaudeProvider::new( - api_key, - model.to_owned(), - cloud.max_tokens, - )) - } - #[cfg(feature = "openai")] - "openai" => { - let openai_cfg = config - .llm - .openai - .as_ref() - .context("llm.openai config required for openai sub-provider")?; - let api_key = config - .secrets - .openai_api_key - .as_ref() - .context("ZEPH_OPENAI_API_KEY required for openai sub-provider")? - .expose() - .to_owned(); - let model = pcfg.model.as_deref().unwrap_or(&openai_cfg.model); - SubProvider::OpenAi(OpenAiProvider::new( - api_key, - openai_cfg.base_url.clone(), - model.to_owned(), - openai_cfg.max_tokens, - openai_cfg.embedding_model.clone(), - openai_cfg.reasoning_effort.clone(), - )) - } - #[cfg(feature = "candle")] - "candle" => { - let candle_cfg = config - .llm - .candle - .as_ref() - .context("llm.candle config required for candle sub-provider")?; - let source = match candle_cfg.source.as_str() { - "local" => zeph_llm::candle_provider::loader::ModelSource::Local { - path: std::path::PathBuf::from(&candle_cfg.local_path), - }, - _ => zeph_llm::candle_provider::loader::ModelSource::HuggingFace { - repo_id: pcfg - .model - .clone() - .unwrap_or_else(|| config.llm.model.clone()), - filename: candle_cfg.filename.clone(), - }, - }; - let template = zeph_llm::candle_provider::template::ChatTemplate::parse_str( - &candle_cfg.chat_template, - ); - let device_pref = pcfg.device.as_deref().unwrap_or(&candle_cfg.device); - let device = select_device(device_pref)?; - let gen_config = zeph_llm::candle_provider::generate::GenerationConfig { - temperature: candle_cfg.generation.temperature, - top_p: candle_cfg.generation.top_p, - top_k: candle_cfg.generation.top_k, - max_tokens: candle_cfg.generation.capped_max_tokens(), - seed: candle_cfg.generation.seed, - repeat_penalty: candle_cfg.generation.repeat_penalty, - repeat_last_n: candle_cfg.generation.repeat_last_n, - }; - let candle_provider = zeph_llm::candle_provider::CandleProvider::new( - &source, - template, - gen_config, - candle_cfg.embedding_repo.as_deref(), - device, - )?; - SubProvider::Candle(candle_provider) - } - other => bail!("unknown orchestrator sub-provider type: {other}"), - }; - providers.insert(name.clone(), provider); - } - - let mut routes = HashMap::new(); - for (task_str, chain) in &orch_cfg.routes { - let task = TaskType::parse_str(task_str); - routes.insert(task, chain.clone()); - } - - Ok(ModelOrchestrator::new( - routes, - providers, - orch_cfg.default.clone(), - orch_cfg.embed.clone(), - )?) -} - -#[cfg_attr(not(feature = "vault-age"), allow(dead_code))] -struct VaultArgs { - backend: String, - key_path: Option, - vault_path: Option, -} - -/// Priority: CLI --vault > `ZEPH_VAULT_BACKEND` env > config.vault.backend > "env" -fn parse_vault_args(config: &Config) -> VaultArgs { - let args: Vec = std::env::args().collect(); - let cli_backend = args - .windows(2) - .find(|w| w[0] == "--vault") - .map(|w| w[1].clone()); - let env_backend = std::env::var("ZEPH_VAULT_BACKEND").ok(); - let backend = cli_backend - .or(env_backend) - .unwrap_or_else(|| config.vault.backend.clone()); - let key_path = args - .windows(2) - .find(|w| w[0] == "--vault-key") - .map(|w| w[1].clone()); - let vault_path = args - .windows(2) - .find(|w| w[0] == "--vault-path") - .map(|w| w[1].clone()); - VaultArgs { - backend, - key_path, - vault_path, - } -} - -fn resolve_config_path() -> PathBuf { - let args: Vec = std::env::args().collect(); - if let Some(path) = args.windows(2).find(|w| w[0] == "--config").map(|w| &w[1]) { - return PathBuf::from(path); - } - if let Ok(path) = std::env::var("ZEPH_CONFIG") { - return PathBuf::from(path); - } - PathBuf::from("config/default.toml") -} - -#[cfg(feature = "tui")] -struct TuiHandle { - user_tx: tokio::sync::mpsc::Sender, - agent_tx: tokio::sync::mpsc::Sender, - agent_rx: tokio::sync::mpsc::Receiver, -} - -#[cfg(feature = "tui")] -fn is_tui_requested() -> bool { - std::env::args().any(|a| a == "--tui") - || std::env::var("ZEPH_TUI") - .map(|v| v == "true" || v == "1") - .unwrap_or(false) -} - -#[cfg(feature = "tui")] -async fn create_channel_with_tui( - config: &Config, -) -> anyhow::Result<(AppChannel, Option)> { - if is_tui_requested() { - let (user_tx, user_rx) = tokio::sync::mpsc::channel(32); - let (agent_tx, agent_rx) = tokio::sync::mpsc::channel(256); - let agent_tx_clone = agent_tx.clone(); - let channel = TuiChannel::new(user_rx, agent_tx); - let handle = TuiHandle { - user_tx, - agent_tx: agent_tx_clone, - agent_rx, - }; - return Ok((AppChannel::Tui(channel), Some(handle))); - } - let channel = create_channel_inner(config).await?; - Ok((AppChannel::Standard(channel), None)) -} - -#[cfg_attr(feature = "tui", allow(dead_code))] -async fn create_channel(config: &Config) -> anyhow::Result { - create_channel_inner(config).await -} - #[allow(clippy::unused_async)] async fn create_channel_inner(config: &Config) -> anyhow::Result { #[cfg(feature = "discord")] @@ -1381,26 +691,66 @@ async fn create_channel_inner(config: &Config) -> anyhow::Result { Ok(AnyChannel::Cli(CliChannel::new())) } -#[cfg(not(feature = "tui"))] -fn init_subscriber(config_path: &Path) { - use tracing_subscriber::layer::SubscriberExt; - use tracing_subscriber::util::SubscriberInitExt; - - let filter = tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - let fmt_layer = tracing_subscriber::fmt::layer(); +#[cfg(feature = "tui")] +struct TuiHandle { + user_tx: tokio::sync::mpsc::Sender, + agent_tx: tokio::sync::mpsc::Sender, + agent_rx: tokio::sync::mpsc::Receiver, +} - #[cfg(feature = "otel")] - { - let config = Config::load(config_path).ok(); - let use_otlp = config - .as_ref() - .is_some_and(|c| c.observability.exporter == "otlp"); +#[cfg(feature = "tui")] +fn is_tui_requested() -> bool { + std::env::args().any(|a| a == "--tui") + || std::env::var("ZEPH_TUI") + .map(|v| v == "true" || v == "1") + .unwrap_or(false) +} - if use_otlp { - let endpoint = config - .as_ref() - .map_or("http://localhost:4317", |c| &c.observability.endpoint); +#[cfg(feature = "tui")] +async fn create_channel_with_tui( + config: &Config, +) -> anyhow::Result<(AppChannel, Option)> { + if is_tui_requested() { + let (user_tx, user_rx) = tokio::sync::mpsc::channel(32); + let (agent_tx, agent_rx) = tokio::sync::mpsc::channel(256); + let agent_tx_clone = agent_tx.clone(); + let channel = TuiChannel::new(user_rx, agent_tx); + let handle = TuiHandle { + user_tx, + agent_tx: agent_tx_clone, + agent_rx, + }; + return Ok((AppChannel::Tui(channel), Some(handle))); + } + let channel = create_channel_inner(config).await?; + Ok((AppChannel::Standard(channel), None)) +} + +#[cfg_attr(feature = "tui", allow(dead_code))] +async fn create_channel(config: &Config) -> anyhow::Result { + create_channel_inner(config).await +} + +#[cfg(not(feature = "tui"))] +fn init_subscriber(config_path: &std::path::Path) { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let fmt_layer = tracing_subscriber::fmt::layer(); + + #[cfg(feature = "otel")] + { + let config = Config::load(config_path).ok(); + let use_otlp = config + .as_ref() + .is_some_and(|c| c.observability.exporter == "otlp"); + + if use_otlp { + let endpoint = config + .as_ref() + .map_or("http://localhost:4317", |c| &c.observability.endpoint); match setup_otel_tracer(endpoint) { Ok(tracer) => { @@ -1451,58 +801,10 @@ fn setup_otel_tracer(endpoint: &str) -> anyhow::Result = vec![]; - let result = create_skill_matcher(&config, &provider, &meta, &memory, "test-model").await; - assert!(result.is_none()); - - let _ = std::fs::remove_file(&tmp); - } - #[test] fn any_channel_debug_telegram() { use zeph_channels::telegram::TelegramChannel; @@ -2060,253 +959,18 @@ mod tests { ); } - #[cfg(feature = "candle")] - #[tokio::test] - async fn health_check_candle_logs_device() { - use zeph_llm::candle_provider::CandleProvider; - - let source = zeph_llm::candle_provider::loader::ModelSource::HuggingFace { - repo_id: "test/model".to_string(), - filename: Some("model.gguf".to_string()), - }; - let template = zeph_llm::candle_provider::template::ChatTemplate::parse_str( - "{{ bos_token }}{{ messages[0].content }}", - ); - let gen_config = zeph_llm::candle_provider::generate::GenerationConfig { - temperature: 0.7, - top_p: Some(0.9), - top_k: Some(50), - max_tokens: 512, - seed: 42, - repeat_penalty: 1.1, - repeat_last_n: 64, - }; - let device = zeph_llm::candle_provider::Device::Cpu; - - let candle_result = - CandleProvider::new(&source, template, gen_config, Some("embed/model"), device); - - if let Ok(candle) = candle_result { - let provider = AnyProvider::Candle(candle); - health_check(&provider).await; - } - } - - #[cfg(feature = "orchestrator")] - #[tokio::test] - async fn health_check_orchestrator_logs_providers() { - use std::collections::HashMap; - use zeph_llm::orchestrator::{ModelOrchestrator, SubProvider}; - - let mut providers = HashMap::new(); - providers.insert( - "ollama_local".to_string(), - SubProvider::Ollama(OllamaProvider::new( - "http://localhost:11434", - "test".into(), - "embed".into(), - )), - ); - - let routes = HashMap::new(); - let orch = ModelOrchestrator::new( - routes, - providers, - "ollama_local".to_string(), - "ollama_local".to_string(), - ) - .unwrap(); - - let provider = AnyProvider::Orchestrator(Box::new(orch)); - health_check(&provider).await; - } - - #[cfg(all(feature = "orchestrator", feature = "candle"))] - #[test] - fn build_orchestrator_with_candle_local_source() { - use std::collections::HashMap; - use zeph_core::config::OrchestratorProviderConfig; - - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::Orchestrator; - config.llm.candle = Some(zeph_core::config::CandleConfig { - source: "local".into(), - local_path: "/tmp/model.gguf".into(), - filename: Some("model.gguf".to_string()), - chat_template: "{{ messages[0].content }}".into(), - device: "cpu".into(), - embedding_repo: Some("embed/model".into()), - generation: zeph_core::config::GenerationParams { - temperature: 0.7, - top_p: Some(0.9), - top_k: Some(50), - max_tokens: 512, - seed: 42, - repeat_penalty: 1.1, - repeat_last_n: 64, - }, - }); - - let mut providers = HashMap::new(); - providers.insert( - "candle_local".to_string(), - OrchestratorProviderConfig { - provider_type: "candle".to_string(), - model: Some("local-model".to_string()), - filename: None, - device: Some("cpu".to_string()), - }, - ); - - config.llm.orchestrator = Some(zeph_core::config::OrchestratorConfig { - providers, - routes: HashMap::new(), - default: "candle_local".to_string(), - embed: "candle_local".to_string(), - }); - - let result = build_orchestrator(&config); - assert!(result.is_err(), "expected error loading nonexistent model"); - } - - #[cfg(feature = "orchestrator")] - #[test] - fn build_orchestrator_with_ollama_sub_provider() { - use std::collections::HashMap; - use zeph_core::config::OrchestratorProviderConfig; - - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::Orchestrator; - - let mut providers = HashMap::new(); - providers.insert( - "ollama_sub".to_string(), - OrchestratorProviderConfig { - provider_type: "ollama".to_string(), - model: Some("llama2".to_string()), - filename: None, - device: None, - }, - ); - - config.llm.orchestrator = Some(zeph_core::config::OrchestratorConfig { - providers, - routes: HashMap::new(), - default: "ollama_sub".to_string(), - embed: "ollama_sub".to_string(), - }); - - let result = build_orchestrator(&config); - assert!(result.is_ok()); - } - - #[cfg(feature = "orchestrator")] - #[test] - fn build_orchestrator_routes_parsing() { - use std::collections::HashMap; - use zeph_core::config::OrchestratorProviderConfig; - - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::Orchestrator; - - let mut providers = HashMap::new(); - providers.insert( - "ollama_sub".to_string(), - OrchestratorProviderConfig { - provider_type: "ollama".to_string(), - model: None, - filename: None, - device: None, - }, - ); - - let mut routes = HashMap::new(); - routes.insert("chat".to_string(), vec!["ollama_sub".to_string()]); - routes.insert("embed".to_string(), vec!["ollama_sub".to_string()]); - - config.llm.orchestrator = Some(zeph_core::config::OrchestratorConfig { - providers, - routes, - default: "ollama_sub".to_string(), - embed: "ollama_sub".to_string(), - }); - - let result = build_orchestrator(&config); - assert!(result.is_ok()); - } - - #[cfg(feature = "openai")] - #[test] - fn create_provider_openai_missing_config_errors() { - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::OpenAi; - config.llm.openai = None; - let result = create_provider(&config); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("llm.openai config section required") - ); - } - - #[test] - fn effective_embedding_model_defaults_to_llm() { - let config = Config::load(Path::new("/nonexistent")).unwrap(); - assert_eq!(effective_embedding_model(&config), "qwen3-embedding"); - } - - #[cfg(feature = "openai")] - #[test] - fn effective_embedding_model_uses_openai_when_set() { - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::OpenAi; - config.llm.openai = Some(zeph_core::config::OpenAiConfig { - base_url: "https://api.openai.com/v1".into(), - model: "gpt-5.2".into(), - max_tokens: 4096, - embedding_model: Some("text-embedding-3-small".into()), - reasoning_effort: None, - }); - assert_eq!(effective_embedding_model(&config), "text-embedding-3-small"); - } - - #[cfg(feature = "openai")] - #[test] - fn effective_embedding_model_falls_back_when_openai_embed_missing() { - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::OpenAi; - config.llm.openai = Some(zeph_core::config::OpenAiConfig { - base_url: "https://api.openai.com/v1".into(), - model: "gpt-5.2".into(), - max_tokens: 4096, - embedding_model: None, - reasoning_effort: None, - }); - assert_eq!(effective_embedding_model(&config), "qwen3-embedding"); - } - - #[cfg(feature = "openai")] + #[cfg(feature = "a2a")] #[test] - fn create_provider_openai_missing_api_key_errors() { - let mut config = Config::load(Path::new("/nonexistent")).unwrap(); - config.llm.provider = ProviderKind::OpenAi; - config.llm.openai = Some(zeph_core::config::OpenAiConfig { - base_url: "https://api.openai.com/v1".into(), - model: "gpt-4o".into(), - max_tokens: 4096, - embedding_model: None, - reasoning_effort: None, - }); - config.secrets.openai_api_key = None; - let result = create_provider(&config); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("ZEPH_OPENAI_API_KEY not found") - ); + fn agent_task_processor_construction() { + let provider = std::sync::Arc::new(AnyProvider::Ollama(OllamaProvider::new( + "http://localhost:11434", + "test".into(), + "embed".into(), + ))); + let processor = AgentTaskProcessor { + provider, + system_prompt: "test prompt".into(), + }; + assert!(!processor.system_prompt.is_empty()); } }