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