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