zeph-core 0.22.1

Core agent loop, configuration, context builder, metrics, and vault for Zeph
Documentation
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Hot-reload of the runtime configuration overlay.
//!
//! Extracted from `agent/mod.rs` (#4923). Re-reads the config file, applies the
//! shell overlay, recomputes the context budget, and warns when the reloaded shell
//! configuration diverges from the active one.

use super::{Agent, resolve_context_budget};
use crate::channel::Channel;
use crate::config::Config;
use crate::context::ContextBudget;
use zeph_llm::provider::LlmProvider as _;

impl<C: Channel> Agent<C> {
    #[allow(clippy::too_many_lines)]
    pub(super) fn reload_config(&mut self) {
        let Some(path) = self.runtime.lifecycle.config_path.clone() else {
            return;
        };
        let Some(config) = self.load_config_with_overlay(&path) else {
            return;
        };
        let budget_tokens = resolve_context_budget(&config, &self.provider);
        self.runtime.config.security = config.security;
        self.runtime.config.timeouts = config.timeouts;
        self.runtime.config.redact_credentials = config.memory.redact_credentials;
        self.services.memory.persistence.history_limit = config.memory.history_limit;
        self.services.memory.persistence.recall_limit = config.memory.semantic.recall_limit;
        self.services.memory.compaction.summarization_threshold =
            config.memory.summarization_threshold;
        self.services.skill.max_active_skills = config.skills.max_active_skills.get();
        self.services.skill.disambiguation_threshold = config.skills.disambiguation_threshold;
        self.services.skill.min_injection_score = config.skills.min_injection_score;
        self.services.skill.cosine_weight = config.skills.cosine_weight.clamp(0.0, 1.0);
        self.services.skill.hybrid_search = config.skills.hybrid_search;
        {
            let alpha = config.skills.bm25_alpha;
            if !(0.0..=1.0).contains(&alpha) {
                tracing::warn!(
                    bm25_alpha = alpha,
                    "bm25_alpha is outside [0.0, 1.0]; clamping to valid range"
                );
            }
            self.services.skill.bm25_alpha = alpha.clamp(0.0, 1.0);
        }
        self.services.skill.two_stage_matching = config.skills.two_stage_matching;
        self.services.skill.confusability_threshold =
            config.skills.confusability_threshold.clamp(0.0, 1.0);
        self.services.skill.group_structured = config.skills.group_structured;
        self.services.skill.support_similarity_threshold =
            config.skills.support_similarity_threshold;
        config
            .skills
            .query_rewrite_provider
            .as_str()
            .clone_into(&mut self.services.skill.query_rewrite_provider_name);
        config
            .skills
            .generation_provider
            .as_str()
            .clone_into(&mut self.services.skill.generation_provider_name);
        config
            .skills
            .disambiguate_provider
            .as_str()
            .clone_into(&mut self.services.skill.disambiguate_provider_name);
        self.services.skill.generation_timeout_ms = config.skills.generation_timeout_ms;
        self.services.skill.semantic_scan = config.skills.semantic_scan;
        config
            .skills
            .semantic_scan_provider
            .as_str()
            .clone_into(&mut self.services.skill.semantic_scan_provider);
        self.services.skill.generation_output_dir =
            config.skills.generation_output_dir.as_deref().map(|p| {
                if let Some(stripped) = p.strip_prefix("~/") {
                    dirs::home_dir()
                        .map_or_else(|| std::path::PathBuf::from(p), |h| h.join(stripped))
                } else {
                    std::path::PathBuf::from(p)
                }
            });

        self.context_manager.budget = Some(
            ContextBudget::new(budget_tokens, 0.20).with_graph_enabled(config.memory.graph.enabled),
        );

        {
            let graph_cfg = &config.memory.graph;
            if graph_cfg.rpe.enabled {
                // Re-create router only if it doesn't exist yet; preserve state on hot-reload.
                if self.services.memory.extraction.rpe_router.is_none() {
                    self.services.memory.extraction.rpe_router =
                        Some(std::sync::Mutex::new(zeph_memory::RpeRouter::new(
                            graph_cfg.rpe.threshold,
                            graph_cfg.rpe.max_skip_turns,
                        )));
                }
            } else {
                self.services.memory.extraction.rpe_router = None;
            }
            self.services.memory.extraction.graph_config = graph_cfg.clone();
        }
        self.context_manager.soft_compaction_threshold = config.memory.soft_compaction_threshold;
        self.context_manager.hard_compaction_threshold = config.memory.hard_compaction_threshold;
        self.context_manager.compaction_preserve_tail = config.memory.compaction_preserve_tail;
        self.context_manager
            .set_compaction_cooldown_turns(config.memory.compaction_cooldown_turns);
        self.context_manager.prune_protect_tokens = config.memory.prune_protect_tokens;
        self.context_manager.compression = config.memory.compression.clone();
        self.context_manager.routing = config.memory.store_routing.clone();
        // Resolve routing_classifier_provider from the provider pool (#2484).
        self.context_manager.store_routing_provider = if config
            .memory
            .store_routing
            .routing_classifier_provider
            .is_empty()
        {
            None
        } else {
            let resolved = self.resolve_background_provider(
                config
                    .memory
                    .store_routing
                    .routing_classifier_provider
                    .as_str(),
            );
            Some(std::sync::Arc::new(resolved))
        };
        self.services
            .memory
            .persistence
            .cross_session_score_threshold = config.memory.cross_session_score_threshold;

        self.services.index.repo_map_tokens = config.index.repo_map_tokens;
        self.services.index.repo_map_ttl =
            std::time::Duration::from_secs(config.index.repo_map_ttl_secs);

        self.services
            .session
            .hooks_config
            .cwd_changed
            .clone_from(&config.hooks.cwd_changed);
        self.services
            .session
            .hooks_config
            .permission_denied
            .clone_from(&config.hooks.permission_denied);
        self.services
            .session
            .hooks_config
            .turn_complete
            .clone_from(&config.hooks.turn_complete);
        // file_changed_hooks require watcher restart to take effect — skipped here.

        // Re-derive the settings-view provider/agent-definition lists (issue #6024): a
        // hot-reload can add, remove, or edit `[[llm.providers]]` entries or sub-agent
        // definitions, and — per the same rationale as `apply_provider_switch_metrics`
        // — `providers`/`agent_definitions` are never refreshed by the MCP-lifecycle-driven
        // `update_mcp_metrics`, so they must be re-emitted explicitly here.
        let active_provider_name = if self.runtime.config.active_provider_name.is_empty() {
            self.provider.name().to_owned()
        } else {
            self.runtime.config.active_provider_name.clone()
        };
        let providers = crate::metrics::ProviderSummary::build_pool(
            &self.runtime.providers.provider_pool,
            &active_provider_name,
        );
        let agent_definitions = self
            .services
            .orchestration
            .subagent_manager
            .as_ref()
            .map(|mgr| crate::metrics::AgentDefSummary::build_all(mgr.definitions()))
            .unwrap_or_default();
        self.update_metrics(|m| {
            m.providers = providers;
            m.agent_definitions = agent_definitions;
        });

        tracing::info!("config reloaded");
    }
    /// Load config from disk, apply plugin overlays, validate, and warn on shell divergence.
    ///
    /// Returns `None` when loading, overlay merge, or validation fails (caller keeps prior
    /// runtime state).
    fn load_config_with_overlay(&mut self, path: &std::path::Path) -> Option<Config> {
        let mut config = match Config::load(path) {
            Ok(c) => c,
            Err(e) => {
                tracing::warn!("config reload failed: {e:#}");
                return None;
            }
        };

        // Re-apply plugin overlays. On error, keep previous runtime state intact.
        let new_overlay = if self.runtime.lifecycle.plugins_dir.as_os_str().is_empty() {
            None
        } else {
            match zeph_plugins::apply_plugin_config_overlays(
                &mut config,
                &self.runtime.lifecycle.plugins_dir,
            ) {
                Ok(o) => Some(o),
                Err(e) => {
                    tracing::warn!(
                        "plugin overlay merge failed during reload: {e:#}; \
                         keeping previous runtime state"
                    );
                    return None;
                }
            }
        };

        // Validate the fully-assembled config (post-overlay) before it can reach the live
        // agent — matches the startup path (Config::load + validate in bootstrap/mod.rs).
        if let Err(e) = config.validate() {
            tracing::warn!(
                "config reload failed validation: {e:#}; keeping previous runtime state"
            );
            return None;
        }

        // M4: detect shell-level divergence from the baked-in executor and warn loudly.
        // ShellExecutor is not rebuilt on hot-reload; only skill threshold is live.
        // A follow-up P2 issue tracks live-rebuild of ShellExecutor.
        if let Some(ref overlay) = new_overlay {
            self.warn_on_shell_overlay_divergence(overlay, &config);
        }
        Some(config)
    }
    /// React to shell policy divergence detected on hot-reload.
    ///
    /// `blocked_commands` is rebuilt live via `ShellPolicyHandle::rebuild` — no restart needed.
    /// `allowed_commands` cannot be rebuilt (feeds sandbox path intersection at construction time)
    /// — emit a warn + status banner when it changes.
    pub(super) fn warn_on_shell_overlay_divergence(
        &self,
        new_overlay: &zeph_plugins::ResolvedOverlay,
        config: &Config,
    ) {
        let new_blocked: Vec<String> = {
            let mut v = config.tools.shell.blocked_commands.clone();
            v.sort();
            v
        };
        let new_allowed: Vec<String> = {
            let mut v = config.tools.shell.allowed_commands.clone();
            v.sort();
            v
        };

        let startup = &self.runtime.lifecycle.startup_shell_overlay;
        let blocked_changed = new_blocked != startup.blocked;
        let allowed_changed = new_allowed != startup.allowed;

        // blocked_commands IS rebuilt live — emit info-level confirmation only.
        if blocked_changed && let Some(ref h) = self.runtime.lifecycle.shell_policy_handle {
            h.rebuild(&config.tools.shell);
            tracing::info!(
                blocked_count = h.snapshot_blocked().len(),
                "shell blocked_commands rebuilt from hot-reload"
            );
        }

        // allowed_commands cannot be rebuilt — sandbox path intersection is computed at
        // executor construction time. Warn loudly so the user restarts.
        //
        // Note: when base `allowed_commands` is empty (the default), the overlay's
        // intersection semantics keep it empty, so this branch is silently unreachable
        // for users who do not set a non-empty base list.
        if allowed_changed {
            let msg = "plugin config overlay changed shell allowed_commands; RESTART REQUIRED \
                 for sandbox path recomputation (blocked_commands was rebuilt live)";
            tracing::warn!("{msg}");
            if let Some(ref tx) = self.services.session.status_tx {
                let _ = tx.send(msg.to_owned());
            }
        }

        let _ = new_overlay;
    }
}

#[cfg(test)]
mod tests {
    use super::super::agent_tests::QuickTestAgent;
    use super::*;

    /// Writes `config` as TOML to a fresh file inside a per-test [`tempfile::TempDir`] and
    /// returns both, so the directory outlives the returned path.
    fn write_config(config: &Config) -> (tempfile::TempDir, std::path::PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("config.toml");
        std::fs::write(&path, toml::to_string_pretty(config).unwrap()).unwrap();
        (dir, path)
    }

    #[test]
    fn load_config_with_overlay_returns_none_on_invalid_config() {
        let mut config = Config::default();
        // Inverted fidelity thresholds: full_threshold must be >= compressed_threshold.
        config.memory.fidelity = Some(zeph_config::fidelity::FidelityConfig {
            full_threshold: 0.2,
            compressed_threshold: 0.5,
            ..Default::default()
        });
        assert!(
            config.validate().is_err(),
            "fixture must actually fail validate()"
        );
        let (_dir, path) = write_config(&config);

        let mut agent = QuickTestAgent::minimal("ok").agent;
        agent.runtime.lifecycle.plugins_dir = std::path::PathBuf::new();

        let result = agent.load_config_with_overlay(&path);
        assert!(
            result.is_none(),
            "must return None when the reloaded config fails validate()"
        );
    }

    #[test]
    fn load_config_with_overlay_returns_some_on_valid_config() {
        let config = Config::default();
        assert!(config.validate().is_ok(), "fixture must be valid");
        let (_dir, path) = write_config(&config);

        let mut agent = QuickTestAgent::minimal("ok").agent;
        agent.runtime.lifecycle.plugins_dir = std::path::PathBuf::new();

        let result = agent.load_config_with_overlay(&path);
        assert!(
            result.is_some(),
            "must return Some when the reloaded config passes validate()"
        );
    }

    #[test]
    fn reload_config_preserves_runtime_state_on_validation_failure() {
        let mut config = Config::default();
        config.memory.fidelity = Some(zeph_config::fidelity::FidelityConfig {
            full_threshold: 0.2,
            compressed_threshold: 0.5,
            ..Default::default()
        });
        assert!(
            config.validate().is_err(),
            "fixture must actually fail validate()"
        );
        let (_dir, path) = write_config(&config);

        let mut agent = QuickTestAgent::minimal("ok").agent;
        agent.runtime.lifecycle.plugins_dir = std::path::PathBuf::new();
        agent.runtime.lifecycle.config_path = Some(path);

        // Sentinel: Config::default() sets max_active_skills to 5 (root.rs), so any
        // value far from that proves the field was never touched by the aborted reload.
        agent.services.skill.max_active_skills = 999;

        agent.reload_config();

        assert_eq!(
            agent.services.skill.max_active_skills, 999,
            "prior runtime state must be preserved when the reloaded config fails validate()"
        );
    }

    // ── Settings-view metrics refresh on hot-reload (issue #6024) ──────────────────

    #[test]
    fn reload_config_repopulates_settings_metrics() {
        let config = Config::default();
        let (_dir, path) = write_config(&config);

        let mut agent = QuickTestAgent::minimal("ok").agent;
        agent.runtime.lifecycle.plugins_dir = std::path::PathBuf::new();
        agent.runtime.lifecycle.config_path = Some(path);
        agent.runtime.providers.provider_pool = vec![zeph_config::ProviderEntry {
            name: Some("fast".to_owned()),
            default: true,
            ..zeph_config::ProviderEntry::default()
        }];
        let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default());
        agent.runtime.metrics.metrics_tx = Some(tx);

        agent.reload_config();

        let snapshot = rx.borrow();
        assert_eq!(snapshot.providers.len(), 1);
        assert_eq!(snapshot.providers[0].name, "fast");
        assert!(snapshot.providers[0].default);
    }
}