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