Skip to main content

meerkat_core/
config.rs

1//! Configuration for Meerkat
2//!
3//! Supports layered configuration: defaults → file → env (secrets only) → CLI
4
5use crate::connection::RealmConfigSection;
6use crate::mcp_config::McpServerConfig;
7use crate::model_profile::catalog::ModelTier;
8use crate::{
9    budget::BudgetLimits,
10    hooks::{HookCapability, HookExecutionMode, HookFailurePolicy, HookId, HookPoint},
11    retry::RetryPolicy,
12    types::{OutputSchema, SecurityMode},
13};
14use schemars::JsonSchema;
15use serde::de::Deserializer;
16use serde::ser::SerializeStruct;
17use serde::{Deserialize, Serialize};
18use serde_json::value::RawValue;
19use serde_json::{Map, Value};
20use std::collections::{BTreeMap, HashMap};
21use std::path::PathBuf;
22use std::sync::OnceLock;
23use std::time::Duration;
24
25/// Default active session admission capacity for RPC/CLI runtime surfaces.
26pub const DEFAULT_MAX_SESSIONS: usize = 100_000;
27
28/// Complete configuration for Meerkat
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(default)]
31pub struct Config {
32    pub agent: AgentConfig,
33    pub storage: StorageConfig,
34    pub budget: BudgetConfig,
35    pub retry: RetryConfig,
36    pub tools: ToolsConfig,
37    // New schema fields for interface consolidation.
38    pub models: ModelDefaults,
39    pub max_tokens: u32,
40    pub shell: ShellDefaults,
41    pub store: StoreConfig,
42    pub comms: CommsRuntimeConfig,
43    pub compaction: CompactionRuntimeConfig,
44    pub limits: LimitsConfig,
45    pub rest: RestServerConfig,
46    pub hooks: HooksConfig,
47    pub skills: crate::skills_config::SkillsConfig,
48    pub self_hosted: SelfHostedConfig,
49    pub provider_tools: ProviderToolsConfig,
50    pub presentation: PresentationConfig,
51    /// Realm-scoped connection sets (backend profiles, auth profiles,
52    /// bindings). TOML keys use the singular `[realm.<id>.*]` namespace
53    /// even though the Rust field is plural-adjacent — `#[serde(rename)]`
54    /// bridges the two. See
55    /// `/Users/luka/.claude/plans/yes-make-a-plan-shimmying-bengio.md`.
56    #[serde(rename = "realm", default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub realm: BTreeMap<String, RealmConfigSection>,
58}
59
60impl Default for Config {
61    fn default() -> Self {
62        let defaults = template_defaults();
63        let agent = AgentConfig::default();
64        let max_tokens = defaults
65            .max_tokens
66            .filter(|value| *value > 0)
67            .unwrap_or(agent.max_tokens_per_turn);
68        Self {
69            agent,
70            storage: StorageConfig::default(),
71            budget: BudgetConfig::default(),
72            retry: RetryConfig::default(),
73            tools: ToolsConfig::default(),
74            models: ModelDefaults::default(),
75            max_tokens,
76            shell: ShellDefaults::default(),
77            store: StoreConfig::default(),
78            comms: CommsRuntimeConfig::default(),
79            compaction: CompactionRuntimeConfig::default(),
80            limits: LimitsConfig::default(),
81            rest: RestServerConfig::default(),
82            hooks: HooksConfig::default(),
83            skills: crate::skills_config::SkillsConfig::default(),
84            self_hosted: SelfHostedConfig::default(),
85            provider_tools: ProviderToolsConfig::default(),
86            presentation: PresentationConfig::default(),
87            realm: BTreeMap::new(),
88        }
89    }
90}
91
92impl Config {
93    /// Active/staged session admission capacity for runtime surfaces.
94    pub fn max_sessions(&self) -> usize {
95        self.limits.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS)
96    }
97
98    /// Build the effective model registry for this config snapshot.
99    pub fn model_registry(&self) -> Result<crate::ModelRegistry, ConfigError> {
100        crate::ModelRegistry::from_config(self)
101    }
102
103    /// Return the config template as TOML.
104    pub fn template_toml() -> &'static str {
105        CONFIG_TEMPLATE_TOML
106    }
107
108    /// Parse the config template into a Config value.
109    pub fn template() -> Result<Self, ConfigError> {
110        toml::from_str(CONFIG_TEMPLATE_TOML).map_err(ConfigError::Parse)
111    }
112}
113
114// File-system dependent methods — not available on wasm32.
115#[cfg(not(target_arch = "wasm32"))]
116impl Config {
117    /// Load configuration from all sources with proper layering
118    /// Order: defaults → project config OR global config → env vars (secrets only)
119    /// → CLI (CLI applied separately)
120    pub async fn load() -> Result<Self, ConfigError> {
121        let cwd = std::env::current_dir()?;
122        let home = dirs::home_dir();
123        Self::load_from_with_env(&cwd, home.as_deref(), |key| std::env::var(key).ok()).await
124    }
125
126    /// Load config like [`Config::load`], but with explicit start directory, home directory,
127    /// and environment variable provider.
128    ///
129    /// This exists primarily to make tests deterministic without mutating the process-wide
130    /// environment (which is unsafe in multi-threaded programs on Unix).
131    #[doc(hidden)]
132    pub async fn load_from_with_env<F>(
133        start_dir: &std::path::Path,
134        home_dir: Option<&std::path::Path>,
135        env: F,
136    ) -> Result<Self, ConfigError>
137    where
138        F: FnMut(&str) -> Option<String>,
139    {
140        let mut config = Self::default();
141
142        // 1. Load project config (if exists). Local config replaces global.
143        if let Some(path) = Self::find_project_config_from(start_dir).await {
144            config.merge_file(&path).await?;
145        } else if let Some(path) = home_dir.map(|home| home.join(".rkat/config.toml"))
146            && tokio::fs::try_exists(&path).await.unwrap_or(false)
147        {
148            config.merge_file(&path).await?;
149        }
150
151        // 2. Apply environment variable overrides (secrets only)
152        config.apply_env_overrides_from(env)?;
153
154        Ok(config)
155    }
156
157    /// Load config like [`Config::load`], but with explicit start directory and home directory.
158    #[doc(hidden)]
159    pub async fn load_from(
160        start_dir: &std::path::Path,
161        home_dir: Option<&std::path::Path>,
162    ) -> Result<Self, ConfigError> {
163        Self::load_from_with_env(start_dir, home_dir, |key| std::env::var(key).ok()).await
164    }
165
166    /// Load only hook configuration with explicit global -> project layering.
167    ///
168    /// This preserves existing config precedence for non-hook fields while allowing
169    /// deterministic hook registration ordering across scopes.
170    pub async fn load_layered_hooks() -> Result<HooksConfig, ConfigError> {
171        let cwd = std::env::current_dir()?;
172        let home = dirs::home_dir();
173        Self::load_layered_hooks_from(&cwd, home.as_deref()).await
174    }
175
176    /// Load only hook configuration with explicit global -> project layering.
177    pub async fn load_layered_hooks_from(
178        start_dir: &std::path::Path,
179        home_dir: Option<&std::path::Path>,
180    ) -> Result<HooksConfig, ConfigError> {
181        let mut hooks = HooksConfig::default();
182
183        if let Some(global_path) = home_dir.map(|home| home.join(".rkat/config.toml"))
184            && tokio::fs::try_exists(&global_path).await.unwrap_or(false)
185        {
186            let content = tokio::fs::read_to_string(&global_path).await?;
187            let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
188            hooks.append_entries_from(&cfg.hooks);
189        }
190
191        if let Some(project_path) = Self::find_project_config_from(start_dir).await
192            && tokio::fs::try_exists(&project_path).await.unwrap_or(false)
193        {
194            let content = tokio::fs::read_to_string(&project_path).await?;
195            let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
196            hooks.append_entries_from(&cfg.hooks);
197        }
198
199        Ok(hooks)
200    }
201}
202
203impl Config {
204    /// Convert config limits into runtime budget limits.
205    pub fn budget_limits(&self) -> BudgetLimits {
206        self.limits.to_budget_limits()
207    }
208
209    /// Get global config path (~/.rkat/config.toml)
210    #[cfg(not(target_arch = "wasm32"))]
211    pub fn global_config_path() -> Option<PathBuf> {
212        dirs::home_dir().map(|h| h.join(".rkat/config.toml"))
213    }
214
215    /// Find project config by walking up directories (.rkat/config.toml)
216    ///
217    /// Only returns a path if both `.rkat/` directory AND `config.toml` exist.
218    /// This allows `.rkat/` to be created for session storage without requiring
219    /// a config file.
220    #[cfg(not(target_arch = "wasm32"))]
221    async fn find_project_config_from(start_dir: &std::path::Path) -> Option<PathBuf> {
222        let mut current = start_dir.to_path_buf();
223        loop {
224            let marker_dir = current.join(".rkat");
225            let config_path = marker_dir.join("config.toml");
226
227            // Only treat as project config if config.toml actually exists
228            let config_exists = tokio::fs::try_exists(&config_path).await.unwrap_or(false);
229            if config_exists {
230                return Some(config_path);
231            }
232
233            if !current.pop() {
234                return None;
235            }
236        }
237    }
238
239    /// Merge configuration from a TOML file
240    #[cfg(not(target_arch = "wasm32"))]
241    pub async fn merge_file(&mut self, path: &PathBuf) -> Result<(), ConfigError> {
242        let content = tokio::fs::read_to_string(path).await?;
243        self.merge_toml_str(&content)
244    }
245
246    /// Merge configuration from a TOML string.
247    pub fn merge_toml_str(&mut self, content: &str) -> Result<(), ConfigError> {
248        let file_config: Config = toml::from_str(content).map_err(ConfigError::Parse)?;
249        let tools_layer = file_config.tools.clone();
250        let retry_layer = file_config.retry.clone();
251        let self_hosted_layer = file_config.self_hosted.clone();
252        let provider_tools_layer = file_config.provider_tools.clone();
253        // Merge (file values override defaults)
254        self.merge(file_config);
255        let parsed: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
256        self.merge_tools_from_toml_presence(&parsed, &tools_layer);
257        self.merge_retry_from_toml_presence(&parsed, &retry_layer);
258        self.merge_self_hosted_from_toml_presence(&parsed, &self_hosted_layer);
259        self.merge_provider_tools_from_toml_presence(&parsed, &provider_tools_layer);
260        Ok(())
261    }
262
263    /// Merge another config into this one.
264    ///
265    /// Merge semantics are field-specific:
266    /// - Scalar/option fields: last non-default wins.
267    /// - Structured sections (`provider`, `providers`, `store`, `comms`, `compaction`, etc.):
268    ///   whole-section replace when incoming value is non-default.
269    /// - Hook entries: append/extend.
270    ///
271    /// JSON merge-patch semantics are handled separately by `ConfigStore::patch`.
272    fn merge(&mut self, other: Config) {
273        // Agent config
274        if other.agent.system_prompt.is_some() {
275            self.agent.system_prompt = other.agent.system_prompt;
276        }
277        if other.agent.tool_instructions.is_some() {
278            self.agent.tool_instructions = other.agent.tool_instructions;
279        }
280        if other.agent.model != AgentConfig::default().model {
281            self.agent.model = other.agent.model;
282        }
283        if other.agent.max_tokens_per_turn != AgentConfig::default().max_tokens_per_turn {
284            self.agent.max_tokens_per_turn = other.agent.max_tokens_per_turn;
285        }
286        if other.agent.extraction_prompt.is_some() {
287            self.agent.extraction_prompt = other.agent.extraction_prompt;
288        }
289
290        // Storage config
291        if other.storage.directory.is_some() {
292            self.storage.directory = other.storage.directory;
293        }
294
295        // Budget config
296        if other.budget.max_tokens.is_some() {
297            self.budget.max_tokens = other.budget.max_tokens;
298        }
299        if other.budget.max_duration.is_some() {
300            self.budget.max_duration = other.budget.max_duration;
301        }
302        if other.budget.max_tool_calls.is_some() {
303            self.budget.max_tool_calls = other.budget.max_tool_calls;
304        }
305        self.merge_retry(&other.retry);
306        self.merge_tools(&other.tools);
307
308        // New schema fields (replace if non-default)
309        if other.models != ModelDefaults::default() {
310            self.models = other.models;
311        }
312        if other.max_tokens != Config::default().max_tokens {
313            self.max_tokens = other.max_tokens;
314        }
315        if other.shell != ShellDefaults::default() {
316            self.shell = other.shell;
317        }
318        if other.store != StoreConfig::default() {
319            self.store = other.store;
320        }
321        if other.comms != CommsRuntimeConfig::default() {
322            self.comms = other.comms;
323        }
324        if other.compaction != CompactionRuntimeConfig::default() {
325            self.compaction = other.compaction;
326        }
327        if other.limits != LimitsConfig::default() {
328            self.limits = other.limits;
329        }
330        if other.rest != RestServerConfig::default() {
331            self.rest = other.rest;
332        }
333        if other.hooks != HooksConfig::default() {
334            let default_hooks = HooksConfig::default();
335            if other.hooks.default_timeout_ms != default_hooks.default_timeout_ms {
336                self.hooks.default_timeout_ms = other.hooks.default_timeout_ms;
337            }
338            if other.hooks.payload_max_bytes != default_hooks.payload_max_bytes {
339                self.hooks.payload_max_bytes = other.hooks.payload_max_bytes;
340            }
341            if other.hooks.background_max_concurrency != default_hooks.background_max_concurrency {
342                self.hooks.background_max_concurrency = other.hooks.background_max_concurrency;
343            }
344            self.hooks.entries.extend(other.hooks.entries);
345        }
346    }
347
348    fn merge_retry(&mut self, other: &RetryConfig) {
349        let defaults = RetryConfig::default();
350        if other.max_retries != defaults.max_retries {
351            self.retry.max_retries = other.max_retries;
352        }
353        if other.initial_delay != defaults.initial_delay {
354            self.retry.initial_delay = other.initial_delay;
355        }
356        if other.max_delay != defaults.max_delay {
357            self.retry.max_delay = other.max_delay;
358        }
359        if other.multiplier != defaults.multiplier {
360            self.retry.multiplier = other.multiplier;
361        }
362        if other.call_timeout_override != defaults.call_timeout_override {
363            self.retry.call_timeout_override = other.call_timeout_override.clone();
364        }
365    }
366
367    fn merge_tools(&mut self, other: &ToolsConfig) {
368        let defaults = ToolsConfig::default();
369        if !other.mcp_servers.is_empty() {
370            self.tools.mcp_servers.clone_from(&other.mcp_servers);
371        }
372        if other.default_timeout != defaults.default_timeout {
373            self.tools.default_timeout = other.default_timeout;
374        }
375        if other.tool_timeouts != defaults.tool_timeouts {
376            self.tools.tool_timeouts.clone_from(&other.tool_timeouts);
377        }
378        if other.max_concurrent != defaults.max_concurrent {
379            self.tools.max_concurrent = other.max_concurrent;
380        }
381        if other.builtins_enabled != defaults.builtins_enabled {
382            self.tools.builtins_enabled = other.builtins_enabled;
383        }
384        if other.shell_enabled != defaults.shell_enabled {
385            self.tools.shell_enabled = other.shell_enabled;
386        }
387        if other.comms_enabled != defaults.comms_enabled {
388            self.tools.comms_enabled = other.comms_enabled;
389        }
390        if other.mob_enabled != defaults.mob_enabled {
391            self.tools.mob_enabled = other.mob_enabled;
392        }
393        if other.schedule_enabled != defaults.schedule_enabled {
394            self.tools.schedule_enabled = other.schedule_enabled;
395        }
396        if other.workgraph_enabled != defaults.workgraph_enabled {
397            self.tools.workgraph_enabled = other.workgraph_enabled;
398        }
399    }
400
401    fn merge_tools_from_toml_presence(&mut self, parsed: &toml::Value, layer: &ToolsConfig) {
402        let Some(tools) = parsed.get("tools").and_then(toml::Value::as_table) else {
403            return;
404        };
405        if tools.contains_key("mcp_servers") {
406            self.tools.mcp_servers.clone_from(&layer.mcp_servers);
407        }
408        if tools.contains_key("default_timeout") {
409            self.tools.default_timeout = layer.default_timeout;
410        }
411        if tools.contains_key("tool_timeouts") {
412            self.tools.tool_timeouts.clone_from(&layer.tool_timeouts);
413        }
414        if tools.contains_key("max_concurrent") {
415            self.tools.max_concurrent = layer.max_concurrent;
416        }
417        if tools.contains_key("builtins_enabled") {
418            self.tools.builtins_enabled = layer.builtins_enabled;
419        }
420        if tools.contains_key("shell_enabled") {
421            self.tools.shell_enabled = layer.shell_enabled;
422        }
423        if tools.contains_key("comms_enabled") {
424            self.tools.comms_enabled = layer.comms_enabled;
425        }
426        if tools.contains_key("mob_enabled") {
427            self.tools.mob_enabled = layer.mob_enabled;
428        }
429        if tools.contains_key("schedule_enabled") {
430            self.tools.schedule_enabled = layer.schedule_enabled;
431        }
432        if tools.contains_key("workgraph_enabled") {
433            self.tools.workgraph_enabled = layer.workgraph_enabled;
434        }
435    }
436
437    fn merge_retry_from_toml_presence(&mut self, parsed: &toml::Value, layer: &RetryConfig) {
438        let Some(retry) = parsed.get("retry").and_then(toml::Value::as_table) else {
439            return;
440        };
441        if retry.contains_key("max_retries") {
442            self.retry.max_retries = layer.max_retries;
443        }
444        if retry.contains_key("initial_delay") {
445            self.retry.initial_delay = layer.initial_delay;
446        }
447        if retry.contains_key("max_delay") {
448            self.retry.max_delay = layer.max_delay;
449        }
450        if retry.contains_key("multiplier") {
451            self.retry.multiplier = layer.multiplier;
452        }
453        if retry.contains_key("call_timeout") {
454            self.retry.call_timeout_override = layer.call_timeout_override.clone();
455        }
456    }
457
458    fn merge_provider_tools_from_toml_presence(
459        &mut self,
460        parsed: &toml::Value,
461        layer: &ProviderToolsConfig,
462    ) {
463        let Some(pt) = parsed.get("provider_tools").and_then(toml::Value::as_table) else {
464            return;
465        };
466        if let Some(anthropic) = pt.get("anthropic").and_then(toml::Value::as_table)
467            && anthropic.contains_key("web_search")
468        {
469            self.provider_tools.anthropic.web_search = layer.anthropic.web_search;
470        }
471        if let Some(openai) = pt.get("openai").and_then(toml::Value::as_table)
472            && openai.contains_key("web_search")
473        {
474            self.provider_tools.openai.web_search = layer.openai.web_search;
475        }
476        if let Some(gemini) = pt.get("gemini").and_then(toml::Value::as_table)
477            && gemini.contains_key("google_search")
478        {
479            self.provider_tools.gemini.google_search = layer.gemini.google_search;
480        }
481    }
482
483    fn merge_self_hosted_from_toml_presence(
484        &mut self,
485        parsed: &toml::Value,
486        layer: &SelfHostedConfig,
487    ) {
488        let Some(self_hosted) = parsed.get("self_hosted").and_then(toml::Value::as_table) else {
489            return;
490        };
491
492        if let Some(servers) = self_hosted.get("servers").and_then(toml::Value::as_table) {
493            if servers.is_empty() {
494                self.self_hosted.servers.clear();
495                self.self_hosted.models.clear();
496            } else {
497                let mut merged_servers = self.self_hosted.servers.clone();
498                for (server_id, server_value) in servers {
499                    let Some(server_table) = server_value.as_table() else {
500                        continue;
501                    };
502                    let mut merged = self
503                        .self_hosted
504                        .servers
505                        .get(server_id)
506                        .cloned()
507                        .unwrap_or_default();
508                    let Some(server_layer) = layer.servers.get(server_id) else {
509                        continue;
510                    };
511                    if server_table.contains_key("transport") {
512                        merged.transport = server_layer.transport;
513                    }
514                    if server_table.contains_key("base_url") {
515                        merged.base_url = server_layer.base_url.clone();
516                    }
517                    if server_table.contains_key("api_style") {
518                        merged.api_style = server_layer.api_style;
519                    }
520                    if server_table.contains_key("bearer_token") {
521                        merged.bearer_token = server_layer.bearer_token.clone();
522                    }
523                    if server_table.contains_key("bearer_token_env") {
524                        merged.bearer_token_env = server_layer.bearer_token_env.clone();
525                    }
526                    merged_servers.insert(server_id.clone(), merged);
527                }
528                self.self_hosted.servers = merged_servers;
529            }
530        }
531
532        if let Some(models) = self_hosted.get("models").and_then(toml::Value::as_table) {
533            if models.is_empty() {
534                self.self_hosted.models.clear();
535            } else {
536                let mut merged_models = self.self_hosted.models.clone();
537                for (model_id, model_value) in models {
538                    let Some(model_table) = model_value.as_table() else {
539                        continue;
540                    };
541                    let mut merged = self
542                        .self_hosted
543                        .models
544                        .get(model_id)
545                        .cloned()
546                        .unwrap_or_default();
547                    let Some(model_layer) = layer.models.get(model_id) else {
548                        continue;
549                    };
550                    if model_table.contains_key("server") {
551                        merged.server = model_layer.server.clone();
552                    }
553                    if model_table.contains_key("remote_model") {
554                        merged.remote_model = model_layer.remote_model.clone();
555                    }
556                    if model_table.contains_key("display_name") {
557                        merged.display_name = model_layer.display_name.clone();
558                    }
559                    if model_table.contains_key("family") {
560                        merged.family = model_layer.family.clone();
561                    }
562                    if model_table.contains_key("tier") {
563                        merged.tier = model_layer.tier;
564                    }
565                    if model_table.contains_key("context_window") {
566                        merged.context_window = model_layer.context_window;
567                    }
568                    if model_table.contains_key("max_output_tokens") {
569                        merged.max_output_tokens = model_layer.max_output_tokens;
570                    }
571                    if model_table.contains_key("vision") {
572                        merged.vision = model_layer.vision;
573                    }
574                    if model_table.contains_key("image_tool_results") {
575                        merged.image_tool_results = model_layer.image_tool_results;
576                    }
577                    if model_table.contains_key("inline_video") {
578                        merged.inline_video = model_layer.inline_video;
579                    }
580                    if model_table.contains_key("supports_temperature") {
581                        merged.supports_temperature = model_layer.supports_temperature;
582                    }
583                    if model_table.contains_key("supports_thinking") {
584                        merged.supports_thinking = model_layer.supports_thinking;
585                    }
586                    if model_table.contains_key("supports_reasoning") {
587                        merged.supports_reasoning = model_layer.supports_reasoning;
588                    }
589                    if model_table.contains_key("call_timeout_secs") {
590                        merged.call_timeout_secs = model_layer.call_timeout_secs;
591                    }
592                    merged_models.insert(model_id.clone(), merged);
593                }
594                self.self_hosted.models = merged_models;
595            }
596        }
597    }
598
599    /// Apply environment variable overrides
600    pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
601        self.apply_env_overrides_from(|key| std::env::var(key).ok())
602    }
603
604    /// Apply environment variable overrides (secrets only) using an explicit env provider.
605    ///
606    /// This exists primarily to make tests deterministic without mutating the process-wide
607    /// environment (which is unsafe in multi-threaded programs on Unix).
608    #[doc(hidden)]
609    pub fn apply_env_overrides_from<F>(&mut self, _env: F) -> Result<(), ConfigError>
610    where
611        F: FnMut(&str) -> Option<String>,
612    {
613        // Plan §6.9 deleted the legacy `config.provider = ProviderConfig::X
614        // { api_key }` path. Env-var-based credentials are now read at
615        // resolve time through `ResolverEnvironment::env_lookup` applied
616        // to `CredentialSourceSpec::Env` inside the provider runtime
617        // registry — there is no longer a mutable in-memory field on
618        // `Config` that env vars write into. This method is retained as
619        // a no-op so callers compile through 0.6.0; it will be deleted
620        // once surfaces drop the apply_env_overrides call entirely.
621        Ok(())
622    }
623
624    /// Apply CLI argument overrides.
625    ///
626    /// - Explicit CLI flags override scalar runtime knobs.
627    /// - `override_config` applies RFC 7396 JSON merge-patch semantics.
628    #[cfg(not(target_arch = "wasm32"))]
629    pub fn apply_cli_overrides(&mut self, cli: CliOverrides) {
630        if let Some(model) = cli.model {
631            self.agent.model = model;
632        }
633        if let Some(tokens) = cli.max_tokens {
634            self.budget.max_tokens = Some(tokens);
635        }
636        if let Some(duration) = cli.max_duration {
637            self.budget.max_duration = Some(duration);
638        }
639        if let Some(calls) = cli.max_tool_calls {
640            self.budget.max_tool_calls = Some(calls);
641        }
642        // Apply JSON merge patch override if present
643        if let Some(delta) = cli.override_config {
644            let mut value = serde_json::to_value(&self).unwrap_or_default();
645            crate::config_store::merge_patch(&mut value, delta.0);
646            if let Ok(updated) = serde_json::from_value(value) {
647                *self = updated;
648            }
649        }
650    }
651}
652
653impl Config {
654    /// Validate configuration invariants.
655    ///
656    /// Called after loading and after persisting. Checks:
657    /// - No `"*"` wildcards in allowlists
658    /// - Every concrete provider key is present and non-empty
659    /// - Resolved defaults are valid
660    pub fn validate(&self) -> Result<(), ConfigError> {
661        if self.max_tokens == 0 {
662            return Err(ConfigError::Validation(
663                "max_tokens must be greater than 0".to_string(),
664            ));
665        }
666        if self.agent.max_tokens_per_turn == 0 {
667            return Err(ConfigError::Validation(
668                "agent.max_tokens_per_turn must be greater than 0".to_string(),
669            ));
670        }
671        if self.budget.max_tokens == Some(0) {
672            return Err(ConfigError::Validation(
673                "budget.max_tokens must be greater than 0 when set".to_string(),
674            ));
675        }
676        if self.limits.budget == Some(0) {
677            return Err(ConfigError::Validation(
678                "limits.budget must be greater than 0 when set".to_string(),
679            ));
680        }
681        if self.limits.max_sessions == Some(0) {
682            return Err(ConfigError::Validation(
683                "limits.max_sessions must be greater than 0 when set".to_string(),
684            ));
685        }
686        if self.compaction.auto_compact_threshold == 0 {
687            return Err(ConfigError::Validation(
688                "compaction.auto_compact_threshold must be greater than 0".to_string(),
689            ));
690        }
691        if self.compaction.recent_turn_budget == 0 {
692            return Err(ConfigError::Validation(
693                "compaction.recent_turn_budget must be greater than 0".to_string(),
694            ));
695        }
696        if self.compaction.max_summary_tokens == 0 {
697            return Err(ConfigError::Validation(
698                "compaction.max_summary_tokens must be greater than 0".to_string(),
699            ));
700        }
701        if self.compaction.min_turns_between_compactions == 0 {
702            return Err(ConfigError::Validation(
703                "compaction.min_turns_between_compactions must be greater than 0".to_string(),
704            ));
705        }
706
707        // Plan §6.9 deleted the per-provider config enum block, so
708        // there is no longer a nominal conflict between the legacy
709        // inline api_key/base_url fields and the shared
710        // `config.providers.{base_urls,api_keys}` maps. Those maps stay
711        // (scheduled for deletion in §6.10) and are consumed directly.
712
713        crate::model_registry::ModelRegistry::from_config(self)?;
714
715        Ok(())
716    }
717}
718
719/// CLI argument overrides
720#[derive(Debug, Clone, Default)]
721pub struct CliOverrides {
722    pub model: Option<String>,
723    pub max_tokens: Option<u64>,
724    pub max_duration: Option<Duration>,
725    pub max_tool_calls: Option<usize>,
726    /// Arbitrary configuration overrides via JSON merge patch
727    pub override_config: Option<ConfigDelta>,
728}
729
730fn default_structured_output_retries() -> u32 {
731    2
732}
733
734/// Agent behavior configuration
735#[derive(Debug, Clone, Serialize, Deserialize)]
736#[serde(default)]
737pub struct AgentConfig {
738    /// System prompt to prepend
739    pub system_prompt: Option<String>,
740    /// Path to system prompt file (alternative to inline system_prompt)
741    pub system_prompt_file: Option<PathBuf>,
742    /// Optional tool usage instructions appended to system prompt
743    pub tool_instructions: Option<String>,
744    /// Model identifier (provider-specific)
745    pub model: String,
746    /// Maximum tokens to generate per turn
747    pub max_tokens_per_turn: u32,
748    /// Temperature for sampling
749    pub temperature: Option<f32>,
750    /// Warning threshold for budget (0.0-1.0)
751    pub budget_warning_threshold: f32,
752    /// Maximum turns before forced stop
753    pub max_turns: Option<u32>,
754    /// Provider-specific parameters (e.g., thinking config, reasoning effort)
755    ///
756    /// This is a generic JSON bag that providers can extract provider-specific
757    /// options from. Each provider implementation is responsible for reading
758    /// and applying relevant parameters.
759    #[serde(default, skip_serializing_if = "Option::is_none")]
760    pub provider_params: Option<serde_json::Value>,
761    /// Provider-native tool defaults resolved at factory build time.
762    ///
763    /// **Intentionally non-persisted** (`#[serde(skip)]`): re-derived on every
764    /// build (including resume) from `Config.provider_tools` +
765    /// `ModelProfile.supports_web_search`. This means config changes (e.g.,
766    /// disabling web search) take effect immediately on resumed sessions without
767    /// requiring session recreation. Explicit per-request overrides live in
768    /// `provider_params` which IS persisted.
769    ///
770    /// Merged with `provider_params` per-turn via RFC 7396 merge-patch.
771    #[serde(skip)]
772    pub provider_tool_defaults: Option<serde_json::Value>,
773    /// Output schema for structured output extraction.
774    ///
775    /// When set, the agent will perform an extraction turn after completing
776    /// the agentic work, forcing the LLM to output validated JSON. The main
777    /// response text remains the committed agentic output; extraction populates
778    /// structured output on success or extraction error details on failure.
779    #[serde(default, skip_serializing_if = "Option::is_none")]
780    pub output_schema: Option<OutputSchema>,
781    /// Maximum retries for structured output validation failures.
782    #[serde(default = "default_structured_output_retries")]
783    pub structured_output_retries: u32,
784    /// Custom prompt for the structured output extraction turn.
785    ///
786    /// When `output_schema` is set, this prompt is sent as a user message
787    /// after the agentic loop to elicit schema-valid JSON. Defaults to a
788    /// built-in prompt if `None`.
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub extraction_prompt: Option<String>,
791}
792
793impl Default for AgentConfig {
794    fn default() -> Self {
795        let defaults = template_defaults();
796        let agent = defaults.agent.as_ref();
797        Self {
798            system_prompt: None,
799            system_prompt_file: None,
800            tool_instructions: None,
801            model: agent.and_then(|cfg| cfg.model.clone()).unwrap_or_default(),
802            max_tokens_per_turn: agent
803                .and_then(|cfg| cfg.max_tokens_per_turn)
804                .unwrap_or_default(),
805            temperature: None,
806            budget_warning_threshold: agent
807                .and_then(|cfg| cfg.budget_warning_threshold)
808                .unwrap_or_default(),
809            max_turns: None,
810            provider_params: None,
811            provider_tool_defaults: None,
812            output_schema: None,
813            structured_output_retries: default_structured_output_retries(),
814            extraction_prompt: None,
815        }
816    }
817}
818
819/// Presentation defaults for surface-owned renderings.
820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
821#[serde(default)]
822pub struct PresentationConfig {
823    pub html: HtmlPresentationConfig,
824}
825
826/// Defaults for CLI HTML output mode.
827///
828/// This config controls template selection only. It does not enable HTML output
829/// for ordinary runs; surfaces must request the HTML presentation explicitly.
830#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
831#[serde(default)]
832pub struct HtmlPresentationConfig {
833    pub default_template: String,
834    pub templates: BTreeMap<String, HtmlTemplateConfig>,
835}
836
837impl Default for HtmlPresentationConfig {
838    fn default() -> Self {
839        Self {
840            default_template: "polished".to_string(),
841            templates: BTreeMap::new(),
842        }
843    }
844}
845
846/// User-defined HTML output template.
847#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
848#[serde(default)]
849pub struct HtmlTemplateConfig {
850    pub path: Option<PathBuf>,
851    pub body: Option<String>,
852}
853
854/// Model defaults by provider.
855#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
856#[serde(default)]
857pub struct ModelDefaults {
858    pub anthropic: String,
859    pub openai: String,
860    pub gemini: String,
861}
862
863impl Default for ModelDefaults {
864    fn default() -> Self {
865        Self {
866            anthropic: crate::model_profile::catalog::default_model("anthropic")
867                .unwrap_or_default()
868                .to_string(),
869            openai: crate::model_profile::catalog::default_model("openai")
870                .unwrap_or_default()
871                .to_string(),
872            gemini: crate::model_profile::catalog::default_model("gemini")
873                .unwrap_or_default()
874                .to_string(),
875        }
876    }
877}
878
879/// Default shell program
880pub const DEFAULT_SHELL_PROGRAM: &str = "nu";
881/// Default shell timeout in seconds
882pub const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 30;
883/// Default shell security mode
884pub const DEFAULT_SHELL_SECURITY_MODE: SecurityMode = SecurityMode::Unrestricted;
885
886/// Shell defaults configured at the config layer.
887#[derive(Debug, Clone, Serialize, PartialEq)]
888#[serde(default)]
889pub struct ShellDefaults {
890    pub program: String,
891    pub timeout_secs: u64,
892    /// Security mode: unrestricted, allow_list, deny_list
893    pub security_mode: SecurityMode,
894    /// Patterns for allow/deny lists (glob format)
895    pub security_patterns: Vec<String>,
896}
897
898#[derive(Debug, Deserialize, Default)]
899#[serde(default)]
900struct ShellDefaultsSeed {
901    program: Option<String>,
902    timeout_secs: Option<u64>,
903    security_mode: Option<SecurityMode>,
904    security_patterns: Option<Vec<String>>,
905    #[serde(alias = "allowlist")]
906    allowlist: Option<Vec<String>>,
907}
908
909impl<'de> Deserialize<'de> for ShellDefaults {
910    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
911    where
912        D: Deserializer<'de>,
913    {
914        let seed = ShellDefaultsSeed::deserialize(deserializer)?;
915        let mut defaults = ShellDefaults::default();
916
917        if let Some(program) = seed.program {
918            defaults.program = program;
919        }
920        if let Some(timeout_secs) = seed.timeout_secs {
921            defaults.timeout_secs = timeout_secs;
922        }
923        if let Some(security_mode) = seed.security_mode {
924            defaults.security_mode = security_mode;
925        }
926        if let Some(security_patterns) = seed.security_patterns.or(seed.allowlist.clone()) {
927            defaults.security_patterns = security_patterns;
928        }
929
930        if seed.security_mode.is_none() && seed.allowlist.is_some() {
931            defaults.security_mode = SecurityMode::AllowList;
932        }
933
934        Ok(defaults)
935    }
936}
937
938impl Default for ShellDefaults {
939    fn default() -> Self {
940        let defaults = template_defaults();
941        let shell = defaults.shell.as_ref();
942        Self {
943            program: shell
944                .and_then(|cfg| cfg.program.clone())
945                .unwrap_or_else(|| DEFAULT_SHELL_PROGRAM.to_string()),
946            timeout_secs: shell
947                .and_then(|cfg| cfg.timeout_secs)
948                .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS),
949            security_mode: shell
950                .and_then(|cfg| cfg.security_mode)
951                .unwrap_or(DEFAULT_SHELL_SECURITY_MODE),
952            security_patterns: shell
953                .and_then(|cfg| cfg.security_patterns.clone())
954                .unwrap_or_default(),
955        }
956    }
957}
958
959const CONFIG_TEMPLATE_TOML: &str = include_str!("config_template.toml");
960
961#[derive(Debug, Deserialize)]
962struct TemplateAgentDefaults {
963    model: Option<String>,
964    max_tokens_per_turn: Option<u32>,
965    budget_warning_threshold: Option<f32>,
966}
967
968#[derive(Debug, Deserialize)]
969struct TemplateShellDefaults {
970    program: Option<String>,
971    timeout_secs: Option<u64>,
972    security_mode: Option<SecurityMode>,
973    security_patterns: Option<Vec<String>>,
974}
975
976#[derive(Debug, Deserialize)]
977struct TemplateDefaults {
978    agent: Option<TemplateAgentDefaults>,
979    shell: Option<TemplateShellDefaults>,
980    max_tokens: Option<u32>,
981}
982
983impl TemplateDefaults {
984    fn empty() -> Self {
985        Self {
986            agent: None,
987            shell: None,
988            max_tokens: None,
989        }
990    }
991}
992
993fn template_defaults() -> &'static TemplateDefaults {
994    static DEFAULTS: OnceLock<TemplateDefaults> = OnceLock::new();
995    DEFAULTS.get_or_init(|| {
996        toml::from_str(CONFIG_TEMPLATE_TOML).unwrap_or_else(|e| {
997            // This is an embedded resource, so it really shouldn't fail in production
998            // but we avoid panic! to satisfy lint policies.
999            tracing::error!("Invalid config template defaults: {}", e);
1000            TemplateDefaults::empty()
1001        })
1002    })
1003}
1004
1005/// Paths for session and task persistence.
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1007#[serde(default)]
1008pub struct StoreConfig {
1009    pub sessions_path: Option<PathBuf>,
1010    pub tasks_path: Option<PathBuf>,
1011    /// Directory for the realm-scoped session database (server surfaces).
1012    pub database_dir: Option<PathBuf>,
1013}
1014
1015// Plan §6.10 deleted `ProviderSettings` entirely. Per-provider api keys
1016// and base URLs now live exclusively in `[realm.<id>]` config blocks
1017// (programmatically constructed via `RealmConfigSection::from_inline_api_keys`
1018// for surfaces that receive credentials at bootstrap).
1019
1020#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1021#[serde(rename_all = "snake_case")]
1022pub enum SelfHostedTransport {
1023    #[default]
1024    #[serde(alias = "openai_compatible")]
1025    OpenAiCompatible,
1026}
1027
1028#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1029#[serde(rename_all = "snake_case")]
1030pub enum SelfHostedApiStyle {
1031    Responses,
1032    #[default]
1033    ChatCompletions,
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1037#[serde(default)]
1038pub struct SelfHostedServerConfig {
1039    pub transport: SelfHostedTransport,
1040    pub base_url: String,
1041    pub api_style: SelfHostedApiStyle,
1042    #[serde(default, skip_serializing)]
1043    pub bearer_token: Option<String>,
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub bearer_token_env: Option<String>,
1046}
1047
1048impl Default for SelfHostedServerConfig {
1049    fn default() -> Self {
1050        Self {
1051            transport: SelfHostedTransport::OpenAiCompatible,
1052            base_url: String::new(),
1053            api_style: SelfHostedApiStyle::ChatCompletions,
1054            bearer_token: None,
1055            bearer_token_env: None,
1056        }
1057    }
1058}
1059
1060#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1061#[serde(default)]
1062pub struct SelfHostedModelConfig {
1063    pub server: String,
1064    pub remote_model: String,
1065    pub display_name: String,
1066    pub family: String,
1067    pub tier: ModelTier,
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub context_window: Option<u32>,
1070    #[serde(default, skip_serializing_if = "Option::is_none")]
1071    pub max_output_tokens: Option<u32>,
1072    pub vision: bool,
1073    pub image_tool_results: bool,
1074    pub inline_video: bool,
1075    pub supports_temperature: bool,
1076    pub supports_thinking: bool,
1077    pub supports_reasoning: bool,
1078    /// Whether the model supports provider-native web search tools.
1079    #[serde(default)]
1080    pub supports_web_search: bool,
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub call_timeout_secs: Option<u64>,
1083}
1084
1085impl Default for SelfHostedModelConfig {
1086    fn default() -> Self {
1087        Self {
1088            server: String::new(),
1089            remote_model: String::new(),
1090            display_name: String::new(),
1091            family: String::new(),
1092            tier: ModelTier::Supported,
1093            context_window: None,
1094            max_output_tokens: None,
1095            vision: false,
1096            image_tool_results: false,
1097            inline_video: false,
1098            supports_temperature: true,
1099            supports_thinking: false,
1100            supports_reasoning: false,
1101            supports_web_search: false,
1102            call_timeout_secs: None,
1103        }
1104    }
1105}
1106
1107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1108#[serde(default)]
1109pub struct SelfHostedConfig {
1110    pub servers: BTreeMap<String, SelfHostedServerConfig>,
1111    pub models: BTreeMap<String, SelfHostedModelConfig>,
1112}
1113
1114// ---------------------------------------------------------------------------
1115// Provider-native tool defaults
1116// ---------------------------------------------------------------------------
1117
1118/// Per-provider defaults for provider-native tools (web search, etc.).
1119///
1120/// These defaults are resolved at factory build time and injected as
1121/// non-persisted `AgentConfig.provider_tool_defaults`. The `enabled` flags
1122/// here control whether the factory injects tool config for models whose
1123/// `ModelProfile.supports_web_search` is `true`.
1124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1125#[serde(default)]
1126pub struct ProviderToolsConfig {
1127    pub anthropic: AnthropicProviderToolsConfig,
1128    pub openai: OpenAiProviderToolsConfig,
1129    pub gemini: GeminiProviderToolsConfig,
1130}
1131
1132/// Anthropic provider-native tool defaults.
1133#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1134#[serde(default)]
1135pub struct AnthropicProviderToolsConfig {
1136    /// Enable web search for Anthropic models that support it.
1137    pub web_search: bool,
1138}
1139
1140impl Default for AnthropicProviderToolsConfig {
1141    fn default() -> Self {
1142        Self { web_search: true }
1143    }
1144}
1145
1146/// OpenAI provider-native tool defaults.
1147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1148#[serde(default)]
1149pub struct OpenAiProviderToolsConfig {
1150    /// Enable web search for OpenAI models that support it.
1151    pub web_search: bool,
1152}
1153
1154impl Default for OpenAiProviderToolsConfig {
1155    fn default() -> Self {
1156        Self { web_search: true }
1157    }
1158}
1159
1160/// Gemini provider-native tool defaults.
1161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1162#[serde(default)]
1163pub struct GeminiProviderToolsConfig {
1164    /// Enable Google Search for Gemini models that support it.
1165    pub google_search: bool,
1166}
1167
1168impl Default for GeminiProviderToolsConfig {
1169    fn default() -> Self {
1170        Self {
1171            google_search: true,
1172        }
1173    }
1174}
1175
1176/// Runtime limits configured at the config layer.
1177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1178#[serde(default)]
1179pub struct LimitsConfig {
1180    pub budget: Option<u64>,
1181    /// Active session admission capacity. Persisted history does not consume
1182    /// this limit. RPC observes runtime config updates dynamically; REST/MCP
1183    /// process-local services apply changes on service restart.
1184    pub max_sessions: Option<usize>,
1185    #[serde(with = "optional_duration_serde")]
1186    pub max_duration: Option<Duration>,
1187}
1188
1189impl LimitsConfig {
1190    pub fn to_budget_limits(&self) -> BudgetLimits {
1191        BudgetLimits {
1192            max_tokens: self.budget,
1193            max_duration: self.max_duration,
1194            max_tool_calls: None,
1195        }
1196    }
1197}
1198
1199/// REST server configuration sourced from config.
1200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1201#[serde(default)]
1202pub struct RestServerConfig {
1203    pub host: String,
1204    pub port: u16,
1205}
1206
1207impl Default for RestServerConfig {
1208    fn default() -> Self {
1209        Self {
1210            host: "127.0.0.1".to_string(),
1211            port: 8080,
1212        }
1213    }
1214}
1215
1216/// Authentication mode for comms listeners.
1217///
1218/// `Open` (default) accepts plain JSON messages over TCP/UDS — no cryptographic
1219/// verification. Suitable for local agent-to-agent communication where the OS
1220/// provides process isolation. Non-loopback binding with `Open` auth is a hard
1221/// error unless explicitly overridden.
1222///
1223/// `Ed25519` requires signed CBOR envelopes and a trusted peers list. Use for
1224/// multi-machine or untrusted network communication.
1225#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
1226#[serde(rename_all = "snake_case")]
1227pub enum CommsAuthMode {
1228    #[default]
1229    #[serde(rename = "none")]
1230    Open,
1231    Ed25519,
1232}
1233
1234/// Source identifier for plain (unauthenticated) events.
1235///
1236/// Used for diagnostics and prompt formatting — tells the agent where
1237/// an external event originated from.
1238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1239#[serde(rename_all = "snake_case")]
1240pub enum PlainEventSource {
1241    Tcp,
1242    Uds,
1243    Stdin,
1244    Webhook,
1245    Rpc,
1246}
1247
1248impl std::fmt::Display for PlainEventSource {
1249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1250        match self {
1251            Self::Tcp => write!(f, "tcp"),
1252            Self::Uds => write!(f, "uds"),
1253            Self::Stdin => write!(f, "stdin"),
1254            Self::Webhook => write!(f, "webhook"),
1255            Self::Rpc => write!(f, "rpc"),
1256        }
1257    }
1258}
1259
1260/// Runtime comms configuration (portable across interfaces).
1261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1262#[serde(default)]
1263pub struct CommsRuntimeConfig {
1264    pub mode: CommsRuntimeMode,
1265    /// Address for agent-to-agent (signed) listener.
1266    pub address: Option<String>,
1267    pub auth: CommsAuthMode,
1268    /// Whether inter-agent peer traffic requires cryptographic validation.
1269    ///
1270    /// - `true`: messages require signatures and trusted-sender checks.
1271    /// - `false`: signatures are not verified and outgoing peer envelopes are
1272    ///   sent without signing.
1273    pub require_peer_auth: bool,
1274    /// Address for the plain-text external event listener.
1275    /// Only active when `auth = "none"`. Accepts newline-delimited JSON or text.
1276    pub event_address: Option<String>,
1277}
1278
1279impl Default for CommsRuntimeConfig {
1280    fn default() -> Self {
1281        Self {
1282            mode: CommsRuntimeMode::Inproc,
1283            address: None,
1284            auth: CommsAuthMode::default(),
1285            require_peer_auth: true,
1286            event_address: None,
1287        }
1288    }
1289}
1290
1291/// Runtime compaction configuration (portable across interfaces).
1292///
1293/// This config is serialized/deserialized in realm config and mapped to
1294/// `meerkat_core::CompactionConfig` when wiring the session compactor.
1295#[derive(Debug, Clone, PartialEq)]
1296pub struct CompactionRuntimeConfig {
1297    /// Trigger compaction when input tokens for a turn reach this threshold.
1298    pub auto_compact_threshold: u64,
1299    /// Whether `auto_compact_threshold` was explicitly present in config.
1300    ///
1301    /// This preserves the difference between inheriting Meerkat's default
1302    /// threshold and deliberately pinning that same numeric value.
1303    pub auto_compact_threshold_explicit: bool,
1304    /// Number of recent complete turns to retain after compaction.
1305    pub recent_turn_budget: usize,
1306    /// Maximum tokens for the compaction summary response.
1307    pub max_summary_tokens: u32,
1308    /// Minimum session-scoped pre-LLM boundaries between compactions.
1309    pub min_turns_between_compactions: u32,
1310}
1311
1312impl Default for CompactionRuntimeConfig {
1313    fn default() -> Self {
1314        Self {
1315            auto_compact_threshold: 100_000,
1316            auto_compact_threshold_explicit: false,
1317            recent_turn_budget: 4,
1318            max_summary_tokens: 4096,
1319            min_turns_between_compactions: 3,
1320        }
1321    }
1322}
1323
1324impl Serialize for CompactionRuntimeConfig {
1325    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1326    where
1327        S: serde::Serializer,
1328    {
1329        let defaults = Self::default();
1330        let include_threshold = self.auto_compact_threshold_explicit
1331            || self.auto_compact_threshold != defaults.auto_compact_threshold;
1332        let mut len = 3;
1333        if include_threshold {
1334            len += 1;
1335        }
1336
1337        let mut state = serializer.serialize_struct("CompactionRuntimeConfig", len)?;
1338        if include_threshold {
1339            state.serialize_field("auto_compact_threshold", &self.auto_compact_threshold)?;
1340        }
1341        state.serialize_field("recent_turn_budget", &self.recent_turn_budget)?;
1342        state.serialize_field("max_summary_tokens", &self.max_summary_tokens)?;
1343        state.serialize_field(
1344            "min_turns_between_compactions",
1345            &self.min_turns_between_compactions,
1346        )?;
1347        state.end()
1348    }
1349}
1350
1351impl<'de> Deserialize<'de> for CompactionRuntimeConfig {
1352    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1353    where
1354        D: Deserializer<'de>,
1355    {
1356        #[derive(Deserialize)]
1357        struct Seed {
1358            auto_compact_threshold: Option<u64>,
1359            recent_turn_budget: Option<usize>,
1360            max_summary_tokens: Option<u32>,
1361            min_turns_between_compactions: Option<u32>,
1362        }
1363
1364        let seed = Seed::deserialize(deserializer)?;
1365        let defaults = Self::default();
1366        Ok(Self {
1367            auto_compact_threshold: seed
1368                .auto_compact_threshold
1369                .unwrap_or(defaults.auto_compact_threshold),
1370            auto_compact_threshold_explicit: seed.auto_compact_threshold.is_some(),
1371            recent_turn_budget: seed
1372                .recent_turn_budget
1373                .unwrap_or(defaults.recent_turn_budget),
1374            max_summary_tokens: seed
1375                .max_summary_tokens
1376                .unwrap_or(defaults.max_summary_tokens),
1377            min_turns_between_compactions: seed
1378                .min_turns_between_compactions
1379                .unwrap_or(defaults.min_turns_between_compactions),
1380        })
1381    }
1382}
1383
1384impl From<CompactionRuntimeConfig> for crate::CompactionConfig {
1385    fn from(value: CompactionRuntimeConfig) -> Self {
1386        Self {
1387            auto_compact_threshold: value.auto_compact_threshold,
1388            recent_turn_budget: value.recent_turn_budget,
1389            max_summary_tokens: value.max_summary_tokens,
1390            min_turns_between_compactions: value.min_turns_between_compactions,
1391        }
1392    }
1393}
1394
1395/// Transport mode for comms runtime.
1396#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
1397#[serde(rename_all = "snake_case")]
1398pub enum CommsRuntimeMode {
1399    #[default]
1400    Inproc,
1401    Tcp,
1402    Uds,
1403}
1404
1405// Plan §6.9 deleted the `ProviderConfig` enum entirely. Per-provider
1406// credentials now live in:
1407//   - env vars (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY,
1408//     RKAT_*-prefixed overrides) — the default path, consumed through
1409//     `RealmConnectionSet::synthesize_env_default(provider)`.
1410//   - `[realm.<id>]` blocks in TOML — explicit realm/binding declarations,
1411//     consumed through `ProviderRuntimeRegistry::resolve`.
1412//
1413// The legacy `config.provider = ProviderConfig::{Anthropic,OpenAI,Gemini}`
1414// block and the legacy shared settings maps are
1415// removed in the same 0.6.0 cutover (plan §6.10).
1416
1417/// Storage configuration
1418#[derive(Debug, Clone, Serialize, Deserialize)]
1419#[serde(default)]
1420pub struct StorageConfig {
1421    /// Directory for file-based storage
1422    pub directory: Option<PathBuf>,
1423}
1424
1425impl Default for StorageConfig {
1426    fn default() -> Self {
1427        Self {
1428            directory: data_dir().map(|d| d.join("sessions")),
1429        }
1430    }
1431}
1432
1433/// Budget configuration
1434#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1435#[serde(default)]
1436pub struct BudgetConfig {
1437    /// Maximum tokens to consume
1438    pub max_tokens: Option<u64>,
1439    /// Maximum duration
1440    #[serde(with = "optional_duration_serde")]
1441    pub max_duration: Option<Duration>,
1442    /// Maximum tool calls
1443    pub max_tool_calls: Option<usize>,
1444}
1445
1446/// Tri-state override for per-call LLM timeout policy.
1447///
1448/// This type exists because `Option<Duration>` cannot distinguish between
1449/// "inherit lower-layer/profile default" and "explicitly disable timeout."
1450/// Build and config seams use this type; the resolved effective policy on
1451/// `RetryPolicy` collapses to `Option<Duration>`.
1452///
1453/// TOML representation:
1454/// - omitted key => `Inherit`
1455/// - `call_timeout = "disabled"` => `Disabled`
1456/// - `call_timeout = "45s"` => `Value(45s)`
1457#[derive(Debug, Clone, Default, PartialEq, Eq)]
1458#[non_exhaustive]
1459pub enum CallTimeoutOverride {
1460    /// Inherit the lower-layer or profile-derived default.
1461    #[default]
1462    Inherit,
1463    /// Explicitly disable call timeout (no timeout applied regardless of profile).
1464    Disabled,
1465    /// Explicitly set the call timeout to this duration.
1466    Value(Duration),
1467}
1468
1469impl Serialize for CallTimeoutOverride {
1470    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1471        match self {
1472            // Inherit is the default; serialized as absence (skip_serializing_if handles this)
1473            Self::Inherit => serializer.serialize_none(),
1474            Self::Disabled => serializer.serialize_str("disabled"),
1475            Self::Value(d) => {
1476                let s = humantime_serde::re::humantime::format_duration(*d).to_string();
1477                serializer.serialize_str(&s)
1478            }
1479        }
1480    }
1481}
1482
1483impl<'de> Deserialize<'de> for CallTimeoutOverride {
1484    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1485        let s = String::deserialize(deserializer)?;
1486        if s == "disabled" {
1487            return Ok(Self::Disabled);
1488        }
1489        let d: Duration = s
1490            .parse::<humantime_serde::re::humantime::Duration>()
1491            .map(|ht| *ht)
1492            .map_err(serde::de::Error::custom)?;
1493        Ok(Self::Value(d))
1494    }
1495}
1496
1497impl CallTimeoutOverride {
1498    /// Returns `true` when this override is `Inherit` (the default / absent state).
1499    pub fn is_inherit(&self) -> bool {
1500        matches!(self, Self::Inherit)
1501    }
1502}
1503
1504/// Retry configuration
1505#[derive(Debug, Clone, Serialize, Deserialize)]
1506#[serde(default)]
1507pub struct RetryConfig {
1508    /// Maximum number of retry attempts
1509    pub max_retries: u32,
1510    /// Initial delay before first retry (supports humantime format: "500ms", "1s")
1511    #[serde(with = "humantime_serde")]
1512    pub initial_delay: Duration,
1513    /// Maximum delay between retries (supports humantime format: "30s", "1m")
1514    #[serde(with = "humantime_serde")]
1515    pub max_delay: Duration,
1516    /// Multiplier for exponential backoff
1517    pub multiplier: f64,
1518    /// Tri-state call-timeout override for per-LLM-call timeout policy.
1519    ///
1520    /// - `Inherit` (default / omitted): defer to profile-derived or build-override default
1521    /// - `Disabled`: explicitly disable call timeout
1522    /// - `Value(duration)`: explicitly set call timeout
1523    #[serde(
1524        default,
1525        rename = "call_timeout",
1526        skip_serializing_if = "CallTimeoutOverride::is_inherit"
1527    )]
1528    pub call_timeout_override: CallTimeoutOverride,
1529}
1530
1531impl Default for RetryConfig {
1532    fn default() -> Self {
1533        let policy = RetryPolicy::default();
1534        Self {
1535            max_retries: policy.max_retries,
1536            initial_delay: policy.initial_delay,
1537            max_delay: policy.max_delay,
1538            multiplier: policy.multiplier,
1539            call_timeout_override: CallTimeoutOverride::default(),
1540        }
1541    }
1542}
1543
1544impl From<RetryConfig> for RetryPolicy {
1545    fn from(config: RetryConfig) -> Self {
1546        // Resolve explicit config override into effective call_timeout.
1547        // `Inherit` means None here — the agent loop resolves profile defaults later.
1548        let call_timeout = match config.call_timeout_override {
1549            CallTimeoutOverride::Inherit => None,
1550            CallTimeoutOverride::Disabled => None,
1551            CallTimeoutOverride::Value(d) => Some(d),
1552        };
1553        RetryPolicy {
1554            max_retries: config.max_retries,
1555            initial_delay: config.initial_delay,
1556            max_delay: config.max_delay,
1557            multiplier: config.multiplier,
1558            call_timeout,
1559        }
1560    }
1561}
1562
1563/// Tools configuration
1564#[derive(Debug, Clone, Serialize, Deserialize)]
1565#[serde(default)]
1566pub struct ToolsConfig {
1567    /// MCP server configurations
1568    #[serde(default)]
1569    pub mcp_servers: Vec<McpServerConfig>,
1570    /// Default timeout for tool execution (supports humantime format: "30s", "1m")
1571    #[serde(with = "humantime_serde")]
1572    pub default_timeout: Duration,
1573    /// Per-tool timeout overrides (supports humantime format: "30s", "1m")
1574    #[serde(default)]
1575    pub tool_timeouts: HashMap<String, Duration>,
1576    /// Maximum concurrent tool executions
1577    pub max_concurrent: usize,
1578    /// Builtin tools enabled
1579    pub builtins_enabled: bool,
1580    /// Shell tools enabled
1581    pub shell_enabled: bool,
1582    /// Comms tools enabled
1583    pub comms_enabled: bool,
1584    /// Mob (multi-agent orchestration) tools enabled
1585    pub mob_enabled: bool,
1586    /// Scheduler tools enabled
1587    pub schedule_enabled: bool,
1588    /// WorkGraph tools enabled
1589    pub workgraph_enabled: bool,
1590}
1591
1592impl Default for ToolsConfig {
1593    fn default() -> Self {
1594        Self {
1595            mcp_servers: Vec::new(),
1596            default_timeout: Duration::from_secs(600),
1597            tool_timeouts: HashMap::new(),
1598            max_concurrent: 10,
1599            builtins_enabled: false,
1600            shell_enabled: false,
1601            comms_enabled: false,
1602            mob_enabled: false,
1603            schedule_enabled: true,
1604            workgraph_enabled: false,
1605        }
1606    }
1607}
1608
1609/// Hook configuration root.
1610#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1611#[serde(default)]
1612pub struct HooksConfig {
1613    /// Default timeout for one hook invocation.
1614    pub default_timeout_ms: u64,
1615    /// Max serialized invocation payload size.
1616    pub payload_max_bytes: usize,
1617    /// Max number of background hook tasks allowed to run concurrently.
1618    pub background_max_concurrency: usize,
1619    /// Ordered hook registrations.
1620    #[serde(default)]
1621    pub entries: Vec<HookEntryConfig>,
1622}
1623
1624impl HooksConfig {
1625    pub fn append_entries_from(&mut self, other: &HooksConfig) {
1626        self.entries.extend(other.entries.clone());
1627    }
1628}
1629
1630impl Default for HooksConfig {
1631    fn default() -> Self {
1632        Self {
1633            default_timeout_ms: 5_000,
1634            payload_max_bytes: 128 * 1024,
1635            background_max_concurrency: 32,
1636            entries: Vec::new(),
1637        }
1638    }
1639}
1640
1641/// Run-scoped hook overrides.
1642#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
1643#[serde(default)]
1644pub struct HookRunOverrides {
1645    /// Additional hooks appended after layered config entries.
1646    #[serde(default)]
1647    pub entries: Vec<HookEntryConfig>,
1648    /// Hook ids disabled for this run.
1649    #[serde(default)]
1650    pub disable: Vec<HookId>,
1651}
1652
1653/// One hook registration entry.
1654#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
1655#[serde(default)]
1656pub struct HookEntryConfig {
1657    pub id: HookId,
1658    pub enabled: bool,
1659    pub point: HookPoint,
1660    pub mode: HookExecutionMode,
1661    pub capability: HookCapability,
1662    pub priority: i32,
1663    #[serde(default, skip_serializing_if = "Option::is_none")]
1664    pub failure_policy: Option<HookFailurePolicy>,
1665    #[serde(default, skip_serializing_if = "Option::is_none")]
1666    pub timeout_ms: Option<u64>,
1667    pub runtime: HookRuntimeConfig,
1668}
1669
1670impl HookEntryConfig {
1671    /// Legacy compatibility projection for persisted hook configs.
1672    ///
1673    /// Runtime hook admission no longer consults this value; foreground hook
1674    /// runtime failures surface as typed `HookEngineError`s and terminal
1675    /// classification is owned by the runtime machine policy.
1676    pub fn effective_failure_policy(&self) -> HookFailurePolicy {
1677        self.failure_policy
1678            .unwrap_or_else(|| crate::hooks::default_failure_policy(self.capability))
1679    }
1680}
1681
1682impl Default for HookEntryConfig {
1683    fn default() -> Self {
1684        Self {
1685            id: HookId::new("hook"),
1686            enabled: true,
1687            point: HookPoint::TurnBoundary,
1688            mode: HookExecutionMode::Foreground,
1689            capability: HookCapability::Observe,
1690            priority: 100,
1691            failure_policy: None,
1692            timeout_ms: None,
1693            runtime: HookRuntimeConfig::in_process("noop").unwrap_or(HookRuntimeConfig {
1694                kind: HookRuntimeKind::InProcess,
1695                config: None,
1696            }),
1697        }
1698    }
1699}
1700
1701/// Stable identity for an in-process hook handler registered with the runtime.
1702#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
1703#[serde(transparent)]
1704pub struct HookInProcessHandlerId(String);
1705
1706impl HookInProcessHandlerId {
1707    pub fn new(value: impl Into<String>) -> Self {
1708        Self(value.into())
1709    }
1710
1711    pub fn as_str(&self) -> &str {
1712        &self.0
1713    }
1714}
1715
1716impl std::fmt::Display for HookInProcessHandlerId {
1717    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1718        self.0.fmt(f)
1719    }
1720}
1721
1722impl From<&str> for HookInProcessHandlerId {
1723    fn from(value: &str) -> Self {
1724        Self::new(value)
1725    }
1726}
1727
1728impl From<String> for HookInProcessHandlerId {
1729    fn from(value: String) -> Self {
1730        Self::new(value)
1731    }
1732}
1733
1734impl Default for HookInProcessHandlerId {
1735    fn default() -> Self {
1736        Self::new("noop")
1737    }
1738}
1739
1740/// Typed payload for [`HookRuntimeKind::InProcess`].
1741///
1742/// `name` remains accepted for existing configs, but runtime dispatch uses the
1743/// typed handler id rather than fishing a string out of opaque adapter config.
1744#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1745#[serde(default)]
1746pub struct HookInProcessRuntimeConfig {
1747    #[serde(alias = "name")]
1748    pub handler: HookInProcessHandlerId,
1749}
1750
1751impl HookInProcessRuntimeConfig {
1752    pub fn new(handler: impl Into<HookInProcessHandlerId>) -> Self {
1753        Self {
1754            handler: handler.into(),
1755        }
1756    }
1757}
1758
1759impl Default for HookInProcessRuntimeConfig {
1760    fn default() -> Self {
1761        Self::new("noop")
1762    }
1763}
1764
1765/// Closed set of hook runtime adapters the engine can dispatch to.
1766///
1767/// Wire format uses snake_case (`in_process`, `command`, `http`) under the
1768/// `type` field on [`HookRuntimeConfig`] for backwards compatibility.
1769#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1770pub enum HookRuntimeKind {
1771    InProcess,
1772    Command,
1773    Http,
1774}
1775
1776impl HookRuntimeKind {
1777    /// Canonical wire/logging string for this runtime kind.
1778    pub fn as_str(&self) -> &'static str {
1779        match self {
1780            Self::InProcess => "in_process",
1781            Self::Command => "command",
1782            Self::Http => "http",
1783        }
1784    }
1785
1786    /// Parse the canonical wire form. Returns `None` for unrecognized strings.
1787    pub fn parse(s: &str) -> Option<Self> {
1788        match s {
1789            "in_process" => Some(Self::InProcess),
1790            "command" => Some(Self::Command),
1791            "http" => Some(Self::Http),
1792            _ => None,
1793        }
1794    }
1795}
1796
1797impl std::fmt::Display for HookRuntimeKind {
1798    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1799        f.write_str(self.as_str())
1800    }
1801}
1802
1803/// Runtime configuration used by hook adapters.
1804///
1805/// The dispatch kind is typed. In-process handlers use
1806/// [`HookInProcessRuntimeConfig`]; command and HTTP runtimes keep their
1807/// adapter-specific payloads opaque at this boundary.
1808#[derive(Debug, Clone)]
1809pub struct HookRuntimeConfig {
1810    pub kind: HookRuntimeKind,
1811    #[allow(clippy::box_collection)]
1812    pub config: Option<Box<RawValue>>,
1813}
1814
1815impl PartialEq for HookRuntimeConfig {
1816    fn eq(&self, other: &Self) -> bool {
1817        self.kind == other.kind
1818            && self.config.as_ref().map(|raw| raw.get())
1819                == other.config.as_ref().map(|raw| raw.get())
1820    }
1821}
1822
1823impl HookRuntimeConfig {
1824    pub fn new(kind: HookRuntimeKind, config: Option<Value>) -> Result<Self, serde_json::Error> {
1825        let config = match config {
1826            Some(value) => Some(raw_json_from_value(value)?),
1827            None => None,
1828        };
1829        Ok(Self { kind, config })
1830    }
1831
1832    pub fn in_process(
1833        handler: impl Into<HookInProcessHandlerId>,
1834    ) -> Result<Self, serde_json::Error> {
1835        serde_json::to_value(HookInProcessRuntimeConfig::new(handler))
1836            .and_then(|config| Self::new(HookRuntimeKind::InProcess, Some(config)))
1837    }
1838
1839    pub fn in_process_config(
1840        &self,
1841    ) -> Result<Option<HookInProcessRuntimeConfig>, serde_json::Error> {
1842        if self.kind != HookRuntimeKind::InProcess {
1843            return Ok(None);
1844        }
1845
1846        self.config_value()
1847            .and_then(serde_json::from_value::<HookInProcessRuntimeConfig>)
1848            .map(Some)
1849    }
1850
1851    pub fn config_value(&self) -> Result<Value, serde_json::Error> {
1852        match &self.config {
1853            Some(raw) => serde_json::from_str(raw.get()),
1854            None => Ok(Value::Null),
1855        }
1856    }
1857}
1858
1859impl Default for HookRuntimeConfig {
1860    fn default() -> Self {
1861        Self::in_process("noop").unwrap_or(Self {
1862            kind: HookRuntimeKind::InProcess,
1863            config: None,
1864        })
1865    }
1866}
1867
1868impl Serialize for HookRuntimeConfig {
1869    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1870    where
1871        S: serde::Serializer,
1872    {
1873        let mut map = Map::new();
1874        map.insert(
1875            "type".to_string(),
1876            Value::String(self.kind.as_str().to_string()),
1877        );
1878
1879        if let Some(raw) = &self.config {
1880            let parsed: Value =
1881                serde_json::from_str(raw.get()).map_err(serde::ser::Error::custom)?;
1882            match parsed {
1883                Value::Object(obj) => {
1884                    for (key, value) in obj {
1885                        map.insert(key, value);
1886                    }
1887                }
1888                other => {
1889                    map.insert("config".to_string(), other);
1890                }
1891            }
1892        }
1893
1894        Value::Object(map).serialize(serializer)
1895    }
1896}
1897
1898impl<'de> Deserialize<'de> for HookRuntimeConfig {
1899    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1900    where
1901        D: serde::Deserializer<'de>,
1902    {
1903        let value = Value::deserialize(deserializer)?;
1904        let mut obj = value
1905            .as_object()
1906            .cloned()
1907            .ok_or_else(|| serde::de::Error::custom("hook runtime must be an object"))?;
1908
1909        let kind_str = obj
1910            .remove("type")
1911            .and_then(|value| value.as_str().map(ToOwned::to_owned))
1912            .ok_or_else(|| {
1913                serde::de::Error::custom("hook runtime missing required field 'type'")
1914            })?;
1915        let kind = HookRuntimeKind::parse(&kind_str).ok_or_else(|| {
1916            serde::de::Error::custom(format!("unsupported hook runtime '{kind_str}'"))
1917        })?;
1918
1919        let config_value = if let Some(explicit) = obj.remove("config") {
1920            if obj.is_empty() {
1921                explicit
1922            } else {
1923                obj.insert("config".to_string(), explicit);
1924                Value::Object(obj)
1925            }
1926        } else if obj.is_empty() {
1927            Value::Null
1928        } else {
1929            Value::Object(obj)
1930        };
1931
1932        let config = if config_value.is_null() {
1933            None
1934        } else {
1935            Some(raw_json_from_value(config_value).map_err(serde::de::Error::custom)?)
1936        };
1937
1938        Ok(Self { kind, config })
1939    }
1940}
1941
1942impl JsonSchema for HookRuntimeConfig {
1943    fn schema_name() -> std::borrow::Cow<'static, str> {
1944        "HookRuntimeConfig".into()
1945    }
1946
1947    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
1948        schemars::json_schema!({
1949            "type": "object",
1950            "required": ["type"],
1951            "properties": {
1952                "type": { "type": "string" },
1953                "handler": { "type": "string" },
1954                "name": { "type": "string", "deprecated": true },
1955                "config": {}
1956            },
1957            "additionalProperties": true
1958        })
1959    }
1960}
1961
1962fn raw_json_from_value(value: Value) -> Result<Box<RawValue>, serde_json::Error> {
1963    RawValue::from_string(serde_json::to_string(&value)?)
1964}
1965
1966/// Config scope for persisted settings.
1967#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1968#[serde(rename_all = "snake_case")]
1969pub enum ConfigScope {
1970    Global,
1971    Project,
1972}
1973
1974/// Config patch payload (merge-patch semantics applied by ConfigStore).
1975#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1976#[serde(transparent)]
1977pub struct ConfigDelta(pub serde_json::Value);
1978
1979/// Configuration errors
1980#[derive(Debug, thiserror::Error)]
1981pub enum ConfigError {
1982    #[error("IO error: {0}")]
1983    Io(#[from] std::io::Error),
1984
1985    #[error("Parse error: {0}")]
1986    Parse(#[from] toml::de::Error),
1987
1988    #[error("TOML serialization error: {0}")]
1989    TomlSerialize(#[from] toml::ser::Error),
1990
1991    #[error("JSON error: {0}")]
1992    Json(#[from] serde_json::Error),
1993
1994    #[error("UTF-8 error: {0}")]
1995    Utf8(#[from] std::string::FromUtf8Error),
1996
1997    #[allow(dead_code)]
1998    #[error("Invalid value for {0}")]
1999    InvalidValue(String),
2000
2001    #[error("Missing required field: {0}")]
2002    MissingField(String),
2003
2004    #[error("Internal error: {0}")]
2005    InternalError(String),
2006
2007    #[error("Validation error: {0}")]
2008    Validation(String),
2009}
2010
2011/// Serde helpers for Option<Duration> with humantime format
2012mod optional_duration_serde {
2013    use serde::{Deserialize, Deserializer, Serialize, Serializer};
2014    use std::time::Duration;
2015
2016    // Signature constrained by serde's `#[serde(with)]` contract:
2017    // `serialize_with` passes `&Option<T>`, not `Option<&T>`.
2018    #[allow(clippy::ref_option)]
2019    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
2020    where
2021        S: Serializer,
2022    {
2023        match duration {
2024            Some(d) => {
2025                let s = humantime_serde::re::humantime::format_duration(*d).to_string();
2026                s.serialize(serializer)
2027            }
2028            None => serializer.serialize_none(),
2029        }
2030    }
2031
2032    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
2033    where
2034        D: Deserializer<'de>,
2035    {
2036        use serde::de::Error;
2037
2038        // Try deserializing as string first (humantime format)
2039        let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
2040        match value {
2041            None => Ok(None),
2042            Some(serde_json::Value::String(s)) => {
2043                humantime_serde::re::humantime::parse_duration(&s)
2044                    .map(Some)
2045                    .map_err(|e| D::Error::custom(e.to_string()))
2046            }
2047            Some(serde_json::Value::Number(n)) => {
2048                // Support milliseconds as number for backward compat
2049                let millis = n
2050                    .as_u64()
2051                    .ok_or_else(|| D::Error::custom("invalid number"))?;
2052                Ok(Some(Duration::from_millis(millis)))
2053            }
2054            _ => Err(D::Error::custom("expected string or number for duration")),
2055        }
2056    }
2057}
2058
2059/// Find the project root directory by walking up from `start_dir` looking for `.rkat/`.
2060pub fn find_project_root(start_dir: &std::path::Path) -> Option<PathBuf> {
2061    let mut current = start_dir.to_path_buf();
2062    loop {
2063        if current.join(".rkat").is_dir() {
2064            return Some(current);
2065        }
2066        if !current.pop() {
2067            return None;
2068        }
2069    }
2070}
2071
2072/// Get the data directory for Meerkat.
2073///
2074/// Priority:
2075/// 1. Nearest ancestor containing .rkat/
2076/// 2. User's home directory ~/.rkat/
2077pub fn data_dir() -> Option<PathBuf> {
2078    // 1. Check for project root .rkat
2079    if let Ok(cwd) = std::env::current_dir()
2080        && let Some(root) = find_project_root(&cwd)
2081    {
2082        return Some(root.join(".rkat"));
2083    }
2084
2085    // 2. Fallback to ~/.rkat
2086    dirs::home_dir().map(|h| h.join(".rkat"))
2087}
2088
2089// Stub for home directory resolution
2090pub mod dirs {
2091    use std::path::PathBuf;
2092
2093    pub fn home_dir() -> Option<PathBuf> {
2094        std::env::var_os("HOME").map(PathBuf::from)
2095    }
2096}
2097
2098#[cfg(test)]
2099#[allow(clippy::unwrap_used, clippy::expect_used)]
2100mod tests {
2101    use super::*;
2102    use crate::Provider;
2103
2104    #[test]
2105    fn test_config_default() {
2106        let config = Config::default();
2107        assert_eq!(config.agent.model, "gpt-5.5");
2108        assert_eq!(config.agent.max_tokens_per_turn, 16384);
2109        assert_eq!(config.retry.max_retries, 3);
2110        assert_eq!(config.max_sessions(), DEFAULT_MAX_SESSIONS);
2111    }
2112
2113    #[test]
2114    fn config_template_agent_model_tracks_openai_catalog_default() {
2115        let config = Config::template().expect("template parses");
2116        assert_eq!(
2117            config.agent.model.as_str(),
2118            crate::model_profile::catalog::default_model("openai").expect("openai catalog default")
2119        );
2120    }
2121
2122    #[test]
2123    fn test_limits_max_sessions_configures_runtime_capacity() {
2124        let mut config = Config::default();
2125        config
2126            .merge_toml_str(
2127                r"
2128[limits]
2129max_sessions = 7
2130",
2131            )
2132            .expect("merge max_sessions");
2133        assert_eq!(config.max_sessions(), 7);
2134    }
2135
2136    #[test]
2137    fn test_config_layering() {
2138        // 1. Test defaults
2139        let config = Config::default();
2140        assert_eq!(config.agent.model, "gpt-5.5");
2141        assert_eq!(config.budget.max_tokens, None);
2142
2143        // 2. Test env override (secrets only)
2144        {
2145            // Plan §6.9 deleted the `config.provider = ProviderConfig::X`
2146            // mutable sink. apply_env_overrides_from is now a no-op;
2147            // env-var-based credential resolution happens at resolve
2148            // time in the provider-runtime registry. This branch retains
2149            // the call-site shape so the merge precedence test passes.
2150            let env = std::collections::HashMap::from([(
2151                "RKAT_MODEL".to_string(),
2152                "env-model".to_string(),
2153            )]);
2154            let mut config = Config::default();
2155            config
2156                .apply_env_overrides_from(|key| env.get(key).cloned())
2157                .expect("apply env overrides");
2158        }
2159
2160        // 3. Test file merge
2161        let mut config = Config::default();
2162        let file_config = Config {
2163            agent: AgentConfig {
2164                model: "file-model".to_string(),
2165                ..Default::default()
2166            },
2167            ..Default::default()
2168        };
2169        config.merge(file_config);
2170        assert_eq!(config.agent.model, "file-model");
2171
2172        // 4. Test CLI override (highest precedence)
2173        let mut config = Config::default();
2174        config.apply_cli_overrides(CliOverrides {
2175            model: Some("cli-model".to_string()),
2176            max_tokens: Some(50000),
2177            ..Default::default()
2178        });
2179        // CLI should win over defaults
2180        assert_eq!(config.agent.model, "cli-model");
2181        assert_eq!(config.budget.max_tokens, Some(50000));
2182    }
2183
2184    #[test]
2185    fn test_merge_extraction_prompt_survives_layering() {
2186        let mut base = Config::default();
2187        assert!(base.agent.extraction_prompt.is_none());
2188
2189        // Merge from TOML config file
2190        let toml = r#"
2191[agent]
2192extraction_prompt = "Return JSON only."
2193"#;
2194        base.merge_toml_str(toml).expect("merge toml");
2195        assert_eq!(
2196            base.agent.extraction_prompt.as_deref(),
2197            Some("Return JSON only.")
2198        );
2199
2200        // A second merge without the field should preserve it
2201        let toml2 = r#"
2202[agent]
2203model = "custom-model"
2204"#;
2205        base.merge_toml_str(toml2).expect("merge toml2");
2206        assert_eq!(
2207            base.agent.extraction_prompt.as_deref(),
2208            Some("Return JSON only."),
2209            "extraction_prompt must survive merge when absent in later layer"
2210        );
2211        assert_eq!(base.agent.model, "custom-model");
2212    }
2213
2214    #[test]
2215    fn test_merge_hooks_entries_append() {
2216        let mut base = Config::default();
2217        let base_entry = HookEntryConfig {
2218            id: HookId::new("base"),
2219            ..HookEntryConfig::default()
2220        };
2221        base.hooks.entries.push(base_entry);
2222
2223        let mut other = Config::default();
2224        let other_entry = HookEntryConfig {
2225            id: HookId::new("other"),
2226            ..HookEntryConfig::default()
2227        };
2228        other.hooks.entries.push(other_entry);
2229
2230        base.merge(other);
2231        let ids = base
2232            .hooks
2233            .entries
2234            .iter()
2235            .map(|entry| entry.id.0.as_str())
2236            .collect::<Vec<_>>();
2237        assert_eq!(ids, vec!["base", "other"]);
2238    }
2239
2240    #[test]
2241    fn test_merge_self_hosted_preserves_lower_layer_servers_and_models() {
2242        let mut base = Config::default();
2243        base.merge_toml_str(
2244            r#"
2245[self_hosted.servers.local]
2246base_url = "http://127.0.0.1:11434"
2247"#,
2248        )
2249        .expect("base self-hosted server");
2250        base.merge_toml_str(
2251            r#"
2252[self_hosted.models.gemma-4-e2b]
2253server = "local"
2254remote_model = "gemma4:e2b"
2255display_name = "Gemma 4 E2B"
2256family = "gemma-4"
2257"#,
2258        )
2259        .expect("overlay self-hosted model");
2260
2261        assert!(base.self_hosted.servers.contains_key("local"));
2262        assert!(base.self_hosted.models.contains_key("gemma-4-e2b"));
2263        let registry = base.model_registry().expect("merged self-hosted registry");
2264        assert_eq!(
2265            registry
2266                .entry("gemma-4-e2b")
2267                .and_then(|entry| entry.self_hosted.as_ref())
2268                .map(|server| server.server_id.as_str()),
2269            Some("local")
2270        );
2271    }
2272
2273    #[test]
2274    fn test_merge_self_hosted_partial_server_override_preserves_existing_fields() {
2275        let mut config = Config::default();
2276        config
2277            .merge_toml_str(
2278                r#"
2279[self_hosted.servers.local]
2280base_url = "http://127.0.0.1:11434"
2281api_style = "responses"
2282"#,
2283            )
2284            .expect("base server");
2285        config
2286            .merge_toml_str(
2287                r#"
2288[self_hosted.servers.local]
2289bearer_token_env = "OLLAMA_TOKEN"
2290"#,
2291            )
2292            .expect("overlay server");
2293
2294        let server = config
2295            .self_hosted
2296            .servers
2297            .get("local")
2298            .expect("merged server");
2299        assert_eq!(server.base_url, "http://127.0.0.1:11434");
2300        assert_eq!(server.api_style, SelfHostedApiStyle::Responses);
2301        assert_eq!(server.bearer_token_env.as_deref(), Some("OLLAMA_TOKEN"));
2302    }
2303
2304    #[test]
2305    fn test_merge_self_hosted_partial_override_preserves_unrelated_inherited_entries() {
2306        let mut config = Config::default();
2307        config
2308            .merge_toml_str(
2309                r#"
2310[self_hosted.servers.local]
2311base_url = "http://127.0.0.1:11434"
2312
2313[self_hosted.servers.backup]
2314base_url = "http://127.0.0.1:11435"
2315
2316[self_hosted.models.gemma-4-e2b]
2317server = "local"
2318remote_model = "gemma4:e2b"
2319display_name = "Gemma 4 E2B"
2320family = "gemma-4"
2321
2322[self_hosted.models.gemma-4-e4b]
2323server = "backup"
2324remote_model = "gemma4:e4b"
2325display_name = "Gemma 4 E4B"
2326family = "gemma-4"
2327"#,
2328            )
2329            .expect("base self-hosted config");
2330        config
2331            .merge_toml_str(
2332                r#"
2333[self_hosted.servers.local]
2334bearer_token_env = "OLLAMA_TOKEN"
2335"#,
2336            )
2337            .expect("overlay self-hosted config");
2338
2339        assert!(config.self_hosted.servers.contains_key("backup"));
2340        assert!(config.self_hosted.models.contains_key("gemma-4-e4b"));
2341        let registry = config
2342            .model_registry()
2343            .expect("registry should remain valid");
2344        assert_eq!(
2345            registry
2346                .entry("gemma-4-e4b")
2347                .and_then(|entry| entry.self_hosted.as_ref())
2348                .map(|server| server.server_id.as_str()),
2349            Some("backup")
2350        );
2351    }
2352
2353    #[test]
2354    fn test_merge_self_hosted_empty_table_clears_inherited_entries() {
2355        let mut config = Config::default();
2356        config
2357            .merge_toml_str(
2358                r#"
2359[self_hosted.servers.local]
2360base_url = "http://127.0.0.1:11434"
2361
2362[self_hosted.models.gemma-4-e2b]
2363server = "local"
2364remote_model = "gemma4:e2b"
2365display_name = "Gemma 4 E2B"
2366family = "gemma-4"
2367"#,
2368            )
2369            .expect("base self-hosted config");
2370
2371        config
2372            .merge_toml_str(
2373                r"
2374[self_hosted.servers]
2375
2376[self_hosted.models]
2377",
2378            )
2379            .expect("clear self-hosted config");
2380
2381        assert!(config.self_hosted.servers.is_empty());
2382        assert!(config.self_hosted.models.is_empty());
2383    }
2384
2385    #[test]
2386    fn test_self_hosted_bearer_token_is_not_serialized() {
2387        let config: Config = toml::from_str(
2388            r#"
2389[self_hosted.servers.local]
2390base_url = "http://127.0.0.1:11434"
2391bearer_token = "secret-token"
2392"#,
2393        )
2394        .expect("config");
2395
2396        let value = serde_json::to_value(&config).expect("serialize config");
2397        let server = &value["self_hosted"]["servers"]["local"];
2398        assert!(
2399            server.get("bearer_token").is_none(),
2400            "literal bearer tokens must be redacted from serialized config"
2401        );
2402    }
2403
2404    // Plan §6.10 deleted the ProviderSettings struct (and its api_keys /
2405    // base_urls maps) entirely. The corresponding merge test that
2406    // asserted the "non-default other replaces self" semantics went
2407    // with it. Realm-scoped base_urls now live in
2408    // `[realm.<id>.backend.<b>.base_url]` and round-trip through the
2409    // normal TOML merge path that the
2410    // `test_merge_extraction_prompt_survives_layering` case exercises.
2411
2412    #[test]
2413    fn test_merge_toml_tools_omitted_fields_preserve_lower_layer() {
2414        let mut config = Config::default();
2415        config.tools.mob_enabled = true;
2416        config.tools.shell_enabled = true;
2417
2418        config
2419            .merge_toml_str(
2420                r"
2421[tools]
2422shell_enabled = false
2423",
2424            )
2425            .expect("merge should succeed");
2426
2427        assert!(config.tools.mob_enabled);
2428        assert!(!config.tools.shell_enabled);
2429    }
2430
2431    #[test]
2432    fn test_merge_toml_tools_explicit_default_overrides_lower_layer() {
2433        let mut config = Config::default();
2434        config.tools.mob_enabled = true;
2435
2436        config
2437            .merge_toml_str(
2438                r"
2439[tools]
2440mob_enabled = false
2441",
2442            )
2443            .expect("merge should succeed");
2444
2445        assert!(!config.tools.mob_enabled);
2446    }
2447
2448    #[test]
2449    fn test_merge_toml_retry_omitted_fields_preserve_lower_layer() {
2450        let mut config = Config::default();
2451        config.retry.max_retries = 9;
2452
2453        config
2454            .merge_toml_str(
2455                r#"
2456[retry]
2457initial_delay = "750ms"
2458"#,
2459            )
2460            .expect("merge should succeed");
2461
2462        assert_eq!(config.retry.max_retries, 9);
2463        assert_eq!(config.retry.initial_delay, Duration::from_millis(750));
2464    }
2465
2466    #[test]
2467    fn test_compaction_threshold_presence_is_preserved_at_default_value() {
2468        let config: Config = toml::from_str(
2469            r"
2470[compaction]
2471auto_compact_threshold = 100000
2472",
2473        )
2474        .expect("config should parse");
2475
2476        assert_eq!(config.compaction.auto_compact_threshold, 100_000);
2477        assert!(config.compaction.auto_compact_threshold_explicit);
2478    }
2479
2480    #[test]
2481    fn test_default_compaction_threshold_serializes_as_inherited() {
2482        let toml = toml::to_string_pretty(&Config::default()).expect("config should serialize");
2483
2484        assert!(
2485            !toml.contains("auto_compact_threshold"),
2486            "default config should not persist an inherited compaction threshold: {toml}"
2487        );
2488    }
2489
2490    #[test]
2491    fn test_explicit_default_compaction_threshold_serializes() {
2492        let mut config = Config::default();
2493        config.compaction.auto_compact_threshold_explicit = true;
2494
2495        let toml = toml::to_string_pretty(&config).expect("config should serialize");
2496
2497        assert!(
2498            toml.contains("auto_compact_threshold = 100000"),
2499            "explicit default threshold must survive persistence: {toml}"
2500        );
2501    }
2502
2503    #[test]
2504    fn test_validate_rejects_zero_min_turns_between_compactions() {
2505        let config = Config {
2506            compaction: CompactionRuntimeConfig {
2507                min_turns_between_compactions: 0,
2508                ..CompactionRuntimeConfig::default()
2509            },
2510            ..Config::default()
2511        };
2512        let err = config
2513            .validate()
2514            .expect_err("min_turns_between_compactions=0 should be invalid");
2515        assert!(
2516            err.to_string()
2517                .contains("compaction.min_turns_between_compactions")
2518        );
2519    }
2520
2521    // Plan §6.9 deleted the ProviderConfig enum and the
2522    // `test_provider_config_serialization` test that exercised its
2523    // serde discriminator. Realm-based credential configs are round-
2524    // tripped by tests in meerkat-contracts/tests/auth_binding_wire.rs.
2525
2526    #[test]
2527    fn test_budget_config_serialization() {
2528        let budget = BudgetConfig {
2529            max_tokens: Some(100_000),
2530            max_duration: Some(Duration::from_secs(300)),
2531            max_tool_calls: Some(50),
2532        };
2533
2534        let json = serde_json::to_string(&budget).unwrap();
2535        let parsed: BudgetConfig = serde_json::from_str(&json).unwrap();
2536
2537        assert_eq!(parsed.max_tokens, Some(100_000));
2538        assert_eq!(parsed.max_duration, Some(Duration::from_secs(300)));
2539        assert_eq!(parsed.max_tool_calls, Some(50));
2540    }
2541
2542    #[test]
2543    fn test_self_hosted_transport_accepts_openai_compatible_alias() {
2544        let mut config = Config::default();
2545        config
2546            .merge_toml_str(
2547                r#"
2548[self_hosted.servers.ollama]
2549transport = "openai_compatible"
2550base_url = "http://127.0.0.1:11434"
2551api_style = "chat_completions"
2552"#,
2553            )
2554            .expect("alias should parse");
2555
2556        assert_eq!(
2557            config
2558                .self_hosted
2559                .servers
2560                .get("ollama")
2561                .expect("server should exist")
2562                .transport,
2563            SelfHostedTransport::OpenAiCompatible
2564        );
2565    }
2566
2567    #[test]
2568    fn test_self_hosted_server_config_defaults_to_chat_completions() {
2569        assert_eq!(
2570            SelfHostedServerConfig::default().api_style,
2571            SelfHostedApiStyle::ChatCompletions
2572        );
2573    }
2574
2575    #[test]
2576    fn test_retry_config_to_policy() {
2577        let config = RetryConfig::default();
2578        let policy: RetryPolicy = config.into();
2579
2580        assert_eq!(policy.max_retries, 3);
2581        assert_eq!(policy.initial_delay, Duration::from_millis(500));
2582    }
2583
2584    /// Regression test: Config::load() should succeed when .rkat/ directory exists
2585    /// but config.toml is missing. This is a common first-run state where .rkat/
2586    /// is created for session storage.
2587    #[tokio::test]
2588    async fn test_regression_load_succeeds_without_config_toml() {
2589        use tempfile::TempDir;
2590
2591        // Create temp dir with .rkat/ but NO config.toml
2592        let temp_dir = TempDir::new().unwrap();
2593        let rkat_dir = temp_dir.path().join(".rkat");
2594        std::fs::create_dir(&rkat_dir).unwrap();
2595
2596        // Verify .rkat exists but config.toml doesn't
2597        assert!(rkat_dir.exists());
2598        assert!(!rkat_dir.join("config.toml").exists());
2599
2600        // Verify load succeeds without consulting process-global HOME/cwd.
2601        let result =
2602            Config::load_from_with_env(temp_dir.path(), Some(temp_dir.path()), |_| None).await;
2603
2604        assert!(
2605            result.is_ok(),
2606            "Config::load() should succeed when .rkat/ exists without config.toml: {:?}",
2607            result.err()
2608        );
2609    }
2610
2611    #[test]
2612    fn test_validate_rejects_zero_max_tokens() {
2613        let config = Config {
2614            max_tokens: 0,
2615            ..Config::default()
2616        };
2617        let err = config
2618            .validate()
2619            .expect_err("max_tokens=0 should be invalid");
2620        assert!(
2621            err.to_string()
2622                .contains("max_tokens must be greater than 0")
2623        );
2624    }
2625
2626    #[test]
2627    fn test_validate_rejects_zero_limits_max_sessions() {
2628        let mut config = Config::default();
2629        config.limits.max_sessions = Some(0);
2630        let err = config
2631            .validate()
2632            .expect_err("limits.max_sessions=0 should be invalid");
2633        assert!(err.to_string().contains("limits.max_sessions"));
2634    }
2635
2636    #[test]
2637    fn test_validate_rejects_zero_agent_max_tokens_per_turn() {
2638        let mut config = Config::default();
2639        config.agent.max_tokens_per_turn = 0;
2640        let err = config
2641            .validate()
2642            .expect_err("agent.max_tokens_per_turn=0 should be invalid");
2643        assert!(err.to_string().contains("agent.max_tokens_per_turn"));
2644    }
2645
2646    // Plan §6.9 deleted the `config.provider = ProviderConfig::X` block
2647    // and the matching validate-time conflict checks against
2648    // `providers.{base_urls,api_keys}`. Those tests went with it.
2649
2650    #[test]
2651    fn test_provider_parse_strict() {
2652        assert_eq!(
2653            Provider::parse_strict("anthropic"),
2654            Some(Provider::Anthropic)
2655        );
2656        assert_eq!(Provider::parse_strict("openai"), Some(Provider::OpenAI));
2657        assert_eq!(Provider::parse_strict("gemini"), Some(Provider::Gemini));
2658        assert_eq!(Provider::parse_strict("other"), None);
2659        assert_eq!(Provider::parse_strict("claude"), None);
2660        assert_eq!(Provider::parse_strict(""), None);
2661    }
2662
2663    #[test]
2664    fn test_provider_infer_from_model() {
2665        assert_eq!(
2666            Provider::infer_from_model("claude-opus-4-6"),
2667            Some(Provider::Anthropic)
2668        );
2669        assert_eq!(
2670            Provider::infer_from_model("claude-haiku-4-5-20251001"),
2671            Some(Provider::Anthropic)
2672        );
2673        assert_eq!(
2674            Provider::infer_from_model("claude-haiku-4-5"),
2675            Some(Provider::Anthropic)
2676        );
2677        assert_eq!(
2678            Provider::infer_from_model("gpt-5.4"),
2679            Some(Provider::OpenAI)
2680        );
2681        assert_eq!(
2682            Provider::infer_from_model("gpt-5.4-mini"),
2683            Some(Provider::OpenAI)
2684        );
2685        assert_eq!(
2686            Provider::infer_from_model("gemini-3.5-flash"),
2687            Some(Provider::Gemini)
2688        );
2689        assert_eq!(Provider::infer_from_model("gpt-unknown-preview"), None);
2690        assert_eq!(Provider::infer_from_model("claude-unknown-preview"), None);
2691        assert_eq!(Provider::infer_from_model("gemini-unknown-preview"), None);
2692        assert_eq!(Provider::infer_from_model("llama-3"), None);
2693        assert_eq!(Provider::infer_from_model(""), None);
2694    }
2695
2696    // === CommsAuthMode tests ===
2697
2698    #[test]
2699    fn test_comms_auth_mode_default_is_open() {
2700        assert_eq!(CommsAuthMode::default(), CommsAuthMode::Open);
2701    }
2702
2703    #[test]
2704    fn test_comms_auth_mode_serde_roundtrip() {
2705        // Open serializes as "none"
2706        let json = serde_json::to_string(&CommsAuthMode::Open).unwrap();
2707        assert_eq!(json, r#""none""#);
2708        let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
2709        assert_eq!(parsed, CommsAuthMode::Open);
2710
2711        // Ed25519 serializes as "ed25519"
2712        let json = serde_json::to_string(&CommsAuthMode::Ed25519).unwrap();
2713        assert_eq!(json, r#""ed25519""#);
2714        let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
2715        assert_eq!(parsed, CommsAuthMode::Ed25519);
2716    }
2717
2718    #[test]
2719    fn test_comms_auth_mode_toml_roundtrip() {
2720        let config = CommsRuntimeConfig::default();
2721        let toml_str = toml::to_string(&config).unwrap();
2722        let parsed: CommsRuntimeConfig = toml::from_str(&toml_str).unwrap();
2723        assert_eq!(parsed.auth, CommsAuthMode::Open);
2724        assert!(parsed.require_peer_auth);
2725
2726        // Explicit ed25519
2727        let toml_str = r#"
2728mode = "inproc"
2729auth = "ed25519"
2730"#;
2731        let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
2732        assert_eq!(parsed.auth, CommsAuthMode::Ed25519);
2733        assert!(parsed.require_peer_auth);
2734    }
2735
2736    #[test]
2737    fn test_comms_runtime_config_default_has_open_auth() {
2738        let config = CommsRuntimeConfig::default();
2739        assert_eq!(config.auth, CommsAuthMode::Open);
2740        assert!(config.require_peer_auth);
2741    }
2742
2743    // === PlainEventSource tests ===
2744
2745    #[test]
2746    fn test_plain_event_source_serde_roundtrip() {
2747        let cases = [
2748            (PlainEventSource::Tcp, r#""tcp""#),
2749            (PlainEventSource::Uds, r#""uds""#),
2750            (PlainEventSource::Stdin, r#""stdin""#),
2751            (PlainEventSource::Webhook, r#""webhook""#),
2752            (PlainEventSource::Rpc, r#""rpc""#),
2753        ];
2754        for (variant, expected_json) in cases {
2755            let json = serde_json::to_string(&variant).unwrap();
2756            assert_eq!(json, expected_json, "serialize {variant:?}");
2757            let parsed: PlainEventSource = serde_json::from_str(&json).unwrap();
2758            assert_eq!(parsed, variant, "deserialize {variant:?}");
2759        }
2760    }
2761
2762    #[test]
2763    fn test_plain_event_source_display() {
2764        assert_eq!(PlainEventSource::Tcp.to_string(), "tcp");
2765        assert_eq!(PlainEventSource::Uds.to_string(), "uds");
2766        assert_eq!(PlainEventSource::Stdin.to_string(), "stdin");
2767        assert_eq!(PlainEventSource::Webhook.to_string(), "webhook");
2768        assert_eq!(PlainEventSource::Rpc.to_string(), "rpc");
2769    }
2770
2771    // === Regression: event_address in CommsRuntimeConfig ===
2772
2773    #[test]
2774    fn test_comms_config_event_address_toml_roundtrip() {
2775        let toml_str = r#"
2776mode = "tcp"
2777address = "127.0.0.1:4200"
2778auth = "none"
2779require_peer_auth = false
2780event_address = "127.0.0.1:4201"
2781"#;
2782        let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
2783        assert_eq!(parsed.event_address.as_deref(), Some("127.0.0.1:4201"));
2784        assert_eq!(parsed.auth, CommsAuthMode::Open);
2785        assert!(!parsed.require_peer_auth);
2786    }
2787
2788    #[test]
2789    fn test_comms_config_event_address_defaults_none() {
2790        let config = CommsRuntimeConfig::default();
2791        assert!(config.event_address.is_none());
2792    }
2793
2794    // ── CallTimeoutOverride tests ──
2795
2796    #[test]
2797    fn call_timeout_override_default_is_inherit() {
2798        assert_eq!(CallTimeoutOverride::default(), CallTimeoutOverride::Inherit);
2799        assert!(CallTimeoutOverride::default().is_inherit());
2800    }
2801
2802    #[test]
2803    fn call_timeout_override_disabled_is_not_inherit() {
2804        assert!(!CallTimeoutOverride::Disabled.is_inherit());
2805    }
2806
2807    #[test]
2808    fn call_timeout_override_value_is_not_inherit() {
2809        assert!(!CallTimeoutOverride::Value(Duration::from_secs(45)).is_inherit());
2810    }
2811
2812    #[test]
2813    fn call_timeout_override_toml_deserialize_disabled() {
2814        let toml_str = r#"call_timeout = "disabled""#;
2815        #[derive(Deserialize)]
2816        struct Wrapper {
2817            call_timeout: CallTimeoutOverride,
2818        }
2819        let w: Wrapper = toml::from_str(toml_str).unwrap();
2820        assert_eq!(w.call_timeout, CallTimeoutOverride::Disabled);
2821    }
2822
2823    #[test]
2824    fn call_timeout_override_toml_deserialize_duration() {
2825        let toml_str = r#"call_timeout = "45s""#;
2826        #[derive(Deserialize)]
2827        struct Wrapper {
2828            call_timeout: CallTimeoutOverride,
2829        }
2830        let w: Wrapper = toml::from_str(toml_str).unwrap();
2831        assert_eq!(
2832            w.call_timeout,
2833            CallTimeoutOverride::Value(Duration::from_secs(45))
2834        );
2835    }
2836
2837    #[test]
2838    fn call_timeout_override_toml_deserialize_complex_duration() {
2839        let toml_str = r#"call_timeout = "2m 30s""#;
2840        #[derive(Deserialize)]
2841        struct Wrapper {
2842            call_timeout: CallTimeoutOverride,
2843        }
2844        let w: Wrapper = toml::from_str(toml_str).unwrap();
2845        assert_eq!(
2846            w.call_timeout,
2847            CallTimeoutOverride::Value(Duration::from_secs(150))
2848        );
2849    }
2850
2851    #[test]
2852    fn retry_config_default_has_inherit_call_timeout() {
2853        let config = RetryConfig::default();
2854        assert_eq!(config.call_timeout_override, CallTimeoutOverride::Inherit);
2855    }
2856
2857    #[test]
2858    fn retry_config_from_toml_with_call_timeout_value() {
2859        let toml_str = r#"
2860[retry]
2861max_retries = 5
2862call_timeout = "60s"
2863"#;
2864        let config: Config = toml::from_str(toml_str).unwrap();
2865        assert_eq!(config.retry.max_retries, 5);
2866        assert_eq!(
2867            config.retry.call_timeout_override,
2868            CallTimeoutOverride::Value(Duration::from_secs(60))
2869        );
2870    }
2871
2872    #[test]
2873    fn retry_config_from_toml_with_call_timeout_disabled() {
2874        let toml_str = r#"
2875[retry]
2876call_timeout = "disabled"
2877"#;
2878        let config: Config = toml::from_str(toml_str).unwrap();
2879        assert_eq!(
2880            config.retry.call_timeout_override,
2881            CallTimeoutOverride::Disabled
2882        );
2883    }
2884
2885    #[test]
2886    fn retry_config_from_toml_omitted_is_inherit() {
2887        let toml_str = r"
2888[retry]
2889max_retries = 2
2890";
2891        let config: Config = toml::from_str(toml_str).unwrap();
2892        assert_eq!(
2893            config.retry.call_timeout_override,
2894            CallTimeoutOverride::Inherit
2895        );
2896    }
2897
2898    #[test]
2899    fn retry_policy_from_config_with_value_override() {
2900        let config = RetryConfig {
2901            call_timeout_override: CallTimeoutOverride::Value(Duration::from_secs(90)),
2902            ..RetryConfig::default()
2903        };
2904        let policy: crate::retry::RetryPolicy = config.into();
2905        assert_eq!(policy.call_timeout, Some(Duration::from_secs(90)));
2906    }
2907
2908    #[test]
2909    fn retry_policy_from_config_with_disabled_override() {
2910        let config = RetryConfig {
2911            call_timeout_override: CallTimeoutOverride::Disabled,
2912            ..RetryConfig::default()
2913        };
2914        let policy: crate::retry::RetryPolicy = config.into();
2915        // Disabled collapses to None — the agent loop treats None as "no timeout"
2916        assert_eq!(policy.call_timeout, None);
2917    }
2918
2919    #[test]
2920    fn retry_policy_from_config_with_inherit_override() {
2921        let config = RetryConfig {
2922            call_timeout_override: CallTimeoutOverride::Inherit,
2923            ..RetryConfig::default()
2924        };
2925        let policy: crate::retry::RetryPolicy = config.into();
2926        assert_eq!(policy.call_timeout, None);
2927    }
2928
2929    #[test]
2930    fn config_merge_preserves_call_timeout_override() {
2931        let toml_base = r"
2932[retry]
2933max_retries = 2
2934";
2935        let toml_overlay = r#"
2936[retry]
2937call_timeout = "30s"
2938"#;
2939        let mut config: Config = toml::from_str(toml_base).unwrap();
2940        let overlay: Config = toml::from_str(toml_overlay).unwrap();
2941        let overlay_parsed: toml::Value = toml::from_str(toml_overlay).unwrap();
2942        config.merge_retry_from_toml_presence(&overlay_parsed, &overlay.retry);
2943        assert_eq!(config.retry.max_retries, 2); // Not overridden
2944        assert_eq!(
2945            config.retry.call_timeout_override,
2946            CallTimeoutOverride::Value(Duration::from_secs(30))
2947        );
2948    }
2949
2950    // ---- Provider tools config tests ----
2951
2952    #[test]
2953    fn test_provider_tools_defaults_all_enabled() {
2954        let config = Config::default();
2955        assert!(config.provider_tools.anthropic.web_search);
2956        assert!(config.provider_tools.openai.web_search);
2957        assert!(config.provider_tools.gemini.google_search);
2958    }
2959
2960    #[test]
2961    fn test_provider_tools_roundtrip_toml() {
2962        let config = Config::default();
2963        let toml_str = toml::to_string(&config.provider_tools).unwrap();
2964        let parsed: ProviderToolsConfig = toml::from_str(&toml_str).unwrap();
2965        assert_eq!(parsed, config.provider_tools);
2966    }
2967
2968    #[test]
2969    fn test_provider_tools_merge_preserves_when_absent() {
2970        let mut config = Config::default();
2971        config
2972            .merge_toml_str(
2973                r#"[agent]
2974model = "custom-model"
2975"#,
2976            )
2977            .unwrap();
2978        // provider_tools should be untouched
2979        assert!(config.provider_tools.anthropic.web_search);
2980        assert!(config.provider_tools.openai.web_search);
2981        assert!(config.provider_tools.gemini.google_search);
2982    }
2983
2984    #[test]
2985    fn test_provider_tools_merge_overrides_single_provider() {
2986        let mut config = Config::default();
2987        config
2988            .merge_toml_str("[provider_tools.anthropic]\nweb_search = false\n")
2989            .unwrap();
2990        // Only anthropic should be disabled
2991        assert!(!config.provider_tools.anthropic.web_search);
2992        // Others unchanged
2993        assert!(config.provider_tools.openai.web_search);
2994        assert!(config.provider_tools.gemini.google_search);
2995    }
2996
2997    // ---- AgentConfig.provider_tool_defaults serialization test ----
2998
2999    #[test]
3000    fn test_provider_tool_defaults_not_serialized() {
3001        let agent_config = AgentConfig {
3002            provider_tool_defaults: Some(
3003                serde_json::json!({"web_search": {"type": "web_search_20250305"}}),
3004            ),
3005            ..Default::default()
3006        };
3007        let json = serde_json::to_value(&agent_config).unwrap();
3008        assert!(
3009            json.get("provider_tool_defaults").is_none(),
3010            "provider_tool_defaults must not be serialized: {json}"
3011        );
3012    }
3013}