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.providers.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_state.debug_dumper = Some(dumper);
69        self
70    }
71
72    /// Enable `OTel` trace collection. The collector writes `trace.json` at session end.
73    #[must_use]
74    pub fn with_trace_collector(
75        mut self,
76        collector: crate::debug_dump::trace::TracingCollector,
77    ) -> Self {
78        self.debug_state.trace_collector = Some(collector);
79        self
80    }
81
82    /// Store trace config so `/dump-format trace` can create a `TracingCollector` at runtime (CR-04).
83    #[must_use]
84    pub fn with_trace_config(
85        mut self,
86        dump_dir: std::path::PathBuf,
87        service_name: impl Into<String>,
88        redact: bool,
89    ) -> Self {
90        self.debug_state.dump_dir = Some(dump_dir);
91        self.debug_state.trace_service_name = service_name.into();
92        self.debug_state.trace_redact = redact;
93        self
94    }
95
96    /// Enable LSP context injection hooks (diagnostics-on-save, hover-on-read).
97    #[cfg(feature = "lsp-context")]
98    #[must_use]
99    pub fn with_lsp_hooks(mut self, runner: crate::lsp_hooks::LspHookRunner) -> Self {
100        self.lsp_hooks = Some(runner);
101        self
102    }
103
104    #[must_use]
105    pub fn with_update_notifications(mut self, rx: mpsc::Receiver<String>) -> Self {
106        self.lifecycle.update_notify_rx = Some(rx);
107        self
108    }
109
110    #[must_use]
111    pub fn with_custom_task_rx(mut self, rx: mpsc::Receiver<String>) -> Self {
112        self.lifecycle.custom_task_rx = Some(rx);
113        self
114    }
115
116    /// Wrap the current tool executor with an additional executor via `CompositeExecutor`.
117    #[must_use]
118    pub fn add_tool_executor(
119        mut self,
120        extra: impl zeph_tools::executor::ToolExecutor + 'static,
121    ) -> Self {
122        let existing = Arc::clone(&self.tool_executor);
123        let combined = zeph_tools::CompositeExecutor::new(zeph_tools::DynExecutor(existing), extra);
124        self.tool_executor = Arc::new(combined);
125        self
126    }
127
128    #[must_use]
129    pub fn with_max_tool_iterations(mut self, max: usize) -> Self {
130        self.tool_orchestrator.max_iterations = max;
131        self
132    }
133
134    /// Set the maximum number of retry attempts for transient tool errors (0 = disabled, max 5).
135    #[must_use]
136    pub fn with_max_tool_retries(mut self, max: usize) -> Self {
137        self.tool_orchestrator.max_tool_retries = max.min(5);
138        self
139    }
140
141    /// Set the maximum wall-clock budget (seconds) for retries per tool call (0 = unlimited).
142    #[must_use]
143    pub fn with_max_retry_duration_secs(mut self, secs: u64) -> Self {
144        self.tool_orchestrator.max_retry_duration_secs = secs;
145        self
146    }
147
148    /// Set the repeat-detection threshold (0 = disabled).
149    /// Window size is `2 * threshold`.
150    #[must_use]
151    pub fn with_tool_repeat_threshold(mut self, threshold: usize) -> Self {
152        self.tool_orchestrator.repeat_threshold = threshold;
153        self.tool_orchestrator.recent_tool_calls = VecDeque::with_capacity(2 * threshold.max(1));
154        self
155    }
156
157    #[must_use]
158    pub fn with_memory(
159        mut self,
160        memory: Arc<SemanticMemory>,
161        conversation_id: zeph_memory::ConversationId,
162        history_limit: u32,
163        recall_limit: usize,
164        summarization_threshold: usize,
165    ) -> Self {
166        self.memory_state.memory = Some(memory);
167        self.memory_state.conversation_id = Some(conversation_id);
168        self.memory_state.history_limit = history_limit;
169        self.memory_state.recall_limit = recall_limit;
170        self.memory_state.summarization_threshold = summarization_threshold;
171        self.update_metrics(|m| {
172            m.qdrant_available = false;
173            m.sqlite_conversation_id = Some(conversation_id);
174        });
175        self
176    }
177
178    #[must_use]
179    pub fn with_embedding_model(mut self, model: String) -> Self {
180        self.skill_state.embedding_model = model;
181        self
182    }
183
184    #[must_use]
185    pub fn with_disambiguation_threshold(mut self, threshold: f32) -> Self {
186        self.skill_state.disambiguation_threshold = threshold;
187        self
188    }
189
190    #[must_use]
191    pub fn with_skill_prompt_mode(mut self, mode: crate::config::SkillPromptMode) -> Self {
192        self.skill_state.prompt_mode = mode;
193        self
194    }
195
196    #[must_use]
197    pub fn with_document_config(mut self, config: crate::config::DocumentConfig) -> Self {
198        self.memory_state.document_config = config;
199        self
200    }
201
202    #[must_use]
203    pub fn with_compression_guidelines_config(
204        mut self,
205        config: zeph_memory::CompressionGuidelinesConfig,
206    ) -> Self {
207        self.memory_state.compression_guidelines_config = config;
208        self
209    }
210
211    #[must_use]
212    pub fn with_graph_config(mut self, config: crate::config::GraphConfig) -> Self {
213        // R-IMP-03: graph extraction writes raw entity names/relations extracted by the LLM.
214        // No PII redaction is applied on the graph write path (pre-1.0 MVP limitation).
215        if config.enabled {
216            tracing::warn!(
217                "graph-memory is enabled: extracted entities are stored without PII redaction. \
218                 Do not use with sensitive personal data until redaction is implemented."
219            );
220        }
221        self.memory_state.graph_config = config;
222        self
223    }
224
225    #[must_use]
226    pub fn with_anomaly_detector(mut self, detector: zeph_tools::AnomalyDetector) -> Self {
227        self.debug_state.anomaly_detector = Some(detector);
228        self
229    }
230
231    #[must_use]
232    pub fn with_instruction_blocks(
233        mut self,
234        blocks: Vec<crate::instructions::InstructionBlock>,
235    ) -> Self {
236        self.instruction_blocks = blocks;
237        self
238    }
239
240    #[must_use]
241    pub fn with_instruction_reload(
242        mut self,
243        rx: mpsc::Receiver<InstructionEvent>,
244        state: InstructionReloadState,
245    ) -> Self {
246        self.instruction_reload_rx = Some(rx);
247        self.instruction_reload_state = Some(state);
248        self
249    }
250
251    #[must_use]
252    pub fn with_shutdown(mut self, rx: watch::Receiver<bool>) -> Self {
253        self.lifecycle.shutdown = rx;
254        self
255    }
256
257    #[must_use]
258    pub fn with_skill_reload(
259        mut self,
260        paths: Vec<PathBuf>,
261        rx: mpsc::Receiver<SkillEvent>,
262    ) -> Self {
263        self.skill_state.skill_paths = paths;
264        self.skill_state.skill_reload_rx = Some(rx);
265        self
266    }
267
268    #[must_use]
269    pub fn with_managed_skills_dir(mut self, dir: PathBuf) -> Self {
270        self.skill_state.managed_dir = Some(dir);
271        self
272    }
273
274    #[must_use]
275    pub fn with_trust_config(mut self, config: crate::config::TrustConfig) -> Self {
276        self.skill_state.trust_config = config;
277        self
278    }
279
280    #[must_use]
281    pub fn with_config_reload(mut self, path: PathBuf, rx: mpsc::Receiver<ConfigEvent>) -> Self {
282        self.lifecycle.config_path = Some(path);
283        self.lifecycle.config_reload_rx = Some(rx);
284        self
285    }
286
287    #[must_use]
288    pub fn with_logging_config(mut self, logging: crate::config::LoggingConfig) -> Self {
289        self.debug_state.logging_config = logging;
290        self
291    }
292
293    #[must_use]
294    pub fn with_available_secrets(
295        mut self,
296        secrets: impl IntoIterator<Item = (String, crate::vault::Secret)>,
297    ) -> Self {
298        self.skill_state.available_custom_secrets = secrets.into_iter().collect();
299        self
300    }
301
302    /// # Panics
303    ///
304    /// Panics if the registry `RwLock` is poisoned.
305    #[must_use]
306    pub fn with_hybrid_search(mut self, enabled: bool) -> Self {
307        self.skill_state.hybrid_search = enabled;
308        if enabled {
309            let reg = self
310                .skill_state
311                .registry
312                .read()
313                .expect("registry read lock");
314            let all_meta = reg.all_meta();
315            let descs: Vec<&str> = all_meta.iter().map(|m| m.description.as_str()).collect();
316            self.skill_state.bm25_index = Some(zeph_skills::bm25::Bm25Index::build(&descs));
317        }
318        self
319    }
320
321    #[must_use]
322    pub fn with_learning(mut self, config: LearningConfig) -> Self {
323        if config.correction_detection {
324            self.feedback_detector = super::feedback_detector::FeedbackDetector::new(
325                config.correction_confidence_threshold,
326            );
327            if config.detector_mode == crate::config::DetectorMode::Judge {
328                self.judge_detector = Some(super::feedback_detector::JudgeDetector::new(
329                    config.judge_adaptive_low,
330                    config.judge_adaptive_high,
331                ));
332            }
333        }
334        self.learning_engine.config = Some(config);
335        self
336    }
337
338    #[must_use]
339    pub fn with_judge_provider(mut self, provider: AnyProvider) -> Self {
340        self.providers.judge_provider = Some(provider);
341        self
342    }
343
344    /// Enable server-side compaction mode (Claude compact-2026-01-12 beta).
345    ///
346    /// When active, client-side reactive and proactive compaction are skipped.
347    #[must_use]
348    pub fn with_server_compaction(mut self, enabled: bool) -> Self {
349        self.providers.server_compaction_active = enabled;
350        self
351    }
352
353    #[must_use]
354    pub fn with_mcp(
355        mut self,
356        tools: Vec<zeph_mcp::McpTool>,
357        registry: Option<zeph_mcp::McpToolRegistry>,
358        manager: Option<std::sync::Arc<zeph_mcp::McpManager>>,
359        mcp_config: &crate::config::McpConfig,
360    ) -> Self {
361        self.mcp.tools = tools;
362        self.mcp.registry = registry;
363        self.mcp.manager = manager;
364        self.mcp
365            .allowed_commands
366            .clone_from(&mcp_config.allowed_commands);
367        self.mcp.max_dynamic = mcp_config.max_dynamic_servers;
368        self
369    }
370
371    #[must_use]
372    pub fn with_mcp_shared_tools(
373        mut self,
374        shared: std::sync::Arc<std::sync::RwLock<Vec<zeph_mcp::McpTool>>>,
375    ) -> Self {
376        self.mcp.shared_tools = Some(shared);
377        self
378    }
379
380    /// Set the watch receiver for MCP tool list updates from `tools/list_changed` notifications.
381    ///
382    /// The agent polls this receiver at the start of each turn to pick up refreshed tool lists.
383    #[must_use]
384    pub fn with_mcp_tool_rx(
385        mut self,
386        rx: tokio::sync::watch::Receiver<Vec<zeph_mcp::McpTool>>,
387    ) -> Self {
388        self.mcp.tool_rx = Some(rx);
389        self
390    }
391
392    #[must_use]
393    pub fn with_security(mut self, security: SecurityConfig, timeouts: TimeoutConfig) -> Self {
394        self.security.sanitizer =
395            crate::sanitizer::ContentSanitizer::new(&security.content_isolation);
396        self.security.exfiltration_guard = crate::sanitizer::exfiltration::ExfiltrationGuard::new(
397            security.exfiltration_guard.clone(),
398        );
399        self.security.pii_filter =
400            crate::sanitizer::pii::PiiFilter::new(security.pii_filter.clone());
401        self.security.memory_validator =
402            crate::sanitizer::memory_validation::MemoryWriteValidator::new(
403                security.memory_validation.clone(),
404            );
405        self.rate_limiter =
406            crate::agent::rate_limiter::ToolRateLimiter::new(security.rate_limit.clone());
407        self.runtime.security = security;
408        self.runtime.timeouts = timeouts;
409        self
410    }
411
412    #[must_use]
413    pub fn with_redact_credentials(mut self, enabled: bool) -> Self {
414        self.runtime.redact_credentials = enabled;
415        self
416    }
417
418    #[must_use]
419    pub fn with_tool_summarization(mut self, enabled: bool) -> Self {
420        self.tool_orchestrator.summarize_tool_output_enabled = enabled;
421        self
422    }
423
424    #[must_use]
425    pub fn with_overflow_config(mut self, config: zeph_tools::OverflowConfig) -> Self {
426        self.tool_orchestrator.overflow_config = config;
427        self
428    }
429
430    #[must_use]
431    pub fn with_summary_provider(mut self, provider: AnyProvider) -> Self {
432        self.providers.summary_provider = Some(provider);
433        self
434    }
435
436    #[must_use]
437    pub fn with_quarantine_summarizer(
438        mut self,
439        qs: crate::sanitizer::quarantine::QuarantinedSummarizer,
440    ) -> Self {
441        self.security.quarantine_summarizer = Some(qs);
442        self
443    }
444
445    pub(super) fn summary_or_primary_provider(&self) -> &AnyProvider {
446        self.providers
447            .summary_provider
448            .as_ref()
449            .unwrap_or(&self.provider)
450    }
451
452    /// Extract the last assistant message, truncated to 500 chars, for the judge prompt.
453    pub(super) fn last_assistant_response(&self) -> String {
454        self.messages
455            .iter()
456            .rev()
457            .find(|m| m.role == zeph_llm::provider::Role::Assistant)
458            .map(|m| super::context::truncate_chars(&m.content, 500))
459            .unwrap_or_default()
460    }
461
462    #[must_use]
463    pub fn with_permission_policy(mut self, policy: zeph_tools::PermissionPolicy) -> Self {
464        self.runtime.permission_policy = policy;
465        self
466    }
467
468    #[must_use]
469    pub fn with_context_budget(
470        mut self,
471        budget_tokens: usize,
472        reserve_ratio: f32,
473        hard_compaction_threshold: f32,
474        compaction_preserve_tail: usize,
475        prune_protect_tokens: usize,
476    ) -> Self {
477        if budget_tokens > 0 {
478            self.context_manager.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
479        }
480        self.context_manager.hard_compaction_threshold = hard_compaction_threshold;
481        self.context_manager.compaction_preserve_tail = compaction_preserve_tail;
482        self.context_manager.prune_protect_tokens = prune_protect_tokens;
483        self
484    }
485
486    #[must_use]
487    pub fn with_soft_compaction_threshold(mut self, threshold: f32) -> Self {
488        self.context_manager.soft_compaction_threshold = threshold;
489        self
490    }
491
492    /// Sets the number of turns to skip compaction after a successful compaction.
493    ///
494    /// Prevents the compaction loop from re-triggering immediately when the
495    /// summary itself is large. A value of `0` disables the cooldown.
496    #[must_use]
497    pub fn with_compaction_cooldown(mut self, cooldown_turns: u8) -> Self {
498        self.context_manager.compaction_cooldown_turns = cooldown_turns;
499        self
500    }
501
502    #[must_use]
503    pub fn with_compression(mut self, compression: CompressionConfig) -> Self {
504        self.context_manager.compression = compression;
505        self
506    }
507
508    #[must_use]
509    pub fn with_routing(mut self, routing: RoutingConfig) -> Self {
510        self.context_manager.routing = routing;
511        self
512    }
513
514    #[must_use]
515    pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
516        self.runtime.model_name = name.into();
517        self
518    }
519
520    #[must_use]
521    pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
522        let path = path.into();
523        self.env_context =
524            crate::context::EnvironmentContext::gather_for_dir(&self.runtime.model_name, &path);
525        self
526    }
527
528    #[must_use]
529    pub fn with_warmup_ready(mut self, rx: watch::Receiver<bool>) -> Self {
530        self.lifecycle.warmup_ready = Some(rx);
531        self
532    }
533
534    #[must_use]
535    pub fn with_cost_tracker(mut self, tracker: CostTracker) -> Self {
536        self.metrics.cost_tracker = Some(tracker);
537        self
538    }
539
540    #[must_use]
541    pub fn with_extended_context(mut self, enabled: bool) -> Self {
542        self.metrics.extended_context = enabled;
543        self
544    }
545
546    #[must_use]
547    pub fn with_repo_map(mut self, token_budget: usize, ttl_secs: u64) -> Self {
548        self.index.repo_map_tokens = token_budget;
549        self.index.repo_map_ttl = std::time::Duration::from_secs(ttl_secs);
550        self
551    }
552
553    #[must_use]
554    pub fn with_code_retriever(
555        mut self,
556        retriever: std::sync::Arc<zeph_index::retriever::CodeRetriever>,
557    ) -> Self {
558        self.index.retriever = Some(retriever);
559        self
560    }
561
562    /// # Panics
563    ///
564    /// Panics if the registry `RwLock` is poisoned.
565    #[must_use]
566    pub fn with_metrics(mut self, tx: watch::Sender<MetricsSnapshot>) -> Self {
567        let provider_name = self.provider.name().to_string();
568        let model_name = self.runtime.model_name.clone();
569        let total_skills = self
570            .skill_state
571            .registry
572            .read()
573            .expect("registry read lock")
574            .all_meta()
575            .len();
576        let qdrant_available = false;
577        let conversation_id = self.memory_state.conversation_id;
578        let prompt_estimate = self
579            .messages
580            .first()
581            .map_or(0, |m| u64::try_from(m.content.len()).unwrap_or(0) / 4);
582        let mcp_tool_count = self.mcp.tools.len();
583        let mcp_server_count = self
584            .mcp
585            .tools
586            .iter()
587            .map(|t| &t.server_id)
588            .collect::<std::collections::HashSet<_>>()
589            .len();
590        let extended_context = self.metrics.extended_context;
591        tx.send_modify(|m| {
592            m.provider_name = provider_name;
593            m.model_name = model_name;
594            m.total_skills = total_skills;
595            m.qdrant_available = qdrant_available;
596            m.sqlite_conversation_id = conversation_id;
597            m.context_tokens = prompt_estimate;
598            m.prompt_tokens = prompt_estimate;
599            m.total_tokens = prompt_estimate;
600            m.mcp_tool_count = mcp_tool_count;
601            m.mcp_server_count = mcp_server_count;
602            m.extended_context = extended_context;
603        });
604        self.metrics.metrics_tx = Some(tx);
605        self
606    }
607
608    /// Returns a handle that can cancel the current in-flight operation.
609    /// The returned `Notify` is stable across messages — callers invoke
610    /// `notify_waiters()` to cancel whatever operation is running.
611    #[must_use]
612    pub fn cancel_signal(&self) -> Arc<Notify> {
613        Arc::clone(&self.lifecycle.cancel_signal)
614    }
615
616    /// Inject a shared cancel signal so an external caller (e.g. ACP session) can
617    /// interrupt the agent loop by calling `notify_one()`.
618    #[must_use]
619    pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
620        self.lifecycle.cancel_signal = signal;
621        self
622    }
623
624    #[must_use]
625    pub fn with_subagent_manager(mut self, manager: crate::subagent::SubAgentManager) -> Self {
626        self.orchestration.subagent_manager = Some(manager);
627        self
628    }
629
630    #[must_use]
631    pub fn with_subagent_config(mut self, config: crate::config::SubAgentConfig) -> Self {
632        self.orchestration.subagent_config = config;
633        self
634    }
635
636    #[must_use]
637    pub fn with_orchestration_config(mut self, config: crate::config::OrchestrationConfig) -> Self {
638        self.orchestration.orchestration_config = config;
639        self
640    }
641
642    /// Set the experiment configuration for the `/experiment` slash command.
643    #[cfg(feature = "experiments")]
644    #[must_use]
645    pub fn with_experiment_config(mut self, config: crate::config::ExperimentConfig) -> Self {
646        self.experiment_config = config;
647        self
648    }
649
650    /// Set the baseline config snapshot used when the agent runs an experiment.
651    ///
652    /// Call this alongside `with_experiment_config()` so the experiment engine uses
653    /// actual runtime config values (temperature, memory params, etc.) rather than
654    /// hardcoded defaults. Typically built via `ConfigSnapshot::from_config(&config)`.
655    #[cfg(feature = "experiments")]
656    #[must_use]
657    pub fn with_experiment_baseline(
658        mut self,
659        baseline: crate::experiments::ConfigSnapshot,
660    ) -> Self {
661        self.experiment_baseline = baseline;
662        self
663    }
664
665    /// Inject a shared provider override slot for runtime model switching (e.g. via ACP
666    /// `set_session_config_option`). The agent checks and swaps the provider before each turn.
667    #[must_use]
668    pub fn with_provider_override(
669        mut self,
670        slot: Arc<std::sync::RwLock<Option<AnyProvider>>>,
671    ) -> Self {
672        self.providers.provider_override = Some(slot);
673        self
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::super::agent_tests::{
680        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
681    };
682    use super::*;
683    use crate::config::{CompressionStrategy, RoutingStrategy};
684
685    fn make_agent() -> Agent<MockChannel> {
686        Agent::new(
687            mock_provider(vec![]),
688            MockChannel::new(vec![]),
689            create_test_registry(),
690            None,
691            5,
692            MockToolExecutor::no_tools(),
693        )
694    }
695
696    #[test]
697    fn with_compression_sets_proactive_strategy() {
698        let compression = CompressionConfig {
699            strategy: CompressionStrategy::Proactive {
700                threshold_tokens: 50_000,
701                max_summary_tokens: 2_000,
702            },
703            model: String::new(),
704        };
705        let agent = make_agent().with_compression(compression);
706        assert!(
707            matches!(
708                agent.context_manager.compression.strategy,
709                CompressionStrategy::Proactive {
710                    threshold_tokens: 50_000,
711                    max_summary_tokens: 2_000,
712                }
713            ),
714            "expected Proactive strategy after with_compression"
715        );
716    }
717
718    #[test]
719    fn with_routing_sets_routing_config() {
720        let routing = RoutingConfig {
721            strategy: RoutingStrategy::Heuristic,
722        };
723        let agent = make_agent().with_routing(routing);
724        assert_eq!(
725            agent.context_manager.routing.strategy,
726            RoutingStrategy::Heuristic,
727            "routing strategy must be set by with_routing"
728        );
729    }
730
731    #[test]
732    fn default_compression_is_reactive() {
733        let agent = make_agent();
734        assert_eq!(
735            agent.context_manager.compression.strategy,
736            CompressionStrategy::Reactive,
737            "default compression strategy must be Reactive"
738        );
739    }
740
741    #[test]
742    fn default_routing_is_heuristic() {
743        let agent = make_agent();
744        assert_eq!(
745            agent.context_manager.routing.strategy,
746            RoutingStrategy::Heuristic,
747            "default routing strategy must be Heuristic"
748        );
749    }
750
751    #[test]
752    fn with_cancel_signal_replaces_internal_signal() {
753        let agent = Agent::new(
754            mock_provider(vec![]),
755            MockChannel::new(vec![]),
756            create_test_registry(),
757            None,
758            5,
759            MockToolExecutor::no_tools(),
760        );
761
762        let shared = Arc::new(Notify::new());
763        let agent = agent.with_cancel_signal(Arc::clone(&shared));
764
765        // The injected signal and the agent's internal signal must be the same Arc.
766        assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
767    }
768
769    /// Verify that `with_managed_skills_dir` enables the install/remove commands.
770    /// Without a managed dir, `/skill install` sends a "not configured" message.
771    /// With a managed dir configured, it proceeds past that guard (and may fail
772    /// for other reasons such as the source not existing).
773    #[tokio::test]
774    async fn with_managed_skills_dir_enables_install_command() {
775        let provider = mock_provider(vec![]);
776        let channel = MockChannel::new(vec![]);
777        let registry = create_test_registry();
778        let executor = MockToolExecutor::no_tools();
779        let managed = tempfile::tempdir().unwrap();
780
781        let mut agent_no_dir = Agent::new(
782            mock_provider(vec![]),
783            MockChannel::new(vec![]),
784            create_test_registry(),
785            None,
786            5,
787            MockToolExecutor::no_tools(),
788        );
789        agent_no_dir
790            .handle_skill_command("install /some/path")
791            .await
792            .unwrap();
793        let sent_no_dir = agent_no_dir.channel.sent_messages();
794        assert!(
795            sent_no_dir.iter().any(|s| s.contains("not configured")),
796            "without managed dir: {sent_no_dir:?}"
797        );
798
799        let _ = (provider, channel, registry, executor);
800        let mut agent_with_dir = Agent::new(
801            mock_provider(vec![]),
802            MockChannel::new(vec![]),
803            create_test_registry(),
804            None,
805            5,
806            MockToolExecutor::no_tools(),
807        )
808        .with_managed_skills_dir(managed.path().to_path_buf());
809
810        agent_with_dir
811            .handle_skill_command("install /nonexistent/path")
812            .await
813            .unwrap();
814        let sent_with_dir = agent_with_dir.channel.sent_messages();
815        assert!(
816            !sent_with_dir.iter().any(|s| s.contains("not configured")),
817            "with managed dir should not say not configured: {sent_with_dir:?}"
818        );
819        assert!(
820            sent_with_dir.iter().any(|s| s.contains("Install failed")),
821            "with managed dir should fail due to bad path: {sent_with_dir:?}"
822        );
823    }
824
825    #[test]
826    fn default_graph_config_is_disabled() {
827        let agent = make_agent();
828        assert!(
829            !agent.memory_state.graph_config.enabled,
830            "graph_config must default to disabled"
831        );
832    }
833
834    #[test]
835    fn with_graph_config_enabled_sets_flag() {
836        let cfg = crate::config::GraphConfig {
837            enabled: true,
838            ..Default::default()
839        };
840        let agent = make_agent().with_graph_config(cfg);
841        assert!(
842            agent.memory_state.graph_config.enabled,
843            "with_graph_config must set enabled flag"
844        );
845    }
846}