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_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]
120 pub fn with_tool_repeat_threshold(mut self, threshold: usize) -> Self {
121 self.tool_orchestrator.repeat_threshold = threshold;
122 self.tool_orchestrator.recent_tool_calls = VecDeque::with_capacity(2 * threshold.max(1));
123 self
124 }
125
126 #[must_use]
127 pub fn with_memory(
128 mut self,
129 memory: Arc<SemanticMemory>,
130 conversation_id: zeph_memory::ConversationId,
131 history_limit: u32,
132 recall_limit: usize,
133 summarization_threshold: usize,
134 ) -> Self {
135 self.memory_state.memory = Some(memory);
136 self.memory_state.conversation_id = Some(conversation_id);
137 self.memory_state.history_limit = history_limit;
138 self.memory_state.recall_limit = recall_limit;
139 self.memory_state.summarization_threshold = summarization_threshold;
140 self.update_metrics(|m| {
141 m.qdrant_available = false;
142 m.sqlite_conversation_id = Some(conversation_id);
143 });
144 self
145 }
146
147 #[must_use]
148 pub fn with_embedding_model(mut self, model: String) -> Self {
149 self.skill_state.embedding_model = model;
150 self
151 }
152
153 #[must_use]
154 pub fn with_disambiguation_threshold(mut self, threshold: f32) -> Self {
155 self.skill_state.disambiguation_threshold = threshold;
156 self
157 }
158
159 #[must_use]
160 pub fn with_skill_prompt_mode(mut self, mode: crate::config::SkillPromptMode) -> Self {
161 self.skill_state.prompt_mode = mode;
162 self
163 }
164
165 #[must_use]
166 pub fn with_document_config(mut self, config: crate::config::DocumentConfig) -> Self {
167 self.memory_state.document_config = config;
168 self
169 }
170
171 #[must_use]
172 pub fn with_graph_config(mut self, config: crate::config::GraphConfig) -> Self {
173 if config.enabled {
176 tracing::warn!(
177 "graph-memory is enabled: extracted entities are stored without PII redaction. \
178 Do not use with sensitive personal data until redaction is implemented."
179 );
180 }
181 self.memory_state.graph_config = config;
182 self
183 }
184
185 #[must_use]
186 pub fn with_anomaly_detector(mut self, detector: zeph_tools::AnomalyDetector) -> Self {
187 self.anomaly_detector = Some(detector);
188 self
189 }
190
191 #[must_use]
192 pub fn with_instruction_blocks(
193 mut self,
194 blocks: Vec<crate::instructions::InstructionBlock>,
195 ) -> Self {
196 self.instruction_blocks = blocks;
197 self
198 }
199
200 #[must_use]
201 pub fn with_instruction_reload(
202 mut self,
203 rx: mpsc::Receiver<InstructionEvent>,
204 state: InstructionReloadState,
205 ) -> Self {
206 self.instruction_reload_rx = Some(rx);
207 self.instruction_reload_state = Some(state);
208 self
209 }
210
211 #[must_use]
212 pub fn with_shutdown(mut self, rx: watch::Receiver<bool>) -> Self {
213 self.shutdown = rx;
214 self
215 }
216
217 #[must_use]
218 pub fn with_skill_reload(
219 mut self,
220 paths: Vec<PathBuf>,
221 rx: mpsc::Receiver<SkillEvent>,
222 ) -> Self {
223 self.skill_state.skill_paths = paths;
224 self.skill_state.skill_reload_rx = Some(rx);
225 self
226 }
227
228 #[must_use]
229 pub fn with_managed_skills_dir(mut self, dir: PathBuf) -> Self {
230 self.skill_state.managed_dir = Some(dir);
231 self
232 }
233
234 #[must_use]
235 pub fn with_trust_config(mut self, config: crate::config::TrustConfig) -> Self {
236 self.skill_state.trust_config = config;
237 self
238 }
239
240 #[must_use]
241 pub fn with_config_reload(mut self, path: PathBuf, rx: mpsc::Receiver<ConfigEvent>) -> Self {
242 self.config_path = Some(path);
243 self.config_reload_rx = Some(rx);
244 self
245 }
246
247 #[must_use]
248 pub fn with_logging_config(mut self, logging: crate::config::LoggingConfig) -> Self {
249 self.logging_config = logging;
250 self
251 }
252
253 #[must_use]
254 pub fn with_available_secrets(
255 mut self,
256 secrets: impl IntoIterator<Item = (String, crate::vault::Secret)>,
257 ) -> Self {
258 self.skill_state.available_custom_secrets = secrets.into_iter().collect();
259 self
260 }
261
262 #[must_use]
266 pub fn with_hybrid_search(mut self, enabled: bool) -> Self {
267 self.skill_state.hybrid_search = enabled;
268 if enabled {
269 let reg = self
270 .skill_state
271 .registry
272 .read()
273 .expect("registry read lock");
274 let all_meta = reg.all_meta();
275 let descs: Vec<&str> = all_meta.iter().map(|m| m.description.as_str()).collect();
276 self.skill_state.bm25_index = Some(zeph_skills::bm25::Bm25Index::build(&descs));
277 }
278 self
279 }
280
281 #[must_use]
282 pub fn with_learning(mut self, config: LearningConfig) -> Self {
283 if config.correction_detection {
284 self.feedback_detector = super::feedback_detector::FeedbackDetector::new(
285 config.correction_confidence_threshold,
286 );
287 if config.detector_mode == crate::config::DetectorMode::Judge {
288 self.judge_detector = Some(super::feedback_detector::JudgeDetector::new(
289 config.judge_adaptive_low,
290 config.judge_adaptive_high,
291 ));
292 }
293 }
294 self.learning_engine.config = Some(config);
295 self
296 }
297
298 #[must_use]
299 pub fn with_judge_provider(mut self, provider: AnyProvider) -> Self {
300 self.judge_provider = Some(provider);
301 self
302 }
303
304 #[must_use]
305 pub fn with_mcp(
306 mut self,
307 tools: Vec<zeph_mcp::McpTool>,
308 registry: Option<zeph_mcp::McpToolRegistry>,
309 manager: Option<std::sync::Arc<zeph_mcp::McpManager>>,
310 mcp_config: &crate::config::McpConfig,
311 ) -> Self {
312 self.mcp.tools = tools;
313 self.mcp.registry = registry;
314 self.mcp.manager = manager;
315 self.mcp
316 .allowed_commands
317 .clone_from(&mcp_config.allowed_commands);
318 self.mcp.max_dynamic = mcp_config.max_dynamic_servers;
319 self
320 }
321
322 #[must_use]
323 pub fn with_mcp_shared_tools(
324 mut self,
325 shared: std::sync::Arc<std::sync::RwLock<Vec<zeph_mcp::McpTool>>>,
326 ) -> Self {
327 self.mcp.shared_tools = Some(shared);
328 self
329 }
330
331 #[must_use]
332 pub fn with_security(mut self, security: SecurityConfig, timeouts: TimeoutConfig) -> Self {
333 self.sanitizer = crate::sanitizer::ContentSanitizer::new(&security.content_isolation);
334 self.exfiltration_guard = crate::sanitizer::exfiltration::ExfiltrationGuard::new(
335 security.exfiltration_guard.clone(),
336 );
337 self.runtime.security = security;
338 self.runtime.timeouts = timeouts;
339 self
340 }
341
342 #[must_use]
343 pub fn with_redact_credentials(mut self, enabled: bool) -> Self {
344 self.runtime.redact_credentials = enabled;
345 self
346 }
347
348 #[must_use]
349 pub fn with_tool_summarization(mut self, enabled: bool) -> Self {
350 self.tool_orchestrator.summarize_tool_output_enabled = enabled;
351 self
352 }
353
354 #[must_use]
355 pub fn with_overflow_config(mut self, config: zeph_tools::OverflowConfig) -> Self {
356 self.tool_orchestrator.overflow_config = config;
357 self
358 }
359
360 #[must_use]
361 pub fn with_summary_provider(mut self, provider: AnyProvider) -> Self {
362 self.summary_provider = Some(provider);
363 self
364 }
365
366 #[must_use]
367 pub fn with_quarantine_summarizer(
368 mut self,
369 qs: crate::sanitizer::quarantine::QuarantinedSummarizer,
370 ) -> Self {
371 self.quarantine_summarizer = Some(qs);
372 self
373 }
374
375 pub(super) fn summary_or_primary_provider(&self) -> &AnyProvider {
376 self.summary_provider.as_ref().unwrap_or(&self.provider)
377 }
378
379 pub(super) fn last_assistant_response(&self) -> String {
381 self.messages
382 .iter()
383 .rev()
384 .find(|m| m.role == zeph_llm::provider::Role::Assistant)
385 .map(|m| super::context::truncate_chars(&m.content, 500))
386 .unwrap_or_default()
387 }
388
389 #[must_use]
390 pub fn with_permission_policy(mut self, policy: zeph_tools::PermissionPolicy) -> Self {
391 self.runtime.permission_policy = policy;
392 self
393 }
394
395 #[must_use]
396 pub fn with_context_budget(
397 mut self,
398 budget_tokens: usize,
399 reserve_ratio: f32,
400 compaction_threshold: f32,
401 compaction_preserve_tail: usize,
402 prune_protect_tokens: usize,
403 ) -> Self {
404 if budget_tokens > 0 {
405 self.context_manager.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
406 }
407 self.context_manager.compaction_threshold = compaction_threshold;
408 self.context_manager.compaction_preserve_tail = compaction_preserve_tail;
409 self.context_manager.prune_protect_tokens = prune_protect_tokens;
410 self
411 }
412
413 #[must_use]
414 pub fn with_deferred_apply_threshold(mut self, threshold: f32) -> Self {
415 self.context_manager.deferred_apply_threshold = threshold;
416 self
417 }
418
419 #[must_use]
420 pub fn with_compression(mut self, compression: CompressionConfig) -> Self {
421 self.context_manager.compression = compression;
422 self
423 }
424
425 #[must_use]
426 pub fn with_routing(mut self, routing: RoutingConfig) -> Self {
427 self.context_manager.routing = routing;
428 self
429 }
430
431 #[must_use]
432 pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
433 self.runtime.model_name = name.into();
434 self
435 }
436
437 #[must_use]
438 pub fn with_warmup_ready(mut self, rx: watch::Receiver<bool>) -> Self {
439 self.warmup_ready = Some(rx);
440 self
441 }
442
443 #[must_use]
444 pub fn with_cost_tracker(mut self, tracker: CostTracker) -> Self {
445 self.cost_tracker = Some(tracker);
446 self
447 }
448
449 #[cfg(feature = "index")]
450 #[must_use]
451 pub fn with_code_retriever(
452 mut self,
453 retriever: std::sync::Arc<zeph_index::retriever::CodeRetriever>,
454 repo_map_tokens: usize,
455 repo_map_ttl_secs: u64,
456 ) -> Self {
457 self.index.retriever = Some(retriever);
458 self.index.repo_map_tokens = repo_map_tokens;
459 self.index.repo_map_ttl = std::time::Duration::from_secs(repo_map_ttl_secs);
460 self
461 }
462
463 #[must_use]
467 pub fn with_metrics(mut self, tx: watch::Sender<MetricsSnapshot>) -> Self {
468 let provider_name = self.provider.name().to_string();
469 let model_name = self.runtime.model_name.clone();
470 let total_skills = self
471 .skill_state
472 .registry
473 .read()
474 .expect("registry read lock")
475 .all_meta()
476 .len();
477 let qdrant_available = false;
478 let conversation_id = self.memory_state.conversation_id;
479 let prompt_estimate = self
480 .messages
481 .first()
482 .map_or(0, |m| u64::try_from(m.content.len()).unwrap_or(0) / 4);
483 let mcp_tool_count = self.mcp.tools.len();
484 let mcp_server_count = self
485 .mcp
486 .tools
487 .iter()
488 .map(|t| &t.server_id)
489 .collect::<std::collections::HashSet<_>>()
490 .len();
491 tx.send_modify(|m| {
492 m.provider_name = provider_name;
493 m.model_name = model_name;
494 m.total_skills = total_skills;
495 m.qdrant_available = qdrant_available;
496 m.sqlite_conversation_id = conversation_id;
497 m.context_tokens = prompt_estimate;
498 m.prompt_tokens = prompt_estimate;
499 m.total_tokens = prompt_estimate;
500 m.mcp_tool_count = mcp_tool_count;
501 m.mcp_server_count = mcp_server_count;
502 });
503 self.metrics_tx = Some(tx);
504 self
505 }
506
507 #[must_use]
511 pub fn cancel_signal(&self) -> Arc<Notify> {
512 Arc::clone(&self.cancel_signal)
513 }
514
515 #[must_use]
518 pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
519 self.cancel_signal = signal;
520 self
521 }
522
523 #[must_use]
524 pub fn with_subagent_manager(mut self, manager: crate::subagent::SubAgentManager) -> Self {
525 self.subagent_manager = Some(manager);
526 self
527 }
528
529 #[must_use]
530 pub fn with_subagent_config(mut self, config: crate::config::SubAgentConfig) -> Self {
531 self.subagent_config = config;
532 self
533 }
534
535 #[must_use]
536 pub fn with_orchestration_config(mut self, config: crate::config::OrchestrationConfig) -> Self {
537 self.orchestration_config = config;
538 self
539 }
540
541 #[cfg(feature = "experiments")]
543 #[must_use]
544 pub fn with_experiment_config(mut self, config: crate::config::ExperimentConfig) -> Self {
545 self.experiment_config = config;
546 self
547 }
548
549 #[cfg(feature = "experiments")]
555 #[must_use]
556 pub fn with_experiment_baseline(
557 mut self,
558 baseline: crate::experiments::ConfigSnapshot,
559 ) -> Self {
560 self.experiment_baseline = baseline;
561 self
562 }
563
564 #[must_use]
567 pub fn with_provider_override(
568 mut self,
569 slot: Arc<std::sync::RwLock<Option<AnyProvider>>>,
570 ) -> Self {
571 self.provider_override = Some(slot);
572 self
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::super::agent_tests::{
579 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
580 };
581 use super::*;
582 use crate::config::{CompressionStrategy, RoutingStrategy};
583
584 fn make_agent() -> Agent<MockChannel> {
585 Agent::new(
586 mock_provider(vec![]),
587 MockChannel::new(vec![]),
588 create_test_registry(),
589 None,
590 5,
591 MockToolExecutor::no_tools(),
592 )
593 }
594
595 #[test]
596 fn with_compression_sets_proactive_strategy() {
597 let compression = CompressionConfig {
598 strategy: CompressionStrategy::Proactive {
599 threshold_tokens: 50_000,
600 max_summary_tokens: 2_000,
601 },
602 model: String::new(),
603 };
604 let agent = make_agent().with_compression(compression);
605 assert!(
606 matches!(
607 agent.context_manager.compression.strategy,
608 CompressionStrategy::Proactive {
609 threshold_tokens: 50_000,
610 max_summary_tokens: 2_000,
611 }
612 ),
613 "expected Proactive strategy after with_compression"
614 );
615 }
616
617 #[test]
618 fn with_routing_sets_routing_config() {
619 let routing = RoutingConfig {
620 strategy: RoutingStrategy::Heuristic,
621 };
622 let agent = make_agent().with_routing(routing);
623 assert_eq!(
624 agent.context_manager.routing.strategy,
625 RoutingStrategy::Heuristic,
626 "routing strategy must be set by with_routing"
627 );
628 }
629
630 #[test]
631 fn default_compression_is_reactive() {
632 let agent = make_agent();
633 assert_eq!(
634 agent.context_manager.compression.strategy,
635 CompressionStrategy::Reactive,
636 "default compression strategy must be Reactive"
637 );
638 }
639
640 #[test]
641 fn default_routing_is_heuristic() {
642 let agent = make_agent();
643 assert_eq!(
644 agent.context_manager.routing.strategy,
645 RoutingStrategy::Heuristic,
646 "default routing strategy must be Heuristic"
647 );
648 }
649
650 #[test]
651 fn with_cancel_signal_replaces_internal_signal() {
652 let agent = Agent::new(
653 mock_provider(vec![]),
654 MockChannel::new(vec![]),
655 create_test_registry(),
656 None,
657 5,
658 MockToolExecutor::no_tools(),
659 );
660
661 let shared = Arc::new(Notify::new());
662 let agent = agent.with_cancel_signal(Arc::clone(&shared));
663
664 assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
666 }
667
668 #[tokio::test]
673 async fn with_managed_skills_dir_enables_install_command() {
674 let provider = mock_provider(vec![]);
675 let channel = MockChannel::new(vec![]);
676 let registry = create_test_registry();
677 let executor = MockToolExecutor::no_tools();
678 let managed = tempfile::tempdir().unwrap();
679
680 let mut agent_no_dir = Agent::new(
681 mock_provider(vec![]),
682 MockChannel::new(vec![]),
683 create_test_registry(),
684 None,
685 5,
686 MockToolExecutor::no_tools(),
687 );
688 agent_no_dir
689 .handle_skill_command("install /some/path")
690 .await
691 .unwrap();
692 let sent_no_dir = agent_no_dir.channel.sent_messages();
693 assert!(
694 sent_no_dir.iter().any(|s| s.contains("not configured")),
695 "without managed dir: {sent_no_dir:?}"
696 );
697
698 let _ = (provider, channel, registry, executor);
699 let mut agent_with_dir = Agent::new(
700 mock_provider(vec![]),
701 MockChannel::new(vec![]),
702 create_test_registry(),
703 None,
704 5,
705 MockToolExecutor::no_tools(),
706 )
707 .with_managed_skills_dir(managed.path().to_path_buf());
708
709 agent_with_dir
710 .handle_skill_command("install /nonexistent/path")
711 .await
712 .unwrap();
713 let sent_with_dir = agent_with_dir.channel.sent_messages();
714 assert!(
715 !sent_with_dir.iter().any(|s| s.contains("not configured")),
716 "with managed dir should not say not configured: {sent_with_dir:?}"
717 );
718 assert!(
719 sent_with_dir.iter().any(|s| s.contains("Install failed")),
720 "with managed dir should fail due to bad path: {sent_with_dir:?}"
721 );
722 }
723}