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