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_state.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 maximum wall-clock budget (seconds) for retries per tool call (0 = unlimited).
118    #[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    /// Set the repeat-detection threshold (0 = disabled).
125    /// Window size is `2 * threshold`.
126    #[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        // R-IMP-03: graph extraction writes raw entity names/relations extracted by the LLM.
181        // No PII redaction is applied on the graph write path (pre-1.0 MVP limitation).
182        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    /// # Panics
270    ///
271    /// Panics if the registry `RwLock` is poisoned.
272    #[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    /// Enable server-side compaction mode (Claude compact-2026-01-12 beta).
312    ///
313    /// When active, client-side reactive and proactive compaction are skipped.
314    #[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    /// Extract the last assistant message, truncated to 500 chars, for the judge prompt.
397    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    /// Sets the number of turns to skip compaction after a successful compaction.
437    ///
438    /// Prevents the compaction loop from re-triggering immediately when the
439    /// summary itself is large. A value of `0` disables the cooldown.
440    #[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    /// # Panics
501    ///
502    /// Panics if the registry `RwLock` is poisoned.
503    #[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    /// Returns a handle that can cancel the current in-flight operation.
545    /// The returned `Notify` is stable across messages — callers invoke
546    /// `notify_waiters()` to cancel whatever operation is running.
547    #[must_use]
548    pub fn cancel_signal(&self) -> Arc<Notify> {
549        Arc::clone(&self.cancel_signal)
550    }
551
552    /// Inject a shared cancel signal so an external caller (e.g. ACP session) can
553    /// interrupt the agent loop by calling `notify_one()`.
554    #[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    /// Set the experiment configuration for the `/experiment` slash command.
579    #[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    /// Set the baseline config snapshot used when the agent runs an experiment.
587    ///
588    /// Call this alongside `with_experiment_config()` so the experiment engine uses
589    /// actual runtime config values (temperature, memory params, etc.) rather than
590    /// hardcoded defaults. Typically built via `ConfigSnapshot::from_config(&config)`.
591    #[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    /// Inject a shared provider override slot for runtime model switching (e.g. via ACP
602    /// `set_session_config_option`). The agent checks and swaps the provider before each turn.
603    #[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        // The injected signal and the agent's internal signal must be the same Arc.
702        assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
703    }
704
705    /// Verify that `with_managed_skills_dir` enables the install/remove commands.
706    /// Without a managed dir, `/skill install` sends a "not configured" message.
707    /// With a managed dir configured, it proceeds past that guard (and may fail
708    /// for other reasons such as the source not existing).
709    #[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}