1use std::path::PathBuf;
5use std::sync::Arc;
6
7use parking_lot::RwLock;
8
9use tokio::sync::{Notify, mpsc, watch};
10use zeph_llm::any::AnyProvider;
11use zeph_llm::provider::LlmProvider;
12
13use super::Agent;
14use super::session_config::{AgentSessionConfig, CONTEXT_BUDGET_RESERVE_RATIO};
15use crate::agent::state::ProviderConfigSnapshot;
16use crate::channel::Channel;
17use crate::config::{
18 CompressionConfig, LearningConfig, ProviderEntry, SecurityConfig, StoreRoutingConfig,
19 TimeoutConfig,
20};
21use crate::config_watcher::ConfigEvent;
22use crate::context::ContextBudget;
23use crate::cost::CostTracker;
24use crate::instructions::{InstructionEvent, InstructionReloadState};
25use crate::metrics::MetricsSnapshot;
26use zeph_memory::semantic::SemanticMemory;
27use zeph_skills::watcher::SkillEvent;
28
29#[derive(Debug, thiserror::Error)]
33pub enum BuildError {
34 #[error("no LLM provider configured (set via with_*_provider or with_provider_pool)")]
37 MissingProviders,
38}
39
40impl<C: Channel> Agent<C> {
41 pub fn build(self) -> Result<Self, BuildError> {
60 if self.providers.provider_pool.is_empty() && self.runtime.model_name.is_empty() {
65 return Err(BuildError::MissingProviders);
66 }
67 Ok(self)
68 }
69
70 #[must_use]
77 pub fn with_memory(
78 mut self,
79 memory: Arc<SemanticMemory>,
80 conversation_id: zeph_memory::ConversationId,
81 history_limit: u32,
82 recall_limit: usize,
83 summarization_threshold: usize,
84 ) -> Self {
85 self.memory_state.persistence.memory = Some(memory);
86 self.memory_state.persistence.conversation_id = Some(conversation_id);
87 self.memory_state.persistence.history_limit = history_limit;
88 self.memory_state.persistence.recall_limit = recall_limit;
89 self.memory_state.compaction.summarization_threshold = summarization_threshold;
90 self.update_metrics(|m| {
91 m.qdrant_available = false;
92 m.sqlite_conversation_id = Some(conversation_id);
93 });
94 self
95 }
96
97 #[must_use]
99 pub fn with_autosave_config(mut self, autosave_assistant: bool, min_length: usize) -> Self {
100 self.memory_state.persistence.autosave_assistant = autosave_assistant;
101 self.memory_state.persistence.autosave_min_length = min_length;
102 self
103 }
104
105 #[must_use]
108 pub fn with_tool_call_cutoff(mut self, cutoff: usize) -> Self {
109 self.memory_state.persistence.tool_call_cutoff = cutoff;
110 self
111 }
112
113 #[must_use]
115 pub fn with_structured_summaries(mut self, enabled: bool) -> Self {
116 self.memory_state.compaction.structured_summaries = enabled;
117 self
118 }
119
120 #[must_use]
124 pub fn with_memory_formatting_config(
125 mut self,
126 compression_guidelines: zeph_memory::CompressionGuidelinesConfig,
127 digest: crate::config::DigestConfig,
128 context_strategy: crate::config::ContextStrategy,
129 crossover_turn_threshold: u32,
130 ) -> Self {
131 self.memory_state.compaction.compression_guidelines_config = compression_guidelines;
132 self.memory_state.compaction.digest_config = digest;
133 self.memory_state.compaction.context_strategy = context_strategy;
134 self.memory_state.compaction.crossover_turn_threshold = crossover_turn_threshold;
135 self
136 }
137
138 #[must_use]
140 pub fn with_document_config(mut self, config: crate::config::DocumentConfig) -> Self {
141 self.memory_state.extraction.document_config = config;
142 self
143 }
144
145 #[must_use]
147 pub fn with_trajectory_and_category_config(
148 mut self,
149 trajectory: crate::config::TrajectoryConfig,
150 category: crate::config::CategoryConfig,
151 ) -> Self {
152 self.memory_state.extraction.trajectory_config = trajectory;
153 self.memory_state.extraction.category_config = category;
154 self
155 }
156
157 #[must_use]
165 pub fn with_graph_config(mut self, config: crate::config::GraphConfig) -> Self {
166 self.memory_state.extraction.apply_graph_config(config);
169 self
170 }
171
172 #[must_use]
176 pub fn with_shutdown_summary_config(
177 mut self,
178 enabled: bool,
179 min_messages: usize,
180 max_messages: usize,
181 timeout_secs: u64,
182 ) -> Self {
183 self.memory_state.compaction.shutdown_summary = enabled;
184 self.memory_state.compaction.shutdown_summary_min_messages = min_messages;
185 self.memory_state.compaction.shutdown_summary_max_messages = max_messages;
186 self.memory_state.compaction.shutdown_summary_timeout_secs = timeout_secs;
187 self
188 }
189
190 #[must_use]
194 pub fn with_skill_reload(
195 mut self,
196 paths: Vec<PathBuf>,
197 rx: mpsc::Receiver<SkillEvent>,
198 ) -> Self {
199 self.skill_state.skill_paths = paths;
200 self.skill_state.skill_reload_rx = Some(rx);
201 self
202 }
203
204 #[must_use]
206 pub fn with_managed_skills_dir(mut self, dir: PathBuf) -> Self {
207 self.skill_state.managed_dir = Some(dir);
208 self
209 }
210
211 #[must_use]
213 pub fn with_trust_config(mut self, config: crate::config::TrustConfig) -> Self {
214 self.skill_state.trust_config = config;
215 self
216 }
217
218 #[must_use]
220 pub fn with_skill_matching_config(
221 mut self,
222 disambiguation_threshold: f32,
223 two_stage_matching: bool,
224 confusability_threshold: f32,
225 ) -> Self {
226 self.skill_state.disambiguation_threshold = disambiguation_threshold;
227 self.skill_state.two_stage_matching = two_stage_matching;
228 self.skill_state.confusability_threshold = confusability_threshold.clamp(0.0, 1.0);
229 self
230 }
231
232 #[must_use]
234 pub fn with_embedding_model(mut self, model: String) -> Self {
235 self.skill_state.embedding_model = model;
236 self
237 }
238
239 #[must_use]
243 pub fn with_embedding_provider(mut self, provider: AnyProvider) -> Self {
244 self.embedding_provider = provider;
245 self
246 }
247
248 #[must_use]
253 pub fn with_hybrid_search(mut self, enabled: bool) -> Self {
254 self.skill_state.hybrid_search = enabled;
255 if enabled {
256 let reg = self.skill_state.registry.read();
257 let all_meta = reg.all_meta();
258 let descs: Vec<&str> = all_meta.iter().map(|m| m.description.as_str()).collect();
259 self.skill_state.bm25_index = Some(zeph_skills::bm25::Bm25Index::build(&descs));
260 }
261 self
262 }
263
264 #[must_use]
268 pub fn with_rl_routing(
269 mut self,
270 enabled: bool,
271 learning_rate: f32,
272 rl_weight: f32,
273 persist_interval: u32,
274 warmup_updates: u32,
275 ) -> Self {
276 self.learning_engine.rl_routing = Some(crate::agent::learning_engine::RlRoutingConfig {
277 enabled,
278 learning_rate,
279 persist_interval,
280 });
281 self.skill_state.rl_weight = rl_weight;
282 self.skill_state.rl_warmup_updates = warmup_updates;
283 self
284 }
285
286 #[must_use]
288 pub fn with_rl_head(mut self, head: zeph_skills::rl_head::RoutingHead) -> Self {
289 self.skill_state.rl_head = Some(head);
290 self
291 }
292
293 #[must_use]
297 pub fn with_summary_provider(mut self, provider: AnyProvider) -> Self {
298 self.providers.summary_provider = Some(provider);
299 self
300 }
301
302 #[must_use]
304 pub fn with_judge_provider(mut self, provider: AnyProvider) -> Self {
305 self.providers.judge_provider = Some(provider);
306 self
307 }
308
309 #[must_use]
313 pub fn with_probe_provider(mut self, provider: AnyProvider) -> Self {
314 self.providers.probe_provider = Some(provider);
315 self
316 }
317
318 #[must_use]
322 pub fn with_compress_provider(mut self, provider: AnyProvider) -> Self {
323 self.providers.compress_provider = Some(provider);
324 self
325 }
326
327 #[must_use]
329 pub fn with_planner_provider(mut self, provider: AnyProvider) -> Self {
330 self.orchestration.planner_provider = Some(provider);
331 self
332 }
333
334 #[must_use]
338 pub fn with_verify_provider(mut self, provider: AnyProvider) -> Self {
339 self.orchestration.verify_provider = Some(provider);
340 self
341 }
342
343 #[must_use]
348 pub fn with_eval_provider(mut self, provider: AnyProvider) -> Self {
349 self.experiments.eval_provider = Some(provider);
350 self
351 }
352
353 #[must_use]
355 pub fn with_provider_pool(
356 mut self,
357 pool: Vec<ProviderEntry>,
358 snapshot: ProviderConfigSnapshot,
359 ) -> Self {
360 self.providers.provider_pool = pool;
361 self.providers.provider_config_snapshot = Some(snapshot);
362 self
363 }
364
365 #[must_use]
368 pub fn with_provider_override(mut self, slot: Arc<RwLock<Option<AnyProvider>>>) -> Self {
369 self.providers.provider_override = Some(slot);
370 self
371 }
372
373 #[must_use]
378 pub fn with_active_provider_name(mut self, name: impl Into<String>) -> Self {
379 self.runtime.active_provider_name = name.into();
380 self
381 }
382
383 #[must_use]
385 pub fn with_stt(mut self, stt: Box<dyn zeph_llm::stt::SpeechToText>) -> Self {
386 self.providers.stt = Some(stt);
387 self
388 }
389
390 #[must_use]
394 pub fn with_mcp(
395 mut self,
396 tools: Vec<zeph_mcp::McpTool>,
397 registry: Option<zeph_mcp::McpToolRegistry>,
398 manager: Option<std::sync::Arc<zeph_mcp::McpManager>>,
399 mcp_config: &crate::config::McpConfig,
400 ) -> Self {
401 self.mcp.tools = tools;
402 self.mcp.registry = registry;
403 self.mcp.manager = manager;
404 self.mcp
405 .allowed_commands
406 .clone_from(&mcp_config.allowed_commands);
407 self.mcp.max_dynamic = mcp_config.max_dynamic_servers;
408 self.mcp.elicitation_warn_sensitive_fields = mcp_config.elicitation_warn_sensitive_fields;
409 self
410 }
411
412 #[must_use]
414 pub fn with_mcp_server_outcomes(
415 mut self,
416 outcomes: Vec<zeph_mcp::ServerConnectOutcome>,
417 ) -> Self {
418 self.mcp.server_outcomes = outcomes;
419 self
420 }
421
422 #[must_use]
424 pub fn with_mcp_shared_tools(mut self, shared: Arc<RwLock<Vec<zeph_mcp::McpTool>>>) -> Self {
425 self.mcp.shared_tools = Some(shared);
426 self
427 }
428
429 #[must_use]
435 pub fn with_mcp_pruning(
436 mut self,
437 params: zeph_mcp::PruningParams,
438 enabled: bool,
439 pruning_provider: Option<zeph_llm::any::AnyProvider>,
440 ) -> Self {
441 self.mcp.pruning_params = params;
442 self.mcp.pruning_enabled = enabled;
443 self.mcp.pruning_provider = pruning_provider;
444 self
445 }
446
447 #[must_use]
452 pub fn with_mcp_discovery(
453 mut self,
454 strategy: zeph_mcp::ToolDiscoveryStrategy,
455 params: zeph_mcp::DiscoveryParams,
456 discovery_provider: Option<zeph_llm::any::AnyProvider>,
457 ) -> Self {
458 self.mcp.discovery_strategy = strategy;
459 self.mcp.discovery_params = params;
460 self.mcp.discovery_provider = discovery_provider;
461 self
462 }
463
464 #[must_use]
468 pub fn with_mcp_tool_rx(
469 mut self,
470 rx: tokio::sync::watch::Receiver<Vec<zeph_mcp::McpTool>>,
471 ) -> Self {
472 self.mcp.tool_rx = Some(rx);
473 self
474 }
475
476 #[must_use]
481 pub fn with_mcp_elicitation_rx(
482 mut self,
483 rx: tokio::sync::mpsc::Receiver<zeph_mcp::ElicitationEvent>,
484 ) -> Self {
485 self.mcp.elicitation_rx = Some(rx);
486 self
487 }
488
489 #[must_use]
494 pub fn with_security(mut self, security: SecurityConfig, timeouts: TimeoutConfig) -> Self {
495 self.security.sanitizer =
496 zeph_sanitizer::ContentSanitizer::new(&security.content_isolation);
497 self.security.exfiltration_guard = zeph_sanitizer::exfiltration::ExfiltrationGuard::new(
498 security.exfiltration_guard.clone(),
499 );
500 self.security.pii_filter = zeph_sanitizer::pii::PiiFilter::new(security.pii_filter.clone());
501 self.security.memory_validator =
502 zeph_sanitizer::memory_validation::MemoryWriteValidator::new(
503 security.memory_validation.clone(),
504 );
505 self.runtime.rate_limiter =
506 crate::agent::rate_limiter::ToolRateLimiter::new(security.rate_limit.clone());
507
508 let mut verifiers: Vec<Box<dyn zeph_tools::PreExecutionVerifier>> = Vec::new();
513 if security.pre_execution_verify.enabled {
514 let dcfg = &security.pre_execution_verify.destructive_commands;
515 if dcfg.enabled {
516 verifiers.push(Box::new(zeph_tools::DestructiveCommandVerifier::new(dcfg)));
517 }
518 let icfg = &security.pre_execution_verify.injection_patterns;
519 if icfg.enabled {
520 verifiers.push(Box::new(zeph_tools::InjectionPatternVerifier::new(icfg)));
521 }
522 let ucfg = &security.pre_execution_verify.url_grounding;
523 if ucfg.enabled {
524 verifiers.push(Box::new(zeph_tools::UrlGroundingVerifier::new(
525 ucfg,
526 std::sync::Arc::clone(&self.security.user_provided_urls),
527 )));
528 }
529 let fcfg = &security.pre_execution_verify.firewall;
530 if fcfg.enabled {
531 verifiers.push(Box::new(zeph_tools::FirewallVerifier::new(fcfg)));
532 }
533 }
534 self.tool_orchestrator.pre_execution_verifiers = verifiers;
535
536 self.security.response_verifier = zeph_sanitizer::response_verifier::ResponseVerifier::new(
537 security.response_verification.clone(),
538 );
539
540 self.runtime.security = security;
541 self.runtime.timeouts = timeouts;
542 self
543 }
544
545 #[must_use]
547 pub fn with_quarantine_summarizer(
548 mut self,
549 qs: zeph_sanitizer::quarantine::QuarantinedSummarizer,
550 ) -> Self {
551 self.security.quarantine_summarizer = Some(qs);
552 self
553 }
554
555 #[must_use]
559 pub fn with_acp_session(mut self, is_acp: bool) -> Self {
560 self.security.is_acp_session = is_acp;
561 self
562 }
563
564 #[must_use]
568 pub fn with_causal_analyzer(
569 mut self,
570 analyzer: zeph_sanitizer::causal_ipi::TurnCausalAnalyzer,
571 ) -> Self {
572 self.security.causal_analyzer = Some(analyzer);
573 self
574 }
575
576 #[cfg(feature = "classifiers")]
581 #[must_use]
582 pub fn with_injection_classifier(
583 mut self,
584 backend: std::sync::Arc<dyn zeph_llm::classifier::ClassifierBackend>,
585 timeout_ms: u64,
586 threshold: f32,
587 threshold_soft: f32,
588 ) -> Self {
589 let old = std::mem::replace(
591 &mut self.security.sanitizer,
592 zeph_sanitizer::ContentSanitizer::new(
593 &zeph_sanitizer::ContentIsolationConfig::default(),
594 ),
595 );
596 self.security.sanitizer = old
597 .with_classifier(backend, timeout_ms, threshold)
598 .with_injection_threshold_soft(threshold_soft);
599 self
600 }
601
602 #[cfg(feature = "classifiers")]
607 #[must_use]
608 pub fn with_enforcement_mode(mut self, mode: zeph_config::InjectionEnforcementMode) -> Self {
609 let old = std::mem::replace(
610 &mut self.security.sanitizer,
611 zeph_sanitizer::ContentSanitizer::new(
612 &zeph_sanitizer::ContentIsolationConfig::default(),
613 ),
614 );
615 self.security.sanitizer = old.with_enforcement_mode(mode);
616 self
617 }
618
619 #[cfg(feature = "classifiers")]
621 #[must_use]
622 pub fn with_three_class_classifier(
623 mut self,
624 backend: std::sync::Arc<dyn zeph_llm::classifier::ClassifierBackend>,
625 threshold: f32,
626 ) -> Self {
627 let old = std::mem::replace(
628 &mut self.security.sanitizer,
629 zeph_sanitizer::ContentSanitizer::new(
630 &zeph_sanitizer::ContentIsolationConfig::default(),
631 ),
632 );
633 self.security.sanitizer = old.with_three_class_backend(backend, threshold);
634 self
635 }
636
637 #[cfg(feature = "classifiers")]
641 #[must_use]
642 pub fn with_scan_user_input(mut self, value: bool) -> Self {
643 let old = std::mem::replace(
644 &mut self.security.sanitizer,
645 zeph_sanitizer::ContentSanitizer::new(
646 &zeph_sanitizer::ContentIsolationConfig::default(),
647 ),
648 );
649 self.security.sanitizer = old.with_scan_user_input(value);
650 self
651 }
652
653 #[cfg(feature = "classifiers")]
658 #[must_use]
659 pub fn with_pii_detector(
660 mut self,
661 detector: std::sync::Arc<dyn zeph_llm::classifier::PiiDetector>,
662 threshold: f32,
663 ) -> Self {
664 let old = std::mem::replace(
665 &mut self.security.sanitizer,
666 zeph_sanitizer::ContentSanitizer::new(
667 &zeph_sanitizer::ContentIsolationConfig::default(),
668 ),
669 );
670 self.security.sanitizer = old.with_pii_detector(detector, threshold);
671 self
672 }
673
674 #[cfg(feature = "classifiers")]
679 #[must_use]
680 pub fn with_pii_ner_allowlist(mut self, entries: Vec<String>) -> Self {
681 let old = std::mem::replace(
682 &mut self.security.sanitizer,
683 zeph_sanitizer::ContentSanitizer::new(
684 &zeph_sanitizer::ContentIsolationConfig::default(),
685 ),
686 );
687 self.security.sanitizer = old.with_pii_ner_allowlist(entries);
688 self
689 }
690
691 #[cfg(feature = "classifiers")]
696 #[must_use]
697 pub fn with_pii_ner_classifier(
698 mut self,
699 backend: std::sync::Arc<dyn zeph_llm::classifier::ClassifierBackend>,
700 timeout_ms: u64,
701 max_chars: usize,
702 circuit_breaker_threshold: u32,
703 ) -> Self {
704 self.security.pii_ner_backend = Some(backend);
705 self.security.pii_ner_timeout_ms = timeout_ms;
706 self.security.pii_ner_max_chars = max_chars;
707 self.security.pii_ner_circuit_breaker_threshold = circuit_breaker_threshold;
708 self
709 }
710
711 #[must_use]
713 pub fn with_guardrail(mut self, filter: zeph_sanitizer::guardrail::GuardrailFilter) -> Self {
714 use zeph_sanitizer::guardrail::GuardrailAction;
715 let warn_mode = filter.action() == GuardrailAction::Warn;
716 self.security.guardrail = Some(filter);
717 self.update_metrics(|m| {
718 m.guardrail_enabled = true;
719 m.guardrail_warn_mode = warn_mode;
720 });
721 self
722 }
723
724 #[must_use]
726 pub fn with_audit_logger(mut self, logger: std::sync::Arc<zeph_tools::AuditLogger>) -> Self {
727 self.tool_orchestrator.audit_logger = Some(logger);
728 self
729 }
730
731 #[must_use]
735 pub fn with_context_budget(
736 mut self,
737 budget_tokens: usize,
738 reserve_ratio: f32,
739 hard_compaction_threshold: f32,
740 compaction_preserve_tail: usize,
741 prune_protect_tokens: usize,
742 ) -> Self {
743 if budget_tokens == 0 {
744 tracing::warn!("context budget is 0 — agent will have no token tracking");
745 }
746 if budget_tokens > 0 {
747 self.context_manager.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
748 }
749 self.context_manager.hard_compaction_threshold = hard_compaction_threshold;
750 self.context_manager.compaction_preserve_tail = compaction_preserve_tail;
751 self.context_manager.prune_protect_tokens = prune_protect_tokens;
752 self
753 }
754
755 #[must_use]
757 pub fn with_compression(mut self, compression: CompressionConfig) -> Self {
758 self.context_manager.compression = compression;
759 self
760 }
761
762 #[must_use]
764 pub fn with_routing(mut self, routing: StoreRoutingConfig) -> Self {
765 self.context_manager.routing = routing;
766 self
767 }
768
769 #[must_use]
771 pub fn with_focus_and_sidequest_config(
772 mut self,
773 focus: crate::config::FocusConfig,
774 sidequest: crate::config::SidequestConfig,
775 ) -> Self {
776 self.focus = super::focus::FocusState::new(focus);
777 self.sidequest = super::sidequest::SidequestState::new(sidequest);
778 self
779 }
780
781 #[must_use]
785 pub fn add_tool_executor(
786 mut self,
787 extra: impl zeph_tools::executor::ToolExecutor + 'static,
788 ) -> Self {
789 let existing = Arc::clone(&self.tool_executor);
790 let combined = zeph_tools::CompositeExecutor::new(zeph_tools::DynExecutor(existing), extra);
791 self.tool_executor = Arc::new(combined);
792 self
793 }
794
795 #[must_use]
799 pub fn with_tafc_config(mut self, config: zeph_tools::TafcConfig) -> Self {
800 self.tool_orchestrator.tafc = config.validated();
801 self
802 }
803
804 #[must_use]
806 pub fn with_dependency_config(mut self, config: zeph_tools::DependencyConfig) -> Self {
807 self.runtime.dependency_config = config;
808 self
809 }
810
811 #[must_use]
816 pub fn with_tool_dependency_graph(
817 mut self,
818 graph: zeph_tools::ToolDependencyGraph,
819 always_on: std::collections::HashSet<String>,
820 ) -> Self {
821 self.tool_state.dependency_graph = Some(graph);
822 self.tool_state.dependency_always_on = always_on;
823 self
824 }
825
826 pub async fn maybe_init_tool_schema_filter(
831 mut self,
832 config: &crate::config::ToolFilterConfig,
833 provider: &zeph_llm::any::AnyProvider,
834 ) -> Self {
835 use zeph_llm::provider::LlmProvider;
836
837 if !config.enabled {
838 return self;
839 }
840
841 let always_on_set: std::collections::HashSet<&str> =
842 config.always_on.iter().map(String::as_str).collect();
843 let defs = self.tool_executor.tool_definitions_erased();
844 let filterable: Vec<&zeph_tools::registry::ToolDef> = defs
845 .iter()
846 .filter(|d| !always_on_set.contains(d.id.as_ref()))
847 .collect();
848
849 if filterable.is_empty() {
850 tracing::info!("tool schema filter: all tools are always-on, nothing to filter");
851 return self;
852 }
853
854 let mut embeddings = Vec::with_capacity(filterable.len());
855 for def in &filterable {
856 let text = format!("{}: {}", def.id, def.description);
857 match provider.embed(&text).await {
858 Ok(emb) => {
859 embeddings.push(zeph_tools::ToolEmbedding {
860 tool_id: def.id.as_ref().into(),
861 embedding: emb,
862 });
863 }
864 Err(e) => {
865 tracing::info!(
866 provider = provider.name(),
867 "tool schema filter disabled: embedding not supported \
868 by provider ({e:#})"
869 );
870 return self;
871 }
872 }
873 }
874
875 tracing::info!(
876 tool_count = embeddings.len(),
877 always_on = config.always_on.len(),
878 top_k = config.top_k,
879 "tool schema filter initialized"
880 );
881
882 let filter = zeph_tools::ToolSchemaFilter::new(
883 config.always_on.clone(),
884 config.top_k,
885 config.min_description_words,
886 embeddings,
887 );
888 self.tool_state.tool_schema_filter = Some(filter);
889 self
890 }
891
892 #[must_use]
899 pub fn with_index_mcp_server(self, project_root: impl Into<std::path::PathBuf>) -> Self {
900 let server = zeph_index::IndexMcpServer::new(project_root);
901 self.add_tool_executor(server)
902 }
903
904 #[must_use]
906 pub fn with_repo_map(mut self, token_budget: usize, ttl_secs: u64) -> Self {
907 self.index.repo_map_tokens = token_budget;
908 self.index.repo_map_ttl = std::time::Duration::from_secs(ttl_secs);
909 self
910 }
911
912 #[must_use]
916 pub fn with_debug_dumper(mut self, dumper: crate::debug_dump::DebugDumper) -> Self {
917 self.debug_state.debug_dumper = Some(dumper);
918 self
919 }
920
921 #[must_use]
923 pub fn with_trace_collector(
924 mut self,
925 collector: crate::debug_dump::trace::TracingCollector,
926 ) -> Self {
927 self.debug_state.trace_collector = Some(collector);
928 self
929 }
930
931 #[must_use]
933 pub fn with_trace_config(
934 mut self,
935 dump_dir: std::path::PathBuf,
936 service_name: impl Into<String>,
937 redact: bool,
938 ) -> Self {
939 self.debug_state.dump_dir = Some(dump_dir);
940 self.debug_state.trace_service_name = service_name.into();
941 self.debug_state.trace_redact = redact;
942 self
943 }
944
945 #[must_use]
947 pub fn with_anomaly_detector(mut self, detector: zeph_tools::AnomalyDetector) -> Self {
948 self.debug_state.anomaly_detector = Some(detector);
949 self
950 }
951
952 #[must_use]
954 pub fn with_logging_config(mut self, logging: crate::config::LoggingConfig) -> Self {
955 self.debug_state.logging_config = logging;
956 self
957 }
958
959 #[must_use]
963 pub fn with_shutdown(mut self, rx: watch::Receiver<bool>) -> Self {
964 self.lifecycle.shutdown = rx;
965 self
966 }
967
968 #[must_use]
970 pub fn with_config_reload(mut self, path: PathBuf, rx: mpsc::Receiver<ConfigEvent>) -> Self {
971 self.lifecycle.config_path = Some(path);
972 self.lifecycle.config_reload_rx = Some(rx);
973 self
974 }
975
976 #[must_use]
978 pub fn with_warmup_ready(mut self, rx: watch::Receiver<bool>) -> Self {
979 self.lifecycle.warmup_ready = Some(rx);
980 self
981 }
982
983 #[must_use]
985 pub fn with_update_notifications(mut self, rx: mpsc::Receiver<String>) -> Self {
986 self.lifecycle.update_notify_rx = Some(rx);
987 self
988 }
989
990 #[must_use]
992 pub fn with_custom_task_rx(mut self, rx: mpsc::Receiver<String>) -> Self {
993 self.lifecycle.custom_task_rx = Some(rx);
994 self
995 }
996
997 #[must_use]
1000 pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
1001 self.lifecycle.cancel_signal = signal;
1002 self
1003 }
1004
1005 #[must_use]
1011 pub fn with_hooks_config(mut self, config: &zeph_config::HooksConfig) -> Self {
1012 self.session
1013 .hooks_config
1014 .cwd_changed
1015 .clone_from(&config.cwd_changed);
1016
1017 if let Some(ref fc) = config.file_changed {
1018 self.session
1019 .hooks_config
1020 .file_changed_hooks
1021 .clone_from(&fc.hooks);
1022
1023 if !fc.watch_paths.is_empty() {
1024 let (tx, rx) = tokio::sync::mpsc::channel(64);
1025 match crate::file_watcher::FileChangeWatcher::start(
1026 &fc.watch_paths,
1027 fc.debounce_ms,
1028 tx,
1029 ) {
1030 Ok(watcher) => {
1031 self.lifecycle.file_watcher = Some(watcher);
1032 self.lifecycle.file_changed_rx = Some(rx);
1033 tracing::info!(
1034 paths = ?fc.watch_paths,
1035 debounce_ms = fc.debounce_ms,
1036 "file change watcher started"
1037 );
1038 }
1039 Err(e) => {
1040 tracing::warn!(error = %e, "failed to start file change watcher");
1041 }
1042 }
1043 }
1044 }
1045
1046 let cwd_str = &self.session.env_context.working_dir;
1048 if !cwd_str.is_empty() {
1049 self.lifecycle.last_known_cwd = std::path::PathBuf::from(cwd_str);
1050 }
1051
1052 self
1053 }
1054
1055 #[must_use]
1057 pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
1058 let path = path.into();
1059 self.session.env_context =
1060 crate::context::EnvironmentContext::gather_for_dir(&self.runtime.model_name, &path);
1061 self
1062 }
1063
1064 #[must_use]
1066 pub fn with_policy_config(mut self, config: zeph_tools::PolicyConfig) -> Self {
1067 self.session.policy_config = Some(config);
1068 self
1069 }
1070
1071 #[must_use]
1077 pub fn with_parent_tool_use_id(mut self, id: impl Into<String>) -> Self {
1078 self.session.parent_tool_use_id = Some(id.into());
1079 self
1080 }
1081
1082 #[must_use]
1084 pub fn with_response_cache(
1085 mut self,
1086 cache: std::sync::Arc<zeph_memory::ResponseCache>,
1087 ) -> Self {
1088 self.session.response_cache = Some(cache);
1089 self
1090 }
1091
1092 #[must_use]
1094 pub fn with_lsp_hooks(mut self, runner: crate::lsp_hooks::LspHookRunner) -> Self {
1095 self.session.lsp_hooks = Some(runner);
1096 self
1097 }
1098
1099 #[must_use]
1105 pub fn with_supervisor_config(mut self, config: &crate::config::TaskSupervisorConfig) -> Self {
1106 self.lifecycle.supervisor = crate::agent::agent_supervisor::BackgroundSupervisor::new(
1107 config,
1108 self.metrics.histogram_recorder.clone(),
1109 );
1110 self.runtime.supervisor_config = config.clone();
1111 self
1112 }
1113
1114 #[must_use]
1118 pub fn cancel_signal(&self) -> Arc<Notify> {
1119 Arc::clone(&self.lifecycle.cancel_signal)
1120 }
1121
1122 #[must_use]
1126 pub fn with_metrics(mut self, tx: watch::Sender<MetricsSnapshot>) -> Self {
1127 let provider_name = if self.runtime.active_provider_name.is_empty() {
1128 self.provider.name().to_owned()
1129 } else {
1130 self.runtime.active_provider_name.clone()
1131 };
1132 let model_name = self.runtime.model_name.clone();
1133 let total_skills = self.skill_state.registry.read().all_meta().len();
1134 let qdrant_available = false;
1135 let conversation_id = self.memory_state.persistence.conversation_id;
1136 let prompt_estimate = self
1137 .msg
1138 .messages
1139 .first()
1140 .map_or(0, |m| u64::try_from(m.content.len()).unwrap_or(0) / 4);
1141 let mcp_tool_count = self.mcp.tools.len();
1142 let mcp_server_count = if self.mcp.server_outcomes.is_empty() {
1143 self.mcp
1145 .tools
1146 .iter()
1147 .map(|t| &t.server_id)
1148 .collect::<std::collections::HashSet<_>>()
1149 .len()
1150 } else {
1151 self.mcp.server_outcomes.len()
1152 };
1153 let mcp_connected_count = if self.mcp.server_outcomes.is_empty() {
1154 mcp_server_count
1155 } else {
1156 self.mcp
1157 .server_outcomes
1158 .iter()
1159 .filter(|o| o.connected)
1160 .count()
1161 };
1162 let mcp_servers: Vec<crate::metrics::McpServerStatus> = self
1163 .mcp
1164 .server_outcomes
1165 .iter()
1166 .map(|o| crate::metrics::McpServerStatus {
1167 id: o.id.clone(),
1168 status: if o.connected {
1169 crate::metrics::McpServerConnectionStatus::Connected
1170 } else {
1171 crate::metrics::McpServerConnectionStatus::Failed
1172 },
1173 tool_count: o.tool_count,
1174 error: o.error.clone(),
1175 })
1176 .collect();
1177 let extended_context = self.metrics.extended_context;
1178 tx.send_modify(|m| {
1179 m.provider_name = provider_name;
1180 m.model_name = model_name;
1181 m.total_skills = total_skills;
1182 m.qdrant_available = qdrant_available;
1183 m.sqlite_conversation_id = conversation_id;
1184 m.context_tokens = prompt_estimate;
1185 m.prompt_tokens = prompt_estimate;
1186 m.total_tokens = prompt_estimate;
1187 m.mcp_tool_count = mcp_tool_count;
1188 m.mcp_server_count = mcp_server_count;
1189 m.mcp_connected_count = mcp_connected_count;
1190 m.mcp_servers = mcp_servers;
1191 m.extended_context = extended_context;
1192 });
1193 self.metrics.metrics_tx = Some(tx);
1194 self
1195 }
1196
1197 #[must_use]
1199 pub fn with_cost_tracker(mut self, tracker: CostTracker) -> Self {
1200 self.metrics.cost_tracker = Some(tracker);
1201 self
1202 }
1203
1204 #[must_use]
1206 pub fn with_extended_context(mut self, enabled: bool) -> Self {
1207 self.metrics.extended_context = enabled;
1208 self
1209 }
1210
1211 #[must_use]
1219 pub fn with_histogram_recorder(
1220 mut self,
1221 recorder: Option<std::sync::Arc<dyn crate::metrics::HistogramRecorder>>,
1222 ) -> Self {
1223 self.metrics.histogram_recorder = recorder;
1224 self
1225 }
1226
1227 #[must_use]
1235 pub fn with_orchestration(
1236 mut self,
1237 config: crate::config::OrchestrationConfig,
1238 subagent_config: crate::config::SubAgentConfig,
1239 manager: zeph_subagent::SubAgentManager,
1240 ) -> Self {
1241 self.orchestration.orchestration_config = config;
1242 self.orchestration.subagent_config = subagent_config;
1243 self.orchestration.subagent_manager = Some(manager);
1244 self
1245 }
1246
1247 #[must_use]
1249 pub fn with_adversarial_policy_info(
1250 mut self,
1251 info: crate::agent::state::AdversarialPolicyInfo,
1252 ) -> Self {
1253 self.runtime.adversarial_policy_info = Some(info);
1254 self
1255 }
1256
1257 #[must_use]
1269 pub fn with_experiment(
1270 mut self,
1271 config: crate::config::ExperimentConfig,
1272 baseline: zeph_experiments::ConfigSnapshot,
1273 ) -> Self {
1274 self.experiments.config = config;
1275 self.experiments.baseline = baseline;
1276 self
1277 }
1278
1279 #[must_use]
1283 pub fn with_learning(mut self, config: LearningConfig) -> Self {
1284 if config.correction_detection {
1285 self.feedback.detector = super::feedback_detector::FeedbackDetector::new(
1286 config.correction_confidence_threshold,
1287 );
1288 if config.detector_mode == crate::config::DetectorMode::Judge {
1289 self.feedback.judge = Some(super::feedback_detector::JudgeDetector::new(
1290 config.judge_adaptive_low,
1291 config.judge_adaptive_high,
1292 ));
1293 }
1294 }
1295 self.learning_engine.config = Some(config);
1296 self
1297 }
1298
1299 #[must_use]
1305 pub fn with_llm_classifier(
1306 mut self,
1307 classifier: zeph_llm::classifier::llm::LlmClassifier,
1308 ) -> Self {
1309 #[cfg(feature = "classifiers")]
1311 let classifier = if let Some(ref m) = self.metrics.classifier_metrics {
1312 classifier.with_metrics(std::sync::Arc::clone(m))
1313 } else {
1314 classifier
1315 };
1316 self.feedback.llm_classifier = Some(classifier);
1317 self
1318 }
1319
1320 #[must_use]
1322 pub fn with_channel_skills(mut self, config: zeph_config::ChannelSkillsConfig) -> Self {
1323 self.runtime.channel_skills = config;
1324 self
1325 }
1326
1327 pub(super) fn summary_or_primary_provider(&self) -> &AnyProvider {
1330 self.providers
1331 .summary_provider
1332 .as_ref()
1333 .unwrap_or(&self.provider)
1334 }
1335
1336 pub(super) fn probe_or_summary_provider(&self) -> &AnyProvider {
1337 self.providers
1338 .probe_provider
1339 .as_ref()
1340 .or(self.providers.summary_provider.as_ref())
1341 .unwrap_or(&self.provider)
1342 }
1343
1344 pub(super) fn last_assistant_response(&self) -> String {
1346 self.msg
1347 .messages
1348 .iter()
1349 .rev()
1350 .find(|m| m.role == zeph_llm::provider::Role::Assistant)
1351 .map(|m| super::context::truncate_chars(&m.content, 500))
1352 .unwrap_or_default()
1353 }
1354
1355 #[must_use]
1363 #[allow(clippy::too_many_lines)] pub fn apply_session_config(mut self, cfg: AgentSessionConfig) -> Self {
1365 let AgentSessionConfig {
1366 max_tool_iterations,
1367 max_tool_retries,
1368 max_retry_duration_secs,
1369 retry_base_ms,
1370 retry_max_ms,
1371 parameter_reformat_provider,
1372 tool_repeat_threshold,
1373 tool_summarization,
1374 tool_call_cutoff,
1375 max_tool_calls_per_session,
1376 overflow_config,
1377 permission_policy,
1378 model_name,
1379 embed_model,
1380 semantic_cache_enabled,
1381 semantic_cache_threshold,
1382 semantic_cache_max_candidates,
1383 budget_tokens,
1384 soft_compaction_threshold,
1385 hard_compaction_threshold,
1386 compaction_preserve_tail,
1387 compaction_cooldown_turns,
1388 prune_protect_tokens,
1389 redact_credentials,
1390 security,
1391 timeouts,
1392 learning,
1393 document_config,
1394 graph_config,
1395 persona_config,
1396 trajectory_config,
1397 category_config,
1398 tree_config,
1399 microcompact_config,
1400 autodream_config,
1401 magic_docs_config,
1402 anomaly_config,
1403 result_cache_config,
1404 mut utility_config,
1405 orchestration_config,
1406 debug_config: _debug_config,
1409 server_compaction,
1410 budget_hint_enabled,
1411 secrets,
1412 } = cfg;
1413
1414 self.tool_orchestrator.apply_config(
1415 max_tool_iterations,
1416 max_tool_retries,
1417 max_retry_duration_secs,
1418 retry_base_ms,
1419 retry_max_ms,
1420 parameter_reformat_provider,
1421 tool_repeat_threshold,
1422 max_tool_calls_per_session,
1423 tool_summarization,
1424 overflow_config,
1425 );
1426 self.runtime.permission_policy = permission_policy;
1427 self.runtime.model_name = model_name;
1428 self.skill_state.embedding_model = embed_model;
1429 self.context_manager.apply_budget_config(
1430 budget_tokens,
1431 CONTEXT_BUDGET_RESERVE_RATIO,
1432 hard_compaction_threshold,
1433 compaction_preserve_tail,
1434 prune_protect_tokens,
1435 soft_compaction_threshold,
1436 compaction_cooldown_turns,
1437 );
1438 self = self
1439 .with_security(security, timeouts)
1440 .with_learning(learning);
1441 self.runtime.redact_credentials = redact_credentials;
1442 self.memory_state.persistence.tool_call_cutoff = tool_call_cutoff;
1443 self.skill_state.available_custom_secrets = secrets
1444 .iter()
1445 .map(|(k, v)| (k.clone(), crate::vault::Secret::new(v.expose().to_owned())))
1446 .collect();
1447 self.providers.server_compaction_active = server_compaction;
1448 self.memory_state.extraction.document_config = document_config;
1449 self.memory_state
1450 .extraction
1451 .apply_graph_config(graph_config);
1452 self.memory_state.extraction.persona_config = persona_config;
1453 self.memory_state.extraction.trajectory_config = trajectory_config;
1454 self.memory_state.extraction.category_config = category_config;
1455 self.memory_state.subsystems.tree_config = tree_config;
1456 self.memory_state.subsystems.microcompact_config = microcompact_config;
1457 self.memory_state.subsystems.autodream_config = autodream_config;
1458 self.memory_state.subsystems.magic_docs_config = magic_docs_config;
1459 self.orchestration.orchestration_config = orchestration_config;
1460 self.runtime.budget_hint_enabled = budget_hint_enabled;
1461
1462 self.debug_state.reasoning_model_warning = anomaly_config.reasoning_model_warning;
1463 if anomaly_config.enabled {
1464 self = self.with_anomaly_detector(zeph_tools::AnomalyDetector::new(
1465 anomaly_config.window_size,
1466 anomaly_config.error_threshold,
1467 anomaly_config.critical_threshold,
1468 ));
1469 }
1470
1471 self.runtime.semantic_cache_enabled = semantic_cache_enabled;
1472 self.runtime.semantic_cache_threshold = semantic_cache_threshold;
1473 self.runtime.semantic_cache_max_candidates = semantic_cache_max_candidates;
1474 self.tool_orchestrator
1475 .set_cache_config(&result_cache_config);
1476
1477 if self.memory_state.subsystems.magic_docs_config.enabled {
1480 utility_config.exempt_tools.extend(
1481 crate::agent::magic_docs::FILE_READ_TOOLS
1482 .iter()
1483 .map(|s| (*s).to_string()),
1484 );
1485 utility_config.exempt_tools.sort_unstable();
1486 utility_config.exempt_tools.dedup();
1487 }
1488 self.tool_orchestrator.set_utility_config(utility_config);
1489
1490 self
1491 }
1492
1493 #[must_use]
1497 pub fn with_instruction_blocks(
1498 mut self,
1499 blocks: Vec<crate::instructions::InstructionBlock>,
1500 ) -> Self {
1501 self.instructions.blocks = blocks;
1502 self
1503 }
1504
1505 #[must_use]
1507 pub fn with_instruction_reload(
1508 mut self,
1509 rx: mpsc::Receiver<InstructionEvent>,
1510 state: InstructionReloadState,
1511 ) -> Self {
1512 self.instructions.reload_rx = Some(rx);
1513 self.instructions.reload_state = Some(state);
1514 self
1515 }
1516
1517 #[must_use]
1521 pub fn with_status_tx(mut self, tx: tokio::sync::mpsc::UnboundedSender<String>) -> Self {
1522 self.session.status_tx = Some(tx);
1523 self
1524 }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::super::agent_tests::{
1530 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
1531 };
1532 use super::*;
1533 use crate::config::{CompressionStrategy, StoreRoutingConfig, StoreRoutingStrategy};
1534
1535 fn make_agent() -> Agent<MockChannel> {
1536 Agent::new(
1537 mock_provider(vec![]),
1538 MockChannel::new(vec![]),
1539 create_test_registry(),
1540 None,
1541 5,
1542 MockToolExecutor::no_tools(),
1543 )
1544 }
1545
1546 #[test]
1547 #[allow(clippy::default_trait_access)]
1548 fn with_compression_sets_proactive_strategy() {
1549 let compression = CompressionConfig {
1550 strategy: CompressionStrategy::Proactive {
1551 threshold_tokens: 50_000,
1552 max_summary_tokens: 2_000,
1553 },
1554 model: String::new(),
1555 pruning_strategy: crate::config::PruningStrategy::default(),
1556 probe: zeph_memory::CompactionProbeConfig::default(),
1557 compress_provider: zeph_config::ProviderName::default(),
1558 archive_tool_outputs: false,
1559 focus_scorer_provider: zeph_config::ProviderName::default(),
1560 high_density_budget: 0.7,
1561 low_density_budget: 0.3,
1562 predictor: Default::default(),
1563 };
1564 let agent = make_agent().with_compression(compression);
1565 assert!(
1566 matches!(
1567 agent.context_manager.compression.strategy,
1568 CompressionStrategy::Proactive {
1569 threshold_tokens: 50_000,
1570 max_summary_tokens: 2_000,
1571 }
1572 ),
1573 "expected Proactive strategy after with_compression"
1574 );
1575 }
1576
1577 #[test]
1578 fn with_routing_sets_routing_config() {
1579 let routing = StoreRoutingConfig {
1580 strategy: StoreRoutingStrategy::Heuristic,
1581 ..StoreRoutingConfig::default()
1582 };
1583 let agent = make_agent().with_routing(routing);
1584 assert_eq!(
1585 agent.context_manager.routing.strategy,
1586 StoreRoutingStrategy::Heuristic,
1587 "routing strategy must be set by with_routing"
1588 );
1589 }
1590
1591 #[test]
1592 fn default_compression_is_reactive() {
1593 let agent = make_agent();
1594 assert_eq!(
1595 agent.context_manager.compression.strategy,
1596 CompressionStrategy::Reactive,
1597 "default compression strategy must be Reactive"
1598 );
1599 }
1600
1601 #[test]
1602 fn default_routing_is_heuristic() {
1603 let agent = make_agent();
1604 assert_eq!(
1605 agent.context_manager.routing.strategy,
1606 StoreRoutingStrategy::Heuristic,
1607 "default routing strategy must be Heuristic"
1608 );
1609 }
1610
1611 #[test]
1612 fn with_cancel_signal_replaces_internal_signal() {
1613 let agent = Agent::new(
1614 mock_provider(vec![]),
1615 MockChannel::new(vec![]),
1616 create_test_registry(),
1617 None,
1618 5,
1619 MockToolExecutor::no_tools(),
1620 );
1621
1622 let shared = Arc::new(Notify::new());
1623 let agent = agent.with_cancel_signal(Arc::clone(&shared));
1624
1625 assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
1627 }
1628
1629 #[tokio::test]
1634 async fn with_managed_skills_dir_enables_install_command() {
1635 let provider = mock_provider(vec![]);
1636 let channel = MockChannel::new(vec![]);
1637 let registry = create_test_registry();
1638 let executor = MockToolExecutor::no_tools();
1639 let managed = tempfile::tempdir().unwrap();
1640
1641 let mut agent_no_dir = Agent::new(
1642 mock_provider(vec![]),
1643 MockChannel::new(vec![]),
1644 create_test_registry(),
1645 None,
1646 5,
1647 MockToolExecutor::no_tools(),
1648 );
1649 let out_no_dir = agent_no_dir
1650 .handle_skill_command_as_string("install /some/path")
1651 .await
1652 .unwrap();
1653 assert!(
1654 out_no_dir.contains("not configured"),
1655 "without managed dir: {out_no_dir:?}"
1656 );
1657
1658 let _ = (provider, channel, registry, executor);
1659 let mut agent_with_dir = Agent::new(
1660 mock_provider(vec![]),
1661 MockChannel::new(vec![]),
1662 create_test_registry(),
1663 None,
1664 5,
1665 MockToolExecutor::no_tools(),
1666 )
1667 .with_managed_skills_dir(managed.path().to_path_buf());
1668
1669 let out_with_dir = agent_with_dir
1670 .handle_skill_command_as_string("install /nonexistent/path")
1671 .await
1672 .unwrap();
1673 assert!(
1674 !out_with_dir.contains("not configured"),
1675 "with managed dir should not say not configured: {out_with_dir:?}"
1676 );
1677 assert!(
1678 out_with_dir.contains("Install failed"),
1679 "with managed dir should fail due to bad path: {out_with_dir:?}"
1680 );
1681 }
1682
1683 #[test]
1684 fn default_graph_config_is_disabled() {
1685 let agent = make_agent();
1686 assert!(
1687 !agent.memory_state.extraction.graph_config.enabled,
1688 "graph_config must default to disabled"
1689 );
1690 }
1691
1692 #[test]
1693 fn with_graph_config_enabled_sets_flag() {
1694 let cfg = crate::config::GraphConfig {
1695 enabled: true,
1696 ..Default::default()
1697 };
1698 let agent = make_agent().with_graph_config(cfg);
1699 assert!(
1700 agent.memory_state.extraction.graph_config.enabled,
1701 "with_graph_config must set enabled flag"
1702 );
1703 }
1704
1705 #[test]
1711 fn apply_session_config_wires_graph_orchestration_anomaly() {
1712 use crate::config::Config;
1713
1714 let mut config = Config::default();
1715 config.memory.graph.enabled = true;
1716 config.orchestration.enabled = true;
1717 config.orchestration.max_tasks = 42;
1718 config.tools.anomaly.enabled = true;
1719 config.tools.anomaly.window_size = 7;
1720
1721 let session_cfg = AgentSessionConfig::from_config(&config, 100_000);
1722
1723 assert!(session_cfg.graph_config.enabled);
1725 assert!(session_cfg.orchestration_config.enabled);
1726 assert_eq!(session_cfg.orchestration_config.max_tasks, 42);
1727 assert!(session_cfg.anomaly_config.enabled);
1728 assert_eq!(session_cfg.anomaly_config.window_size, 7);
1729
1730 let agent = make_agent().apply_session_config(session_cfg);
1731
1732 assert!(
1734 agent.memory_state.extraction.graph_config.enabled,
1735 "apply_session_config must wire graph_config into agent"
1736 );
1737
1738 assert!(
1740 agent.orchestration.orchestration_config.enabled,
1741 "apply_session_config must wire orchestration_config into agent"
1742 );
1743 assert_eq!(
1744 agent.orchestration.orchestration_config.max_tasks, 42,
1745 "orchestration max_tasks must match config"
1746 );
1747
1748 assert!(
1750 agent.debug_state.anomaly_detector.is_some(),
1751 "apply_session_config must create anomaly_detector when enabled"
1752 );
1753 }
1754
1755 #[test]
1756 fn with_focus_and_sidequest_config_propagates() {
1757 let focus = crate::config::FocusConfig {
1758 enabled: true,
1759 compression_interval: 7,
1760 ..Default::default()
1761 };
1762 let sidequest = crate::config::SidequestConfig {
1763 enabled: true,
1764 interval_turns: 3,
1765 ..Default::default()
1766 };
1767 let agent = make_agent().with_focus_and_sidequest_config(focus, sidequest);
1768 assert!(agent.focus.config.enabled, "must set focus.enabled");
1769 assert_eq!(
1770 agent.focus.config.compression_interval, 7,
1771 "must propagate compression_interval"
1772 );
1773 assert!(agent.sidequest.config.enabled, "must set sidequest.enabled");
1774 assert_eq!(
1775 agent.sidequest.config.interval_turns, 3,
1776 "must propagate interval_turns"
1777 );
1778 }
1779
1780 #[test]
1782 fn apply_session_config_skips_anomaly_detector_when_disabled() {
1783 use crate::config::Config;
1784
1785 let mut config = Config::default();
1786 config.tools.anomaly.enabled = false; let session_cfg = AgentSessionConfig::from_config(&config, 100_000);
1788 assert!(!session_cfg.anomaly_config.enabled);
1789
1790 let agent = make_agent().apply_session_config(session_cfg);
1791 assert!(
1792 agent.debug_state.anomaly_detector.is_none(),
1793 "apply_session_config must not create anomaly_detector when disabled"
1794 );
1795 }
1796
1797 #[test]
1798 fn with_skill_matching_config_sets_fields() {
1799 let agent = make_agent().with_skill_matching_config(0.7, true, 0.85);
1800 assert!(
1801 agent.skill_state.two_stage_matching,
1802 "with_skill_matching_config must set two_stage_matching"
1803 );
1804 assert!(
1805 (agent.skill_state.disambiguation_threshold - 0.7).abs() < f32::EPSILON,
1806 "with_skill_matching_config must set disambiguation_threshold"
1807 );
1808 assert!(
1809 (agent.skill_state.confusability_threshold - 0.85).abs() < f32::EPSILON,
1810 "with_skill_matching_config must set confusability_threshold"
1811 );
1812 }
1813
1814 #[test]
1815 fn with_skill_matching_config_clamps_confusability() {
1816 let agent = make_agent().with_skill_matching_config(0.5, false, 1.5);
1817 assert!(
1818 (agent.skill_state.confusability_threshold - 1.0).abs() < f32::EPSILON,
1819 "with_skill_matching_config must clamp confusability above 1.0"
1820 );
1821
1822 let agent = make_agent().with_skill_matching_config(0.5, false, -0.1);
1823 assert!(
1824 agent.skill_state.confusability_threshold.abs() < f32::EPSILON,
1825 "with_skill_matching_config must clamp confusability below 0.0"
1826 );
1827 }
1828
1829 #[test]
1830 fn build_succeeds_with_provider_pool() {
1831 let (_tx, rx) = watch::channel(false);
1832 let snapshot = crate::agent::state::ProviderConfigSnapshot {
1834 claude_api_key: None,
1835 openai_api_key: None,
1836 gemini_api_key: None,
1837 compatible_api_keys: std::collections::HashMap::new(),
1838 llm_request_timeout_secs: 30,
1839 embedding_model: String::new(),
1840 };
1841 let agent = make_agent()
1842 .with_shutdown(rx)
1843 .with_provider_pool(
1844 vec![ProviderEntry {
1845 name: Some("test".into()),
1846 ..Default::default()
1847 }],
1848 snapshot,
1849 )
1850 .build();
1851 assert!(agent.is_ok(), "build must succeed with a provider pool");
1852 }
1853
1854 #[test]
1855 fn build_fails_without_provider_or_model_name() {
1856 let agent = make_agent().build();
1857 assert!(
1858 matches!(agent, Err(BuildError::MissingProviders)),
1859 "build must return MissingProviders when pool is empty and model_name is unset"
1860 );
1861 }
1862}