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