1pub mod config;
7pub mod health;
8pub mod mcp;
9pub mod oauth;
10pub mod provider;
11pub mod skills;
12
13pub use config::{parse_vault_args, resolve_config_path};
14pub use health::{health_check, warmup_provider};
15pub use mcp::{
16 create_mcp_manager, create_mcp_manager_with_vault, create_mcp_registry, wire_trust_calibration,
17};
18pub use oauth::VaultCredentialStore;
19#[cfg(feature = "candle")]
20pub use provider::select_device;
21pub use provider::{
22 BootstrapError, build_provider_for_switch, build_provider_from_entry, create_named_provider,
23 create_provider, create_summary_provider,
24};
25pub use skills::{
26 create_embedding_provider, create_skill_matcher, effective_embedding_model, managed_skills_dir,
27};
28
29use std::path::{Path, PathBuf};
30use std::sync::Arc;
31
32use tokio::sync::{RwLock, mpsc, watch};
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::LlmProvider;
35use zeph_memory::GraphStore;
36use zeph_memory::QdrantOps;
37use zeph_memory::semantic::SemanticMemory;
38use zeph_skills::loader::SkillMeta;
39use zeph_skills::matcher::SkillMatcherBackend;
40use zeph_skills::registry::SkillRegistry;
41use zeph_skills::watcher::{SkillEvent, SkillWatcher};
42
43use crate::config::{Config, SecretResolver};
44use crate::config_watcher::{ConfigEvent, ConfigWatcher};
45use crate::vault::AgeVaultProvider;
46use crate::vault::{EnvVaultProvider, VaultProvider};
47
48pub struct AppBuilder {
49 config: Config,
50 config_path: PathBuf,
51 vault: Box<dyn VaultProvider>,
52 age_vault: Option<Arc<RwLock<AgeVaultProvider>>>,
55 qdrant_ops: Option<QdrantOps>,
56}
57
58pub struct VaultArgs {
59 pub backend: String,
60 pub key_path: Option<String>,
61 pub vault_path: Option<String>,
62}
63
64pub struct WatcherBundle {
65 pub skill_watcher: Option<SkillWatcher>,
66 pub skill_reload_rx: mpsc::Receiver<SkillEvent>,
67 pub config_watcher: Option<ConfigWatcher>,
68 pub config_reload_rx: mpsc::Receiver<ConfigEvent>,
69}
70
71impl AppBuilder {
72 pub async fn new(
81 config_override: Option<&Path>,
82 vault_override: Option<&str>,
83 vault_key_override: Option<&Path>,
84 vault_path_override: Option<&Path>,
85 ) -> Result<Self, BootstrapError> {
86 let config_path = resolve_config_path(config_override);
87 let mut config = Config::load(&config_path)?;
88 config.validate()?;
89 config.llm.check_legacy_format()?;
90
91 let vault_args = parse_vault_args(
92 &config,
93 vault_override,
94 vault_key_override,
95 vault_path_override,
96 );
97 let (vault, age_vault): (
98 Box<dyn VaultProvider>,
99 Option<Arc<RwLock<AgeVaultProvider>>>,
100 ) = match vault_args.backend.as_str() {
101 "env" => (Box::new(EnvVaultProvider), None),
102 "age" => {
103 let key = vault_args.key_path.ok_or_else(|| {
104 BootstrapError::Provider("--vault-key required for age backend".into())
105 })?;
106 let path = vault_args.vault_path.ok_or_else(|| {
107 BootstrapError::Provider("--vault-path required for age backend".into())
108 })?;
109 let provider = AgeVaultProvider::new(Path::new(&key), Path::new(&path))
110 .map_err(BootstrapError::VaultInit)?;
111 let arc = Arc::new(RwLock::new(provider));
112 let boxed: Box<dyn VaultProvider> =
113 Box::new(crate::vault::ArcAgeVaultProvider(Arc::clone(&arc)));
114 (boxed, Some(arc))
115 }
116 other => {
117 return Err(BootstrapError::Provider(format!(
118 "unknown vault backend: {other}"
119 )));
120 }
121 };
122
123 config.resolve_secrets(vault.as_ref()).await?;
124
125 let qdrant_ops = match config.memory.vector_backend {
126 crate::config::VectorBackend::Qdrant => {
127 let ops = QdrantOps::new(&config.memory.qdrant_url).map_err(|e| {
128 BootstrapError::Provider(format!(
129 "invalid qdrant_url '{}': {e}",
130 config.memory.qdrant_url
131 ))
132 })?;
133 Some(ops)
134 }
135 crate::config::VectorBackend::Sqlite => None,
136 };
137
138 Ok(Self {
139 config,
140 config_path,
141 vault,
142 age_vault,
143 qdrant_ops,
144 })
145 }
146
147 pub fn qdrant_ops(&self) -> Option<&QdrantOps> {
148 self.qdrant_ops.as_ref()
149 }
150
151 pub fn config(&self) -> &Config {
152 &self.config
153 }
154
155 pub fn config_mut(&mut self) -> &mut Config {
156 &mut self.config
157 }
158
159 pub fn config_path(&self) -> &Path {
160 &self.config_path
161 }
162
163 pub fn vault(&self) -> &dyn VaultProvider {
168 self.vault.as_ref()
169 }
170
171 pub fn age_vault_arc(&self) -> Option<&Arc<RwLock<AgeVaultProvider>>> {
176 self.age_vault.as_ref()
177 }
178
179 pub async fn build_provider(
183 &self,
184 ) -> Result<
185 (
186 AnyProvider,
187 tokio::sync::mpsc::UnboundedSender<String>,
188 tokio::sync::mpsc::UnboundedReceiver<String>,
189 ),
190 BootstrapError,
191 > {
192 let mut provider = create_provider(&self.config)?;
193
194 let (status_tx, status_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
195 let status_tx_clone = status_tx.clone();
196 provider.set_status_tx(status_tx);
197
198 health_check(&provider).await;
199
200 if let AnyProvider::Ollama(ref mut ollama) = provider
201 && let Ok(info) = ollama.fetch_model_info().await
202 && let Some(ctx) = info.context_length
203 {
204 ollama.set_context_window(ctx);
205 tracing::info!(context_window = ctx, "detected Ollama model context window");
206 }
207
208 if let Some(ctx) = provider.context_window()
209 && !matches!(provider, AnyProvider::Ollama(_))
210 {
211 tracing::info!(context_window = ctx, "detected provider context window");
212 }
213
214 Ok((provider, status_tx_clone, status_rx))
215 }
216
217 pub fn auto_budget_tokens(&self, provider: &AnyProvider) -> usize {
218 if self.config.memory.auto_budget && self.config.memory.context_budget_tokens == 0 {
219 if let Some(ctx_size) = provider.context_window() {
220 tracing::info!(model_context = ctx_size, "auto-configured context budget");
221 ctx_size
222 } else {
223 0
224 }
225 } else {
226 self.config.memory.context_budget_tokens
227 }
228 }
229
230 pub async fn build_memory(
236 &self,
237 provider: &AnyProvider,
238 ) -> Result<SemanticMemory, BootstrapError> {
239 let embed_model = self.embedding_model();
240 let db_path: &str = self
242 .config
243 .memory
244 .database_url
245 .as_deref()
246 .unwrap_or(&self.config.memory.sqlite_path);
247
248 if zeph_db::is_postgres_url(db_path) {
249 return Err(BootstrapError::Memory(
250 "database_url points to PostgreSQL but binary was compiled with the \
251 sqlite feature. Recompile with --features postgres."
252 .to_string(),
253 ));
254 }
255
256 let mut memory = match self.config.memory.vector_backend {
257 crate::config::VectorBackend::Sqlite => {
258 SemanticMemory::with_sqlite_backend_and_pool_size(
259 db_path,
260 provider.clone(),
261 &embed_model,
262 self.config.memory.semantic.vector_weight,
263 self.config.memory.semantic.keyword_weight,
264 self.config.memory.sqlite_pool_size,
265 )
266 .await
267 .map_err(|e| BootstrapError::Memory(e.to_string()))?
268 }
269 crate::config::VectorBackend::Qdrant => {
270 let ops = self
271 .qdrant_ops
272 .as_ref()
273 .ok_or_else(|| {
274 BootstrapError::Memory(
275 "qdrant_ops must be Some when vector_backend = Qdrant".into(),
276 )
277 })?
278 .clone();
279 SemanticMemory::with_qdrant_ops(
280 db_path,
281 ops,
282 provider.clone(),
283 &embed_model,
284 self.config.memory.semantic.vector_weight,
285 self.config.memory.semantic.keyword_weight,
286 self.config.memory.sqlite_pool_size,
287 )
288 .await
289 .map_err(|e| BootstrapError::Memory(e.to_string()))?
290 }
291 };
292
293 memory = memory.with_ranking_options(
294 self.config.memory.semantic.temporal_decay_enabled,
295 self.config.memory.semantic.temporal_decay_half_life_days,
296 self.config.memory.semantic.mmr_enabled,
297 self.config.memory.semantic.mmr_lambda,
298 );
299
300 memory = memory.with_importance_options(
301 self.config.memory.semantic.importance_enabled,
302 self.config.memory.semantic.importance_weight,
303 );
304
305 if self.config.memory.semantic.enabled && memory.is_vector_store_connected().await {
306 tracing::info!("semantic memory enabled, vector store connected");
307 match memory.embed_missing().await {
308 Ok(n) if n > 0 => tracing::info!("backfilled {n} missing embedding(s)"),
309 Ok(_) => {}
310 Err(e) => tracing::warn!("embed_missing failed: {e:#}"),
311 }
312 }
313
314 if self.config.memory.graph.enabled {
315 let pool = memory.sqlite().pool().clone();
316 let store = Arc::new(GraphStore::new(pool));
317 memory = memory.with_graph_store(store);
318 tracing::info!("graph memory enabled, GraphStore attached");
319 }
320
321 if self.config.memory.admission.enabled {
322 memory = memory.with_admission_control(self.build_admission_control(provider));
323 }
324
325 Ok(memory)
326 }
327
328 fn build_admission_control(
329 &self,
330 fallback_provider: &AnyProvider,
331 ) -> zeph_memory::AdmissionControl {
332 let admission_provider = if self.config.memory.admission.admission_provider.is_empty() {
333 fallback_provider.clone()
334 } else {
335 match create_named_provider(
336 &self.config.memory.admission.admission_provider,
337 &self.config,
338 ) {
339 Ok(p) => {
340 tracing::info!(
341 provider = %self.config.memory.admission.admission_provider,
342 "A-MAC admission provider configured"
343 );
344 p
345 }
346 Err(e) => {
347 tracing::warn!(
348 provider = %self.config.memory.admission.admission_provider,
349 error = %e,
350 "A-MAC admission provider resolution failed — primary provider will be used"
351 );
352 fallback_provider.clone()
353 }
354 }
355 };
356 let w = &self.config.memory.admission.weights;
357 let weights = zeph_memory::AdmissionWeights {
358 future_utility: w.future_utility,
359 factual_confidence: w.factual_confidence,
360 semantic_novelty: w.semantic_novelty,
361 temporal_recency: w.temporal_recency,
362 content_type_prior: w.content_type_prior,
363 goal_utility: w.goal_utility,
364 };
365 let mut control = zeph_memory::AdmissionControl::new(
366 self.config.memory.admission.threshold,
367 self.config.memory.admission.fast_path_margin,
368 weights,
369 )
370 .with_provider(admission_provider);
371
372 if self.config.memory.admission.goal_conditioned_write {
373 let goal_provider = if self
374 .config
375 .memory
376 .admission
377 .goal_utility_provider
378 .is_empty()
379 {
380 None
381 } else {
382 match create_named_provider(
383 &self.config.memory.admission.goal_utility_provider,
384 &self.config,
385 ) {
386 Ok(p) => Some(p),
387 Err(e) => {
388 tracing::warn!(
389 provider = %self.config.memory.admission.goal_utility_provider,
390 error = %e,
391 "goal_utility_provider not found, LLM refinement disabled"
392 );
393 None
394 }
395 }
396 };
397 control = control.with_goal_gate(zeph_memory::GoalGateConfig {
398 threshold: self.config.memory.admission.goal_utility_threshold,
399 provider: goal_provider,
400 weight: self.config.memory.admission.goal_utility_weight,
401 });
402 tracing::info!(
403 threshold = self.config.memory.admission.goal_utility_threshold,
404 weight = self.config.memory.admission.goal_utility_weight,
405 "A-MAC: goal-conditioned write gate enabled"
406 );
407 }
408
409 if self.config.memory.admission.admission_strategy == zeph_config::AdmissionStrategy::Rl {
410 tracing::warn!(
411 "admission_strategy = \"rl\" is configured but the RL model is not yet wired \
412 into the admission path — falling back to heuristic. See #2416."
413 );
414 }
415
416 tracing::info!(
417 threshold = self.config.memory.admission.threshold,
418 "A-MAC admission control enabled"
419 );
420 control
421 }
422
423 pub async fn build_skill_matcher(
424 &self,
425 provider: &AnyProvider,
426 meta: &[&SkillMeta],
427 memory: &SemanticMemory,
428 ) -> Option<SkillMatcherBackend> {
429 let embed_model = self.embedding_model();
430 create_skill_matcher(
431 &self.config,
432 provider,
433 meta,
434 memory,
435 &embed_model,
436 self.qdrant_ops.as_ref(),
437 )
438 .await
439 }
440
441 pub fn build_registry(&self) -> SkillRegistry {
442 #[cfg(feature = "bundled-skills")]
443 {
444 let managed = managed_skills_dir();
445 match zeph_skills::bundled::provision_bundled_skills(&managed) {
446 Ok(report) => {
447 if !report.installed.is_empty() {
448 tracing::info!(
449 skills = ?report.installed,
450 "provisioned new bundled skills"
451 );
452 }
453 if !report.updated.is_empty() {
454 tracing::info!(
455 skills = ?report.updated,
456 "updated bundled skills"
457 );
458 }
459 for (name, err) in &report.failed {
460 tracing::warn!(skill = %name, error = %err, "failed to provision bundled skill");
461 }
462 }
463 Err(e) => {
464 tracing::warn!(error = %e, "bundled skill provisioning failed");
465 }
466 }
467 }
468
469 let skill_paths = self.skill_paths();
470 let registry = SkillRegistry::load(&skill_paths);
471
472 if self.config.skills.trust.scan_on_load {
473 let findings = registry.scan_loaded();
474 if findings.is_empty() {
475 tracing::debug!("skill content scan: no injection patterns found");
476 } else {
477 tracing::warn!(
478 count = findings.len(),
479 "skill content scan complete: {} skill(s) with potential injection patterns",
480 findings.len()
481 );
482 }
483 }
484
485 if self.config.skills.trust.scanner.capability_escalation_check {
486 let default_level = self.config.skills.trust.default_level;
489 let trust_levels: Vec<(String, zeph_tools::TrustLevel)> = registry
490 .all_meta()
491 .iter()
492 .map(|meta| (meta.name.clone(), default_level))
493 .collect();
494
495 let violations = registry.check_escalations(&trust_levels);
496 for v in &violations {
497 tracing::warn!(
498 skill = %v.skill_name,
499 denied_tools = ?v.denied_tools,
500 "capability escalation: skill declares tools exceeding its trust level"
501 );
502 }
503 if violations.is_empty() {
504 tracing::debug!("capability escalation check: no violations found");
505 }
506 }
507
508 registry
509 }
510
511 pub fn skill_paths(&self) -> Vec<PathBuf> {
512 let mut paths: Vec<PathBuf> = self.config.skills.paths.iter().map(PathBuf::from).collect();
513 let managed_dir = managed_skills_dir();
514 if !paths.contains(&managed_dir) {
515 paths.push(managed_dir);
516 }
517 paths
518 }
519
520 pub fn managed_skills_dir() -> PathBuf {
521 managed_skills_dir()
522 }
523
524 pub fn build_watchers(&self) -> WatcherBundle {
525 let skill_paths = self.skill_paths();
526 let (reload_tx, skill_reload_rx) = mpsc::channel(4);
527 let skill_watcher = match SkillWatcher::start(&skill_paths, reload_tx) {
528 Ok(w) => {
529 tracing::info!("skill watcher started");
530 Some(w)
531 }
532 Err(e) => {
533 tracing::warn!("skill watcher unavailable: {e:#}");
534 None
535 }
536 };
537
538 let (config_reload_tx, config_reload_rx) = mpsc::channel(4);
539 let config_watcher = match ConfigWatcher::start(&self.config_path, config_reload_tx) {
540 Ok(w) => {
541 tracing::info!("config watcher started");
542 Some(w)
543 }
544 Err(e) => {
545 tracing::warn!("config watcher unavailable: {e:#}");
546 None
547 }
548 };
549
550 WatcherBundle {
551 skill_watcher,
552 skill_reload_rx,
553 config_watcher,
554 config_reload_rx,
555 }
556 }
557
558 pub fn build_shutdown() -> (watch::Sender<bool>, watch::Receiver<bool>) {
559 watch::channel(false)
560 }
561
562 pub fn embedding_model(&self) -> String {
563 effective_embedding_model(&self.config)
564 }
565
566 pub fn build_summary_provider(&self) -> Option<AnyProvider> {
567 if let Some(ref entry) = self.config.llm.summary_provider {
569 return match build_provider_from_entry(entry, &self.config) {
570 Ok(sp) => {
571 tracing::info!(
572 provider_type = ?entry.provider_type,
573 model = ?entry.model,
574 "summary provider configured via [llm.summary_provider]"
575 );
576 Some(sp)
577 }
578 Err(e) => {
579 tracing::warn!("failed to create summary provider: {e:#}, using primary");
580 None
581 }
582 };
583 }
584 self.config.llm.summary_model.as_ref().and_then(
585 |model_spec| match create_summary_provider(model_spec, &self.config) {
586 Ok(sp) => {
587 tracing::info!(model = %model_spec, "summary provider configured via llm.summary_model");
588 Some(sp)
589 }
590 Err(e) => {
591 tracing::warn!("failed to create summary provider: {e:#}, using primary");
592 None
593 }
594 },
595 )
596 }
597
598 pub fn build_quarantine_provider(
603 &self,
604 ) -> Option<(AnyProvider, zeph_sanitizer::QuarantineConfig)> {
605 let ci = &self.config.security.content_isolation;
606 let qc = &ci.quarantine;
607 if !qc.enabled {
608 if ci.mcp_to_acp_boundary {
609 tracing::warn!(
610 "mcp_to_acp_boundary is enabled but quarantine is disabled — \
611 cross-boundary MCP tool results in ACP sessions will be \
612 spotlighted but NOT quarantine-summarized; enable \
613 [security.content_isolation.quarantine] for full protection"
614 );
615 }
616 return None;
617 }
618 match create_named_provider(&qc.model, &self.config) {
619 Ok(p) => {
620 tracing::info!(model = %qc.model, "quarantine provider configured");
621 Some((p, qc.clone()))
622 }
623 Err(e) => {
624 tracing::warn!(
625 model = %qc.model,
626 error = %e,
627 "quarantine provider resolution failed, quarantine disabled"
628 );
629 None
630 }
631 }
632 }
633
634 #[cfg(feature = "guardrail")]
639 pub fn build_guardrail_filter(&self) -> Option<zeph_sanitizer::guardrail::GuardrailFilter> {
640 let (provider, config) = self.build_guardrail_provider()?;
641 match zeph_sanitizer::guardrail::GuardrailFilter::new(provider, &config) {
642 Ok(filter) => Some(filter),
643 Err(e) => {
644 tracing::warn!(error = %e, "guardrail filter construction failed, guardrail disabled");
645 None
646 }
647 }
648 }
649
650 #[cfg(feature = "guardrail")]
654 pub fn build_guardrail_provider(
655 &self,
656 ) -> Option<(AnyProvider, zeph_sanitizer::guardrail::GuardrailConfig)> {
657 let gc = &self.config.security.guardrail;
658 if !gc.enabled {
659 return None;
660 }
661 let provider_name = gc.provider.as_deref().unwrap_or("ollama");
662 match create_named_provider(provider_name, &self.config) {
663 Ok(p) => {
664 tracing::info!(
665 provider = %provider_name,
666 model = ?gc.model,
667 "guardrail provider configured"
668 );
669 Some((p, gc.clone()))
670 }
671 Err(e) => {
672 tracing::warn!(
673 provider = %provider_name,
674 error = %e,
675 "guardrail provider resolution failed, guardrail disabled"
676 );
677 None
678 }
679 }
680 }
681
682 pub fn build_judge_provider(&self) -> Option<AnyProvider> {
687 use crate::config::DetectorMode;
688 let learning = &self.config.skills.learning;
689 if learning.detector_mode != DetectorMode::Judge {
690 return None;
691 }
692 if learning.judge_model.is_empty() {
693 tracing::warn!(
694 "detector_mode=judge but judge_model is empty — primary provider will be used for judging"
695 );
696 return None;
697 }
698 match create_named_provider(&learning.judge_model, &self.config) {
699 Ok(jp) => {
700 tracing::info!(model = %learning.judge_model, "judge provider configured");
701 Some(jp)
702 }
703 Err(e) => {
704 tracing::warn!("failed to create judge provider: {e:#}, using primary");
705 None
706 }
707 }
708 }
709
710 pub fn build_feedback_classifier(
716 &self,
717 primary: &AnyProvider,
718 ) -> Option<zeph_llm::classifier::llm::LlmClassifier> {
719 use crate::config::DetectorMode;
720 let learning = &self.config.skills.learning;
721 if learning.detector_mode != DetectorMode::Model {
722 return None;
723 }
724 let provider = if learning.feedback_provider.is_empty() {
725 tracing::debug!("feedback_provider empty — using primary provider for LlmClassifier");
726 Some(primary.clone())
727 } else {
728 match crate::bootstrap::provider::create_named_provider(
729 &learning.feedback_provider,
730 &self.config,
731 ) {
732 Ok(p) => {
733 tracing::info!(
734 provider = %learning.feedback_provider,
735 "LlmClassifier feedback provider configured"
736 );
737 Some(p)
738 }
739 Err(e) => {
740 tracing::warn!(
741 provider = %learning.feedback_provider,
742 error = %e,
743 "feedback_provider not found in registry, degrading to regex-only"
744 );
745 None
746 }
747 }
748 };
749 if let Some(p) = provider {
750 Some(zeph_llm::classifier::llm::LlmClassifier::new(
751 std::sync::Arc::new(p),
752 ))
753 } else {
754 tracing::warn!(
755 "detector_mode=model but no provider available, degrading to regex-only"
756 );
757 None
758 }
759 }
760
761 pub fn build_probe_provider(&self) -> Option<AnyProvider> {
766 let name = &self.config.memory.compression.probe.probe_provider;
767 if name.is_empty() {
768 return None;
769 }
770 match create_named_provider(name, &self.config) {
771 Ok(p) => {
772 tracing::info!(provider = %name, "compaction probe provider configured");
773 Some(p)
774 }
775 Err(e) => {
776 tracing::warn!(
777 provider = %name,
778 error = %e,
779 "probe provider resolution failed — summary/primary provider will be used"
780 );
781 None
782 }
783 }
784 }
785
786 #[cfg(feature = "context-compression")]
791 pub fn build_compress_provider(&self) -> Option<AnyProvider> {
792 let name = &self.config.memory.compression.compress_provider;
793 if name.is_empty() {
794 return None;
795 }
796 match create_named_provider(name, &self.config) {
797 Ok(p) => {
798 tracing::info!(provider = %name, "compress_context provider configured");
799 Some(p)
800 }
801 Err(e) => {
802 tracing::warn!(
803 provider = %name,
804 error = %e,
805 "compress_context provider resolution failed — primary provider will be used"
806 );
807 None
808 }
809 }
810 }
811
812 #[cfg(feature = "compression-guidelines")]
820 pub fn build_guidelines_provider(&self) -> Option<AnyProvider> {
821 let name = &self
822 .config
823 .memory
824 .compression_guidelines
825 .guidelines_provider;
826 if name.is_empty() {
827 return None;
828 }
829 match create_named_provider(name, &self.config) {
830 Ok(p) => {
831 tracing::info!(provider = %name, "compression guidelines provider configured");
832 Some(p)
833 }
834 Err(e) => {
835 tracing::warn!(
836 provider = %name,
837 error = %e,
838 "guidelines provider resolution failed — primary provider will be used"
839 );
840 None
841 }
842 }
843 }
844
845 pub fn build_consolidation_provider(&self) -> Option<AnyProvider> {
850 let name = &self.config.memory.consolidation.consolidation_provider;
851 if name.is_empty() {
852 return None;
853 }
854 match create_named_provider(name, &self.config) {
855 Ok(p) => {
856 tracing::info!(provider = %name, "consolidation provider configured");
857 Some(p)
858 }
859 Err(e) => {
860 tracing::warn!(
861 provider = %name,
862 error = %e,
863 "consolidation provider resolution failed — primary provider will be used"
864 );
865 None
866 }
867 }
868 }
869
870 pub fn build_planner_provider(&self) -> Option<AnyProvider> {
878 let name = &self.config.orchestration.planner_provider;
879 if name.is_empty() {
880 return None;
881 }
882 match create_named_provider(name, &self.config) {
883 Ok(p) => {
884 tracing::info!(provider = %name, "planner provider configured");
885 Some(p)
886 }
887 Err(e) => {
888 tracing::warn!(
889 provider = %name,
890 error = %e,
891 "planner provider resolution failed — primary provider will be used"
892 );
893 None
894 }
895 }
896 }
897
898 pub fn build_verify_provider(&self) -> Option<AnyProvider> {
903 let name = &self.config.orchestration.verify_provider;
904 if name.is_empty() {
905 return None;
906 }
907 match create_named_provider(name, &self.config) {
908 Ok(p) => {
909 tracing::info!(provider = %name, "verify provider configured");
910 Some(p)
911 }
912 Err(e) => {
913 tracing::warn!(
914 provider = %name,
915 error = %e,
916 "verify provider resolution failed — primary provider will be used"
917 );
918 None
919 }
920 }
921 }
922
923 #[cfg(feature = "experiments")]
924 pub fn build_eval_provider(&self) -> Option<AnyProvider> {
925 let model_spec = self.config.experiments.eval_model.as_deref()?;
926 match create_summary_provider(model_spec, &self.config) {
927 Ok(p) => {
928 tracing::info!(eval_model = %model_spec, "experiment eval provider configured");
929 Some(p)
930 }
931 Err(e) => {
932 tracing::warn!(
933 eval_model = %model_spec,
934 error = %e,
935 "failed to create eval provider — primary provider will be used as judge"
936 );
937 None
938 }
939 }
940 }
941
942 pub fn build_scene_provider(&self) -> Option<AnyProvider> {
947 let name = &self.config.memory.tiers.scene_provider;
948 if name.is_empty() {
949 return None;
950 }
951 match create_named_provider(name, &self.config) {
952 Ok(p) => {
953 tracing::info!(provider = %name, "scene consolidation provider configured");
954 Some(p)
955 }
956 Err(e) => {
957 tracing::warn!(
958 provider = %name,
959 error = %e,
960 "scene provider resolution failed — primary provider will be used"
961 );
962 None
963 }
964 }
965 }
966}
967
968#[cfg(test)]
969mod tests;