1use std::collections::VecDeque;
5use std::path::PathBuf;
6use std::sync::Arc;
7
8use tokio::sync::{Notify, mpsc, watch};
9use zeph_llm::any::AnyProvider;
10use zeph_llm::provider::LlmProvider;
11
12use super::Agent;
13use crate::channel::Channel;
14use crate::config::{
15 CompressionConfig, LearningConfig, RoutingConfig, SecurityConfig, TimeoutConfig,
16};
17use crate::config_watcher::ConfigEvent;
18use crate::context::ContextBudget;
19use crate::cost::CostTracker;
20use crate::instructions::{InstructionEvent, InstructionReloadState};
21use crate::metrics::MetricsSnapshot;
22use zeph_memory::semantic::SemanticMemory;
23use zeph_skills::watcher::SkillEvent;
24
25impl<C: Channel> Agent<C> {
26 #[must_use]
27 pub fn with_autosave_config(mut self, autosave_assistant: bool, min_length: usize) -> Self {
28 self.memory_state.autosave_assistant = autosave_assistant;
29 self.memory_state.autosave_min_length = min_length;
30 self
31 }
32
33 #[must_use]
34 pub fn with_tool_call_cutoff(mut self, cutoff: usize) -> Self {
35 self.memory_state.tool_call_cutoff = cutoff;
36 self
37 }
38
39 #[must_use]
40 pub fn with_response_cache(
41 mut self,
42 cache: std::sync::Arc<zeph_memory::ResponseCache>,
43 ) -> Self {
44 self.response_cache = Some(cache);
45 self
46 }
47
48 #[must_use]
54 pub fn with_parent_tool_use_id(mut self, id: impl Into<String>) -> Self {
55 self.parent_tool_use_id = Some(id.into());
56 self
57 }
58
59 #[must_use]
60 pub fn with_stt(mut self, stt: Box<dyn zeph_llm::stt::SpeechToText>) -> Self {
61 self.stt = Some(stt);
62 self
63 }
64
65 #[must_use]
67 pub fn with_debug_dumper(mut self, dumper: crate::debug_dump::DebugDumper) -> Self {
68 self.debug_state.debug_dumper = Some(dumper);
69 self
70 }
71
72 #[cfg(feature = "lsp-context")]
74 #[must_use]
75 pub fn with_lsp_hooks(mut self, runner: crate::lsp_hooks::LspHookRunner) -> Self {
76 self.lsp_hooks = Some(runner);
77 self
78 }
79
80 #[must_use]
81 pub fn with_update_notifications(mut self, rx: mpsc::Receiver<String>) -> Self {
82 self.update_notify_rx = Some(rx);
83 self
84 }
85
86 #[must_use]
87 pub fn with_custom_task_rx(mut self, rx: mpsc::Receiver<String>) -> Self {
88 self.custom_task_rx = Some(rx);
89 self
90 }
91
92 #[must_use]
94 pub fn add_tool_executor(
95 mut self,
96 extra: impl zeph_tools::executor::ToolExecutor + 'static,
97 ) -> Self {
98 let existing = Arc::clone(&self.tool_executor);
99 let combined = zeph_tools::CompositeExecutor::new(zeph_tools::DynExecutor(existing), extra);
100 self.tool_executor = Arc::new(combined);
101 self
102 }
103
104 #[must_use]
105 pub fn with_max_tool_iterations(mut self, max: usize) -> Self {
106 self.tool_orchestrator.max_iterations = max;
107 self
108 }
109
110 #[must_use]
112 pub fn with_max_tool_retries(mut self, max: usize) -> Self {
113 self.tool_orchestrator.max_tool_retries = max.min(5);
114 self
115 }
116
117 #[must_use]
119 pub fn with_max_retry_duration_secs(mut self, secs: u64) -> Self {
120 self.tool_orchestrator.max_retry_duration_secs = secs;
121 self
122 }
123
124 #[must_use]
127 pub fn with_tool_repeat_threshold(mut self, threshold: usize) -> Self {
128 self.tool_orchestrator.repeat_threshold = threshold;
129 self.tool_orchestrator.recent_tool_calls = VecDeque::with_capacity(2 * threshold.max(1));
130 self
131 }
132
133 #[must_use]
134 pub fn with_memory(
135 mut self,
136 memory: Arc<SemanticMemory>,
137 conversation_id: zeph_memory::ConversationId,
138 history_limit: u32,
139 recall_limit: usize,
140 summarization_threshold: usize,
141 ) -> Self {
142 self.memory_state.memory = Some(memory);
143 self.memory_state.conversation_id = Some(conversation_id);
144 self.memory_state.history_limit = history_limit;
145 self.memory_state.recall_limit = recall_limit;
146 self.memory_state.summarization_threshold = summarization_threshold;
147 self.update_metrics(|m| {
148 m.qdrant_available = false;
149 m.sqlite_conversation_id = Some(conversation_id);
150 });
151 self
152 }
153
154 #[must_use]
155 pub fn with_embedding_model(mut self, model: String) -> Self {
156 self.skill_state.embedding_model = model;
157 self
158 }
159
160 #[must_use]
161 pub fn with_disambiguation_threshold(mut self, threshold: f32) -> Self {
162 self.skill_state.disambiguation_threshold = threshold;
163 self
164 }
165
166 #[must_use]
167 pub fn with_skill_prompt_mode(mut self, mode: crate::config::SkillPromptMode) -> Self {
168 self.skill_state.prompt_mode = mode;
169 self
170 }
171
172 #[must_use]
173 pub fn with_document_config(mut self, config: crate::config::DocumentConfig) -> Self {
174 self.memory_state.document_config = config;
175 self
176 }
177
178 #[must_use]
179 pub fn with_graph_config(mut self, config: crate::config::GraphConfig) -> Self {
180 if config.enabled {
183 tracing::warn!(
184 "graph-memory is enabled: extracted entities are stored without PII redaction. \
185 Do not use with sensitive personal data until redaction is implemented."
186 );
187 }
188 self.memory_state.graph_config = config;
189 self
190 }
191
192 #[must_use]
193 pub fn with_anomaly_detector(mut self, detector: zeph_tools::AnomalyDetector) -> Self {
194 self.debug_state.anomaly_detector = Some(detector);
195 self
196 }
197
198 #[must_use]
199 pub fn with_instruction_blocks(
200 mut self,
201 blocks: Vec<crate::instructions::InstructionBlock>,
202 ) -> Self {
203 self.instruction_blocks = blocks;
204 self
205 }
206
207 #[must_use]
208 pub fn with_instruction_reload(
209 mut self,
210 rx: mpsc::Receiver<InstructionEvent>,
211 state: InstructionReloadState,
212 ) -> Self {
213 self.instruction_reload_rx = Some(rx);
214 self.instruction_reload_state = Some(state);
215 self
216 }
217
218 #[must_use]
219 pub fn with_shutdown(mut self, rx: watch::Receiver<bool>) -> Self {
220 self.shutdown = rx;
221 self
222 }
223
224 #[must_use]
225 pub fn with_skill_reload(
226 mut self,
227 paths: Vec<PathBuf>,
228 rx: mpsc::Receiver<SkillEvent>,
229 ) -> Self {
230 self.skill_state.skill_paths = paths;
231 self.skill_state.skill_reload_rx = Some(rx);
232 self
233 }
234
235 #[must_use]
236 pub fn with_managed_skills_dir(mut self, dir: PathBuf) -> Self {
237 self.skill_state.managed_dir = Some(dir);
238 self
239 }
240
241 #[must_use]
242 pub fn with_trust_config(mut self, config: crate::config::TrustConfig) -> Self {
243 self.skill_state.trust_config = config;
244 self
245 }
246
247 #[must_use]
248 pub fn with_config_reload(mut self, path: PathBuf, rx: mpsc::Receiver<ConfigEvent>) -> Self {
249 self.config_path = Some(path);
250 self.config_reload_rx = Some(rx);
251 self
252 }
253
254 #[must_use]
255 pub fn with_logging_config(mut self, logging: crate::config::LoggingConfig) -> Self {
256 self.debug_state.logging_config = logging;
257 self
258 }
259
260 #[must_use]
261 pub fn with_available_secrets(
262 mut self,
263 secrets: impl IntoIterator<Item = (String, crate::vault::Secret)>,
264 ) -> Self {
265 self.skill_state.available_custom_secrets = secrets.into_iter().collect();
266 self
267 }
268
269 #[must_use]
273 pub fn with_hybrid_search(mut self, enabled: bool) -> Self {
274 self.skill_state.hybrid_search = enabled;
275 if enabled {
276 let reg = self
277 .skill_state
278 .registry
279 .read()
280 .expect("registry read lock");
281 let all_meta = reg.all_meta();
282 let descs: Vec<&str> = all_meta.iter().map(|m| m.description.as_str()).collect();
283 self.skill_state.bm25_index = Some(zeph_skills::bm25::Bm25Index::build(&descs));
284 }
285 self
286 }
287
288 #[must_use]
289 pub fn with_learning(mut self, config: LearningConfig) -> Self {
290 if config.correction_detection {
291 self.feedback_detector = super::feedback_detector::FeedbackDetector::new(
292 config.correction_confidence_threshold,
293 );
294 if config.detector_mode == crate::config::DetectorMode::Judge {
295 self.judge_detector = Some(super::feedback_detector::JudgeDetector::new(
296 config.judge_adaptive_low,
297 config.judge_adaptive_high,
298 ));
299 }
300 }
301 self.learning_engine.config = Some(config);
302 self
303 }
304
305 #[must_use]
306 pub fn with_judge_provider(mut self, provider: AnyProvider) -> Self {
307 self.judge_provider = Some(provider);
308 self
309 }
310
311 #[must_use]
315 pub fn with_server_compaction(mut self, enabled: bool) -> Self {
316 self.server_compaction_active = enabled;
317 self
318 }
319
320 #[must_use]
321 pub fn with_mcp(
322 mut self,
323 tools: Vec<zeph_mcp::McpTool>,
324 registry: Option<zeph_mcp::McpToolRegistry>,
325 manager: Option<std::sync::Arc<zeph_mcp::McpManager>>,
326 mcp_config: &crate::config::McpConfig,
327 ) -> Self {
328 self.mcp.tools = tools;
329 self.mcp.registry = registry;
330 self.mcp.manager = manager;
331 self.mcp
332 .allowed_commands
333 .clone_from(&mcp_config.allowed_commands);
334 self.mcp.max_dynamic = mcp_config.max_dynamic_servers;
335 self
336 }
337
338 #[must_use]
339 pub fn with_mcp_shared_tools(
340 mut self,
341 shared: std::sync::Arc<std::sync::RwLock<Vec<zeph_mcp::McpTool>>>,
342 ) -> Self {
343 self.mcp.shared_tools = Some(shared);
344 self
345 }
346
347 #[must_use]
348 pub fn with_security(mut self, security: SecurityConfig, timeouts: TimeoutConfig) -> Self {
349 self.security.sanitizer =
350 crate::sanitizer::ContentSanitizer::new(&security.content_isolation);
351 self.security.exfiltration_guard = crate::sanitizer::exfiltration::ExfiltrationGuard::new(
352 security.exfiltration_guard.clone(),
353 );
354 self.runtime.security = security;
355 self.runtime.timeouts = timeouts;
356 self
357 }
358
359 #[must_use]
360 pub fn with_redact_credentials(mut self, enabled: bool) -> Self {
361 self.runtime.redact_credentials = enabled;
362 self
363 }
364
365 #[must_use]
366 pub fn with_tool_summarization(mut self, enabled: bool) -> Self {
367 self.tool_orchestrator.summarize_tool_output_enabled = enabled;
368 self
369 }
370
371 #[must_use]
372 pub fn with_overflow_config(mut self, config: zeph_tools::OverflowConfig) -> Self {
373 self.tool_orchestrator.overflow_config = config;
374 self
375 }
376
377 #[must_use]
378 pub fn with_summary_provider(mut self, provider: AnyProvider) -> Self {
379 self.summary_provider = Some(provider);
380 self
381 }
382
383 #[must_use]
384 pub fn with_quarantine_summarizer(
385 mut self,
386 qs: crate::sanitizer::quarantine::QuarantinedSummarizer,
387 ) -> Self {
388 self.security.quarantine_summarizer = Some(qs);
389 self
390 }
391
392 pub(super) fn summary_or_primary_provider(&self) -> &AnyProvider {
393 self.summary_provider.as_ref().unwrap_or(&self.provider)
394 }
395
396 pub(super) fn last_assistant_response(&self) -> String {
398 self.messages
399 .iter()
400 .rev()
401 .find(|m| m.role == zeph_llm::provider::Role::Assistant)
402 .map(|m| super::context::truncate_chars(&m.content, 500))
403 .unwrap_or_default()
404 }
405
406 #[must_use]
407 pub fn with_permission_policy(mut self, policy: zeph_tools::PermissionPolicy) -> Self {
408 self.runtime.permission_policy = policy;
409 self
410 }
411
412 #[must_use]
413 pub fn with_context_budget(
414 mut self,
415 budget_tokens: usize,
416 reserve_ratio: f32,
417 hard_compaction_threshold: f32,
418 compaction_preserve_tail: usize,
419 prune_protect_tokens: usize,
420 ) -> Self {
421 if budget_tokens > 0 {
422 self.context_manager.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
423 }
424 self.context_manager.hard_compaction_threshold = hard_compaction_threshold;
425 self.context_manager.compaction_preserve_tail = compaction_preserve_tail;
426 self.context_manager.prune_protect_tokens = prune_protect_tokens;
427 self
428 }
429
430 #[must_use]
431 pub fn with_soft_compaction_threshold(mut self, threshold: f32) -> Self {
432 self.context_manager.soft_compaction_threshold = threshold;
433 self
434 }
435
436 #[must_use]
441 pub fn with_compaction_cooldown(mut self, cooldown_turns: u8) -> Self {
442 self.context_manager.compaction_cooldown_turns = cooldown_turns;
443 self
444 }
445
446 #[must_use]
447 pub fn with_compression(mut self, compression: CompressionConfig) -> Self {
448 self.context_manager.compression = compression;
449 self
450 }
451
452 #[must_use]
453 pub fn with_routing(mut self, routing: RoutingConfig) -> Self {
454 self.context_manager.routing = routing;
455 self
456 }
457
458 #[must_use]
459 pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
460 self.runtime.model_name = name.into();
461 self
462 }
463
464 #[must_use]
465 pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
466 let path = path.into();
467 self.env_context =
468 crate::context::EnvironmentContext::gather_for_dir(&self.runtime.model_name, &path);
469 self
470 }
471
472 #[must_use]
473 pub fn with_warmup_ready(mut self, rx: watch::Receiver<bool>) -> Self {
474 self.warmup_ready = Some(rx);
475 self
476 }
477
478 #[must_use]
479 pub fn with_cost_tracker(mut self, tracker: CostTracker) -> Self {
480 self.cost_tracker = Some(tracker);
481 self
482 }
483
484 #[must_use]
485 pub fn with_repo_map(mut self, token_budget: usize, ttl_secs: u64) -> Self {
486 self.index.repo_map_tokens = token_budget;
487 self.index.repo_map_ttl = std::time::Duration::from_secs(ttl_secs);
488 self
489 }
490
491 #[must_use]
492 pub fn with_code_retriever(
493 mut self,
494 retriever: std::sync::Arc<zeph_index::retriever::CodeRetriever>,
495 ) -> Self {
496 self.index.retriever = Some(retriever);
497 self
498 }
499
500 #[must_use]
504 pub fn with_metrics(mut self, tx: watch::Sender<MetricsSnapshot>) -> Self {
505 let provider_name = self.provider.name().to_string();
506 let model_name = self.runtime.model_name.clone();
507 let total_skills = self
508 .skill_state
509 .registry
510 .read()
511 .expect("registry read lock")
512 .all_meta()
513 .len();
514 let qdrant_available = false;
515 let conversation_id = self.memory_state.conversation_id;
516 let prompt_estimate = self
517 .messages
518 .first()
519 .map_or(0, |m| u64::try_from(m.content.len()).unwrap_or(0) / 4);
520 let mcp_tool_count = self.mcp.tools.len();
521 let mcp_server_count = self
522 .mcp
523 .tools
524 .iter()
525 .map(|t| &t.server_id)
526 .collect::<std::collections::HashSet<_>>()
527 .len();
528 tx.send_modify(|m| {
529 m.provider_name = provider_name;
530 m.model_name = model_name;
531 m.total_skills = total_skills;
532 m.qdrant_available = qdrant_available;
533 m.sqlite_conversation_id = conversation_id;
534 m.context_tokens = prompt_estimate;
535 m.prompt_tokens = prompt_estimate;
536 m.total_tokens = prompt_estimate;
537 m.mcp_tool_count = mcp_tool_count;
538 m.mcp_server_count = mcp_server_count;
539 });
540 self.metrics_tx = Some(tx);
541 self
542 }
543
544 #[must_use]
548 pub fn cancel_signal(&self) -> Arc<Notify> {
549 Arc::clone(&self.cancel_signal)
550 }
551
552 #[must_use]
555 pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
556 self.cancel_signal = signal;
557 self
558 }
559
560 #[must_use]
561 pub fn with_subagent_manager(mut self, manager: crate::subagent::SubAgentManager) -> Self {
562 self.subagent_manager = Some(manager);
563 self
564 }
565
566 #[must_use]
567 pub fn with_subagent_config(mut self, config: crate::config::SubAgentConfig) -> Self {
568 self.subagent_config = config;
569 self
570 }
571
572 #[must_use]
573 pub fn with_orchestration_config(mut self, config: crate::config::OrchestrationConfig) -> Self {
574 self.orchestration_config = config;
575 self
576 }
577
578 #[cfg(feature = "experiments")]
580 #[must_use]
581 pub fn with_experiment_config(mut self, config: crate::config::ExperimentConfig) -> Self {
582 self.experiment_config = config;
583 self
584 }
585
586 #[cfg(feature = "experiments")]
592 #[must_use]
593 pub fn with_experiment_baseline(
594 mut self,
595 baseline: crate::experiments::ConfigSnapshot,
596 ) -> Self {
597 self.experiment_baseline = baseline;
598 self
599 }
600
601 #[must_use]
604 pub fn with_provider_override(
605 mut self,
606 slot: Arc<std::sync::RwLock<Option<AnyProvider>>>,
607 ) -> Self {
608 self.provider_override = Some(slot);
609 self
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::super::agent_tests::{
616 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
617 };
618 use super::*;
619 use crate::config::{CompressionStrategy, RoutingStrategy};
620
621 fn make_agent() -> Agent<MockChannel> {
622 Agent::new(
623 mock_provider(vec![]),
624 MockChannel::new(vec![]),
625 create_test_registry(),
626 None,
627 5,
628 MockToolExecutor::no_tools(),
629 )
630 }
631
632 #[test]
633 fn with_compression_sets_proactive_strategy() {
634 let compression = CompressionConfig {
635 strategy: CompressionStrategy::Proactive {
636 threshold_tokens: 50_000,
637 max_summary_tokens: 2_000,
638 },
639 model: String::new(),
640 };
641 let agent = make_agent().with_compression(compression);
642 assert!(
643 matches!(
644 agent.context_manager.compression.strategy,
645 CompressionStrategy::Proactive {
646 threshold_tokens: 50_000,
647 max_summary_tokens: 2_000,
648 }
649 ),
650 "expected Proactive strategy after with_compression"
651 );
652 }
653
654 #[test]
655 fn with_routing_sets_routing_config() {
656 let routing = RoutingConfig {
657 strategy: RoutingStrategy::Heuristic,
658 };
659 let agent = make_agent().with_routing(routing);
660 assert_eq!(
661 agent.context_manager.routing.strategy,
662 RoutingStrategy::Heuristic,
663 "routing strategy must be set by with_routing"
664 );
665 }
666
667 #[test]
668 fn default_compression_is_reactive() {
669 let agent = make_agent();
670 assert_eq!(
671 agent.context_manager.compression.strategy,
672 CompressionStrategy::Reactive,
673 "default compression strategy must be Reactive"
674 );
675 }
676
677 #[test]
678 fn default_routing_is_heuristic() {
679 let agent = make_agent();
680 assert_eq!(
681 agent.context_manager.routing.strategy,
682 RoutingStrategy::Heuristic,
683 "default routing strategy must be Heuristic"
684 );
685 }
686
687 #[test]
688 fn with_cancel_signal_replaces_internal_signal() {
689 let agent = Agent::new(
690 mock_provider(vec![]),
691 MockChannel::new(vec![]),
692 create_test_registry(),
693 None,
694 5,
695 MockToolExecutor::no_tools(),
696 );
697
698 let shared = Arc::new(Notify::new());
699 let agent = agent.with_cancel_signal(Arc::clone(&shared));
700
701 assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
703 }
704
705 #[tokio::test]
710 async fn with_managed_skills_dir_enables_install_command() {
711 let provider = mock_provider(vec![]);
712 let channel = MockChannel::new(vec![]);
713 let registry = create_test_registry();
714 let executor = MockToolExecutor::no_tools();
715 let managed = tempfile::tempdir().unwrap();
716
717 let mut agent_no_dir = Agent::new(
718 mock_provider(vec![]),
719 MockChannel::new(vec![]),
720 create_test_registry(),
721 None,
722 5,
723 MockToolExecutor::no_tools(),
724 );
725 agent_no_dir
726 .handle_skill_command("install /some/path")
727 .await
728 .unwrap();
729 let sent_no_dir = agent_no_dir.channel.sent_messages();
730 assert!(
731 sent_no_dir.iter().any(|s| s.contains("not configured")),
732 "without managed dir: {sent_no_dir:?}"
733 );
734
735 let _ = (provider, channel, registry, executor);
736 let mut agent_with_dir = Agent::new(
737 mock_provider(vec![]),
738 MockChannel::new(vec![]),
739 create_test_registry(),
740 None,
741 5,
742 MockToolExecutor::no_tools(),
743 )
744 .with_managed_skills_dir(managed.path().to_path_buf());
745
746 agent_with_dir
747 .handle_skill_command("install /nonexistent/path")
748 .await
749 .unwrap();
750 let sent_with_dir = agent_with_dir.channel.sent_messages();
751 assert!(
752 !sent_with_dir.iter().any(|s| s.contains("not configured")),
753 "with managed dir should not say not configured: {sent_with_dir:?}"
754 );
755 assert!(
756 sent_with_dir.iter().any(|s| s.contains("Install failed")),
757 "with managed dir should fail due to bad path: {sent_with_dir:?}"
758 );
759 }
760
761 #[test]
762 fn default_graph_config_is_disabled() {
763 let agent = make_agent();
764 assert!(
765 !agent.memory_state.graph_config.enabled,
766 "graph_config must default to disabled"
767 );
768 }
769
770 #[test]
771 fn with_graph_config_enabled_sets_flag() {
772 let cfg = crate::config::GraphConfig {
773 enabled: true,
774 ..Default::default()
775 };
776 let agent = make_agent().with_graph_config(cfg);
777 assert!(
778 agent.memory_state.graph_config.enabled,
779 "with_graph_config must set enabled flag"
780 );
781 }
782}