Skip to main content

zeph_core/agent/
builder.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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    /// Set the parent tool call ID for subagent sessions.
49    ///
50    /// When set, every `LoopbackEvent::ToolStart` and `LoopbackEvent::ToolOutput` emitted
51    /// by this agent will carry the `parent_tool_use_id` so the IDE can build a subagent
52    /// hierarchy tree.
53    #[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    /// Enable debug dump mode, writing LLM requests/responses and raw tool output to `dumper`.
66    #[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    /// Enable LSP context injection hooks (diagnostics-on-save, hover-on-read).
73    #[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    /// Wrap the current tool executor with an additional executor via `CompositeExecutor`.
93    #[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    /// Set the maximum number of retry attempts for transient tool errors (0 = disabled, max 5).
111    #[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    /// Set the repeat-detection threshold (0 = disabled).
118    /// Window size is `2 * threshold`.
119    #[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        // R-IMP-03: graph extraction writes raw entity names/relations extracted by the LLM.
174        // No PII redaction is applied on the graph write path (pre-1.0 MVP limitation).
175        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    /// # Panics
263    ///
264    /// Panics if the registry `RwLock` is poisoned.
265    #[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    /// Extract the last assistant message, truncated to 500 chars, for the judge prompt.
380    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    /// # Panics
464    ///
465    /// Panics if the registry `RwLock` is poisoned.
466    #[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    /// Returns a handle that can cancel the current in-flight operation.
508    /// The returned `Notify` is stable across messages — callers invoke
509    /// `notify_waiters()` to cancel whatever operation is running.
510    #[must_use]
511    pub fn cancel_signal(&self) -> Arc<Notify> {
512        Arc::clone(&self.cancel_signal)
513    }
514
515    /// Inject a shared cancel signal so an external caller (e.g. ACP session) can
516    /// interrupt the agent loop by calling `notify_one()`.
517    #[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    /// Set the experiment configuration for the `/experiment` slash command.
542    #[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    /// Set the baseline config snapshot used when the agent runs an experiment.
550    ///
551    /// Call this alongside `with_experiment_config()` so the experiment engine uses
552    /// actual runtime config values (temperature, memory params, etc.) rather than
553    /// hardcoded defaults. Typically built via `ConfigSnapshot::from_config(&config)`.
554    #[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    /// Inject a shared provider override slot for runtime model switching (e.g. via ACP
565    /// `set_session_config_option`). The agent checks and swaps the provider before each turn.
566    #[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        // The injected signal and the agent's internal signal must be the same Arc.
665        assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
666    }
667
668    /// Verify that with_managed_skills_dir enables the install/remove commands.
669    /// Without a managed dir, `/skill install` sends a "not configured" message.
670    /// With a managed dir configured, it proceeds past that guard (and may fail
671    /// for other reasons such as the source not existing).
672    #[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}