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 super::session_config::{AgentSessionConfig, CONTEXT_BUDGET_RESERVE_RATIO};
14use crate::agent::state::ProviderConfigSnapshot;
15use crate::channel::Channel;
16use crate::config::{
17    CompressionConfig, LearningConfig, ProviderEntry, RoutingConfig, SecurityConfig, TimeoutConfig,
18};
19use crate::config_watcher::ConfigEvent;
20use crate::context::ContextBudget;
21use crate::cost::CostTracker;
22use crate::instructions::{InstructionEvent, InstructionReloadState};
23use crate::metrics::MetricsSnapshot;
24use zeph_memory::semantic::SemanticMemory;
25use zeph_skills::watcher::SkillEvent;
26
27impl<C: Channel> Agent<C> {
28    /// Attach a status channel for spinner/status messages sent to TUI or stderr.
29    /// The sender must be cloned from the provider's `StatusTx` before
30    /// `provider.set_status_tx()` consumes it.
31    #[must_use]
32    pub fn with_status_tx(mut self, tx: tokio::sync::mpsc::UnboundedSender<String>) -> Self {
33        self.session.status_tx = Some(tx);
34        self
35    }
36
37    /// Store a snapshot of the policy config for `/policy` command inspection.
38    #[cfg(feature = "policy-enforcer")]
39    #[must_use]
40    pub fn with_policy_config(mut self, config: zeph_tools::PolicyConfig) -> Self {
41        self.session.policy_config = Some(config);
42        self
43    }
44
45    #[must_use]
46    pub fn with_structured_summaries(mut self, enabled: bool) -> Self {
47        self.memory_state.structured_summaries = enabled;
48        self
49    }
50
51    #[must_use]
52    pub fn with_autosave_config(mut self, autosave_assistant: bool, min_length: usize) -> Self {
53        self.memory_state.autosave_assistant = autosave_assistant;
54        self.memory_state.autosave_min_length = min_length;
55        self
56    }
57
58    #[must_use]
59    pub fn with_tool_call_cutoff(mut self, cutoff: usize) -> Self {
60        self.memory_state.tool_call_cutoff = cutoff;
61        self
62    }
63
64    #[must_use]
65    pub fn with_shutdown_summary_config(
66        mut self,
67        enabled: bool,
68        min_messages: usize,
69        max_messages: usize,
70        timeout_secs: u64,
71    ) -> Self {
72        self.memory_state.shutdown_summary = enabled;
73        self.memory_state.shutdown_summary_min_messages = min_messages;
74        self.memory_state.shutdown_summary_max_messages = max_messages;
75        self.memory_state.shutdown_summary_timeout_secs = timeout_secs;
76        self
77    }
78
79    #[must_use]
80    pub fn with_response_cache(
81        mut self,
82        cache: std::sync::Arc<zeph_memory::ResponseCache>,
83    ) -> Self {
84        self.session.response_cache = Some(cache);
85        self
86    }
87
88    /// Set the parent tool call ID for subagent sessions.
89    ///
90    /// When set, every `LoopbackEvent::ToolStart` and `LoopbackEvent::ToolOutput` emitted
91    /// by this agent will carry the `parent_tool_use_id` so the IDE can build a subagent
92    /// hierarchy tree.
93    #[must_use]
94    pub fn with_parent_tool_use_id(mut self, id: impl Into<String>) -> Self {
95        self.session.parent_tool_use_id = Some(id.into());
96        self
97    }
98
99    #[must_use]
100    pub fn with_stt(mut self, stt: Box<dyn zeph_llm::stt::SpeechToText>) -> Self {
101        self.providers.stt = Some(stt);
102        self
103    }
104
105    /// Set the dedicated embedding provider (resolved once at bootstrap, never changed by
106    /// `/provider switch`). When not called, defaults to the primary provider clone set in
107    /// `Agent::new`.
108    #[must_use]
109    pub fn with_embedding_provider(mut self, provider: AnyProvider) -> Self {
110        self.embedding_provider = provider;
111        self
112    }
113
114    /// Store the provider pool and config snapshot for runtime `/provider` switching.
115    #[must_use]
116    pub fn with_provider_pool(
117        mut self,
118        pool: Vec<ProviderEntry>,
119        snapshot: ProviderConfigSnapshot,
120    ) -> Self {
121        self.providers.provider_pool = pool;
122        self.providers.provider_config_snapshot = Some(snapshot);
123        self
124    }
125
126    /// Enable debug dump mode, writing LLM requests/responses and raw tool output to `dumper`.
127    #[must_use]
128    pub fn with_debug_dumper(mut self, dumper: crate::debug_dump::DebugDumper) -> Self {
129        self.debug_state.debug_dumper = Some(dumper);
130        self
131    }
132
133    /// Enable `OTel` trace collection. The collector writes `trace.json` at session end.
134    #[must_use]
135    pub fn with_trace_collector(
136        mut self,
137        collector: crate::debug_dump::trace::TracingCollector,
138    ) -> Self {
139        self.debug_state.trace_collector = Some(collector);
140        self
141    }
142
143    /// Store trace config so `/dump-format trace` can create a `TracingCollector` at runtime (CR-04).
144    #[must_use]
145    pub fn with_trace_config(
146        mut self,
147        dump_dir: std::path::PathBuf,
148        service_name: impl Into<String>,
149        redact: bool,
150    ) -> Self {
151        self.debug_state.dump_dir = Some(dump_dir);
152        self.debug_state.trace_service_name = service_name.into();
153        self.debug_state.trace_redact = redact;
154        self
155    }
156
157    /// Enable LSP context injection hooks (diagnostics-on-save, hover-on-read).
158    #[cfg(feature = "lsp-context")]
159    #[must_use]
160    pub fn with_lsp_hooks(mut self, runner: crate::lsp_hooks::LspHookRunner) -> Self {
161        self.session.lsp_hooks = Some(runner);
162        self
163    }
164
165    #[must_use]
166    pub fn with_update_notifications(mut self, rx: mpsc::Receiver<String>) -> Self {
167        self.lifecycle.update_notify_rx = Some(rx);
168        self
169    }
170
171    #[must_use]
172    pub fn with_custom_task_rx(mut self, rx: mpsc::Receiver<String>) -> Self {
173        self.lifecycle.custom_task_rx = Some(rx);
174        self
175    }
176
177    /// Wrap the current tool executor with an additional executor via `CompositeExecutor`.
178    #[must_use]
179    pub fn add_tool_executor(
180        mut self,
181        extra: impl zeph_tools::executor::ToolExecutor + 'static,
182    ) -> Self {
183        let existing = Arc::clone(&self.tool_executor);
184        let combined = zeph_tools::CompositeExecutor::new(zeph_tools::DynExecutor(existing), extra);
185        self.tool_executor = Arc::new(combined);
186        self
187    }
188
189    #[must_use]
190    pub fn with_max_tool_iterations(mut self, max: usize) -> Self {
191        self.tool_orchestrator.max_iterations = max;
192        self
193    }
194
195    /// Set the maximum number of retry attempts for transient tool errors (0 = disabled, max 5).
196    #[must_use]
197    pub fn with_max_tool_retries(mut self, max: usize) -> Self {
198        self.tool_orchestrator.max_tool_retries = max.min(5);
199        self
200    }
201
202    /// Set the maximum wall-clock budget (seconds) for retries per tool call (0 = unlimited).
203    #[must_use]
204    pub fn with_max_retry_duration_secs(mut self, secs: u64) -> Self {
205        self.tool_orchestrator.max_retry_duration_secs = secs;
206        self
207    }
208
209    /// Set the provider name for LLM-based parameter reformatting (empty = disabled).
210    #[must_use]
211    pub fn with_parameter_reformat_provider(mut self, provider: impl Into<String>) -> Self {
212        self.tool_orchestrator.parameter_reformat_provider = provider.into();
213        self
214    }
215
216    /// Set the exponential backoff parameters for tool retries.
217    #[must_use]
218    pub fn with_retry_backoff(mut self, base_ms: u64, max_ms: u64) -> Self {
219        self.tool_orchestrator.retry_base_ms = base_ms;
220        self.tool_orchestrator.retry_max_ms = max_ms;
221        self
222    }
223
224    /// Set the repeat-detection threshold (0 = disabled).
225    /// Window size is `2 * threshold`.
226    #[must_use]
227    pub fn with_tool_repeat_threshold(mut self, threshold: usize) -> Self {
228        self.tool_orchestrator.repeat_threshold = threshold;
229        self.tool_orchestrator.recent_tool_calls = VecDeque::with_capacity(2 * threshold.max(1));
230        self
231    }
232
233    #[must_use]
234    pub fn with_memory(
235        mut self,
236        memory: Arc<SemanticMemory>,
237        conversation_id: zeph_memory::ConversationId,
238        history_limit: u32,
239        recall_limit: usize,
240        summarization_threshold: usize,
241    ) -> Self {
242        self.memory_state.memory = Some(memory);
243        self.memory_state.conversation_id = Some(conversation_id);
244        self.memory_state.history_limit = history_limit;
245        self.memory_state.recall_limit = recall_limit;
246        self.memory_state.summarization_threshold = summarization_threshold;
247        self.update_metrics(|m| {
248            m.qdrant_available = false;
249            m.sqlite_conversation_id = Some(conversation_id);
250        });
251        self
252    }
253
254    #[must_use]
255    pub fn with_embedding_model(mut self, model: String) -> Self {
256        self.skill_state.embedding_model = model;
257        self
258    }
259
260    #[must_use]
261    pub fn with_disambiguation_threshold(mut self, threshold: f32) -> Self {
262        self.skill_state.disambiguation_threshold = threshold;
263        self
264    }
265
266    #[must_use]
267    pub fn with_skill_prompt_mode(mut self, mode: crate::config::SkillPromptMode) -> Self {
268        self.skill_state.prompt_mode = mode;
269        self
270    }
271
272    #[must_use]
273    pub fn with_document_config(mut self, config: crate::config::DocumentConfig) -> Self {
274        self.memory_state.document_config = config;
275        self
276    }
277
278    #[must_use]
279    pub fn with_compression_guidelines_config(
280        mut self,
281        config: zeph_memory::CompressionGuidelinesConfig,
282    ) -> Self {
283        self.memory_state.compression_guidelines_config = config;
284        self
285    }
286
287    #[must_use]
288    pub fn with_graph_config(mut self, config: crate::config::GraphConfig) -> Self {
289        // R-IMP-03: graph extraction writes raw entity names/relations extracted by the LLM.
290        // No PII redaction is applied on the graph write path (pre-1.0 MVP limitation).
291        if config.enabled {
292            tracing::warn!(
293                "graph-memory is enabled: extracted entities are stored without PII redaction. \
294                 Do not use with sensitive personal data until redaction is implemented."
295            );
296        }
297        self.memory_state.graph_config = config;
298        self
299    }
300
301    #[must_use]
302    pub fn with_anomaly_detector(mut self, detector: zeph_tools::AnomalyDetector) -> Self {
303        self.debug_state.anomaly_detector = Some(detector);
304        self
305    }
306
307    #[must_use]
308    pub fn with_instruction_blocks(
309        mut self,
310        blocks: Vec<crate::instructions::InstructionBlock>,
311    ) -> Self {
312        self.instructions.blocks = blocks;
313        self
314    }
315
316    #[must_use]
317    pub fn with_instruction_reload(
318        mut self,
319        rx: mpsc::Receiver<InstructionEvent>,
320        state: InstructionReloadState,
321    ) -> Self {
322        self.instructions.reload_rx = Some(rx);
323        self.instructions.reload_state = Some(state);
324        self
325    }
326
327    #[must_use]
328    pub fn with_shutdown(mut self, rx: watch::Receiver<bool>) -> Self {
329        self.lifecycle.shutdown = rx;
330        self
331    }
332
333    #[must_use]
334    pub fn with_skill_reload(
335        mut self,
336        paths: Vec<PathBuf>,
337        rx: mpsc::Receiver<SkillEvent>,
338    ) -> Self {
339        self.skill_state.skill_paths = paths;
340        self.skill_state.skill_reload_rx = Some(rx);
341        self
342    }
343
344    #[must_use]
345    pub fn with_managed_skills_dir(mut self, dir: PathBuf) -> Self {
346        self.skill_state.managed_dir = Some(dir);
347        self
348    }
349
350    #[must_use]
351    pub fn with_trust_config(mut self, config: crate::config::TrustConfig) -> Self {
352        self.skill_state.trust_config = config;
353        self
354    }
355
356    #[must_use]
357    pub fn with_config_reload(mut self, path: PathBuf, rx: mpsc::Receiver<ConfigEvent>) -> Self {
358        self.lifecycle.config_path = Some(path);
359        self.lifecycle.config_reload_rx = Some(rx);
360        self
361    }
362
363    #[must_use]
364    pub fn with_logging_config(mut self, logging: crate::config::LoggingConfig) -> Self {
365        self.debug_state.logging_config = logging;
366        self
367    }
368
369    #[must_use]
370    pub fn with_available_secrets(
371        mut self,
372        secrets: impl IntoIterator<Item = (String, crate::vault::Secret)>,
373    ) -> Self {
374        self.skill_state.available_custom_secrets = secrets.into_iter().collect();
375        self
376    }
377
378    /// # Panics
379    ///
380    /// Panics if the registry `RwLock` is poisoned.
381    #[must_use]
382    pub fn with_hybrid_search(mut self, enabled: bool) -> Self {
383        self.skill_state.hybrid_search = enabled;
384        if enabled {
385            let reg = self
386                .skill_state
387                .registry
388                .read()
389                .expect("registry read lock");
390            let all_meta = reg.all_meta();
391            let descs: Vec<&str> = all_meta.iter().map(|m| m.description.as_str()).collect();
392            self.skill_state.bm25_index = Some(zeph_skills::bm25::Bm25Index::build(&descs));
393        }
394        self
395    }
396
397    #[must_use]
398    pub fn with_learning(mut self, config: LearningConfig) -> Self {
399        if config.correction_detection {
400            self.feedback.detector = super::feedback_detector::FeedbackDetector::new(
401                config.correction_confidence_threshold,
402            );
403            if config.detector_mode == crate::config::DetectorMode::Judge {
404                self.feedback.judge = Some(super::feedback_detector::JudgeDetector::new(
405                    config.judge_adaptive_low,
406                    config.judge_adaptive_high,
407                ));
408            }
409        }
410        self.learning_engine.config = Some(config);
411        self
412    }
413
414    /// Attach an `LlmClassifier` for `detector_mode = "model"` feedback detection.
415    ///
416    /// When attached, the model-based path is used instead of `JudgeDetector`.
417    /// The classifier resolves the provider at construction time — if the provider
418    /// is unavailable, do not call this method (fallback to regex-only).
419    #[must_use]
420    pub fn with_llm_classifier(
421        mut self,
422        classifier: zeph_llm::classifier::llm::LlmClassifier,
423    ) -> Self {
424        self.feedback.llm_classifier = Some(classifier);
425        self
426    }
427
428    #[must_use]
429    pub fn with_judge_provider(mut self, provider: AnyProvider) -> Self {
430        self.providers.judge_provider = Some(provider);
431        self
432    }
433
434    #[must_use]
435    pub fn with_probe_provider(mut self, provider: AnyProvider) -> Self {
436        self.providers.probe_provider = Some(provider);
437        self
438    }
439
440    #[must_use]
441    pub fn with_planner_provider(mut self, provider: AnyProvider) -> Self {
442        self.orchestration.planner_provider = Some(provider);
443        self
444    }
445
446    /// Enable server-side compaction mode (Claude compact-2026-01-12 beta).
447    ///
448    /// When active, client-side reactive and proactive compaction are skipped.
449    #[must_use]
450    pub fn with_server_compaction(mut self, enabled: bool) -> Self {
451        self.providers.server_compaction_active = enabled;
452        self
453    }
454
455    #[must_use]
456    pub fn with_mcp(
457        mut self,
458        tools: Vec<zeph_mcp::McpTool>,
459        registry: Option<zeph_mcp::McpToolRegistry>,
460        manager: Option<std::sync::Arc<zeph_mcp::McpManager>>,
461        mcp_config: &crate::config::McpConfig,
462    ) -> Self {
463        self.mcp.tools = tools;
464        self.mcp.registry = registry;
465        self.mcp.manager = manager;
466        self.mcp
467            .allowed_commands
468            .clone_from(&mcp_config.allowed_commands);
469        self.mcp.max_dynamic = mcp_config.max_dynamic_servers;
470        self
471    }
472
473    #[must_use]
474    pub fn with_mcp_shared_tools(
475        mut self,
476        shared: std::sync::Arc<std::sync::RwLock<Vec<zeph_mcp::McpTool>>>,
477    ) -> Self {
478        self.mcp.shared_tools = Some(shared);
479        self
480    }
481
482    /// Set the watch receiver for MCP tool list updates from `tools/list_changed` notifications.
483    ///
484    /// The agent polls this receiver at the start of each turn to pick up refreshed tool lists.
485    #[must_use]
486    pub fn with_mcp_tool_rx(
487        mut self,
488        rx: tokio::sync::watch::Receiver<Vec<zeph_mcp::McpTool>>,
489    ) -> Self {
490        self.mcp.tool_rx = Some(rx);
491        self
492    }
493
494    #[must_use]
495    pub fn with_security(mut self, security: SecurityConfig, timeouts: TimeoutConfig) -> Self {
496        self.security.sanitizer =
497            zeph_sanitizer::ContentSanitizer::new(&security.content_isolation);
498        self.security.exfiltration_guard = zeph_sanitizer::exfiltration::ExfiltrationGuard::new(
499            security.exfiltration_guard.clone(),
500        );
501        self.security.pii_filter = zeph_sanitizer::pii::PiiFilter::new(security.pii_filter.clone());
502        self.security.memory_validator =
503            zeph_sanitizer::memory_validation::MemoryWriteValidator::new(
504                security.memory_validation.clone(),
505            );
506        self.runtime.rate_limiter =
507            crate::agent::rate_limiter::ToolRateLimiter::new(security.rate_limit.clone());
508
509        // Build pre-execution verifiers from config.
510        // Stored on ToolOrchestrator (not SecurityState) — verifiers inspect tool arguments
511        // at dispatch time, consistent with repeat-detection and rate-limiting which also
512        // live on ToolOrchestrator. SecurityState hosts zeph-core::sanitizer types only.
513        let mut verifiers: Vec<Box<dyn zeph_tools::PreExecutionVerifier>> = Vec::new();
514        if security.pre_execution_verify.enabled {
515            let dcfg = &security.pre_execution_verify.destructive_commands;
516            if dcfg.enabled {
517                verifiers.push(Box::new(zeph_tools::DestructiveCommandVerifier::new(dcfg)));
518            }
519            let icfg = &security.pre_execution_verify.injection_patterns;
520            if icfg.enabled {
521                verifiers.push(Box::new(zeph_tools::InjectionPatternVerifier::new(icfg)));
522            }
523            let ucfg = &security.pre_execution_verify.url_grounding;
524            if ucfg.enabled {
525                verifiers.push(Box::new(zeph_tools::UrlGroundingVerifier::new(
526                    ucfg,
527                    std::sync::Arc::clone(&self.security.user_provided_urls),
528                )));
529            }
530        }
531        self.tool_orchestrator.pre_execution_verifiers = verifiers;
532
533        self.security.response_verifier = zeph_sanitizer::response_verifier::ResponseVerifier::new(
534            security.response_verification.clone(),
535        );
536
537        self.runtime.security = security;
538        self.runtime.timeouts = timeouts;
539        self
540    }
541
542    #[must_use]
543    pub fn with_redact_credentials(mut self, enabled: bool) -> Self {
544        self.runtime.redact_credentials = enabled;
545        self
546    }
547
548    #[must_use]
549    pub fn with_tool_summarization(mut self, enabled: bool) -> Self {
550        self.tool_orchestrator.summarize_tool_output_enabled = enabled;
551        self
552    }
553
554    #[must_use]
555    pub fn with_overflow_config(mut self, config: zeph_tools::OverflowConfig) -> Self {
556        self.tool_orchestrator.overflow_config = config;
557        self
558    }
559
560    /// Configure Think-Augmented Function Calling (TAFC).
561    ///
562    /// `complexity_threshold` is clamped to [0.0, 1.0]; NaN / Inf are reset to 0.6.
563    #[must_use]
564    pub fn with_tafc_config(mut self, config: zeph_tools::TafcConfig) -> Self {
565        self.tool_orchestrator.tafc = config.validated();
566        self
567    }
568
569    #[must_use]
570    pub fn with_result_cache_config(mut self, config: &zeph_tools::ResultCacheConfig) -> Self {
571        self.tool_orchestrator.set_cache_config(config);
572        self
573    }
574
575    #[must_use]
576    pub fn with_summary_provider(mut self, provider: AnyProvider) -> Self {
577        self.providers.summary_provider = Some(provider);
578        self
579    }
580
581    #[must_use]
582    pub fn with_quarantine_summarizer(
583        mut self,
584        qs: zeph_sanitizer::quarantine::QuarantinedSummarizer,
585    ) -> Self {
586        self.security.quarantine_summarizer = Some(qs);
587        self
588    }
589
590    /// Attach an ML classifier backend to the sanitizer for injection detection.
591    ///
592    /// When attached, `classify_injection()` is called on each incoming user message when
593    /// `classifiers.enabled = true`. On error or timeout it falls back to regex detection.
594    #[cfg(feature = "classifiers")]
595    #[must_use]
596    pub fn with_injection_classifier(
597        mut self,
598        backend: std::sync::Arc<dyn zeph_llm::classifier::ClassifierBackend>,
599        timeout_ms: u64,
600        threshold: f32,
601    ) -> Self {
602        // Replace sanitizer in-place: move out, attach classifier, move back.
603        let old = std::mem::replace(
604            &mut self.security.sanitizer,
605            zeph_sanitizer::ContentSanitizer::new(
606                &zeph_sanitizer::ContentIsolationConfig::default(),
607            ),
608        );
609        self.security.sanitizer = old.with_classifier(backend, timeout_ms, threshold);
610        self
611    }
612
613    /// Attach a PII detector backend to the sanitizer.
614    ///
615    /// When attached, `detect_pii()` is called on outgoing assistant responses when
616    /// `classifiers.pii_enabled = true`. On error it falls back to returning no spans.
617    #[cfg(feature = "classifiers")]
618    #[must_use]
619    pub fn with_pii_detector(
620        mut self,
621        detector: std::sync::Arc<dyn zeph_llm::classifier::PiiDetector>,
622        threshold: f32,
623    ) -> Self {
624        let old = std::mem::replace(
625            &mut self.security.sanitizer,
626            zeph_sanitizer::ContentSanitizer::new(
627                &zeph_sanitizer::ContentIsolationConfig::default(),
628            ),
629        );
630        self.security.sanitizer = old.with_pii_detector(detector, threshold);
631        self
632    }
633
634    /// Attach a NER classifier backend for PII detection in the union merge pipeline.
635    ///
636    /// When attached, `sanitize_tool_output()` runs both regex and NER, merges spans, and
637    /// redacts from the merged list in a single pass. References `classifiers.ner_model`.
638    #[cfg(feature = "classifiers")]
639    #[must_use]
640    pub fn with_pii_ner_classifier(
641        mut self,
642        backend: std::sync::Arc<dyn zeph_llm::classifier::ClassifierBackend>,
643        timeout_ms: u64,
644    ) -> Self {
645        self.security.pii_ner_backend = Some(backend);
646        self.security.pii_ner_timeout_ms = timeout_ms;
647        self
648    }
649
650    #[cfg(feature = "guardrail")]
651    #[must_use]
652    pub fn with_guardrail(mut self, filter: zeph_sanitizer::guardrail::GuardrailFilter) -> Self {
653        use zeph_sanitizer::guardrail::GuardrailAction;
654        let warn_mode = filter.action() == GuardrailAction::Warn;
655        self.security.guardrail = Some(filter);
656        self.update_metrics(|m| {
657            m.guardrail_enabled = true;
658            m.guardrail_warn_mode = warn_mode;
659        });
660        self
661    }
662
663    pub(super) fn summary_or_primary_provider(&self) -> &AnyProvider {
664        self.providers
665            .summary_provider
666            .as_ref()
667            .unwrap_or(&self.provider)
668    }
669
670    pub(super) fn probe_or_summary_provider(&self) -> &AnyProvider {
671        self.providers
672            .probe_provider
673            .as_ref()
674            .or(self.providers.summary_provider.as_ref())
675            .unwrap_or(&self.provider)
676    }
677
678    /// Extract the last assistant message, truncated to 500 chars, for the judge prompt.
679    pub(super) fn last_assistant_response(&self) -> String {
680        self.msg
681            .messages
682            .iter()
683            .rev()
684            .find(|m| m.role == zeph_llm::provider::Role::Assistant)
685            .map(|m| super::context::truncate_chars(&m.content, 500))
686            .unwrap_or_default()
687    }
688
689    #[must_use]
690    pub fn with_permission_policy(mut self, policy: zeph_tools::PermissionPolicy) -> Self {
691        self.runtime.permission_policy = policy;
692        self
693    }
694
695    #[must_use]
696    pub fn with_context_budget(
697        mut self,
698        budget_tokens: usize,
699        reserve_ratio: f32,
700        hard_compaction_threshold: f32,
701        compaction_preserve_tail: usize,
702        prune_protect_tokens: usize,
703    ) -> Self {
704        if budget_tokens == 0 {
705            tracing::warn!("context budget is 0 — agent will have no token tracking");
706        }
707        if budget_tokens > 0 {
708            self.context_manager.budget = Some(ContextBudget::new(budget_tokens, reserve_ratio));
709        }
710        self.context_manager.hard_compaction_threshold = hard_compaction_threshold;
711        self.context_manager.compaction_preserve_tail = compaction_preserve_tail;
712        self.context_manager.prune_protect_tokens = prune_protect_tokens;
713        self
714    }
715
716    #[must_use]
717    pub fn with_soft_compaction_threshold(mut self, threshold: f32) -> Self {
718        self.context_manager.soft_compaction_threshold = threshold;
719        self
720    }
721
722    /// Sets the number of turns to skip compaction after a successful compaction.
723    ///
724    /// Prevents the compaction loop from re-triggering immediately when the
725    /// summary itself is large. A value of `0` disables the cooldown.
726    #[must_use]
727    pub fn with_compaction_cooldown(mut self, cooldown_turns: u8) -> Self {
728        self.context_manager.compaction_cooldown_turns = cooldown_turns;
729        self
730    }
731
732    #[must_use]
733    pub fn with_compression(mut self, compression: CompressionConfig) -> Self {
734        self.context_manager.compression = compression;
735        self
736    }
737
738    /// Configure Focus-based active context compression (#1850).
739    #[must_use]
740    pub fn with_focus_config(mut self, config: crate::config::FocusConfig) -> Self {
741        self.focus = super::focus::FocusState::new(config);
742        self
743    }
744
745    /// Configure `SideQuest` LLM-driven tool output eviction (#1885).
746    #[must_use]
747    pub fn with_sidequest_config(mut self, config: crate::config::SidequestConfig) -> Self {
748        self.sidequest = super::sidequest::SidequestState::new(config);
749        self
750    }
751
752    #[must_use]
753    pub fn with_routing(mut self, routing: RoutingConfig) -> Self {
754        self.context_manager.routing = routing;
755        self
756    }
757
758    #[must_use]
759    pub fn with_model_name(mut self, name: impl Into<String>) -> Self {
760        self.runtime.model_name = name.into();
761        self
762    }
763
764    /// Set the configured provider name (from `[[llm.providers]]` `name` field).
765    ///
766    /// Used by the TUI metrics panel and `/provider status` to display the logical name
767    /// instead of the provider type string returned by `LlmProvider::name()`.
768    #[must_use]
769    pub fn with_active_provider_name(mut self, name: impl Into<String>) -> Self {
770        self.runtime.active_provider_name = name.into();
771        self
772    }
773
774    #[must_use]
775    pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
776        let path = path.into();
777        self.session.env_context =
778            crate::context::EnvironmentContext::gather_for_dir(&self.runtime.model_name, &path);
779        self
780    }
781
782    #[must_use]
783    pub fn with_warmup_ready(mut self, rx: watch::Receiver<bool>) -> Self {
784        self.lifecycle.warmup_ready = Some(rx);
785        self
786    }
787
788    #[must_use]
789    pub fn with_cost_tracker(mut self, tracker: CostTracker) -> Self {
790        self.metrics.cost_tracker = Some(tracker);
791        self
792    }
793
794    #[must_use]
795    pub fn with_extended_context(mut self, enabled: bool) -> Self {
796        self.metrics.extended_context = enabled;
797        self
798    }
799
800    #[must_use]
801    pub fn with_repo_map(mut self, token_budget: usize, ttl_secs: u64) -> Self {
802        self.index.repo_map_tokens = token_budget;
803        self.index.repo_map_ttl = std::time::Duration::from_secs(ttl_secs);
804        self
805    }
806
807    #[must_use]
808    pub fn with_code_retriever(
809        mut self,
810        retriever: std::sync::Arc<zeph_index::retriever::CodeRetriever>,
811    ) -> Self {
812        self.index.retriever = Some(retriever);
813        self
814    }
815
816    /// # Panics
817    ///
818    /// Panics if the registry `RwLock` is poisoned.
819    #[must_use]
820    pub fn with_metrics(mut self, tx: watch::Sender<MetricsSnapshot>) -> Self {
821        let provider_name = if self.runtime.active_provider_name.is_empty() {
822            self.provider.name().to_owned()
823        } else {
824            self.runtime.active_provider_name.clone()
825        };
826        let model_name = self.runtime.model_name.clone();
827        let total_skills = self
828            .skill_state
829            .registry
830            .read()
831            .expect("registry read lock")
832            .all_meta()
833            .len();
834        let qdrant_available = false;
835        let conversation_id = self.memory_state.conversation_id;
836        let prompt_estimate = self
837            .msg
838            .messages
839            .first()
840            .map_or(0, |m| u64::try_from(m.content.len()).unwrap_or(0) / 4);
841        let mcp_tool_count = self.mcp.tools.len();
842        let mcp_server_count = self
843            .mcp
844            .tools
845            .iter()
846            .map(|t| &t.server_id)
847            .collect::<std::collections::HashSet<_>>()
848            .len();
849        let extended_context = self.metrics.extended_context;
850        tx.send_modify(|m| {
851            m.provider_name = provider_name;
852            m.model_name = model_name;
853            m.total_skills = total_skills;
854            m.qdrant_available = qdrant_available;
855            m.sqlite_conversation_id = conversation_id;
856            m.context_tokens = prompt_estimate;
857            m.prompt_tokens = prompt_estimate;
858            m.total_tokens = prompt_estimate;
859            m.mcp_tool_count = mcp_tool_count;
860            m.mcp_server_count = mcp_server_count;
861            m.extended_context = extended_context;
862        });
863        self.metrics.metrics_tx = Some(tx);
864        self
865    }
866
867    /// Returns a handle that can cancel the current in-flight operation.
868    /// The returned `Notify` is stable across messages — callers invoke
869    /// `notify_waiters()` to cancel whatever operation is running.
870    #[must_use]
871    pub fn cancel_signal(&self) -> Arc<Notify> {
872        Arc::clone(&self.lifecycle.cancel_signal)
873    }
874
875    /// Inject a shared cancel signal so an external caller (e.g. ACP session) can
876    /// interrupt the agent loop by calling `notify_one()`.
877    #[must_use]
878    pub fn with_cancel_signal(mut self, signal: Arc<Notify>) -> Self {
879        self.lifecycle.cancel_signal = signal;
880        self
881    }
882
883    #[must_use]
884    pub fn with_subagent_manager(mut self, manager: crate::subagent::SubAgentManager) -> Self {
885        self.orchestration.subagent_manager = Some(manager);
886        self
887    }
888
889    #[must_use]
890    pub fn with_subagent_config(mut self, config: crate::config::SubAgentConfig) -> Self {
891        self.orchestration.subagent_config = config;
892        self
893    }
894
895    #[must_use]
896    pub fn with_orchestration_config(mut self, config: crate::config::OrchestrationConfig) -> Self {
897        self.orchestration.orchestration_config = config;
898        self
899    }
900
901    /// Set the experiment configuration for the `/experiment` slash command.
902    #[cfg(feature = "experiments")]
903    #[must_use]
904    pub fn with_experiment_config(mut self, config: crate::config::ExperimentConfig) -> Self {
905        self.experiments.config = config;
906        self
907    }
908
909    /// Set the baseline config snapshot used when the agent runs an experiment.
910    ///
911    /// Call this alongside `with_experiment_config()` so the experiment engine uses
912    /// actual runtime config values (temperature, memory params, etc.) rather than
913    /// hardcoded defaults. Typically built via `ConfigSnapshot::from_config(&config)`.
914    #[cfg(feature = "experiments")]
915    #[must_use]
916    pub fn with_experiment_baseline(
917        mut self,
918        baseline: crate::experiments::ConfigSnapshot,
919    ) -> Self {
920        self.experiments.baseline = baseline;
921        self
922    }
923
924    /// Set a dedicated judge provider for experiment evaluation.
925    ///
926    /// When set, the evaluator uses this provider instead of the agent's primary provider,
927    /// eliminating self-judge bias. Corresponds to `experiments.eval_model` in config.
928    #[cfg(feature = "experiments")]
929    #[must_use]
930    pub fn with_eval_provider(mut self, provider: AnyProvider) -> Self {
931        self.experiments.eval_provider = Some(provider);
932        self
933    }
934
935    /// Inject a shared provider override slot for runtime model switching (e.g. via ACP
936    /// `set_session_config_option`). The agent checks and swaps the provider before each turn.
937    #[must_use]
938    pub fn with_provider_override(
939        mut self,
940        slot: Arc<std::sync::RwLock<Option<AnyProvider>>>,
941    ) -> Self {
942        self.providers.provider_override = Some(slot);
943        self
944    }
945
946    /// Set the dynamic tool schema filter (pre-computed tool embeddings).
947    #[must_use]
948    pub fn with_tool_schema_filter(mut self, filter: zeph_tools::ToolSchemaFilter) -> Self {
949        self.tool_schema_filter = Some(filter);
950        self
951    }
952
953    /// Set dependency config parameters (boost values) used per-turn.
954    #[must_use]
955    pub fn with_dependency_config(mut self, config: zeph_tools::DependencyConfig) -> Self {
956        self.runtime.dependency_config = config;
957        self
958    }
959
960    /// Attach a tool dependency graph for sequential tool availability (issue #2024).
961    ///
962    /// When set, hard gates (`requires`) are applied after schema filtering, and soft boosts
963    /// (`prefers`) are added to similarity scores. Always-on tool IDs bypass hard gates.
964    #[must_use]
965    pub fn with_tool_dependency_graph(
966        mut self,
967        graph: zeph_tools::ToolDependencyGraph,
968        always_on: std::collections::HashSet<String>,
969    ) -> Self {
970        self.dependency_graph = Some(graph);
971        self.dependency_always_on = always_on;
972        self
973    }
974
975    /// Initialize and attach the tool schema filter if enabled in config.
976    ///
977    /// Embeds all filterable tool descriptions at startup and caches the embeddings.
978    /// Gracefully degrades: returns `self` unchanged if embedding is unsupported or fails.
979    pub async fn maybe_init_tool_schema_filter(
980        mut self,
981        config: &crate::config::ToolFilterConfig,
982        provider: &zeph_llm::any::AnyProvider,
983    ) -> Self {
984        use zeph_llm::provider::LlmProvider;
985
986        if !config.enabled {
987            return self;
988        }
989
990        let always_on_set: std::collections::HashSet<&str> =
991            config.always_on.iter().map(String::as_str).collect();
992        let defs = self.tool_executor.tool_definitions_erased();
993        let filterable: Vec<&zeph_tools::registry::ToolDef> = defs
994            .iter()
995            .filter(|d| !always_on_set.contains(d.id.as_ref()))
996            .collect();
997
998        if filterable.is_empty() {
999            tracing::info!("tool schema filter: all tools are always-on, nothing to filter");
1000            return self;
1001        }
1002
1003        let mut embeddings = Vec::with_capacity(filterable.len());
1004        for def in &filterable {
1005            let text = format!("{}: {}", def.id, def.description);
1006            match provider.embed(&text).await {
1007                Ok(emb) => {
1008                    embeddings.push(zeph_tools::ToolEmbedding {
1009                        tool_id: def.id.to_string(),
1010                        embedding: emb,
1011                    });
1012                }
1013                Err(e) => {
1014                    tracing::info!(
1015                        provider = provider.name(),
1016                        "tool schema filter disabled: embedding not supported \
1017                        by provider ({e:#})"
1018                    );
1019                    return self;
1020                }
1021            }
1022        }
1023
1024        tracing::info!(
1025            tool_count = embeddings.len(),
1026            always_on = config.always_on.len(),
1027            top_k = config.top_k,
1028            "tool schema filter initialized"
1029        );
1030
1031        let filter = zeph_tools::ToolSchemaFilter::new(
1032            config.always_on.clone(),
1033            config.top_k,
1034            config.min_description_words,
1035            embeddings,
1036        );
1037        self.tool_schema_filter = Some(filter);
1038        self
1039    }
1040
1041    /// Apply all config-derived settings from [`AgentSessionConfig`] in a single call.
1042    ///
1043    /// Takes `cfg` by value and destructures it so the compiler emits an unused-variable warning
1044    /// for any field that is added to [`AgentSessionConfig`] but not consumed here (S4).
1045    ///
1046    /// Per-session wiring (`cancel_signal`, `provider_override`, `memory`, `debug_dumper`, etc.)
1047    /// must still be applied separately after this call, since those depend on runtime state.
1048    #[must_use]
1049    pub fn apply_session_config(mut self, cfg: AgentSessionConfig) -> Self {
1050        let AgentSessionConfig {
1051            max_tool_iterations,
1052            max_tool_retries,
1053            max_retry_duration_secs,
1054            retry_base_ms,
1055            retry_max_ms,
1056            parameter_reformat_provider,
1057            tool_repeat_threshold,
1058            tool_summarization,
1059            tool_call_cutoff,
1060            overflow_config,
1061            permission_policy,
1062            model_name,
1063            embed_model,
1064            semantic_cache_enabled,
1065            semantic_cache_threshold,
1066            semantic_cache_max_candidates,
1067            budget_tokens,
1068            soft_compaction_threshold,
1069            hard_compaction_threshold,
1070            compaction_preserve_tail,
1071            compaction_cooldown_turns,
1072            prune_protect_tokens,
1073            redact_credentials,
1074            security,
1075            timeouts,
1076            learning,
1077            document_config,
1078            graph_config,
1079            anomaly_config,
1080            result_cache_config,
1081            orchestration_config,
1082            // Not applied here: caller clones this before `apply_session_config` and applies
1083            // it per-session (e.g. `spawn_acp_agent` passes it to `with_debug_config`).
1084            debug_config: _debug_config,
1085            server_compaction,
1086            secrets,
1087        } = cfg;
1088
1089        self = self
1090            .with_max_tool_iterations(max_tool_iterations)
1091            .with_max_tool_retries(max_tool_retries)
1092            .with_max_retry_duration_secs(max_retry_duration_secs)
1093            .with_retry_backoff(retry_base_ms, retry_max_ms)
1094            .with_parameter_reformat_provider(parameter_reformat_provider)
1095            .with_tool_repeat_threshold(tool_repeat_threshold)
1096            .with_model_name(model_name)
1097            .with_embedding_model(embed_model)
1098            .with_context_budget(
1099                budget_tokens,
1100                CONTEXT_BUDGET_RESERVE_RATIO,
1101                hard_compaction_threshold,
1102                compaction_preserve_tail,
1103                prune_protect_tokens,
1104            )
1105            .with_soft_compaction_threshold(soft_compaction_threshold)
1106            .with_compaction_cooldown(compaction_cooldown_turns)
1107            .with_security(security, timeouts)
1108            .with_redact_credentials(redact_credentials)
1109            .with_tool_summarization(tool_summarization)
1110            .with_overflow_config(overflow_config)
1111            .with_permission_policy(permission_policy)
1112            .with_learning(learning)
1113            .with_tool_call_cutoff(tool_call_cutoff)
1114            .with_available_secrets(
1115                secrets
1116                    .iter()
1117                    .map(|(k, v)| (k.clone(), crate::vault::Secret::new(v.expose().to_owned()))),
1118            )
1119            .with_server_compaction(server_compaction)
1120            .with_document_config(document_config)
1121            .with_graph_config(graph_config)
1122            .with_orchestration_config(orchestration_config);
1123
1124        if anomaly_config.enabled {
1125            self = self.with_anomaly_detector(zeph_tools::AnomalyDetector::new(
1126                anomaly_config.window_size,
1127                anomaly_config.error_threshold,
1128                anomaly_config.critical_threshold,
1129            ));
1130        }
1131
1132        self.runtime.semantic_cache_enabled = semantic_cache_enabled;
1133        self.runtime.semantic_cache_threshold = semantic_cache_threshold;
1134        self.runtime.semantic_cache_max_candidates = semantic_cache_max_candidates;
1135        self = self.with_result_cache_config(&result_cache_config);
1136
1137        self
1138    }
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::super::agent_tests::{
1144        MockChannel, MockToolExecutor, create_test_registry, mock_provider,
1145    };
1146    use super::*;
1147    use crate::config::{CompressionStrategy, RoutingStrategy};
1148
1149    fn make_agent() -> Agent<MockChannel> {
1150        Agent::new(
1151            mock_provider(vec![]),
1152            MockChannel::new(vec![]),
1153            create_test_registry(),
1154            None,
1155            5,
1156            MockToolExecutor::no_tools(),
1157        )
1158    }
1159
1160    #[test]
1161    fn with_compression_sets_proactive_strategy() {
1162        let compression = CompressionConfig {
1163            strategy: CompressionStrategy::Proactive {
1164                threshold_tokens: 50_000,
1165                max_summary_tokens: 2_000,
1166            },
1167            model: String::new(),
1168            pruning_strategy: crate::config::PruningStrategy::default(),
1169            probe: Default::default(),
1170        };
1171        let agent = make_agent().with_compression(compression);
1172        assert!(
1173            matches!(
1174                agent.context_manager.compression.strategy,
1175                CompressionStrategy::Proactive {
1176                    threshold_tokens: 50_000,
1177                    max_summary_tokens: 2_000,
1178                }
1179            ),
1180            "expected Proactive strategy after with_compression"
1181        );
1182    }
1183
1184    #[test]
1185    fn with_routing_sets_routing_config() {
1186        let routing = RoutingConfig {
1187            strategy: RoutingStrategy::Heuristic,
1188        };
1189        let agent = make_agent().with_routing(routing);
1190        assert_eq!(
1191            agent.context_manager.routing.strategy,
1192            RoutingStrategy::Heuristic,
1193            "routing strategy must be set by with_routing"
1194        );
1195    }
1196
1197    #[test]
1198    fn default_compression_is_reactive() {
1199        let agent = make_agent();
1200        assert_eq!(
1201            agent.context_manager.compression.strategy,
1202            CompressionStrategy::Reactive,
1203            "default compression strategy must be Reactive"
1204        );
1205    }
1206
1207    #[test]
1208    fn default_routing_is_heuristic() {
1209        let agent = make_agent();
1210        assert_eq!(
1211            agent.context_manager.routing.strategy,
1212            RoutingStrategy::Heuristic,
1213            "default routing strategy must be Heuristic"
1214        );
1215    }
1216
1217    #[test]
1218    fn with_cancel_signal_replaces_internal_signal() {
1219        let agent = Agent::new(
1220            mock_provider(vec![]),
1221            MockChannel::new(vec![]),
1222            create_test_registry(),
1223            None,
1224            5,
1225            MockToolExecutor::no_tools(),
1226        );
1227
1228        let shared = Arc::new(Notify::new());
1229        let agent = agent.with_cancel_signal(Arc::clone(&shared));
1230
1231        // The injected signal and the agent's internal signal must be the same Arc.
1232        assert!(Arc::ptr_eq(&shared, &agent.cancel_signal()));
1233    }
1234
1235    /// Verify that `with_managed_skills_dir` enables the install/remove commands.
1236    /// Without a managed dir, `/skill install` sends a "not configured" message.
1237    /// With a managed dir configured, it proceeds past that guard (and may fail
1238    /// for other reasons such as the source not existing).
1239    #[tokio::test]
1240    async fn with_managed_skills_dir_enables_install_command() {
1241        let provider = mock_provider(vec![]);
1242        let channel = MockChannel::new(vec![]);
1243        let registry = create_test_registry();
1244        let executor = MockToolExecutor::no_tools();
1245        let managed = tempfile::tempdir().unwrap();
1246
1247        let mut agent_no_dir = Agent::new(
1248            mock_provider(vec![]),
1249            MockChannel::new(vec![]),
1250            create_test_registry(),
1251            None,
1252            5,
1253            MockToolExecutor::no_tools(),
1254        );
1255        agent_no_dir
1256            .handle_skill_command("install /some/path")
1257            .await
1258            .unwrap();
1259        let sent_no_dir = agent_no_dir.channel.sent_messages();
1260        assert!(
1261            sent_no_dir.iter().any(|s| s.contains("not configured")),
1262            "without managed dir: {sent_no_dir:?}"
1263        );
1264
1265        let _ = (provider, channel, registry, executor);
1266        let mut agent_with_dir = Agent::new(
1267            mock_provider(vec![]),
1268            MockChannel::new(vec![]),
1269            create_test_registry(),
1270            None,
1271            5,
1272            MockToolExecutor::no_tools(),
1273        )
1274        .with_managed_skills_dir(managed.path().to_path_buf());
1275
1276        agent_with_dir
1277            .handle_skill_command("install /nonexistent/path")
1278            .await
1279            .unwrap();
1280        let sent_with_dir = agent_with_dir.channel.sent_messages();
1281        assert!(
1282            !sent_with_dir.iter().any(|s| s.contains("not configured")),
1283            "with managed dir should not say not configured: {sent_with_dir:?}"
1284        );
1285        assert!(
1286            sent_with_dir.iter().any(|s| s.contains("Install failed")),
1287            "with managed dir should fail due to bad path: {sent_with_dir:?}"
1288        );
1289    }
1290
1291    #[test]
1292    fn default_graph_config_is_disabled() {
1293        let agent = make_agent();
1294        assert!(
1295            !agent.memory_state.graph_config.enabled,
1296            "graph_config must default to disabled"
1297        );
1298    }
1299
1300    #[test]
1301    fn with_graph_config_enabled_sets_flag() {
1302        let cfg = crate::config::GraphConfig {
1303            enabled: true,
1304            ..Default::default()
1305        };
1306        let agent = make_agent().with_graph_config(cfg);
1307        assert!(
1308            agent.memory_state.graph_config.enabled,
1309            "with_graph_config must set enabled flag"
1310        );
1311    }
1312
1313    /// Verify that `apply_session_config` wires graph memory, orchestration, and anomaly
1314    /// detector configs into the agent in a single call — the acceptance criterion for issue #1812.
1315    ///
1316    /// This exercises the full path: AgentSessionConfig::from_config → apply_session_config →
1317    /// agent internal state, confirming that all three feature configs are propagated correctly.
1318    #[test]
1319    fn apply_session_config_wires_graph_orchestration_anomaly() {
1320        use crate::config::Config;
1321
1322        let mut config = Config::default();
1323        config.memory.graph.enabled = true;
1324        config.orchestration.enabled = true;
1325        config.orchestration.max_tasks = 42;
1326        config.tools.anomaly.enabled = true;
1327        config.tools.anomaly.window_size = 7;
1328
1329        let session_cfg = AgentSessionConfig::from_config(&config, 100_000);
1330
1331        // Precondition: from_config captured the values.
1332        assert!(session_cfg.graph_config.enabled);
1333        assert!(session_cfg.orchestration_config.enabled);
1334        assert_eq!(session_cfg.orchestration_config.max_tasks, 42);
1335        assert!(session_cfg.anomaly_config.enabled);
1336        assert_eq!(session_cfg.anomaly_config.window_size, 7);
1337
1338        let agent = make_agent().apply_session_config(session_cfg);
1339
1340        // Graph config must be set on memory_state.
1341        assert!(
1342            agent.memory_state.graph_config.enabled,
1343            "apply_session_config must wire graph_config into agent"
1344        );
1345
1346        // Orchestration config must be propagated.
1347        assert!(
1348            agent.orchestration.orchestration_config.enabled,
1349            "apply_session_config must wire orchestration_config into agent"
1350        );
1351        assert_eq!(
1352            agent.orchestration.orchestration_config.max_tasks, 42,
1353            "orchestration max_tasks must match config"
1354        );
1355
1356        // Anomaly detector must be created when anomaly_config.enabled = true.
1357        assert!(
1358            agent.debug_state.anomaly_detector.is_some(),
1359            "apply_session_config must create anomaly_detector when enabled"
1360        );
1361    }
1362
1363    #[test]
1364    fn with_focus_config_propagates_to_focus_state() {
1365        let cfg = crate::config::FocusConfig {
1366            enabled: true,
1367            compression_interval: 7,
1368            ..Default::default()
1369        };
1370        let agent = make_agent().with_focus_config(cfg.clone());
1371        assert!(
1372            agent.focus.config.enabled,
1373            "with_focus_config must set enabled"
1374        );
1375        assert_eq!(
1376            agent.focus.config.compression_interval, 7,
1377            "with_focus_config must propagate compression_interval"
1378        );
1379    }
1380
1381    #[test]
1382    fn with_sidequest_config_propagates_to_sidequest_state() {
1383        let cfg = crate::config::SidequestConfig {
1384            enabled: true,
1385            interval_turns: 3,
1386            ..Default::default()
1387        };
1388        let agent = make_agent().with_sidequest_config(cfg.clone());
1389        assert!(
1390            agent.sidequest.config.enabled,
1391            "with_sidequest_config must set enabled"
1392        );
1393        assert_eq!(
1394            agent.sidequest.config.interval_turns, 3,
1395            "with_sidequest_config must propagate interval_turns"
1396        );
1397    }
1398
1399    /// Verify that apply_session_config does NOT create an anomaly detector when disabled.
1400    #[test]
1401    fn apply_session_config_skips_anomaly_detector_when_disabled() {
1402        use crate::config::Config;
1403
1404        let mut config = Config::default();
1405        config.tools.anomaly.enabled = false; // explicitly disable to test the disabled path
1406        let session_cfg = AgentSessionConfig::from_config(&config, 100_000);
1407        assert!(!session_cfg.anomaly_config.enabled);
1408
1409        let agent = make_agent().apply_session_config(session_cfg);
1410        assert!(
1411            agent.debug_state.anomaly_detector.is_none(),
1412            "apply_session_config must not create anomaly_detector when disabled"
1413        );
1414    }
1415}