Skip to main content

meerkat_core/
config.rs

1//! Configuration for Meerkat
2//!
3//! Supports layered configuration: defaults → file → env (secrets only) → CLI
4
5use crate::connection::RealmConfigSection;
6use crate::mcp_config::McpServerConfig;
7use crate::model_profile::catalog::ModelTier;
8use crate::{
9    budget::BudgetLimits,
10    hooks::{HookCapability, HookExecutionMode, HookId, HookPoint},
11    retry::RetryPolicy,
12    types::{OutputSchema, SecurityMode},
13};
14use schemars::JsonSchema;
15use serde::de::Deserializer;
16use serde::ser::SerializeStruct;
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value};
19use std::collections::{BTreeMap, HashMap};
20use std::path::PathBuf;
21use std::sync::OnceLock;
22use std::time::Duration;
23
24/// Default active session admission capacity for RPC/CLI runtime surfaces.
25pub const DEFAULT_MAX_SESSIONS: usize = 100_000;
26
27/// Complete configuration for Meerkat
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(default)]
30pub struct Config {
31    pub agent: AgentConfig,
32    pub storage: StorageConfig,
33    pub budget: BudgetConfig,
34    pub retry: RetryConfig,
35    pub tools: ToolsConfig,
36    // New schema fields for interface consolidation.
37    pub models: ModelDefaults,
38    /// Top-level per-turn output-token limit.
39    ///
40    /// `None` means "inherit / use the template default"; an explicit value
41    /// (including one equal to the template default) is an override that wins
42    /// over an inherited parent realm value. The operative runtime value is
43    /// resolved at point-of-use via [`Config::resolved_max_tokens`] — keeping
44    /// the field optional is what lets realm inheritance distinguish "unset"
45    /// from "explicitly set to the default" (presence, not a `!= default`
46    /// heuristic).
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub max_tokens: Option<u32>,
49    pub shell: ShellDefaults,
50    pub store: StoreConfig,
51    pub comms: CommsRuntimeConfig,
52    pub compaction: CompactionRuntimeConfig,
53    pub limits: LimitsConfig,
54    pub rest: RestServerConfig,
55    pub hooks: HooksConfig,
56    pub skills: crate::skills_config::SkillsConfig,
57    pub self_hosted: SelfHostedConfig,
58    pub provider_tools: ProviderToolsConfig,
59    pub model_fallback: ModelFallbackConfig,
60    pub presentation: PresentationConfig,
61    /// Realm-scoped connection sets (backend profiles, auth profiles,
62    /// bindings). TOML keys use the singular `[realm.<id>.*]` namespace
63    /// even though the Rust field is plural-adjacent — `#[serde(rename)]`
64    /// bridges the two. See
65    /// `/Users/luka/.claude/plans/yes-make-a-plan-shimmying-bengio.md`.
66    #[serde(rename = "realm", default, skip_serializing_if = "BTreeMap::is_empty")]
67    pub realm: BTreeMap<String, RealmConfigSection>,
68}
69
70impl Default for Config {
71    fn default() -> Self {
72        let agent = AgentConfig::default();
73        Self {
74            agent,
75            storage: StorageConfig::default(),
76            budget: BudgetConfig::default(),
77            retry: RetryConfig::default(),
78            tools: ToolsConfig::default(),
79            models: ModelDefaults::default(),
80            // `None` = use the template/resolved default at point-of-use. Do
81            // NOT materialize the template value here: a concrete default would
82            // make realm inheritance unable to tell "unset" from "explicitly set
83            // to the default". See [`Config::resolved_max_tokens`].
84            max_tokens: None,
85            shell: ShellDefaults::default(),
86            store: StoreConfig::default(),
87            comms: CommsRuntimeConfig::default(),
88            compaction: CompactionRuntimeConfig::default(),
89            limits: LimitsConfig::default(),
90            rest: RestServerConfig::default(),
91            hooks: HooksConfig::default(),
92            skills: crate::skills_config::SkillsConfig::default(),
93            self_hosted: SelfHostedConfig::default(),
94            provider_tools: ProviderToolsConfig::default(),
95            model_fallback: ModelFallbackConfig::default(),
96            presentation: PresentationConfig::default(),
97            realm: BTreeMap::new(),
98        }
99    }
100}
101
102impl Config {
103    /// Active/staged session admission capacity for runtime surfaces.
104    pub fn max_sessions(&self) -> usize {
105        self.limits.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS)
106    }
107
108    /// Build the effective model registry for this config snapshot and an
109    /// explicitly injected model catalog (canonically
110    /// `meerkat_models::canonical()`).
111    pub fn model_registry(
112        &self,
113        catalog: crate::model_profile::ModelCatalog,
114    ) -> Result<crate::ModelRegistry, ConfigError> {
115        crate::ModelRegistry::from_config(self, catalog)
116    }
117
118    /// Return the config template as TOML.
119    pub fn template_toml() -> &'static str {
120        CONFIG_TEMPLATE_TOML
121    }
122
123    /// Parse the config template into a Config value.
124    pub fn template() -> Result<Self, ConfigError> {
125        toml::from_str(CONFIG_TEMPLATE_TOML).map_err(ConfigError::Parse)
126    }
127}
128
129// File-system dependent methods — not available on wasm32.
130#[cfg(not(target_arch = "wasm32"))]
131impl Config {
132    /// Load configuration from all sources with proper layering
133    /// Order: defaults → project config OR global config → env vars (secrets only)
134    /// → CLI (CLI applied separately)
135    pub async fn load() -> Result<Self, ConfigError> {
136        let cwd = std::env::current_dir()?;
137        let home = dirs::home_dir();
138        Self::load_from_with_env(&cwd, home.as_deref(), |key| std::env::var(key).ok()).await
139    }
140
141    /// Load config like [`Config::load`], but with explicit start directory, home directory,
142    /// and environment variable provider.
143    ///
144    /// This exists primarily to make tests deterministic without mutating the process-wide
145    /// environment (which is unsafe in multi-threaded programs on Unix).
146    #[doc(hidden)]
147    pub async fn load_from_with_env<F>(
148        start_dir: &std::path::Path,
149        home_dir: Option<&std::path::Path>,
150        env: F,
151    ) -> Result<Self, ConfigError>
152    where
153        F: FnMut(&str) -> Option<String>,
154    {
155        let mut config = Self::default();
156
157        // 1. Load project config (if exists). Local config replaces global.
158        if let Some(path) = Self::find_project_config_from(start_dir).await {
159            config.merge_file(&path).await?;
160        } else if let Some(path) = home_dir.map(|home| home.join(".rkat/config.toml"))
161            && tokio::fs::try_exists(&path).await.unwrap_or(false)
162        {
163            config.merge_file(&path).await?;
164        }
165
166        // 2. Apply environment variable overrides (secrets only)
167        config.apply_env_overrides_from(env)?;
168
169        Ok(config)
170    }
171
172    /// Load config like [`Config::load`], but with explicit start directory and home directory.
173    #[doc(hidden)]
174    pub async fn load_from(
175        start_dir: &std::path::Path,
176        home_dir: Option<&std::path::Path>,
177    ) -> Result<Self, ConfigError> {
178        Self::load_from_with_env(start_dir, home_dir, |key| std::env::var(key).ok()).await
179    }
180
181    /// Load only hook configuration with explicit global -> project layering.
182    ///
183    /// This preserves existing config precedence for non-hook fields while allowing
184    /// deterministic hook registration ordering across scopes.
185    pub async fn load_layered_hooks() -> Result<HooksConfig, ConfigError> {
186        let cwd = std::env::current_dir()?;
187        let home = dirs::home_dir();
188        Self::load_layered_hooks_from(&cwd, home.as_deref()).await
189    }
190
191    /// Load only hook configuration with explicit global -> project layering.
192    pub async fn load_layered_hooks_from(
193        start_dir: &std::path::Path,
194        home_dir: Option<&std::path::Path>,
195    ) -> Result<HooksConfig, ConfigError> {
196        let mut hooks = HooksConfig::default();
197
198        if let Some(global_path) = home_dir.map(|home| home.join(".rkat/config.toml"))
199            && tokio::fs::try_exists(&global_path).await.unwrap_or(false)
200        {
201            let content = tokio::fs::read_to_string(&global_path).await?;
202            let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
203            hooks.append_entries_from(&cfg.hooks);
204        }
205
206        if let Some(project_path) = Self::find_project_config_from(start_dir).await
207            && tokio::fs::try_exists(&project_path).await.unwrap_or(false)
208        {
209            let content = tokio::fs::read_to_string(&project_path).await?;
210            let cfg: Config = toml::from_str(&content).map_err(ConfigError::Parse)?;
211            hooks.append_entries_from(&cfg.hooks);
212        }
213
214        Ok(hooks)
215    }
216}
217
218impl Config {
219    /// Convert config limits into runtime budget limits.
220    pub fn budget_limits(&self) -> BudgetLimits {
221        self.limits.to_budget_limits()
222    }
223
224    /// Get global config path (~/.rkat/config.toml)
225    #[cfg(not(target_arch = "wasm32"))]
226    pub fn global_config_path() -> Option<PathBuf> {
227        dirs::home_dir().map(|h| h.join(".rkat/config.toml"))
228    }
229
230    /// Find project config by walking up directories (.rkat/config.toml)
231    ///
232    /// Only returns a path if both `.rkat/` directory AND `config.toml` exist.
233    /// This allows `.rkat/` to be created for session storage without requiring
234    /// a config file.
235    #[cfg(not(target_arch = "wasm32"))]
236    async fn find_project_config_from(start_dir: &std::path::Path) -> Option<PathBuf> {
237        let mut current = start_dir.to_path_buf();
238        loop {
239            let marker_dir = current.join(".rkat");
240            let config_path = marker_dir.join("config.toml");
241
242            // Only treat as project config if config.toml actually exists
243            let config_exists = tokio::fs::try_exists(&config_path).await.unwrap_or(false);
244            if config_exists {
245                return Some(config_path);
246            }
247
248            if !current.pop() {
249                return None;
250            }
251        }
252    }
253
254    /// Merge configuration from a TOML file
255    #[cfg(not(target_arch = "wasm32"))]
256    pub async fn merge_file(&mut self, path: &PathBuf) -> Result<(), ConfigError> {
257        let content = tokio::fs::read_to_string(path).await?;
258        self.merge_toml_str(&content)
259    }
260
261    /// Merge configuration from a TOML string.
262    pub fn merge_toml_str(&mut self, content: &str) -> Result<(), ConfigError> {
263        let file_config: Config = toml::from_str(content).map_err(ConfigError::Parse)?;
264        let tools_layer = file_config.tools.clone();
265        let retry_layer = file_config.retry.clone();
266        let self_hosted_layer = file_config.self_hosted.clone();
267        let provider_tools_layer = file_config.provider_tools.clone();
268        // Merge (file values override defaults)
269        self.merge(file_config);
270        let parsed: toml::Value = toml::from_str(content).map_err(ConfigError::Parse)?;
271        self.merge_tools_from_toml_presence(&parsed, &tools_layer);
272        self.merge_retry_from_toml_presence(&parsed, &retry_layer);
273        self.merge_self_hosted_from_toml_presence(&parsed, &self_hosted_layer);
274        self.merge_provider_tools_from_toml_presence(&parsed, &provider_tools_layer);
275        Ok(())
276    }
277
278    /// Merge another config into this one.
279    ///
280    /// Merge semantics are field-specific:
281    /// - Scalar/option fields: last non-default wins.
282    /// - Structured sections (`provider`, `providers`, `store`, `comms`, `compaction`, etc.):
283    ///   whole-section replace when incoming value is non-default.
284    /// - Hook entries: append/extend.
285    ///
286    /// JSON merge-patch semantics are handled separately by `ConfigStore::patch`.
287    fn merge(&mut self, other: Config) {
288        // Agent config
289        if other.agent.system_prompt.is_some() {
290            self.agent.system_prompt = other.agent.system_prompt;
291        }
292        if other.agent.tool_instructions.is_some() {
293            self.agent.tool_instructions = other.agent.tool_instructions;
294        }
295        if other.agent.model != AgentConfig::default().model {
296            self.agent.model = other.agent.model;
297        }
298        if other.agent.max_tokens_per_turn.is_some() {
299            self.agent.max_tokens_per_turn = other.agent.max_tokens_per_turn;
300        }
301        if other.agent.extraction_prompt.is_some() {
302            self.agent.extraction_prompt = other.agent.extraction_prompt;
303        }
304
305        // Storage config
306        if other.storage.directory.is_some() {
307            self.storage.directory = other.storage.directory;
308        }
309
310        // Budget config
311        if other.budget.max_tokens.is_some() {
312            self.budget.max_tokens = other.budget.max_tokens;
313        }
314        if other.budget.max_duration.is_some() {
315            self.budget.max_duration = other.budget.max_duration;
316        }
317        if other.budget.max_tool_calls.is_some() {
318            self.budget.max_tool_calls = other.budget.max_tool_calls;
319        }
320        self.merge_retry(&other.retry);
321        self.merge_tools(&other.tools);
322
323        // New schema fields (replace if non-default)
324        // Per-provider union, child-wins: a child overriding its anthropic
325        // default still inherits the parent's openai default. Custom model
326        // registry entries union by id (child overrides the same id).
327        let default_models = ModelDefaults::default();
328        if other.models.anthropic != default_models.anthropic {
329            self.models.anthropic = other.models.anthropic;
330        }
331        if other.models.openai != default_models.openai {
332            self.models.openai = other.models.openai;
333        }
334        if other.models.gemini != default_models.gemini {
335            self.models.gemini = other.models.gemini;
336        }
337        for (id, entry) in other.models.custom {
338            self.models.custom.insert(id, entry);
339        }
340        if other.max_tokens.is_some() {
341            self.max_tokens = other.max_tokens;
342        }
343        if other.shell != ShellDefaults::default() {
344            self.shell = other.shell;
345        }
346        if other.store != StoreConfig::default() {
347            self.store = other.store;
348        }
349        if other.comms != CommsRuntimeConfig::default() {
350            self.comms = other.comms;
351        }
352        if other.compaction != CompactionRuntimeConfig::default() {
353            self.compaction = other.compaction;
354        }
355        // Per-field child-wins so a child tightens one limit while inheriting
356        // the rest of the caps.
357        if other.limits.budget.is_some() {
358            self.limits.budget = other.limits.budget;
359        }
360        if other.limits.max_sessions.is_some() {
361            self.limits.max_sessions = other.limits.max_sessions;
362        }
363        if other.limits.max_duration.is_some() {
364            self.limits.max_duration = other.limits.max_duration;
365        }
366        if other.rest != RestServerConfig::default() {
367            self.rest = other.rest;
368        }
369        if other.hooks != HooksConfig::default() {
370            let default_hooks = HooksConfig::default();
371            if other.hooks.default_timeout_ms != default_hooks.default_timeout_ms {
372                self.hooks.default_timeout_ms = other.hooks.default_timeout_ms;
373            }
374            if other.hooks.payload_max_bytes != default_hooks.payload_max_bytes {
375                self.hooks.payload_max_bytes = other.hooks.payload_max_bytes;
376            }
377            if other.hooks.background_max_concurrency != default_hooks.background_max_concurrency {
378                self.hooks.background_max_concurrency = other.hooks.background_max_concurrency;
379            }
380            self.hooks.entries.extend(other.hooks.entries);
381        }
382        if other.model_fallback.use_catalog_default_chain {
383            self.model_fallback = ModelFallbackConfig::default();
384        } else if other.model_fallback != ModelFallbackConfig::default() {
385            self.model_fallback = other.model_fallback;
386        }
387
388        // Skills: scalar toggles child-wins when non-default; repositories
389        // append parent-first, with a child shadowing a same-named parent
390        // source rather than dropping the rest. No removal of inherited sources.
391        let default_skills = crate::skills_config::SkillsConfig::default();
392        if other.skills.enabled != default_skills.enabled {
393            self.skills.enabled = other.skills.enabled;
394        }
395        if other.skills.max_injection_bytes != default_skills.max_injection_bytes {
396            self.skills.max_injection_bytes = other.skills.max_injection_bytes;
397        }
398        if other.skills.inventory_threshold != default_skills.inventory_threshold {
399            self.skills.inventory_threshold = other.skills.inventory_threshold;
400        }
401        for repo in other.skills.repositories {
402            if let Some(existing) = self
403                .skills
404                .repositories
405                .iter_mut()
406                .find(|existing| existing.name == repo.name)
407            {
408                *existing = repo;
409            } else {
410                self.skills.repositories.push(repo);
411            }
412        }
413
414        // Realm sections: OUTER-KEY union, child-wins. A child's `[realm.X]`
415        // entry overrides/adds at the realm-id level; the backend/auth/binding
416        // sub-maps are NEVER merged across realms, so each section stays intact
417        // and the chain walk resolves a binding only within its owning realm
418        // (MF-11, preserves the no-inherit-for-backend/auth invariant).
419        for (realm_id, section) in other.realm {
420            self.realm.insert(realm_id, section);
421        }
422    }
423
424    fn merge_retry(&mut self, other: &RetryConfig) {
425        let defaults = RetryConfig::default();
426        if other.max_retries != defaults.max_retries {
427            self.retry.max_retries = other.max_retries;
428        }
429        if other.initial_delay != defaults.initial_delay {
430            self.retry.initial_delay = other.initial_delay;
431        }
432        if other.max_delay != defaults.max_delay {
433            self.retry.max_delay = other.max_delay;
434        }
435        if other.multiplier != defaults.multiplier {
436            self.retry.multiplier = other.multiplier;
437        }
438        if other.call_timeout_override != defaults.call_timeout_override {
439            self.retry.call_timeout_override = other.call_timeout_override.clone();
440        }
441    }
442
443    fn merge_tools(&mut self, other: &ToolsConfig) {
444        let defaults = ToolsConfig::default();
445        // Union by server name, child-wins; never remove inherited servers
446        // (an empty child list keeps the parent's set — emptiness != removal,
447        // no tombstones).
448        for server in &other.mcp_servers {
449            if let Some(existing) = self
450                .tools
451                .mcp_servers
452                .iter_mut()
453                .find(|existing| existing.name == server.name)
454            {
455                existing.clone_from(server);
456            } else {
457                self.tools.mcp_servers.push(server.clone());
458            }
459        }
460        if other.default_timeout != defaults.default_timeout {
461            self.tools.default_timeout = other.default_timeout;
462        }
463        if other.tool_timeouts != defaults.tool_timeouts {
464            self.tools.tool_timeouts.clone_from(&other.tool_timeouts);
465        }
466        if other.max_concurrent != defaults.max_concurrent {
467            self.tools.max_concurrent = other.max_concurrent;
468        }
469        if other.builtins_enabled != defaults.builtins_enabled {
470            self.tools.builtins_enabled = other.builtins_enabled;
471        }
472        if other.shell_enabled != defaults.shell_enabled {
473            self.tools.shell_enabled = other.shell_enabled;
474        }
475        if other.comms_enabled != defaults.comms_enabled {
476            self.tools.comms_enabled = other.comms_enabled;
477        }
478        if other.mob_enabled != defaults.mob_enabled {
479            self.tools.mob_enabled = other.mob_enabled;
480        }
481        if other.schedule_enabled != defaults.schedule_enabled {
482            self.tools.schedule_enabled = other.schedule_enabled;
483        }
484        if other.workgraph_enabled != defaults.workgraph_enabled {
485            self.tools.workgraph_enabled = other.workgraph_enabled;
486        }
487    }
488
489    fn merge_tools_from_toml_presence(&mut self, parsed: &toml::Value, layer: &ToolsConfig) {
490        let Some(tools) = parsed.get("tools").and_then(toml::Value::as_table) else {
491            return;
492        };
493        // NOTE: `mcp_servers` is deliberately NOT presence-replaced here. It is a
494        // union-by-name collection (no tombstones) handled by `merge_tools`
495        // (the value-merge); replacing it with only the current layer's servers
496        // would drop inherited parent-realm servers when this helper runs on the
497        // realm-composition path. The union is presence-correct on its own — a
498        // child realm ADDS/overrides servers and can never shed an inherited one.
499        if tools.contains_key("default_timeout") {
500            self.tools.default_timeout = layer.default_timeout;
501        }
502        if tools.contains_key("tool_timeouts") {
503            self.tools.tool_timeouts.clone_from(&layer.tool_timeouts);
504        }
505        if tools.contains_key("max_concurrent") {
506            self.tools.max_concurrent = layer.max_concurrent;
507        }
508        if tools.contains_key("builtins_enabled") {
509            self.tools.builtins_enabled = layer.builtins_enabled;
510        }
511        if tools.contains_key("shell_enabled") {
512            self.tools.shell_enabled = layer.shell_enabled;
513        }
514        if tools.contains_key("comms_enabled") {
515            self.tools.comms_enabled = layer.comms_enabled;
516        }
517        if tools.contains_key("mob_enabled") {
518            self.tools.mob_enabled = layer.mob_enabled;
519        }
520        if tools.contains_key("schedule_enabled") {
521            self.tools.schedule_enabled = layer.schedule_enabled;
522        }
523        if tools.contains_key("workgraph_enabled") {
524            self.tools.workgraph_enabled = layer.workgraph_enabled;
525        }
526    }
527
528    fn merge_retry_from_toml_presence(&mut self, parsed: &toml::Value, layer: &RetryConfig) {
529        let Some(retry) = parsed.get("retry").and_then(toml::Value::as_table) else {
530            return;
531        };
532        if retry.contains_key("max_retries") {
533            self.retry.max_retries = layer.max_retries;
534        }
535        if retry.contains_key("initial_delay") {
536            self.retry.initial_delay = layer.initial_delay;
537        }
538        if retry.contains_key("max_delay") {
539            self.retry.max_delay = layer.max_delay;
540        }
541        if retry.contains_key("multiplier") {
542            self.retry.multiplier = layer.multiplier;
543        }
544        if retry.contains_key("call_timeout") {
545            self.retry.call_timeout_override = layer.call_timeout_override.clone();
546        }
547    }
548
549    fn merge_provider_tools_from_toml_presence(
550        &mut self,
551        parsed: &toml::Value,
552        layer: &ProviderToolsConfig,
553    ) {
554        let Some(pt) = parsed.get("provider_tools").and_then(toml::Value::as_table) else {
555            return;
556        };
557        if let Some(anthropic) = pt.get("anthropic").and_then(toml::Value::as_table)
558            && anthropic.contains_key("web_search")
559        {
560            self.provider_tools.anthropic.web_search = layer.anthropic.web_search;
561        }
562        if let Some(openai) = pt.get("openai").and_then(toml::Value::as_table)
563            && openai.contains_key("web_search")
564        {
565            self.provider_tools.openai.web_search = layer.openai.web_search;
566        }
567        if let Some(gemini) = pt.get("gemini").and_then(toml::Value::as_table)
568            && gemini.contains_key("google_search")
569        {
570            self.provider_tools.gemini.google_search = layer.gemini.google_search;
571        }
572    }
573
574    fn merge_self_hosted_from_toml_presence(
575        &mut self,
576        parsed: &toml::Value,
577        layer: &SelfHostedConfig,
578    ) {
579        let Some(self_hosted) = parsed.get("self_hosted").and_then(toml::Value::as_table) else {
580            return;
581        };
582
583        // Presence-based: a layer adopts `default_model` only when it declares
584        // one, otherwise the inherited explicit default is preserved (it is the
585        // typed self-hosted default owner, not a re-derived key-order artifact).
586        if self_hosted.contains_key("default_model") {
587            self.self_hosted.default_model = layer.default_model.clone();
588        }
589
590        if let Some(servers) = self_hosted.get("servers").and_then(toml::Value::as_table) {
591            if servers.is_empty() {
592                self.self_hosted.servers.clear();
593                self.self_hosted.models.clear();
594            } else {
595                let mut merged_servers = self.self_hosted.servers.clone();
596                for (server_id, server_value) in servers {
597                    let Some(server_table) = server_value.as_table() else {
598                        continue;
599                    };
600                    let mut merged = self
601                        .self_hosted
602                        .servers
603                        .get(server_id)
604                        .cloned()
605                        .unwrap_or_default();
606                    let Some(server_layer) = layer.servers.get(server_id) else {
607                        continue;
608                    };
609                    if server_table.contains_key("transport") {
610                        merged.transport = server_layer.transport;
611                    }
612                    if server_table.contains_key("base_url") {
613                        merged.base_url = server_layer.base_url.clone();
614                    }
615                    if server_table.contains_key("api_style") {
616                        merged.api_style = server_layer.api_style;
617                    }
618                    merged_servers.insert(server_id.clone(), merged);
619                }
620                self.self_hosted.servers = merged_servers;
621            }
622        }
623
624        if let Some(models) = self_hosted.get("models").and_then(toml::Value::as_table) {
625            if models.is_empty() {
626                self.self_hosted.models.clear();
627            } else {
628                let mut merged_models = self.self_hosted.models.clone();
629                for (model_id, model_value) in models {
630                    let Some(model_table) = model_value.as_table() else {
631                        continue;
632                    };
633                    let mut merged = self
634                        .self_hosted
635                        .models
636                        .get(model_id)
637                        .cloned()
638                        .unwrap_or_default();
639                    let Some(model_layer) = layer.models.get(model_id) else {
640                        continue;
641                    };
642                    if model_table.contains_key("server") {
643                        merged.server = model_layer.server.clone();
644                    }
645                    if model_table.contains_key("remote_model") {
646                        merged.remote_model = model_layer.remote_model.clone();
647                    }
648                    if model_table.contains_key("display_name") {
649                        merged.display_name = model_layer.display_name.clone();
650                    }
651                    if model_table.contains_key("family") {
652                        merged.family = model_layer.family.clone();
653                    }
654                    if model_table.contains_key("tier") {
655                        merged.tier = model_layer.tier;
656                    }
657                    if model_table.contains_key("context_window") {
658                        merged.context_window = model_layer.context_window;
659                    }
660                    if model_table.contains_key("max_output_tokens") {
661                        merged.max_output_tokens = model_layer.max_output_tokens;
662                    }
663                    if model_table.contains_key("vision") {
664                        merged.vision = model_layer.vision;
665                    }
666                    if model_table.contains_key("image_tool_results") {
667                        merged.image_tool_results = model_layer.image_tool_results;
668                    }
669                    if model_table.contains_key("inline_video") {
670                        merged.inline_video = model_layer.inline_video;
671                    }
672                    if model_table.contains_key("supports_temperature") {
673                        merged.supports_temperature = model_layer.supports_temperature;
674                    }
675                    if model_table.contains_key("supports_thinking") {
676                        merged.supports_thinking = model_layer.supports_thinking;
677                    }
678                    if model_table.contains_key("supports_reasoning") {
679                        merged.supports_reasoning = model_layer.supports_reasoning;
680                    }
681                    if model_table.contains_key("supports_web_search") {
682                        merged.supports_web_search = model_layer.supports_web_search;
683                    }
684                    if model_table.contains_key("call_timeout_secs") {
685                        merged.call_timeout_secs = model_layer.call_timeout_secs;
686                    }
687                    merged_models.insert(model_id.clone(), merged);
688                }
689                self.self_hosted.models = merged_models;
690            }
691        }
692    }
693
694    fn merge_skills_from_toml_presence(
695        &mut self,
696        parsed: &toml::Value,
697        layer: &crate::skills_config::SkillsConfig,
698    ) {
699        let Some(skills) = parsed.get("skills").and_then(toml::Value::as_table) else {
700            return;
701        };
702        // Presence-based scalar toggles: a child realm wins even when it sets a
703        // scalar back to its struct default (e.g. re-enabling `skills.enabled`
704        // that a parent disabled). The `!= default` value-merge in `Config::merge`
705        // cannot see a value-equals-default override; the explicit raw key can.
706        if skills.contains_key("enabled") {
707            self.skills.enabled = layer.enabled;
708        }
709        if skills.contains_key("max_injection_bytes") {
710            self.skills.max_injection_bytes = layer.max_injection_bytes;
711        }
712        if skills.contains_key("inventory_threshold") {
713            self.skills.inventory_threshold = layer.inventory_threshold;
714        }
715    }
716
717    /// Apply environment variable overrides
718    pub fn apply_env_overrides(&mut self) -> Result<(), ConfigError> {
719        self.apply_env_overrides_from(|key| std::env::var(key).ok())
720    }
721
722    /// Apply environment variable overrides (secrets only) using an explicit env provider.
723    ///
724    /// This exists primarily to make tests deterministic without mutating the process-wide
725    /// environment (which is unsafe in multi-threaded programs on Unix).
726    #[doc(hidden)]
727    pub fn apply_env_overrides_from<F>(&mut self, _env: F) -> Result<(), ConfigError>
728    where
729        F: FnMut(&str) -> Option<String>,
730    {
731        // Plan §6.9 deleted the legacy `config.provider = ProviderConfig::X
732        // { api_key }` path. Env-var-based credentials are now read at
733        // resolve time through `ResolverEnvironment::env_lookup` applied
734        // to `CredentialSourceSpec::Env` inside the provider runtime
735        // registry — there is no longer a mutable in-memory field on
736        // `Config` that env vars write into. This method is retained as
737        // a no-op so callers compile through 0.6.0; it will be deleted
738        // once surfaces drop the apply_env_overrides call entirely.
739        Ok(())
740    }
741
742    /// Apply CLI argument overrides.
743    ///
744    /// - Explicit CLI flags override scalar runtime knobs.
745    /// - `override_config` applies RFC 7396 JSON merge-patch semantics.
746    #[cfg(not(target_arch = "wasm32"))]
747    pub fn apply_cli_overrides(&mut self, cli: CliOverrides) {
748        if let Some(model) = cli.model {
749            self.agent.model = model;
750        }
751        if let Some(tokens) = cli.max_tokens {
752            self.budget.max_tokens = Some(tokens);
753        }
754        if let Some(duration) = cli.max_duration {
755            self.budget.max_duration = Some(duration);
756        }
757        if let Some(calls) = cli.max_tool_calls {
758            self.budget.max_tool_calls = Some(calls);
759        }
760        // Apply JSON merge patch override if present
761        if let Some(delta) = cli.override_config {
762            let mut value = serde_json::to_value(&self).unwrap_or_default();
763            crate::config_store::merge_patch(&mut value, delta.0);
764            if let Ok(updated) = serde_json::from_value(value) {
765                *self = updated;
766            }
767        }
768    }
769}
770
771impl Config {
772    /// Resolve the operative top-level per-turn output-token limit.
773    ///
774    /// Precedence: explicit `max_tokens` → embedded template `max_tokens` →
775    /// the agent's [`AgentConfig::resolved_max_tokens_per_turn`]. Keeping the
776    /// field `Option` (rather than materializing a default) is what lets realm
777    /// inheritance distinguish an unset child (inherit the parent) from a child
778    /// that explicitly pins the default (override the parent).
779    pub fn resolved_max_tokens(&self) -> u32 {
780        self.max_tokens
781            .or_else(|| template_defaults().max_tokens)
782            .filter(|value| *value > 0)
783            .unwrap_or_else(|| self.agent.resolved_max_tokens_per_turn())
784    }
785
786    /// Validate configuration invariants.
787    ///
788    /// Called after loading and after persisting. Checks:
789    /// - No `"*"` wildcards in allowlists
790    /// - Every concrete provider key is present and non-empty
791    /// - Resolved defaults are valid
792    /// - The effective model registry (custom + self-hosted entries merged
793    ///   over the injected catalog) constructs without conflicts
794    pub fn validate(&self, catalog: crate::model_profile::ModelCatalog) -> Result<(), ConfigError> {
795        if self.max_tokens == Some(0) {
796            return Err(ConfigError::Validation(
797                "max_tokens must be greater than 0 when set".to_string(),
798            ));
799        }
800        if self.agent.max_tokens_per_turn == Some(0) {
801            return Err(ConfigError::Validation(
802                "agent.max_tokens_per_turn must be greater than 0 when set".to_string(),
803            ));
804        }
805        if self.budget.max_tokens == Some(0) {
806            return Err(ConfigError::Validation(
807                "budget.max_tokens must be greater than 0 when set".to_string(),
808            ));
809        }
810        if self.limits.budget == Some(0) {
811            return Err(ConfigError::Validation(
812                "limits.budget must be greater than 0 when set".to_string(),
813            ));
814        }
815        if self.limits.max_sessions == Some(0) {
816            return Err(ConfigError::Validation(
817                "limits.max_sessions must be greater than 0 when set".to_string(),
818            ));
819        }
820        if self.compaction.auto_compact_threshold == 0 {
821            return Err(ConfigError::Validation(
822                "compaction.auto_compact_threshold must be greater than 0".to_string(),
823            ));
824        }
825        if self.compaction.recent_turn_budget == 0 {
826            return Err(ConfigError::Validation(
827                "compaction.recent_turn_budget must be greater than 0".to_string(),
828            ));
829        }
830        if self.compaction.max_summary_tokens == 0 {
831            return Err(ConfigError::Validation(
832                "compaction.max_summary_tokens must be greater than 0".to_string(),
833            ));
834        }
835        if self.compaction.min_turns_between_compactions == 0 {
836            return Err(ConfigError::Validation(
837                "compaction.min_turns_between_compactions must be greater than 0".to_string(),
838            ));
839        }
840
841        // Plan §6.9 deleted the per-provider config enum block, so
842        // there is no longer a nominal conflict between the legacy
843        // inline api_key/base_url fields and the shared
844        // `config.providers.{base_urls,api_keys}` maps. Those maps stay
845        // (scheduled for deletion in §6.10) and are consumed directly.
846
847        crate::model_registry::ModelRegistry::from_config(self, catalog)?;
848
849        Ok(())
850    }
851}
852
853/// CLI argument overrides
854#[derive(Debug, Clone, Default)]
855pub struct CliOverrides {
856    pub model: Option<String>,
857    pub max_tokens: Option<u64>,
858    pub max_duration: Option<Duration>,
859    pub max_tool_calls: Option<usize>,
860    /// Arbitrary configuration overrides via JSON merge patch
861    pub override_config: Option<ConfigDelta>,
862}
863
864/// Canonical default for the structured-output validation retry budget.
865///
866/// This is the single owner of the default value. Surfaces (RPC/REST/MCP) and
867/// build options carry `Option<u32>` intent (`None` = inherit) and resolve
868/// against this owner at the `AgentFactory` seam — they must not define their
869/// own default constant.
870pub fn default_structured_output_retries() -> u32 {
871    2
872}
873
874/// Default maximum number of agent-loop turns before a forced stop.
875///
876/// Canonical owner of the turn-cap default: the agent loop reads this when
877/// [`AgentConfig::max_turns`] is `None` rather than inventing a literal at the
878/// run-loop seam.
879pub const DEFAULT_MAX_TURNS: u32 = 100;
880
881/// Final fallback for the per-turn output-token limit when neither the config
882/// nor the embedded template provides one. Materialized at point-of-use only;
883/// the config fields stay `Option` so realm inheritance keeps presence
884/// semantics (see [`Config::max_tokens`] / [`AgentConfig::max_tokens_per_turn`]).
885pub const DEFAULT_MAX_TOKENS_PER_TURN: u32 = 16384;
886
887/// Agent behavior configuration
888#[derive(Debug, Clone, Serialize, Deserialize)]
889#[serde(default)]
890pub struct AgentConfig {
891    /// System prompt to prepend
892    pub system_prompt: Option<String>,
893    /// Path to system prompt file (alternative to inline system_prompt)
894    pub system_prompt_file: Option<PathBuf>,
895    /// Optional tool usage instructions appended to system prompt
896    pub tool_instructions: Option<String>,
897    /// Model identifier (provider-specific)
898    pub model: String,
899    /// Maximum tokens to generate per turn.
900    ///
901    /// `None` means "inherit / use the template default"; an explicit value is
902    /// an override that wins over an inherited parent realm value. Resolve the
903    /// operative value via [`AgentConfig::resolved_max_tokens_per_turn`].
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub max_tokens_per_turn: Option<u32>,
906    /// Temperature for sampling
907    pub temperature: Option<f32>,
908    /// Warning threshold for budget (0.0-1.0)
909    pub budget_warning_threshold: f32,
910    /// Maximum turns before forced stop
911    pub max_turns: Option<u32>,
912    /// Typed provider parameter carrier (explicit overrides + build-derived
913    /// provider-native tool defaults).
914    ///
915    /// Parsed FAIL-CLOSED at config ingress: a malformed or unknown-keyed
916    /// provider-params shape is rejected when the config is read, never
917    /// deferred to the first LLM call. The explicit `params` half is
918    /// persisted; `tool_defaults` is `#[serde(skip)]` inside the carrier and
919    /// re-derived on every build (including resume) from
920    /// `Config.provider_tools` + `ModelProfile.supports_web_search`, so config
921    /// changes (e.g. disabling web search) take effect immediately on resumed
922    /// sessions. The per-turn effective params come from the carrier's typed
923    /// field-wise merge ([`ProviderParamsCarrier::effective_params`]).
924    ///
925    /// [`ProviderParamsCarrier::effective_params`]: crate::lifecycle::run_primitive::ProviderParamsCarrier::effective_params
926    #[serde(
927        default,
928        skip_serializing_if = "crate::lifecycle::run_primitive::ProviderParamsCarrier::serializes_empty"
929    )]
930    pub provider_params: crate::lifecycle::run_primitive::ProviderParamsCarrier,
931    /// Output schema for structured output extraction.
932    ///
933    /// When set, the agent will perform an extraction turn after completing
934    /// the agentic work, forcing the LLM to output validated JSON. The main
935    /// response text remains the committed agentic output; extraction populates
936    /// structured output on success or extraction error details on failure.
937    #[serde(default, skip_serializing_if = "Option::is_none")]
938    pub output_schema: Option<OutputSchema>,
939    /// Maximum retries for structured output validation failures.
940    #[serde(default = "default_structured_output_retries")]
941    pub structured_output_retries: u32,
942    /// Custom prompt for the structured output extraction turn.
943    ///
944    /// When `output_schema` is set, this prompt is sent as a user message
945    /// after the agentic loop to elicit schema-valid JSON. Defaults to a
946    /// built-in prompt if `None`.
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub extraction_prompt: Option<String>,
949}
950
951impl Default for AgentConfig {
952    fn default() -> Self {
953        let defaults = template_defaults();
954        let agent = defaults.agent.as_ref();
955        Self {
956            system_prompt: None,
957            system_prompt_file: None,
958            tool_instructions: None,
959            model: agent.and_then(|cfg| cfg.model.clone()).unwrap_or_default(),
960            // `None` = inherit / resolve the template default at point-of-use
961            // (see [`AgentConfig::resolved_max_tokens_per_turn`]); never
962            // materialize a concrete default into the field (presence semantics).
963            max_tokens_per_turn: None,
964            temperature: None,
965            budget_warning_threshold: agent
966                .and_then(|cfg| cfg.budget_warning_threshold)
967                .unwrap_or_default(),
968            max_turns: None,
969            provider_params: crate::lifecycle::run_primitive::ProviderParamsCarrier::default(),
970            output_schema: None,
971            structured_output_retries: default_structured_output_retries(),
972            extraction_prompt: None,
973        }
974    }
975}
976
977impl AgentConfig {
978    /// Resolve the operative per-turn output-token limit.
979    ///
980    /// Precedence: explicit field → embedded template → [`DEFAULT_MAX_TOKENS_PER_TURN`].
981    /// A `Some(0)` is rejected by [`Config::validate`], so it never reaches here.
982    pub fn resolved_max_tokens_per_turn(&self) -> u32 {
983        self.max_tokens_per_turn
984            .or_else(|| {
985                template_defaults()
986                    .agent
987                    .as_ref()
988                    .and_then(|cfg| cfg.max_tokens_per_turn)
989            })
990            .filter(|value| *value > 0)
991            .unwrap_or(DEFAULT_MAX_TOKENS_PER_TURN)
992    }
993}
994
995/// Presentation defaults for surface-owned renderings.
996// Default is all-empty: empty per-provider defaults mean "inherit from the
997// injected model catalog". Core owns no provider data, so it cannot (and
998// must not) pre-populate these; surfaces resolve empty entries through the
999// catalog's per-provider defaults and global default at build time.
1000#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1001#[serde(default)]
1002pub struct PresentationConfig {
1003    pub html: HtmlPresentationConfig,
1004}
1005
1006/// Defaults for CLI HTML output mode.
1007///
1008/// This config controls template selection only. It does not enable HTML output
1009/// for ordinary runs; surfaces must request the HTML presentation explicitly.
1010#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1011#[serde(default)]
1012pub struct HtmlPresentationConfig {
1013    pub default_template: String,
1014    pub templates: BTreeMap<String, HtmlTemplateConfig>,
1015}
1016
1017impl Default for HtmlPresentationConfig {
1018    fn default() -> Self {
1019        Self {
1020            default_template: "polished".to_string(),
1021            templates: BTreeMap::new(),
1022        }
1023    }
1024}
1025
1026/// User-defined HTML output template.
1027#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1028#[serde(default)]
1029pub struct HtmlTemplateConfig {
1030    pub path: Option<PathBuf>,
1031    pub body: Option<String>,
1032}
1033
1034/// Model defaults by provider, plus user-defined custom model registry
1035/// entries (`[models.<id>]` tables).
1036#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1037#[serde(default)]
1038pub struct ModelDefaults {
1039    pub anthropic: String,
1040    pub openai: String,
1041    pub gemini: String,
1042    /// User-defined model registry entries keyed by model id.
1043    ///
1044    /// TOML form: `[models.<id>]` tables alongside the per-provider default
1045    /// strings. Each entry is merged into the effective [`crate::ModelRegistry`]
1046    /// by `ModelRegistry::from_config`, the single owner that feeds provider
1047    /// inference, compaction scaling, capability gates, and call timeouts.
1048    #[serde(flatten)]
1049    pub custom: BTreeMap<String, CustomModelConfig>,
1050}
1051
1052/// User-defined model registry entry (`[models.<id>]` in `config.toml` or a
1053/// mob definition).
1054///
1055/// Declares an uncatalogued model that an API provider serves so a single
1056/// definition feeds provider inference, compaction scaling, capability gates,
1057/// and call timeouts through the effective [`crate::ModelRegistry`].
1058///
1059/// Capability flags are conservative when omitted: an undeclared capability is
1060/// treated as absent rather than guessed from the model name.
1061#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1062#[serde(deny_unknown_fields)]
1063pub struct CustomModelConfig {
1064    /// Typed provider that serves this model. Parsed fail-closed at config
1065    /// ingress: unknown provider names reject the entry.
1066    pub provider: crate::Provider,
1067    /// Human-readable display name. Defaults to the model id.
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub display_name: Option<String>,
1070    /// Model context window in tokens (drives compaction scaling).
1071    #[serde(default, skip_serializing_if = "Option::is_none")]
1072    pub context_window: Option<u32>,
1073    /// Maximum output tokens per call.
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub max_output_tokens: Option<u32>,
1076    /// Whether the model accepts image input. Conservative default: false.
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub vision: Option<bool>,
1079    /// Whether the model supports provider-native web search tools.
1080    /// Conservative default: false.
1081    #[serde(default, skip_serializing_if = "Option::is_none")]
1082    pub web_search: Option<bool>,
1083    /// Default call timeout in seconds.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub call_timeout_secs: Option<u64>,
1086}
1087
1088/// Default shell program
1089pub const DEFAULT_SHELL_PROGRAM: &str = "nu";
1090/// Default shell timeout in seconds
1091pub const DEFAULT_SHELL_TIMEOUT_SECS: u64 = 30;
1092/// Default shell security mode
1093pub const DEFAULT_SHELL_SECURITY_MODE: SecurityMode = SecurityMode::Unrestricted;
1094
1095/// Shell defaults configured at the config layer.
1096#[derive(Debug, Clone, Serialize, PartialEq)]
1097#[serde(default)]
1098pub struct ShellDefaults {
1099    pub program: String,
1100    pub timeout_secs: u64,
1101    /// Security mode: unrestricted, allow_list, deny_list
1102    pub security_mode: SecurityMode,
1103    /// Patterns for allow/deny lists (glob format)
1104    pub security_patterns: Vec<String>,
1105}
1106
1107#[derive(Debug, Deserialize, Default)]
1108#[serde(default)]
1109struct ShellDefaultsSeed {
1110    program: Option<String>,
1111    timeout_secs: Option<u64>,
1112    security_mode: Option<SecurityMode>,
1113    security_patterns: Option<Vec<String>>,
1114    #[serde(alias = "allowlist")]
1115    allowlist: Option<Vec<String>>,
1116}
1117
1118impl<'de> Deserialize<'de> for ShellDefaults {
1119    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1120    where
1121        D: Deserializer<'de>,
1122    {
1123        let seed = ShellDefaultsSeed::deserialize(deserializer)?;
1124        let mut defaults = ShellDefaults::default();
1125
1126        if let Some(program) = seed.program {
1127            defaults.program = program;
1128        }
1129        if let Some(timeout_secs) = seed.timeout_secs {
1130            defaults.timeout_secs = timeout_secs;
1131        }
1132        if let Some(security_mode) = seed.security_mode {
1133            defaults.security_mode = security_mode;
1134        }
1135        if let Some(security_patterns) = seed.security_patterns.or(seed.allowlist.clone()) {
1136            defaults.security_patterns = security_patterns;
1137        }
1138
1139        if seed.security_mode.is_none() && seed.allowlist.is_some() {
1140            defaults.security_mode = SecurityMode::AllowList;
1141        }
1142
1143        Ok(defaults)
1144    }
1145}
1146
1147impl Default for ShellDefaults {
1148    fn default() -> Self {
1149        let defaults = template_defaults();
1150        let shell = defaults.shell.as_ref();
1151        Self {
1152            program: shell
1153                .and_then(|cfg| cfg.program.clone())
1154                .unwrap_or_else(|| DEFAULT_SHELL_PROGRAM.to_string()),
1155            timeout_secs: shell
1156                .and_then(|cfg| cfg.timeout_secs)
1157                .unwrap_or(DEFAULT_SHELL_TIMEOUT_SECS),
1158            security_mode: shell
1159                .and_then(|cfg| cfg.security_mode)
1160                .unwrap_or(DEFAULT_SHELL_SECURITY_MODE),
1161            security_patterns: shell
1162                .and_then(|cfg| cfg.security_patterns.clone())
1163                .unwrap_or_default(),
1164        }
1165    }
1166}
1167
1168const CONFIG_TEMPLATE_TOML: &str = include_str!("config_template.toml");
1169
1170#[derive(Debug, Deserialize)]
1171struct TemplateAgentDefaults {
1172    model: Option<String>,
1173    max_tokens_per_turn: Option<u32>,
1174    budget_warning_threshold: Option<f32>,
1175}
1176
1177#[derive(Debug, Deserialize)]
1178struct TemplateShellDefaults {
1179    program: Option<String>,
1180    timeout_secs: Option<u64>,
1181    security_mode: Option<SecurityMode>,
1182    security_patterns: Option<Vec<String>>,
1183}
1184
1185#[derive(Debug, Deserialize)]
1186struct TemplateDefaults {
1187    agent: Option<TemplateAgentDefaults>,
1188    shell: Option<TemplateShellDefaults>,
1189    max_tokens: Option<u32>,
1190}
1191
1192impl TemplateDefaults {
1193    fn empty() -> Self {
1194        Self {
1195            agent: None,
1196            shell: None,
1197            max_tokens: None,
1198        }
1199    }
1200}
1201
1202fn template_defaults() -> &'static TemplateDefaults {
1203    static DEFAULTS: OnceLock<TemplateDefaults> = OnceLock::new();
1204    DEFAULTS.get_or_init(|| {
1205        toml::from_str(CONFIG_TEMPLATE_TOML).unwrap_or_else(|e| {
1206            // This is an embedded resource, so it really shouldn't fail in production
1207            // but we avoid panic! to satisfy lint policies.
1208            tracing::error!("Invalid config template defaults: {}", e);
1209            TemplateDefaults::empty()
1210        })
1211    })
1212}
1213
1214/// Paths for session and task persistence.
1215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1216#[serde(default)]
1217pub struct StoreConfig {
1218    pub sessions_path: Option<PathBuf>,
1219    pub tasks_path: Option<PathBuf>,
1220    /// Directory for the realm-scoped session database (server surfaces).
1221    pub database_dir: Option<PathBuf>,
1222}
1223
1224// Plan §6.10 deleted `ProviderSettings` entirely. Per-provider api keys
1225// and base URLs now live exclusively in `[realm.<id>]` config blocks
1226// (programmatically constructed via `RealmConfigSection::from_inline_api_keys`
1227// for surfaces that receive credentials at bootstrap).
1228
1229#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1230#[serde(rename_all = "snake_case")]
1231pub enum SelfHostedTransport {
1232    #[default]
1233    #[serde(alias = "openai_compatible")]
1234    OpenAiCompatible,
1235}
1236
1237#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Default)]
1238#[serde(rename_all = "snake_case")]
1239pub enum SelfHostedApiStyle {
1240    Responses,
1241    #[default]
1242    ChatCompletions,
1243}
1244
1245/// Self-hosted server connection target.
1246///
1247/// Carries connection/transport facts only. Credentials have exactly one
1248/// owner: the realm auth profile selected via `auth_binding` or a realm
1249/// default binding. The legacy `bearer_token` / `bearer_token_env` fields are
1250/// gone and `deny_unknown_fields` rejects them (and any other unknown key) at
1251/// config parse with a typed [`ConfigError::Parse`] instead of silently
1252/// tolerating dead credential material in config files.
1253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1254#[serde(default, deny_unknown_fields)]
1255pub struct SelfHostedServerConfig {
1256    pub transport: SelfHostedTransport,
1257    pub base_url: String,
1258    pub api_style: SelfHostedApiStyle,
1259}
1260
1261impl Default for SelfHostedServerConfig {
1262    fn default() -> Self {
1263        Self {
1264            transport: SelfHostedTransport::OpenAiCompatible,
1265            base_url: String::new(),
1266            api_style: SelfHostedApiStyle::ChatCompletions,
1267        }
1268    }
1269}
1270
1271#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
1272#[serde(default)]
1273pub struct SelfHostedModelConfig {
1274    pub server: String,
1275    pub remote_model: String,
1276    pub display_name: String,
1277    pub family: String,
1278    pub tier: ModelTier,
1279    #[serde(default, skip_serializing_if = "Option::is_none")]
1280    pub context_window: Option<u32>,
1281    #[serde(default, skip_serializing_if = "Option::is_none")]
1282    pub max_output_tokens: Option<u32>,
1283    pub vision: bool,
1284    pub image_tool_results: bool,
1285    pub inline_video: bool,
1286    pub supports_temperature: bool,
1287    pub supports_thinking: bool,
1288    pub supports_reasoning: bool,
1289    /// Whether the model supports provider-native web search tools.
1290    #[serde(default)]
1291    pub supports_web_search: bool,
1292    #[serde(default, skip_serializing_if = "Option::is_none")]
1293    pub call_timeout_secs: Option<u64>,
1294}
1295
1296impl Default for SelfHostedModelConfig {
1297    fn default() -> Self {
1298        Self {
1299            server: String::new(),
1300            remote_model: String::new(),
1301            display_name: String::new(),
1302            family: String::new(),
1303            tier: ModelTier::Supported,
1304            context_window: None,
1305            max_output_tokens: None,
1306            vision: false,
1307            image_tool_results: false,
1308            inline_video: false,
1309            supports_temperature: true,
1310            supports_thinking: false,
1311            supports_reasoning: false,
1312            supports_web_search: false,
1313            call_timeout_secs: None,
1314        }
1315    }
1316}
1317
1318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default, JsonSchema)]
1319#[serde(default)]
1320pub struct SelfHostedConfig {
1321    pub servers: BTreeMap<String, SelfHostedServerConfig>,
1322    pub models: BTreeMap<String, SelfHostedModelConfig>,
1323    /// The model id (key into [`Self::models`]) that is the canonical
1324    /// self-hosted default.
1325    ///
1326    /// The config owns its own default — it is a declared choice, never a
1327    /// `BTreeMap` key-order artifact. When more than one model is configured
1328    /// this MUST be set (and reference a configured model), otherwise the
1329    /// registry fails closed. With exactly one configured model the default is
1330    /// unambiguous and may be omitted.
1331    #[serde(skip_serializing_if = "Option::is_none")]
1332    pub default_model: Option<String>,
1333}
1334
1335// ---------------------------------------------------------------------------
1336// Provider-native tool defaults
1337// ---------------------------------------------------------------------------
1338
1339/// Per-provider defaults for provider-native tools (web search, etc.).
1340///
1341/// These defaults are resolved at factory build time and injected as the
1342/// non-persisted `tool_defaults` half of `AgentConfig.provider_params`
1343/// (a typed `ProviderTag`). The `enabled` flags
1344/// here control whether the factory injects tool config for models whose
1345/// `ModelProfile.supports_web_search` is `true`.
1346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1347#[serde(default)]
1348pub struct ProviderToolsConfig {
1349    pub anthropic: AnthropicProviderToolsConfig,
1350    pub openai: OpenAiProviderToolsConfig,
1351    pub gemini: GeminiProviderToolsConfig,
1352}
1353
1354/// Ordered model failover policy used when a turn reaches a recoverable LLM
1355/// failure boundary.
1356///
1357/// Empty `chain` means "use the catalog-owned default fallback chain" at the
1358/// factory seam. Core keeps this provider-data-free; the `meerkat` facade
1359/// resolves catalog defaults and builds concrete clients.
1360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1361#[serde(default)]
1362pub struct ModelFallbackConfig {
1363    /// Enables runtime model failover for factory-built agents.
1364    pub enabled: bool,
1365    /// When true in a higher-precedence layer, restore the catalog-owned
1366    /// default chain (`enabled = true`, empty `chain`) over an inherited
1367    /// disabled/custom fallback policy.
1368    #[serde(default, skip_serializing_if = "is_false")]
1369    pub use_catalog_default_chain: bool,
1370    /// Ordered operator-provided backup targets.
1371    #[serde(skip_serializing_if = "Vec::is_empty")]
1372    pub chain: Vec<ModelFallbackTarget>,
1373}
1374
1375impl Default for ModelFallbackConfig {
1376    fn default() -> Self {
1377        Self {
1378            enabled: true,
1379            use_catalog_default_chain: false,
1380            chain: Vec::new(),
1381        }
1382    }
1383}
1384
1385/// One configured model fallback target.
1386#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
1387#[serde(deny_unknown_fields)]
1388pub struct ModelFallbackTarget {
1389    /// Model id, resolved through the effective [`crate::ModelRegistry`].
1390    pub model: String,
1391    /// Optional typed provider override. When omitted, registry ownership of
1392    /// `model` supplies the provider.
1393    #[serde(default, skip_serializing_if = "Option::is_none")]
1394    pub provider: Option<crate::Provider>,
1395    /// Optional realm-scoped auth binding for this target. When omitted, the
1396    /// provider's default binding is resolved by the existing provider-runtime
1397    /// registry.
1398    #[serde(default, skip_serializing_if = "Option::is_none")]
1399    pub auth_binding: Option<crate::AuthBindingRef>,
1400}
1401
1402/// Anthropic provider-native tool defaults.
1403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1404#[serde(default)]
1405pub struct AnthropicProviderToolsConfig {
1406    /// Enable web search for Anthropic models that support it.
1407    pub web_search: bool,
1408}
1409
1410impl Default for AnthropicProviderToolsConfig {
1411    fn default() -> Self {
1412        Self { web_search: true }
1413    }
1414}
1415
1416/// OpenAI provider-native tool defaults.
1417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1418#[serde(default)]
1419pub struct OpenAiProviderToolsConfig {
1420    /// Enable web search for OpenAI models that support it.
1421    pub web_search: bool,
1422}
1423
1424impl Default for OpenAiProviderToolsConfig {
1425    fn default() -> Self {
1426        Self { web_search: true }
1427    }
1428}
1429
1430/// Gemini provider-native tool defaults.
1431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1432#[serde(default)]
1433pub struct GeminiProviderToolsConfig {
1434    /// Enable Google Search for Gemini models that support it.
1435    pub google_search: bool,
1436}
1437
1438impl Default for GeminiProviderToolsConfig {
1439    fn default() -> Self {
1440        Self {
1441            google_search: true,
1442        }
1443    }
1444}
1445
1446/// Runtime limits configured at the config layer.
1447#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1448#[serde(default)]
1449pub struct LimitsConfig {
1450    pub budget: Option<u64>,
1451    /// Active session admission capacity. Persisted history does not consume
1452    /// this limit. RPC observes runtime config updates dynamically; REST/MCP
1453    /// process-local services apply changes on service restart.
1454    pub max_sessions: Option<usize>,
1455    #[serde(with = "optional_duration_serde")]
1456    pub max_duration: Option<Duration>,
1457}
1458
1459impl LimitsConfig {
1460    pub fn to_budget_limits(&self) -> BudgetLimits {
1461        BudgetLimits {
1462            max_tokens: self.budget,
1463            max_duration: self.max_duration,
1464            max_tool_calls: None,
1465        }
1466    }
1467}
1468
1469/// REST server configuration sourced from config.
1470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1471#[serde(default)]
1472pub struct RestServerConfig {
1473    pub host: String,
1474    pub port: u16,
1475}
1476
1477impl Default for RestServerConfig {
1478    fn default() -> Self {
1479        Self {
1480            host: "127.0.0.1".to_string(),
1481            port: 8080,
1482        }
1483    }
1484}
1485
1486/// Authentication mode for comms listeners.
1487///
1488/// `Open` (default) accepts plain JSON messages over TCP/UDS — no cryptographic
1489/// verification. Suitable for local agent-to-agent communication where the OS
1490/// provides process isolation. Non-loopback binding with `Open` auth is a hard
1491/// error unless explicitly overridden.
1492///
1493/// `Ed25519` requires signed CBOR envelopes and a trusted peers list. Use for
1494/// multi-machine or untrusted network communication.
1495#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
1496#[serde(rename_all = "snake_case")]
1497pub enum CommsAuthMode {
1498    #[default]
1499    #[serde(rename = "none")]
1500    Open,
1501    Ed25519,
1502}
1503
1504/// Source identifier for plain (unauthenticated) events.
1505///
1506/// Used for diagnostics and prompt formatting — tells the agent where
1507/// an external event originated from.
1508#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1509#[serde(rename_all = "snake_case")]
1510pub enum PlainEventSource {
1511    Tcp,
1512    Uds,
1513    Stdin,
1514    Webhook,
1515    Rpc,
1516}
1517
1518impl std::fmt::Display for PlainEventSource {
1519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1520        match self {
1521            Self::Tcp => write!(f, "tcp"),
1522            Self::Uds => write!(f, "uds"),
1523            Self::Stdin => write!(f, "stdin"),
1524            Self::Webhook => write!(f, "webhook"),
1525            Self::Rpc => write!(f, "rpc"),
1526        }
1527    }
1528}
1529
1530/// Runtime comms configuration (portable across interfaces).
1531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1532#[serde(default)]
1533pub struct CommsRuntimeConfig {
1534    pub mode: CommsRuntimeMode,
1535    /// Address for agent-to-agent (signed) listener.
1536    pub address: Option<String>,
1537    /// Peer address advertised to other signed-comms participants.
1538    pub advertise_address: Option<String>,
1539    pub auth: CommsAuthMode,
1540    /// Whether inter-agent peer traffic requires cryptographic validation.
1541    ///
1542    /// - `true`: messages require signatures and trusted-sender checks.
1543    /// - `false`: signatures are not verified and outgoing peer envelopes are
1544    ///   sent without signing.
1545    pub require_peer_auth: bool,
1546    /// Address for the plain-text external event listener.
1547    /// Only active when `auth = "none"`. Accepts newline-delimited JSON or text.
1548    pub event_address: Option<String>,
1549    /// Runtime-only enrollment password for initial comms pairing.
1550    ///
1551    /// This is deliberately skipped by serde so CLI/env/file overrides do not
1552    /// persist a bootstrap secret into realm config or session metadata.
1553    #[serde(skip)]
1554    pub pairing_password: Option<String>,
1555}
1556
1557impl Default for CommsRuntimeConfig {
1558    fn default() -> Self {
1559        Self {
1560            mode: CommsRuntimeMode::Inproc,
1561            address: None,
1562            advertise_address: None,
1563            auth: CommsAuthMode::default(),
1564            require_peer_auth: true,
1565            event_address: None,
1566            pairing_password: None,
1567        }
1568    }
1569}
1570
1571/// Runtime compaction configuration (portable across interfaces).
1572///
1573/// This config is serialized/deserialized in realm config and mapped to
1574/// `meerkat_core::CompactionConfig` when wiring the session compactor.
1575#[derive(Debug, Clone, PartialEq)]
1576pub struct CompactionRuntimeConfig {
1577    /// Trigger compaction when input tokens for a turn reach this threshold.
1578    pub auto_compact_threshold: u64,
1579    /// Whether `auto_compact_threshold` was explicitly present in config.
1580    ///
1581    /// This preserves the difference between inheriting Meerkat's default
1582    /// threshold and deliberately pinning that same numeric value.
1583    pub auto_compact_threshold_explicit: bool,
1584    /// Number of recent complete turns to retain after compaction.
1585    pub recent_turn_budget: usize,
1586    /// Maximum tokens for the compaction summary response.
1587    pub max_summary_tokens: u32,
1588    /// Minimum session-scoped pre-LLM boundaries between compactions.
1589    pub min_turns_between_compactions: u32,
1590}
1591
1592impl Default for CompactionRuntimeConfig {
1593    fn default() -> Self {
1594        Self {
1595            auto_compact_threshold: 100_000,
1596            auto_compact_threshold_explicit: false,
1597            recent_turn_budget: 4,
1598            max_summary_tokens: 4096,
1599            min_turns_between_compactions: 3,
1600        }
1601    }
1602}
1603
1604impl Serialize for CompactionRuntimeConfig {
1605    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1606    where
1607        S: serde::Serializer,
1608    {
1609        let defaults = Self::default();
1610        let include_threshold = self.auto_compact_threshold_explicit
1611            || self.auto_compact_threshold != defaults.auto_compact_threshold;
1612        let mut len = 3;
1613        if include_threshold {
1614            len += 1;
1615        }
1616
1617        let mut state = serializer.serialize_struct("CompactionRuntimeConfig", len)?;
1618        if include_threshold {
1619            state.serialize_field("auto_compact_threshold", &self.auto_compact_threshold)?;
1620        }
1621        state.serialize_field("recent_turn_budget", &self.recent_turn_budget)?;
1622        state.serialize_field("max_summary_tokens", &self.max_summary_tokens)?;
1623        state.serialize_field(
1624            "min_turns_between_compactions",
1625            &self.min_turns_between_compactions,
1626        )?;
1627        state.end()
1628    }
1629}
1630
1631impl<'de> Deserialize<'de> for CompactionRuntimeConfig {
1632    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1633    where
1634        D: Deserializer<'de>,
1635    {
1636        #[derive(Deserialize)]
1637        struct Seed {
1638            auto_compact_threshold: Option<u64>,
1639            recent_turn_budget: Option<usize>,
1640            max_summary_tokens: Option<u32>,
1641            min_turns_between_compactions: Option<u32>,
1642        }
1643
1644        let seed = Seed::deserialize(deserializer)?;
1645        let defaults = Self::default();
1646        Ok(Self {
1647            auto_compact_threshold: seed
1648                .auto_compact_threshold
1649                .unwrap_or(defaults.auto_compact_threshold),
1650            auto_compact_threshold_explicit: seed.auto_compact_threshold.is_some(),
1651            recent_turn_budget: seed
1652                .recent_turn_budget
1653                .unwrap_or(defaults.recent_turn_budget),
1654            max_summary_tokens: seed
1655                .max_summary_tokens
1656                .unwrap_or(defaults.max_summary_tokens),
1657            min_turns_between_compactions: seed
1658                .min_turns_between_compactions
1659                .unwrap_or(defaults.min_turns_between_compactions),
1660        })
1661    }
1662}
1663
1664impl From<CompactionRuntimeConfig> for crate::CompactionConfig {
1665    fn from(value: CompactionRuntimeConfig) -> Self {
1666        Self {
1667            auto_compact_threshold: value.auto_compact_threshold,
1668            recent_turn_budget: value.recent_turn_budget,
1669            max_summary_tokens: value.max_summary_tokens,
1670            min_turns_between_compactions: value.min_turns_between_compactions,
1671        }
1672    }
1673}
1674
1675/// Transport mode for comms runtime.
1676#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
1677#[serde(rename_all = "snake_case")]
1678pub enum CommsRuntimeMode {
1679    #[default]
1680    Inproc,
1681    Tcp,
1682    Uds,
1683}
1684
1685// Plan §6.9 deleted the `ProviderConfig` enum entirely. Per-provider
1686// credentials now live in:
1687//   - env vars (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY,
1688//     RKAT_*-prefixed overrides) — the default path, consumed through
1689//     `RealmConnectionSet::synthesize_env_default(provider)`.
1690//   - `[realm.<id>]` blocks in TOML — explicit realm/binding declarations,
1691//     consumed through `ProviderRuntimeRegistry::resolve`.
1692//
1693// The legacy `config.provider = ProviderConfig::{Anthropic,OpenAI,Gemini}`
1694// block and the legacy shared settings maps are
1695// removed in the same 0.6.0 cutover (plan §6.10).
1696
1697/// Storage configuration
1698#[derive(Debug, Clone, Serialize, Deserialize)]
1699#[serde(default)]
1700pub struct StorageConfig {
1701    /// Directory for file-based storage
1702    pub directory: Option<PathBuf>,
1703}
1704
1705impl Default for StorageConfig {
1706    fn default() -> Self {
1707        Self {
1708            directory: data_dir().map(|d| d.join("sessions")),
1709        }
1710    }
1711}
1712
1713/// Budget configuration
1714#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1715#[serde(default)]
1716pub struct BudgetConfig {
1717    /// Maximum tokens to consume
1718    pub max_tokens: Option<u64>,
1719    /// Maximum duration
1720    #[serde(with = "optional_duration_serde")]
1721    pub max_duration: Option<Duration>,
1722    /// Maximum tool calls
1723    pub max_tool_calls: Option<usize>,
1724}
1725
1726/// Tri-state override for per-call LLM timeout policy.
1727///
1728/// This type exists because `Option<Duration>` cannot distinguish between
1729/// "inherit lower-layer/profile default" and "explicitly disable timeout."
1730/// Build and config seams use this type; the resolved effective policy on
1731/// `RetryPolicy` collapses to `Option<Duration>`.
1732///
1733/// TOML representation:
1734/// - omitted key => `Inherit`
1735/// - `call_timeout = "disabled"` => `Disabled`
1736/// - `call_timeout = "45s"` => `Value(45s)`
1737#[derive(Debug, Clone, Default, PartialEq, Eq)]
1738#[non_exhaustive]
1739pub enum CallTimeoutOverride {
1740    /// Inherit the lower-layer or profile-derived default.
1741    #[default]
1742    Inherit,
1743    /// Explicitly disable call timeout (no timeout applied regardless of profile).
1744    Disabled,
1745    /// Explicitly set the call timeout to this duration.
1746    Value(Duration),
1747}
1748
1749impl Serialize for CallTimeoutOverride {
1750    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1751        match self {
1752            // Inherit is the default; serialized as absence (skip_serializing_if handles this)
1753            Self::Inherit => serializer.serialize_none(),
1754            Self::Disabled => serializer.serialize_str("disabled"),
1755            Self::Value(d) => {
1756                let s = humantime_serde::re::humantime::format_duration(*d).to_string();
1757                serializer.serialize_str(&s)
1758            }
1759        }
1760    }
1761}
1762
1763impl<'de> Deserialize<'de> for CallTimeoutOverride {
1764    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1765        let s = String::deserialize(deserializer)?;
1766        if s == "disabled" {
1767            return Ok(Self::Disabled);
1768        }
1769        let d: Duration = s
1770            .parse::<humantime_serde::re::humantime::Duration>()
1771            .map(|ht| *ht)
1772            .map_err(serde::de::Error::custom)?;
1773        Ok(Self::Value(d))
1774    }
1775}
1776
1777impl CallTimeoutOverride {
1778    /// Returns `true` when this override is `Inherit` (the default / absent state).
1779    pub fn is_inherit(&self) -> bool {
1780        matches!(self, Self::Inherit)
1781    }
1782}
1783
1784/// Typed per-request system-prompt policy.
1785///
1786/// **Dogma §10:** inherit, set, and disable are three distinct facts that an
1787/// overloaded `Option<String>` cannot express — `None` collapses "inherit
1788/// config/AGENTS/default" together with "no opinion", and there is no way to
1789/// say "suppress every prompt source". This mirrors the `Inherit`/`Set`/`Clear`
1790/// shape of `TurnMetadataOverride` and the `Inherit`/`Disabled`/`Value` shape
1791/// of [`CallTimeoutOverride`].
1792///
1793/// This is the canonical type at every boundary that carries the decision:
1794/// the wire `CreateSessionRequest`/`CoreCreateParams.system_prompt` field, the
1795/// persisted `SessionBuildState.system_prompt` field, and
1796/// `AgentBuildConfig.system_prompt`. Its serde implementation below IS the
1797/// wire/persisted representation — there is no adapter pair:
1798///
1799/// - absent / `null` ⇔ `Inherit` (lossless with the retired `Option<String>`
1800///   `None` shape)
1801/// - JSON/TOML string ⇔ `Set` (lossless with the retired `Some(prompt)` shape)
1802/// - `{"action": "disable"}` ⇔ `Disable`
1803#[derive(Debug, Clone, Default, PartialEq, Eq)]
1804pub enum SystemPromptOverride {
1805    /// No per-request opinion: fall through to the config-file override, the
1806    /// config inline override, then the default prompt + AGENTS.md files.
1807    #[default]
1808    Inherit,
1809    /// Explicit per-request prompt. Wins outright, skipping the config and
1810    /// AGENTS.md sources (dispatcher/tool/extra sections are still appended).
1811    Set(String),
1812    /// Explicitly suppress *every* prompt source: no config override, no
1813    /// AGENTS.md, no default prompt. Only the appended sections
1814    /// (extra/config-tool/dispatcher) remain.
1815    Disable,
1816}
1817
1818impl SystemPromptOverride {
1819    /// Returns `true` when this override is `Inherit` (the default / absent
1820    /// state). Used by `skip_serializing_if` at field sites.
1821    #[must_use]
1822    pub fn is_inherit(&self) -> bool {
1823        matches!(self, Self::Inherit)
1824    }
1825
1826    /// Whether this override carries an explicit per-request decision (either a
1827    /// `Set` prompt or an explicit `Disable`). Used to decide whether the
1828    /// prompt must be (re)assembled even for a resumed session.
1829    #[must_use]
1830    pub fn is_explicit(&self) -> bool {
1831        !matches!(self, Self::Inherit)
1832    }
1833
1834    /// The explicit per-request prompt text, if any (`Set` only).
1835    #[must_use]
1836    pub fn as_set_prompt(&self) -> Option<&str> {
1837        match self {
1838            Self::Set(prompt) => Some(prompt.as_str()),
1839            Self::Inherit | Self::Disable => None,
1840        }
1841    }
1842}
1843
1844const SYSTEM_PROMPT_OVERRIDE_DISABLE_ACTION: &str = "disable";
1845
1846impl Serialize for SystemPromptOverride {
1847    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
1848        match self {
1849            // Inherit is the default; field sites pair this with
1850            // `skip_serializing_if = "SystemPromptOverride::is_inherit"` so it
1851            // is normally omitted entirely.
1852            Self::Inherit => serializer.serialize_none(),
1853            Self::Set(prompt) => serializer.serialize_str(prompt),
1854            Self::Disable => {
1855                use serde::ser::SerializeMap;
1856                let mut map = serializer.serialize_map(Some(1))?;
1857                map.serialize_entry("action", SYSTEM_PROMPT_OVERRIDE_DISABLE_ACTION)?;
1858                map.end()
1859            }
1860        }
1861    }
1862}
1863
1864impl<'de> Deserialize<'de> for SystemPromptOverride {
1865    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
1866        struct SystemPromptOverrideVisitor;
1867
1868        impl<'de> serde::de::Visitor<'de> for SystemPromptOverrideVisitor {
1869            type Value = SystemPromptOverride;
1870
1871            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1872                formatter.write_str(
1873                    "a system prompt string, null (inherit), or {\"action\": \"disable\"}",
1874                )
1875            }
1876
1877            fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
1878                Ok(SystemPromptOverride::Set(value.to_owned()))
1879            }
1880
1881            fn visit_string<E: serde::de::Error>(self, value: String) -> Result<Self::Value, E> {
1882                Ok(SystemPromptOverride::Set(value))
1883            }
1884
1885            fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1886                Ok(SystemPromptOverride::Inherit)
1887            }
1888
1889            fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
1890                Ok(SystemPromptOverride::Inherit)
1891            }
1892
1893            fn visit_some<D2: Deserializer<'de>>(
1894                self,
1895                deserializer: D2,
1896            ) -> Result<Self::Value, D2::Error> {
1897                deserializer.deserialize_any(SystemPromptOverrideVisitor)
1898            }
1899
1900            fn visit_map<A: serde::de::MapAccess<'de>>(
1901                self,
1902                mut map: A,
1903            ) -> Result<Self::Value, A::Error> {
1904                let mut action: Option<String> = None;
1905                while let Some(key) = map.next_key::<String>()? {
1906                    match key.as_str() {
1907                        "action" => {
1908                            if action.is_some() {
1909                                return Err(serde::de::Error::duplicate_field("action"));
1910                            }
1911                            action = Some(map.next_value()?);
1912                        }
1913                        other => {
1914                            return Err(serde::de::Error::unknown_field(other, &["action"]));
1915                        }
1916                    }
1917                }
1918                let action = action.ok_or_else(|| serde::de::Error::missing_field("action"))?;
1919                if action == SYSTEM_PROMPT_OVERRIDE_DISABLE_ACTION {
1920                    Ok(SystemPromptOverride::Disable)
1921                } else {
1922                    Err(serde::de::Error::custom(format!(
1923                        "unknown system_prompt override action '{action}' (expected \"disable\")"
1924                    )))
1925                }
1926            }
1927        }
1928
1929        deserializer.deserialize_any(SystemPromptOverrideVisitor)
1930    }
1931}
1932
1933impl JsonSchema for SystemPromptOverride {
1934    fn schema_name() -> std::borrow::Cow<'static, str> {
1935        "SystemPromptOverride".into()
1936    }
1937
1938    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1939        schemars::json_schema!({
1940            "description": "Per-request system-prompt policy: omit/null to inherit, a string to set an explicit prompt, or {\"action\": \"disable\"} to suppress every prompt source.",
1941            "anyOf": [
1942                { "type": "null", "description": "Inherit the configured/default prompt sources." },
1943                { "type": "string", "description": "Explicit per-request system prompt." },
1944                {
1945                    "type": "object",
1946                    "properties": { "action": { "const": "disable" } },
1947                    "required": ["action"],
1948                    "additionalProperties": false,
1949                    "description": "Suppress every prompt source."
1950                }
1951            ]
1952        })
1953    }
1954}
1955
1956/// Retry configuration
1957#[derive(Debug, Clone, Serialize, Deserialize)]
1958#[serde(default)]
1959pub struct RetryConfig {
1960    /// Maximum number of retry attempts
1961    pub max_retries: u32,
1962    /// Initial delay before first retry (supports humantime format: "500ms", "1s")
1963    #[serde(with = "humantime_serde")]
1964    pub initial_delay: Duration,
1965    /// Maximum delay between retries (supports humantime format: "30s", "1m")
1966    #[serde(with = "humantime_serde")]
1967    pub max_delay: Duration,
1968    /// Multiplier for exponential backoff
1969    pub multiplier: f64,
1970    /// Tri-state call-timeout override for per-LLM-call timeout policy.
1971    ///
1972    /// - `Inherit` (default / omitted): defer to profile-derived or build-override default
1973    /// - `Disabled`: explicitly disable call timeout
1974    /// - `Value(duration)`: explicitly set call timeout
1975    #[serde(
1976        default,
1977        rename = "call_timeout",
1978        skip_serializing_if = "CallTimeoutOverride::is_inherit"
1979    )]
1980    pub call_timeout_override: CallTimeoutOverride,
1981}
1982
1983impl Default for RetryConfig {
1984    fn default() -> Self {
1985        let policy = RetryPolicy::default();
1986        Self {
1987            max_retries: policy.max_retries,
1988            initial_delay: policy.initial_delay,
1989            max_delay: policy.max_delay,
1990            multiplier: policy.multiplier,
1991            call_timeout_override: CallTimeoutOverride::default(),
1992        }
1993    }
1994}
1995
1996impl From<RetryConfig> for RetryPolicy {
1997    fn from(config: RetryConfig) -> Self {
1998        // Resolve explicit config override into effective call_timeout.
1999        // `Inherit` means None here — the agent loop resolves profile defaults later.
2000        let call_timeout = match config.call_timeout_override {
2001            CallTimeoutOverride::Inherit => None,
2002            CallTimeoutOverride::Disabled => None,
2003            CallTimeoutOverride::Value(d) => Some(d),
2004        };
2005        RetryPolicy {
2006            max_retries: config.max_retries,
2007            initial_delay: config.initial_delay,
2008            max_delay: config.max_delay,
2009            multiplier: config.multiplier,
2010            call_timeout,
2011        }
2012    }
2013}
2014
2015/// Tools configuration
2016#[derive(Debug, Clone, Serialize, Deserialize)]
2017#[serde(default)]
2018pub struct ToolsConfig {
2019    /// MCP server configurations
2020    #[serde(default)]
2021    pub mcp_servers: Vec<McpServerConfig>,
2022    /// Default timeout for tool execution (supports humantime format: "30s", "1m")
2023    #[serde(with = "humantime_serde")]
2024    pub default_timeout: Duration,
2025    /// Per-tool timeout overrides (supports humantime format: "30s", "1m")
2026    #[serde(default)]
2027    pub tool_timeouts: HashMap<String, Duration>,
2028    /// Maximum concurrent tool executions
2029    pub max_concurrent: usize,
2030    /// Builtin tools enabled
2031    pub builtins_enabled: bool,
2032    /// Shell tools enabled
2033    pub shell_enabled: bool,
2034    /// Comms tools enabled
2035    pub comms_enabled: bool,
2036    /// Mob (multi-agent orchestration) tools enabled
2037    pub mob_enabled: bool,
2038    /// Scheduler tools enabled
2039    pub schedule_enabled: bool,
2040    /// WorkGraph tools enabled
2041    pub workgraph_enabled: bool,
2042}
2043
2044impl Default for ToolsConfig {
2045    fn default() -> Self {
2046        Self {
2047            mcp_servers: Vec::new(),
2048            default_timeout: Duration::from_secs(600),
2049            tool_timeouts: HashMap::new(),
2050            max_concurrent: 10,
2051            builtins_enabled: false,
2052            shell_enabled: false,
2053            comms_enabled: false,
2054            mob_enabled: false,
2055            schedule_enabled: true,
2056            workgraph_enabled: false,
2057        }
2058    }
2059}
2060
2061/// Hook configuration root.
2062#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
2063#[serde(default)]
2064pub struct HooksConfig {
2065    /// Default timeout for one hook invocation.
2066    pub default_timeout_ms: u64,
2067    /// Max serialized invocation payload size.
2068    pub payload_max_bytes: usize,
2069    /// Max number of background hook tasks allowed to run concurrently.
2070    pub background_max_concurrency: usize,
2071    /// Ordered hook registrations.
2072    #[serde(default)]
2073    pub entries: Vec<HookEntryConfig>,
2074}
2075
2076impl HooksConfig {
2077    pub fn append_entries_from(&mut self, other: &HooksConfig) {
2078        self.entries.extend(other.entries.clone());
2079    }
2080}
2081
2082impl Default for HooksConfig {
2083    fn default() -> Self {
2084        Self {
2085            default_timeout_ms: 5_000,
2086            payload_max_bytes: 128 * 1024,
2087            background_max_concurrency: 32,
2088            entries: Vec::new(),
2089        }
2090    }
2091}
2092
2093/// Run-scoped hook overrides.
2094#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
2095#[serde(default)]
2096pub struct HookRunOverrides {
2097    /// Additional hooks appended after layered config entries.
2098    #[serde(default)]
2099    pub entries: Vec<HookEntryConfig>,
2100    /// Hook ids disabled for this run.
2101    #[serde(default)]
2102    pub disable: Vec<HookId>,
2103}
2104
2105/// One hook registration entry.
2106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
2107#[serde(default)]
2108pub struct HookEntryConfig {
2109    pub id: HookId,
2110    pub enabled: bool,
2111    pub point: HookPoint,
2112    pub mode: HookExecutionMode,
2113    pub capability: HookCapability,
2114    pub priority: i32,
2115    #[serde(default, skip_serializing_if = "Option::is_none")]
2116    pub timeout_ms: Option<u64>,
2117    pub runtime: HookAdapterConfig,
2118}
2119
2120impl Default for HookEntryConfig {
2121    fn default() -> Self {
2122        Self {
2123            id: HookId::new("hook"),
2124            enabled: true,
2125            point: HookPoint::TurnBoundary,
2126            mode: HookExecutionMode::Foreground,
2127            capability: HookCapability::Observe,
2128            priority: 100,
2129            timeout_ms: None,
2130            runtime: HookAdapterConfig::in_process("noop"),
2131        }
2132    }
2133}
2134
2135/// Stable identity for an in-process hook handler registered with the runtime.
2136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
2137#[serde(transparent)]
2138pub struct HookInProcessHandlerId(String);
2139
2140impl HookInProcessHandlerId {
2141    pub fn new(value: impl Into<String>) -> Self {
2142        Self(value.into())
2143    }
2144
2145    pub fn as_str(&self) -> &str {
2146        &self.0
2147    }
2148}
2149
2150impl std::fmt::Display for HookInProcessHandlerId {
2151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2152        self.0.fmt(f)
2153    }
2154}
2155
2156impl From<&str> for HookInProcessHandlerId {
2157    fn from(value: &str) -> Self {
2158        Self::new(value)
2159    }
2160}
2161
2162impl From<String> for HookInProcessHandlerId {
2163    fn from(value: String) -> Self {
2164        Self::new(value)
2165    }
2166}
2167
2168impl Default for HookInProcessHandlerId {
2169    fn default() -> Self {
2170        Self::new("noop")
2171    }
2172}
2173
2174/// Typed payload for [`HookRuntimeKind::InProcess`].
2175///
2176/// `name` remains accepted for existing configs, but runtime dispatch uses the
2177/// typed handler id rather than fishing a string out of opaque adapter config.
2178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2179#[serde(default)]
2180pub struct HookInProcessRuntimeConfig {
2181    #[serde(alias = "name")]
2182    pub handler: HookInProcessHandlerId,
2183}
2184
2185impl HookInProcessRuntimeConfig {
2186    pub fn new(handler: impl Into<HookInProcessHandlerId>) -> Self {
2187        Self {
2188            handler: handler.into(),
2189        }
2190    }
2191}
2192
2193impl Default for HookInProcessRuntimeConfig {
2194    fn default() -> Self {
2195        Self::new("noop")
2196    }
2197}
2198
2199/// Discriminant naming the runtime adapter a [`HookAdapterConfig`] selects.
2200///
2201/// This is a pure derivation of the [`HookAdapterConfig`] variant
2202/// ([`HookAdapterConfig::kind`]); it carries no payload and exists only for
2203/// wire/logging labels and for the `(kind, value)` convenience constructor
2204/// [`HookAdapterConfig::from_kind_and_value`].
2205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2206pub enum HookRuntimeKind {
2207    InProcess,
2208    Command,
2209    Http,
2210}
2211
2212impl HookRuntimeKind {
2213    /// Canonical wire/logging string for this runtime kind.
2214    pub fn as_str(&self) -> &'static str {
2215        match self {
2216            Self::InProcess => "in_process",
2217            Self::Command => "command",
2218            Self::Http => "http",
2219        }
2220    }
2221
2222    /// Parse the canonical wire form. Returns `None` for unrecognized strings.
2223    pub fn parse(s: &str) -> Option<Self> {
2224        match s {
2225            "in_process" => Some(Self::InProcess),
2226            "command" => Some(Self::Command),
2227            "http" => Some(Self::Http),
2228            _ => None,
2229        }
2230    }
2231}
2232
2233impl std::fmt::Display for HookRuntimeKind {
2234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2235        f.write_str(self.as_str())
2236    }
2237}
2238
2239/// Typed payload for a [`HookRuntimeKind::Command`] adapter.
2240#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2241pub struct CommandRuntimeConfig {
2242    pub command: String,
2243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2244    pub args: Vec<String>,
2245    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2246    pub env: HashMap<String, String>,
2247}
2248
2249fn default_http_method() -> String {
2250    "POST".to_string()
2251}
2252
2253fn is_false(value: &bool) -> bool {
2254    !*value
2255}
2256
2257/// Typed payload for a [`HookRuntimeKind::Http`] adapter.
2258#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2259pub struct HttpRuntimeConfig {
2260    pub url: String,
2261    #[serde(default = "default_http_method")]
2262    pub method: String,
2263    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
2264    pub headers: HashMap<String, String>,
2265}
2266
2267/// Closed, typed set of hook runtime adapters the engine can dispatch to.
2268///
2269/// This is the single typed owner of "which runtime adapter and its config".
2270/// It is deserialized once at the config-layering boundary; the hook engine
2271/// reads the typed variant directly rather than re-parsing an opaque JSON
2272/// payload on every execution. A malformed command/HTTP payload therefore fails
2273/// closed at config-deserialization time, not at hook-execution time.
2274///
2275/// Wire format is internally tagged on `type` (`in_process`, `command`,
2276/// `http`), with the variant payload flattened alongside the tag, e.g.
2277/// `{"type":"command","command":"sh","args":["-c","..."]}`.
2278#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2279#[serde(tag = "type", rename_all = "snake_case")]
2280pub enum HookAdapterConfig {
2281    InProcess(HookInProcessRuntimeConfig),
2282    Command(CommandRuntimeConfig),
2283    Http(HttpRuntimeConfig),
2284}
2285
2286impl HookAdapterConfig {
2287    /// Build an in-process adapter referencing a registered handler id.
2288    pub fn in_process(handler: impl Into<HookInProcessHandlerId>) -> Self {
2289        Self::InProcess(HookInProcessRuntimeConfig::new(handler))
2290    }
2291
2292    /// Build a command adapter.
2293    pub fn command(
2294        command: impl Into<String>,
2295        args: Vec<String>,
2296        env: HashMap<String, String>,
2297    ) -> Self {
2298        Self::Command(CommandRuntimeConfig {
2299            command: command.into(),
2300            args,
2301            env,
2302        })
2303    }
2304
2305    /// Build an HTTP adapter.
2306    pub fn http(
2307        url: impl Into<String>,
2308        method: impl Into<String>,
2309        headers: HashMap<String, String>,
2310    ) -> Self {
2311        Self::Http(HttpRuntimeConfig {
2312            url: url.into(),
2313            method: method.into(),
2314            headers,
2315        })
2316    }
2317
2318    /// Deserialize a `(kind, flattened-payload)` pair into the typed adapter.
2319    ///
2320    /// The payload is the variant's fields without the `type` tag (e.g.
2321    /// `{"command":"sh"}` for [`HookRuntimeKind::Command`]); a `None`/`Null`
2322    /// payload is treated as an empty object so defaulted fields apply. The
2323    /// payload is validated here, at the config boundary, and fails closed on
2324    /// an unknown/malformed shape.
2325    pub fn from_kind_and_value(
2326        kind: HookRuntimeKind,
2327        config: Option<Value>,
2328    ) -> Result<Self, serde_json::Error> {
2329        let mut obj = match config {
2330            Some(Value::Object(obj)) => obj,
2331            Some(Value::Null) | None => Map::new(),
2332            Some(other) => {
2333                let mut obj = Map::new();
2334                obj.insert("config".to_string(), other);
2335                obj
2336            }
2337        };
2338        obj.insert("type".to_string(), Value::String(kind.as_str().to_string()));
2339        serde_json::from_value(Value::Object(obj))
2340    }
2341
2342    /// Pure-derivation discriminant of this adapter.
2343    pub fn kind(&self) -> HookRuntimeKind {
2344        match self {
2345            Self::InProcess(_) => HookRuntimeKind::InProcess,
2346            Self::Command(_) => HookRuntimeKind::Command,
2347            Self::Http(_) => HookRuntimeKind::Http,
2348        }
2349    }
2350}
2351
2352impl Default for HookAdapterConfig {
2353    fn default() -> Self {
2354        Self::in_process("noop")
2355    }
2356}
2357
2358/// Config scope for persisted settings.
2359#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
2360#[serde(rename_all = "snake_case")]
2361pub enum ConfigScope {
2362    Global,
2363    Project,
2364}
2365
2366/// Config patch payload (merge-patch semantics applied by ConfigStore).
2367#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2368#[serde(transparent)]
2369pub struct ConfigDelta(pub serde_json::Value);
2370
2371/// Configuration errors
2372#[derive(Debug, thiserror::Error)]
2373pub enum ConfigError {
2374    #[error("IO error: {0}")]
2375    Io(#[from] std::io::Error),
2376
2377    #[error("Parse error: {0}")]
2378    Parse(#[from] toml::de::Error),
2379
2380    #[error("TOML serialization error: {0}")]
2381    TomlSerialize(#[from] toml::ser::Error),
2382
2383    #[error("JSON error: {0}")]
2384    Json(#[from] serde_json::Error),
2385
2386    #[error("UTF-8 error: {0}")]
2387    Utf8(#[from] std::string::FromUtf8Error),
2388
2389    #[allow(dead_code)]
2390    #[error("Invalid value for {0}")]
2391    InvalidValue(String),
2392
2393    #[error("Missing required field: {0}")]
2394    MissingField(String),
2395
2396    #[error("Internal error: {0}")]
2397    InternalError(String),
2398
2399    #[error("Validation error: {0}")]
2400    Validation(String),
2401
2402    #[error("realm inheritance chain error: {0}")]
2403    RealmChain(#[from] crate::connection::RealmChainError),
2404}
2405
2406/// Compose the effective flat [`Config`] for `head` by folding the per-realm
2407/// config docs along `head`'s parent chain, root-first / child-wins.
2408///
2409/// `docs` is the set of fetched per-realm Configs keyed by realm id. An absent
2410/// ancestor simply contributes nothing — it is NOT a `Config::default` clobber
2411/// (the caller passes only the docs it actually found; see
2412/// [`crate::config_store::RealmConfigSource`]). [`crate::connection::RealmChain`]
2413/// is the single chain authority (cycle/depth/global/env_default validation),
2414/// so this composition order can never diverge from the connection resolvers'
2415/// chain order. The fold reuses the one [`Config::merge`] engine.
2416pub fn compose_effective_config(
2417    docs: &std::collections::BTreeMap<crate::connection::RealmId, Config>,
2418    raw_docs: &std::collections::BTreeMap<crate::connection::RealmId, toml::Value>,
2419    head: &crate::connection::RealmId,
2420) -> Result<Config, crate::connection::RealmChainError> {
2421    use crate::connection::RealmChain;
2422    // Combined topology: union every fetched doc's realm sections so the chain
2423    // authority sees each member's parent edge regardless of which doc carried
2424    // it. Outer-key only — sub-maps are never merged across realms.
2425    let mut topology = Config::default();
2426    for doc in docs.values() {
2427        for (realm_id, section) in &doc.realm {
2428            topology.realm.insert(realm_id.clone(), section.clone());
2429        }
2430    }
2431    let chain = RealmChain::resolve(&topology, head)?;
2432    // Fold root-first (chain is head-first, so iterate reversed) — the
2433    // most-derived head merges last and wins.
2434    let mut effective = Config::default();
2435    let default_self_hosted = SelfHostedConfig::default();
2436    let default_provider_tools = ProviderToolsConfig::default();
2437    for member in chain.realms().iter().rev() {
2438        if let Some(doc) = docs.get(member) {
2439            effective.merge(doc.clone());
2440            // Presence-aware correction. `Config::merge` uses a `!= default`
2441            // heuristic that cannot distinguish an unset scalar from one
2442            // explicitly set to its struct default — so a child cannot override
2443            // a parent's non-default `tools.*_enabled` toggle (or max_concurrent
2444            // / timeouts / retry field) back to the default. When the doc's raw
2445            // TOML is available (filesystem source), re-apply the same
2446            // presence helpers `apply_toml` uses, so an EXPLICIT child key wins
2447            // even when its value equals the default — honoring the plan's
2448            // `child-wins-scalar` contract. Without raw TOML (e.g. an in-memory
2449            // head supplied to `effective_config_over_head`) this is a no-op and
2450            // the value-merge stands.
2451            // `Config::merge` does NOT carry self_hosted/provider_tools at all,
2452            // so when raw TOML is available the presence helpers are the sole
2453            // composition path for them (and for the tools/retry presence-
2454            // sensitive scalars). They honor an explicit child key even when its
2455            // value equals the struct default — so a child realm can re-enable a
2456            // parent-disabled provider web_search (default true) it inherited.
2457            if let Some(raw) = raw_docs.get(member) {
2458                effective.merge_tools_from_toml_presence(raw, &doc.tools);
2459                effective.merge_retry_from_toml_presence(raw, &doc.retry);
2460                effective.merge_provider_tools_from_toml_presence(raw, &doc.provider_tools);
2461                effective.merge_self_hosted_from_toml_presence(raw, &doc.self_hosted);
2462                effective.merge_skills_from_toml_presence(raw, &doc.skills);
2463            } else {
2464                // No raw TOML for this doc (e.g. a non-filesystem source): fall
2465                // back to a whole-section child-wins swap so self_hosted /
2466                // provider_tools still compose (the value-merge ignores them).
2467                if doc.self_hosted != default_self_hosted {
2468                    effective.self_hosted = doc.self_hosted.clone();
2469                }
2470                if doc.provider_tools != default_provider_tools {
2471                    effective.provider_tools = doc.provider_tools.clone();
2472                }
2473            }
2474        }
2475    }
2476    // NOTE (Finding D, non-blocking per adversarial review): `effective.realm`
2477    // is the outer-key union of every folded doc's sections (via Config::merge),
2478    // so a doc that hand-places a foreign `[realm.X]` (X != its owning realm)
2479    // could shadow the authoritative ancestor section. Reachability is narrow —
2480    // the standard tooling reads `global` from a dedicated home-rooted doc and
2481    // each realm from its own path, never co-locating a foreign section — and a
2482    // strict owner-only rebuild is INCOMPATIBLE with the supported
2483    // single-file-multiple-realms model (one doc carrying `[realm.a]`,
2484    // `[realm.b]`, ... is how the CLI and surfaces resolve a `--realm`/section
2485    // that is not itself a chain member). Tracked, not gated.
2486    Ok(effective)
2487}
2488
2489/// Serde helpers for Option<Duration> with humantime format
2490mod optional_duration_serde {
2491    use serde::{Deserialize, Deserializer, Serialize, Serializer};
2492    use std::time::Duration;
2493
2494    // Signature constrained by serde's `#[serde(with)]` contract:
2495    // `serialize_with` passes `&Option<T>`, not `Option<&T>`.
2496    #[allow(clippy::ref_option)]
2497    pub fn serialize<S>(duration: &Option<Duration>, serializer: S) -> Result<S::Ok, S::Error>
2498    where
2499        S: Serializer,
2500    {
2501        match duration {
2502            Some(d) => {
2503                let s = humantime_serde::re::humantime::format_duration(*d).to_string();
2504                s.serialize(serializer)
2505            }
2506            None => serializer.serialize_none(),
2507        }
2508    }
2509
2510    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
2511    where
2512        D: Deserializer<'de>,
2513    {
2514        use serde::de::Error;
2515
2516        // Try deserializing as string first (humantime format)
2517        let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
2518        match value {
2519            None => Ok(None),
2520            Some(serde_json::Value::String(s)) => {
2521                humantime_serde::re::humantime::parse_duration(&s)
2522                    .map(Some)
2523                    .map_err(|e| D::Error::custom(e.to_string()))
2524            }
2525            Some(serde_json::Value::Number(n)) => {
2526                // Support milliseconds as number for backward compat
2527                let millis = n
2528                    .as_u64()
2529                    .ok_or_else(|| D::Error::custom("invalid number"))?;
2530                Ok(Some(Duration::from_millis(millis)))
2531            }
2532            _ => Err(D::Error::custom("expected string or number for duration")),
2533        }
2534    }
2535}
2536
2537/// Find the project root directory by walking up from `start_dir` looking for `.rkat/`.
2538pub fn find_project_root(start_dir: &std::path::Path) -> Option<PathBuf> {
2539    let mut current = start_dir.to_path_buf();
2540    loop {
2541        if current.join(".rkat").is_dir() {
2542            return Some(current);
2543        }
2544        if !current.pop() {
2545            return None;
2546        }
2547    }
2548}
2549
2550/// Get the data directory for Meerkat.
2551///
2552/// Priority:
2553/// 1. Nearest ancestor containing .rkat/
2554/// 2. User's home directory ~/.rkat/
2555pub fn data_dir() -> Option<PathBuf> {
2556    // 1. Check for project root .rkat
2557    if let Ok(cwd) = std::env::current_dir()
2558        && let Some(root) = find_project_root(&cwd)
2559    {
2560        return Some(root.join(".rkat"));
2561    }
2562
2563    // 2. Fallback to ~/.rkat
2564    dirs::home_dir().map(|h| h.join(".rkat"))
2565}
2566
2567// Stub for home directory resolution
2568pub mod dirs {
2569    use std::path::PathBuf;
2570
2571    pub fn home_dir() -> Option<PathBuf> {
2572        std::env::var_os("HOME").map(PathBuf::from)
2573    }
2574}
2575
2576#[cfg(test)]
2577#[allow(clippy::unwrap_used, clippy::expect_used)]
2578mod tests {
2579    use super::*;
2580    use crate::Provider;
2581
2582    #[test]
2583    fn test_config_default() {
2584        let config = Config::default();
2585        assert!(
2586            config.agent.model.is_empty(),
2587            "core embeds no provider data: the default agent model is empty \
2588             and resolves through the injected catalog at build time"
2589        );
2590        // The field is `None` by default (presence semantics for realm
2591        // inheritance); the operative default resolves to the template's 16384.
2592        assert_eq!(config.agent.max_tokens_per_turn, None);
2593        assert_eq!(config.agent.resolved_max_tokens_per_turn(), 16384);
2594        assert_eq!(config.retry.max_retries, 3);
2595        assert_eq!(config.max_sessions(), DEFAULT_MAX_SESSIONS);
2596    }
2597
2598    #[test]
2599    fn config_template_pins_no_model() {
2600        let config = Config::template().expect("template parses");
2601        assert!(
2602            config.agent.model.is_empty(),
2603            "the core config template must not pin a provider model; defaults \
2604             come from the injected catalog (meerkat-models)"
2605        );
2606        assert!(config.models.anthropic.is_empty());
2607        assert!(config.models.openai.is_empty());
2608        assert!(config.models.gemini.is_empty());
2609    }
2610
2611    #[test]
2612    fn test_limits_max_sessions_configures_runtime_capacity() {
2613        let mut config = Config::default();
2614        config
2615            .merge_toml_str(
2616                r"
2617[limits]
2618max_sessions = 7
2619",
2620            )
2621            .expect("merge max_sessions");
2622        assert_eq!(config.max_sessions(), 7);
2623    }
2624
2625    #[test]
2626    fn test_config_layering() {
2627        // 1. Test defaults
2628        let config = Config::default();
2629        assert!(config.agent.model.is_empty());
2630        assert_eq!(config.budget.max_tokens, None);
2631
2632        // 2. Test env override (secrets only)
2633        {
2634            // Plan §6.9 deleted the `config.provider = ProviderConfig::X`
2635            // mutable sink. apply_env_overrides_from is now a no-op;
2636            // env-var-based credential resolution happens at resolve
2637            // time in the provider-runtime registry. This branch retains
2638            // the call-site shape so the merge precedence test passes.
2639            let env = std::collections::HashMap::from([(
2640                "RKAT_MODEL".to_string(),
2641                "env-model".to_string(),
2642            )]);
2643            let mut config = Config::default();
2644            config
2645                .apply_env_overrides_from(|key| env.get(key).cloned())
2646                .expect("apply env overrides");
2647        }
2648
2649        // 3. Test file merge
2650        let mut config = Config::default();
2651        let file_config = Config {
2652            agent: AgentConfig {
2653                model: "file-model".to_string(),
2654                ..Default::default()
2655            },
2656            ..Default::default()
2657        };
2658        config.merge(file_config);
2659        assert_eq!(config.agent.model, "file-model");
2660
2661        // 4. Test CLI override (highest precedence)
2662        let mut config = Config::default();
2663        config.apply_cli_overrides(CliOverrides {
2664            model: Some("cli-model".to_string()),
2665            max_tokens: Some(50000),
2666            ..Default::default()
2667        });
2668        // CLI should win over defaults
2669        assert_eq!(config.agent.model, "cli-model");
2670        assert_eq!(config.budget.max_tokens, Some(50000));
2671    }
2672
2673    #[test]
2674    fn test_merge_extraction_prompt_survives_layering() {
2675        let mut base = Config::default();
2676        assert!(base.agent.extraction_prompt.is_none());
2677
2678        // Merge from TOML config file
2679        let toml = r#"
2680[agent]
2681extraction_prompt = "Return JSON only."
2682"#;
2683        base.merge_toml_str(toml).expect("merge toml");
2684        assert_eq!(
2685            base.agent.extraction_prompt.as_deref(),
2686            Some("Return JSON only.")
2687        );
2688
2689        // A second merge without the field should preserve it
2690        let toml2 = r#"
2691[agent]
2692model = "custom-model"
2693"#;
2694        base.merge_toml_str(toml2).expect("merge toml2");
2695        assert_eq!(
2696            base.agent.extraction_prompt.as_deref(),
2697            Some("Return JSON only."),
2698            "extraction_prompt must survive merge when absent in later layer"
2699        );
2700        assert_eq!(base.agent.model, "custom-model");
2701    }
2702
2703    #[test]
2704    fn test_merge_hooks_entries_append() {
2705        let mut base = Config::default();
2706        let base_entry = HookEntryConfig {
2707            id: HookId::new("base"),
2708            ..HookEntryConfig::default()
2709        };
2710        base.hooks.entries.push(base_entry);
2711
2712        let mut other = Config::default();
2713        let other_entry = HookEntryConfig {
2714            id: HookId::new("other"),
2715            ..HookEntryConfig::default()
2716        };
2717        other.hooks.entries.push(other_entry);
2718
2719        base.merge(other);
2720        let ids = base
2721            .hooks
2722            .entries
2723            .iter()
2724            .map(|entry| entry.id.0.as_str())
2725            .collect::<Vec<_>>();
2726        assert_eq!(ids, vec!["base", "other"]);
2727    }
2728
2729    // ---- P3a: Config::merge inheritance reworks ---------------------------
2730
2731    fn mcp_server(name: &str, command: &str) -> crate::mcp_config::McpServerConfig {
2732        crate::mcp_config::McpServerConfig {
2733            name: name.to_string(),
2734            transport: crate::mcp_config::McpTransportConfig::Stdio(
2735                crate::mcp_config::McpStdioConfig {
2736                    command: command.to_string(),
2737                    args: Vec::new(),
2738                    env: std::collections::HashMap::new(),
2739                },
2740            ),
2741            connect_timeout_secs: None,
2742        }
2743    }
2744
2745    fn skill_repo(
2746        name: &str,
2747        uuid: &str,
2748        path: &str,
2749    ) -> crate::skills_config::SkillRepositoryConfig {
2750        crate::skills_config::SkillRepositoryConfig {
2751            name: name.to_string(),
2752            source_uuid: crate::skills::SourceUuid::parse(uuid).expect("valid uuid"),
2753            transport: crate::skills_config::SkillRepoTransport::Filesystem {
2754                path: path.to_string(),
2755            },
2756        }
2757    }
2758
2759    // RCT-12
2760    #[test]
2761    fn effective_config_unions_model_defaults_child_wins() {
2762        let mut base = Config::default();
2763        base.models.openai = "parent-openai".to_string();
2764        base.models.anthropic = "parent-anthropic".to_string();
2765        let mut child = Config::default();
2766        child.models.anthropic = "child-anthropic".to_string();
2767        base.merge(child);
2768        assert_eq!(
2769            base.models.openai, "parent-openai",
2770            "child inherits the parent's openai default"
2771        );
2772        assert_eq!(
2773            base.models.anthropic, "child-anthropic",
2774            "child overrides only its anthropic default"
2775        );
2776    }
2777
2778    // RCT-14
2779    #[test]
2780    fn effective_config_unions_mcp_limits_skills() {
2781        let mut base = Config::default();
2782        base.limits.budget = Some(1000);
2783        base.tools.mcp_servers = vec![mcp_server("shared", "base-cmd")];
2784        base.skills.repositories = vec![skill_repo(
2785            "base-repo",
2786            "00000000-0000-4000-8000-000000000001",
2787            "/b",
2788        )];
2789
2790        let mut child = Config::default();
2791        child.limits.max_sessions = Some(7);
2792        child.tools.mcp_servers = vec![mcp_server("shared", "child-cmd"), mcp_server("extra", "x")];
2793        child.skills.repositories = vec![skill_repo(
2794            "child-repo",
2795            "00000000-0000-4000-8000-000000000002",
2796            "/c",
2797        )];
2798
2799        base.merge(child);
2800
2801        // limits: per-field child-wins (both survive).
2802        assert_eq!(base.limits.budget, Some(1000));
2803        assert_eq!(base.limits.max_sessions, Some(7));
2804
2805        // mcp: union by name, child overrides same name, appends new.
2806        let names: Vec<&str> = base
2807            .tools
2808            .mcp_servers
2809            .iter()
2810            .map(|s| s.name.as_str())
2811            .collect();
2812        assert_eq!(names, vec!["shared", "extra"]);
2813        let shared = base
2814            .tools
2815            .mcp_servers
2816            .iter()
2817            .find(|s| s.name == "shared")
2818            .unwrap();
2819        assert!(
2820            matches!(
2821                &shared.transport,
2822                crate::mcp_config::McpTransportConfig::Stdio(c) if c.command == "child-cmd"
2823            ),
2824            "child overrides the same-named inherited server"
2825        );
2826
2827        // skills: repositories append parent-first.
2828        let repos: Vec<&str> = base
2829            .skills
2830            .repositories
2831            .iter()
2832            .map(|r| r.name.as_str())
2833            .collect();
2834        assert_eq!(repos, vec!["base-repo", "child-repo"]);
2835    }
2836
2837    // RCT-15
2838    #[test]
2839    fn config_merge_folds_realm_map_child_wins() {
2840        let mut base = Config::default();
2841        base.realm.insert(
2842            "global".to_string(),
2843            crate::connection::RealmConfigSection::default(),
2844        );
2845        let mut child = Config::default();
2846        child.realm.insert(
2847            "team".to_string(),
2848            crate::connection::RealmConfigSection {
2849                parent: Some(crate::connection::RealmId::global()),
2850                ..Default::default()
2851            },
2852        );
2853        base.merge(child);
2854        assert!(base.realm.contains_key("global"), "inherited realm visible");
2855        assert!(base.realm.contains_key("team"));
2856        assert_eq!(
2857            base.realm.get("team").and_then(|s| s.parent.clone()),
2858            Some(crate::connection::RealmId::global())
2859        );
2860    }
2861
2862    // RCT-37
2863    #[test]
2864    fn child_cannot_remove_inherited_mcp_or_hook_entries() {
2865        let mut base = Config::default();
2866        base.tools.mcp_servers = vec![mcp_server("inherited", "cmd")];
2867        base.hooks.entries.push(HookEntryConfig {
2868            id: HookId::new("inherited-hook"),
2869            ..HookEntryConfig::default()
2870        });
2871
2872        // Child sets nothing: an empty child must NOT drop inherited entries
2873        // (emptiness != removal, no tombstones).
2874        let child = Config::default();
2875        base.merge(child);
2876
2877        assert_eq!(
2878            base.tools.mcp_servers.len(),
2879            1,
2880            "empty child must not remove an inherited mcp server"
2881        );
2882        assert_eq!(base.tools.mcp_servers[0].name, "inherited");
2883        assert!(
2884            base.hooks
2885                .entries
2886                .iter()
2887                .any(|h| h.id.0.as_str() == "inherited-hook"),
2888            "empty child must not remove an inherited hook"
2889        );
2890    }
2891
2892    #[test]
2893    fn test_merge_self_hosted_preserves_lower_layer_servers_and_models() {
2894        let mut base = Config::default();
2895        base.merge_toml_str(
2896            r#"
2897[self_hosted.servers.local]
2898base_url = "http://127.0.0.1:11434"
2899"#,
2900        )
2901        .expect("base self-hosted server");
2902        base.merge_toml_str(
2903            r#"
2904[self_hosted.models.gemma-4-e2b]
2905server = "local"
2906remote_model = "gemma4:e2b"
2907display_name = "Gemma 4 E2B"
2908family = "gemma-4"
2909"#,
2910        )
2911        .expect("overlay self-hosted model");
2912
2913        assert!(base.self_hosted.servers.contains_key("local"));
2914        assert!(base.self_hosted.models.contains_key("gemma-4-e2b"));
2915        let registry = base
2916            .model_registry(*crate::model_profile::test_catalog::TEST_CATALOG)
2917            .expect("merged self-hosted registry");
2918        assert_eq!(
2919            registry
2920                .entry("gemma-4-e2b")
2921                .and_then(|entry| entry.self_hosted.as_ref())
2922                .map(|server| server.server_id.as_str()),
2923            Some("local")
2924        );
2925    }
2926
2927    #[test]
2928    fn test_merge_self_hosted_partial_server_override_preserves_existing_fields() {
2929        let mut config = Config::default();
2930        config
2931            .merge_toml_str(
2932                r#"
2933[self_hosted.servers.local]
2934base_url = "http://127.0.0.1:11434"
2935api_style = "responses"
2936"#,
2937            )
2938            .expect("base server");
2939        config
2940            .merge_toml_str(
2941                r#"
2942[self_hosted.servers.local]
2943transport = "openai_compatible"
2944"#,
2945            )
2946            .expect("overlay server");
2947
2948        let server = config
2949            .self_hosted
2950            .servers
2951            .get("local")
2952            .expect("merged server");
2953        assert_eq!(server.base_url, "http://127.0.0.1:11434");
2954        assert_eq!(server.api_style, SelfHostedApiStyle::Responses);
2955        assert_eq!(server.transport, SelfHostedTransport::OpenAiCompatible);
2956    }
2957
2958    #[test]
2959    fn test_merge_self_hosted_partial_override_preserves_unrelated_inherited_entries() {
2960        let mut config = Config::default();
2961        config
2962            .merge_toml_str(
2963                r#"
2964[self_hosted]
2965default_model = "gemma-4-e4b"
2966
2967[self_hosted.servers.local]
2968base_url = "http://127.0.0.1:11434"
2969
2970[self_hosted.servers.backup]
2971base_url = "http://127.0.0.1:11435"
2972
2973[self_hosted.models.gemma-4-e2b]
2974server = "local"
2975remote_model = "gemma4:e2b"
2976display_name = "Gemma 4 E2B"
2977family = "gemma-4"
2978
2979[self_hosted.models.gemma-4-e4b]
2980server = "backup"
2981remote_model = "gemma4:e4b"
2982display_name = "Gemma 4 E4B"
2983family = "gemma-4"
2984"#,
2985            )
2986            .expect("base self-hosted config");
2987        config
2988            .merge_toml_str(
2989                r#"
2990[self_hosted.servers.local]
2991api_style = "responses"
2992"#,
2993            )
2994            .expect("overlay self-hosted config");
2995
2996        assert!(config.self_hosted.servers.contains_key("backup"));
2997        assert!(config.self_hosted.models.contains_key("gemma-4-e4b"));
2998        let registry = config
2999            .model_registry(*crate::model_profile::test_catalog::TEST_CATALOG)
3000            .expect("registry should remain valid");
3001        assert_eq!(
3002            registry
3003                .entry("gemma-4-e4b")
3004                .and_then(|entry| entry.self_hosted.as_ref())
3005                .map(|server| server.server_id.as_str()),
3006            Some("backup")
3007        );
3008    }
3009
3010    #[test]
3011    fn test_merge_self_hosted_empty_table_clears_inherited_entries() {
3012        let mut config = Config::default();
3013        config
3014            .merge_toml_str(
3015                r#"
3016[self_hosted.servers.local]
3017base_url = "http://127.0.0.1:11434"
3018
3019[self_hosted.models.gemma-4-e2b]
3020server = "local"
3021remote_model = "gemma4:e2b"
3022display_name = "Gemma 4 E2B"
3023family = "gemma-4"
3024"#,
3025            )
3026            .expect("base self-hosted config");
3027
3028        config
3029            .merge_toml_str(
3030                r"
3031[self_hosted.servers]
3032
3033[self_hosted.models]
3034",
3035            )
3036            .expect("clear self-hosted config");
3037
3038        assert!(config.self_hosted.servers.is_empty());
3039        assert!(config.self_hosted.models.is_empty());
3040    }
3041
3042    #[test]
3043    fn test_self_hosted_legacy_bearer_token_rejected_at_parse() {
3044        // Pre-1.0 clean break: server credentials live exclusively in realm
3045        // auth profiles. The retired `bearer_token` carrier is rejected at
3046        // config parse with a typed error, never tolerated.
3047        let err = toml::from_str::<Config>(
3048            r#"
3049[self_hosted.servers.local]
3050base_url = "http://127.0.0.1:11434"
3051bearer_token = "secret-token"
3052"#,
3053        )
3054        .expect_err("legacy bearer_token must be rejected at config parse");
3055        assert!(
3056            err.to_string().contains("bearer_token"),
3057            "rejection must name the offending field: {err}"
3058        );
3059    }
3060
3061    #[test]
3062    fn test_self_hosted_legacy_bearer_token_env_rejected_at_parse() {
3063        let err = toml::from_str::<Config>(
3064            r#"
3065[self_hosted.servers.local]
3066base_url = "http://127.0.0.1:11434"
3067bearer_token_env = "OLLAMA_TOKEN"
3068"#,
3069        )
3070        .expect_err("legacy bearer_token_env must be rejected at config parse");
3071        assert!(
3072            err.to_string().contains("bearer_token_env"),
3073            "rejection must name the offending field: {err}"
3074        );
3075    }
3076
3077    #[test]
3078    fn test_self_hosted_legacy_bearer_token_rejected_in_layered_merge() {
3079        // The layered merge path parses each overlay through the same typed
3080        // Config deserializer, so a legacy credential field in any layer is
3081        // rejected with the same typed parse error.
3082        let mut config = Config::default();
3083        config
3084            .merge_toml_str(
3085                r#"
3086[self_hosted.servers.local]
3087base_url = "http://127.0.0.1:11434"
3088"#,
3089            )
3090            .expect("base server");
3091        let err = config
3092            .merge_toml_str(
3093                r#"
3094[self_hosted.servers.local]
3095bearer_token_env = "OLLAMA_TOKEN"
3096"#,
3097            )
3098            .expect_err("legacy bearer_token_env overlay must be rejected");
3099        assert!(matches!(err, ConfigError::Parse(_)));
3100        assert!(err.to_string().contains("bearer_token_env"));
3101    }
3102
3103    // Plan §6.10 deleted the ProviderSettings struct (and its api_keys /
3104    // base_urls maps) entirely. The corresponding merge test that
3105    // asserted the "non-default other replaces self" semantics went
3106    // with it. Realm-scoped base_urls now live in
3107    // `[realm.<id>.backend.<b>.base_url]` and round-trip through the
3108    // normal TOML merge path that the
3109    // `test_merge_extraction_prompt_survives_layering` case exercises.
3110
3111    #[test]
3112    fn test_merge_toml_tools_omitted_fields_preserve_lower_layer() {
3113        let mut config = Config::default();
3114        config.tools.mob_enabled = true;
3115        config.tools.shell_enabled = true;
3116
3117        config
3118            .merge_toml_str(
3119                r"
3120[tools]
3121shell_enabled = false
3122",
3123            )
3124            .expect("merge should succeed");
3125
3126        assert!(config.tools.mob_enabled);
3127        assert!(!config.tools.shell_enabled);
3128    }
3129
3130    #[test]
3131    fn test_merge_toml_tools_explicit_default_overrides_lower_layer() {
3132        let mut config = Config::default();
3133        config.tools.mob_enabled = true;
3134
3135        config
3136            .merge_toml_str(
3137                r"
3138[tools]
3139mob_enabled = false
3140",
3141            )
3142            .expect("merge should succeed");
3143
3144        assert!(!config.tools.mob_enabled);
3145    }
3146
3147    #[test]
3148    fn test_merge_toml_retry_omitted_fields_preserve_lower_layer() {
3149        let mut config = Config::default();
3150        config.retry.max_retries = 9;
3151
3152        config
3153            .merge_toml_str(
3154                r#"
3155[retry]
3156initial_delay = "750ms"
3157"#,
3158            )
3159            .expect("merge should succeed");
3160
3161        assert_eq!(config.retry.max_retries, 9);
3162        assert_eq!(config.retry.initial_delay, Duration::from_millis(750));
3163    }
3164
3165    #[test]
3166    fn test_compaction_threshold_presence_is_preserved_at_default_value() {
3167        let config: Config = toml::from_str(
3168            r"
3169[compaction]
3170auto_compact_threshold = 100000
3171",
3172        )
3173        .expect("config should parse");
3174
3175        assert_eq!(config.compaction.auto_compact_threshold, 100_000);
3176        assert!(config.compaction.auto_compact_threshold_explicit);
3177    }
3178
3179    #[test]
3180    fn test_default_compaction_threshold_serializes_as_inherited() {
3181        let toml = toml::to_string_pretty(&Config::default()).expect("config should serialize");
3182
3183        assert!(
3184            !toml.contains("auto_compact_threshold"),
3185            "default config should not persist an inherited compaction threshold: {toml}"
3186        );
3187    }
3188
3189    #[test]
3190    fn test_explicit_default_compaction_threshold_serializes() {
3191        let mut config = Config::default();
3192        config.compaction.auto_compact_threshold_explicit = true;
3193
3194        let toml = toml::to_string_pretty(&config).expect("config should serialize");
3195
3196        assert!(
3197            toml.contains("auto_compact_threshold = 100000"),
3198            "explicit default threshold must survive persistence: {toml}"
3199        );
3200    }
3201
3202    #[test]
3203    fn test_validate_rejects_zero_min_turns_between_compactions() {
3204        let config = Config {
3205            compaction: CompactionRuntimeConfig {
3206                min_turns_between_compactions: 0,
3207                ..CompactionRuntimeConfig::default()
3208            },
3209            ..Config::default()
3210        };
3211        let err = config
3212            .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3213            .expect_err("min_turns_between_compactions=0 should be invalid");
3214        assert!(
3215            err.to_string()
3216                .contains("compaction.min_turns_between_compactions")
3217        );
3218    }
3219
3220    // Plan §6.9 deleted the ProviderConfig enum and the
3221    // `test_provider_config_serialization` test that exercised its
3222    // serde discriminator. Realm-based credential configs are round-
3223    // tripped by tests in meerkat-contracts/tests/auth_binding_wire.rs.
3224
3225    #[test]
3226    fn test_budget_config_serialization() {
3227        let budget = BudgetConfig {
3228            max_tokens: Some(100_000),
3229            max_duration: Some(Duration::from_secs(300)),
3230            max_tool_calls: Some(50),
3231        };
3232
3233        let json = serde_json::to_string(&budget).unwrap();
3234        let parsed: BudgetConfig = serde_json::from_str(&json).unwrap();
3235
3236        assert_eq!(parsed.max_tokens, Some(100_000));
3237        assert_eq!(parsed.max_duration, Some(Duration::from_secs(300)));
3238        assert_eq!(parsed.max_tool_calls, Some(50));
3239    }
3240
3241    #[test]
3242    fn test_self_hosted_transport_accepts_openai_compatible_alias() {
3243        let mut config = Config::default();
3244        config
3245            .merge_toml_str(
3246                r#"
3247[self_hosted.servers.ollama]
3248transport = "openai_compatible"
3249base_url = "http://127.0.0.1:11434"
3250api_style = "chat_completions"
3251"#,
3252            )
3253            .expect("alias should parse");
3254
3255        assert_eq!(
3256            config
3257                .self_hosted
3258                .servers
3259                .get("ollama")
3260                .expect("server should exist")
3261                .transport,
3262            SelfHostedTransport::OpenAiCompatible
3263        );
3264    }
3265
3266    #[test]
3267    fn test_self_hosted_server_config_defaults_to_chat_completions() {
3268        assert_eq!(
3269            SelfHostedServerConfig::default().api_style,
3270            SelfHostedApiStyle::ChatCompletions
3271        );
3272    }
3273
3274    #[test]
3275    fn test_retry_config_to_policy() {
3276        let config = RetryConfig::default();
3277        let policy: RetryPolicy = config.into();
3278
3279        assert_eq!(policy.max_retries, 3);
3280        assert_eq!(policy.initial_delay, Duration::from_millis(500));
3281    }
3282
3283    /// Regression test: Config::load() should succeed when .rkat/ directory exists
3284    /// but config.toml is missing. This is a common first-run state where .rkat/
3285    /// is created for session storage.
3286    #[tokio::test]
3287    async fn test_regression_load_succeeds_without_config_toml() {
3288        use tempfile::TempDir;
3289
3290        // Create temp dir with .rkat/ but NO config.toml
3291        let temp_dir = TempDir::new().unwrap();
3292        let rkat_dir = temp_dir.path().join(".rkat");
3293        std::fs::create_dir(&rkat_dir).unwrap();
3294
3295        // Verify .rkat exists but config.toml doesn't
3296        assert!(rkat_dir.exists());
3297        assert!(!rkat_dir.join("config.toml").exists());
3298
3299        // Verify load succeeds without consulting process-global HOME/cwd.
3300        let result =
3301            Config::load_from_with_env(temp_dir.path(), Some(temp_dir.path()), |_| None).await;
3302
3303        assert!(
3304            result.is_ok(),
3305            "Config::load() should succeed when .rkat/ exists without config.toml: {:?}",
3306            result.err()
3307        );
3308    }
3309
3310    #[test]
3311    fn test_validate_rejects_zero_max_tokens() {
3312        let config = Config {
3313            max_tokens: Some(0),
3314            ..Config::default()
3315        };
3316        let err = config
3317            .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3318            .expect_err("max_tokens=0 should be invalid");
3319        assert!(
3320            err.to_string()
3321                .contains("max_tokens must be greater than 0")
3322        );
3323    }
3324
3325    #[test]
3326    fn test_validate_rejects_zero_limits_max_sessions() {
3327        let mut config = Config::default();
3328        config.limits.max_sessions = Some(0);
3329        let err = config
3330            .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3331            .expect_err("limits.max_sessions=0 should be invalid");
3332        assert!(err.to_string().contains("limits.max_sessions"));
3333    }
3334
3335    #[test]
3336    fn test_validate_rejects_zero_agent_max_tokens_per_turn() {
3337        let mut config = Config::default();
3338        config.agent.max_tokens_per_turn = Some(0);
3339        let err = config
3340            .validate(*crate::model_profile::test_catalog::TEST_CATALOG)
3341            .expect_err("agent.max_tokens_per_turn=0 should be invalid");
3342        assert!(err.to_string().contains("agent.max_tokens_per_turn"));
3343    }
3344
3345    // Plan §6.9 deleted the `config.provider = ProviderConfig::X` block
3346    // and the matching validate-time conflict checks against
3347    // `providers.{base_urls,api_keys}`. Those tests went with it.
3348
3349    #[test]
3350    fn test_provider_parse_strict() {
3351        assert_eq!(
3352            Provider::parse_strict("anthropic"),
3353            Some(Provider::Anthropic)
3354        );
3355        assert_eq!(Provider::parse_strict("openai"), Some(Provider::OpenAI));
3356        assert_eq!(Provider::parse_strict("gemini"), Some(Provider::Gemini));
3357        assert_eq!(Provider::parse_strict("other"), None);
3358        assert_eq!(Provider::parse_strict("claude"), None);
3359        assert_eq!(Provider::parse_strict(""), None);
3360    }
3361
3362    // === CommsAuthMode tests ===
3363
3364    #[test]
3365    fn test_comms_auth_mode_default_is_open() {
3366        assert_eq!(CommsAuthMode::default(), CommsAuthMode::Open);
3367    }
3368
3369    #[test]
3370    fn test_comms_auth_mode_serde_roundtrip() {
3371        // Open serializes as "none"
3372        let json = serde_json::to_string(&CommsAuthMode::Open).unwrap();
3373        assert_eq!(json, r#""none""#);
3374        let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
3375        assert_eq!(parsed, CommsAuthMode::Open);
3376
3377        // Ed25519 serializes as "ed25519"
3378        let json = serde_json::to_string(&CommsAuthMode::Ed25519).unwrap();
3379        assert_eq!(json, r#""ed25519""#);
3380        let parsed: CommsAuthMode = serde_json::from_str(&json).unwrap();
3381        assert_eq!(parsed, CommsAuthMode::Ed25519);
3382    }
3383
3384    #[test]
3385    fn test_comms_auth_mode_toml_roundtrip() {
3386        let config = CommsRuntimeConfig::default();
3387        let toml_str = toml::to_string(&config).unwrap();
3388        let parsed: CommsRuntimeConfig = toml::from_str(&toml_str).unwrap();
3389        assert_eq!(parsed.auth, CommsAuthMode::Open);
3390        assert!(parsed.require_peer_auth);
3391
3392        // Explicit ed25519
3393        let toml_str = r#"
3394mode = "inproc"
3395auth = "ed25519"
3396"#;
3397        let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
3398        assert_eq!(parsed.auth, CommsAuthMode::Ed25519);
3399        assert!(parsed.require_peer_auth);
3400    }
3401
3402    #[test]
3403    fn test_comms_runtime_config_default_has_open_auth() {
3404        let config = CommsRuntimeConfig::default();
3405        assert_eq!(config.auth, CommsAuthMode::Open);
3406        assert!(config.require_peer_auth);
3407    }
3408
3409    // === PlainEventSource tests ===
3410
3411    #[test]
3412    fn test_plain_event_source_serde_roundtrip() {
3413        let cases = [
3414            (PlainEventSource::Tcp, r#""tcp""#),
3415            (PlainEventSource::Uds, r#""uds""#),
3416            (PlainEventSource::Stdin, r#""stdin""#),
3417            (PlainEventSource::Webhook, r#""webhook""#),
3418            (PlainEventSource::Rpc, r#""rpc""#),
3419        ];
3420        for (variant, expected_json) in cases {
3421            let json = serde_json::to_string(&variant).unwrap();
3422            assert_eq!(json, expected_json, "serialize {variant:?}");
3423            let parsed: PlainEventSource = serde_json::from_str(&json).unwrap();
3424            assert_eq!(parsed, variant, "deserialize {variant:?}");
3425        }
3426    }
3427
3428    #[test]
3429    fn test_plain_event_source_display() {
3430        assert_eq!(PlainEventSource::Tcp.to_string(), "tcp");
3431        assert_eq!(PlainEventSource::Uds.to_string(), "uds");
3432        assert_eq!(PlainEventSource::Stdin.to_string(), "stdin");
3433        assert_eq!(PlainEventSource::Webhook.to_string(), "webhook");
3434        assert_eq!(PlainEventSource::Rpc.to_string(), "rpc");
3435    }
3436
3437    // === Regression: event_address in CommsRuntimeConfig ===
3438
3439    #[test]
3440    fn test_comms_config_event_address_toml_roundtrip() {
3441        let toml_str = r#"
3442mode = "tcp"
3443address = "127.0.0.1:4200"
3444advertise_address = "tcp://203.0.113.10:4200"
3445auth = "none"
3446require_peer_auth = false
3447event_address = "127.0.0.1:4201"
3448"#;
3449        let parsed: CommsRuntimeConfig = toml::from_str(toml_str).unwrap();
3450        assert_eq!(parsed.event_address.as_deref(), Some("127.0.0.1:4201"));
3451        assert_eq!(
3452            parsed.advertise_address.as_deref(),
3453            Some("tcp://203.0.113.10:4200")
3454        );
3455        assert_eq!(parsed.auth, CommsAuthMode::Open);
3456        assert!(!parsed.require_peer_auth);
3457    }
3458
3459    #[test]
3460    fn test_comms_config_event_address_defaults_none() {
3461        let config = CommsRuntimeConfig::default();
3462        assert!(config.event_address.is_none());
3463    }
3464
3465    // ── CallTimeoutOverride tests ──
3466
3467    #[test]
3468    fn call_timeout_override_default_is_inherit() {
3469        assert_eq!(CallTimeoutOverride::default(), CallTimeoutOverride::Inherit);
3470        assert!(CallTimeoutOverride::default().is_inherit());
3471    }
3472
3473    #[test]
3474    fn call_timeout_override_disabled_is_not_inherit() {
3475        assert!(!CallTimeoutOverride::Disabled.is_inherit());
3476    }
3477
3478    #[test]
3479    fn call_timeout_override_value_is_not_inherit() {
3480        assert!(!CallTimeoutOverride::Value(Duration::from_secs(45)).is_inherit());
3481    }
3482
3483    #[test]
3484    fn call_timeout_override_toml_deserialize_disabled() {
3485        let toml_str = r#"call_timeout = "disabled""#;
3486        #[derive(Deserialize)]
3487        struct Wrapper {
3488            call_timeout: CallTimeoutOverride,
3489        }
3490        let w: Wrapper = toml::from_str(toml_str).unwrap();
3491        assert_eq!(w.call_timeout, CallTimeoutOverride::Disabled);
3492    }
3493
3494    #[test]
3495    fn call_timeout_override_toml_deserialize_duration() {
3496        let toml_str = r#"call_timeout = "45s""#;
3497        #[derive(Deserialize)]
3498        struct Wrapper {
3499            call_timeout: CallTimeoutOverride,
3500        }
3501        let w: Wrapper = toml::from_str(toml_str).unwrap();
3502        assert_eq!(
3503            w.call_timeout,
3504            CallTimeoutOverride::Value(Duration::from_secs(45))
3505        );
3506    }
3507
3508    #[test]
3509    fn call_timeout_override_toml_deserialize_complex_duration() {
3510        let toml_str = r#"call_timeout = "2m 30s""#;
3511        #[derive(Deserialize)]
3512        struct Wrapper {
3513            call_timeout: CallTimeoutOverride,
3514        }
3515        let w: Wrapper = toml::from_str(toml_str).unwrap();
3516        assert_eq!(
3517            w.call_timeout,
3518            CallTimeoutOverride::Value(Duration::from_secs(150))
3519        );
3520    }
3521
3522    // ── SystemPromptOverride tests ──
3523
3524    #[derive(Debug, PartialEq, Serialize, Deserialize)]
3525    struct SystemPromptWrapper {
3526        #[serde(default, skip_serializing_if = "SystemPromptOverride::is_inherit")]
3527        system_prompt: SystemPromptOverride,
3528    }
3529
3530    #[test]
3531    fn system_prompt_override_default_is_inherit() {
3532        assert_eq!(
3533            SystemPromptOverride::default(),
3534            SystemPromptOverride::Inherit
3535        );
3536        assert!(SystemPromptOverride::default().is_inherit());
3537        assert!(!SystemPromptOverride::default().is_explicit());
3538        assert!(SystemPromptOverride::Set("p".to_string()).is_explicit());
3539        assert!(SystemPromptOverride::Disable.is_explicit());
3540        assert_eq!(
3541            SystemPromptOverride::Set("p".to_string()).as_set_prompt(),
3542            Some("p")
3543        );
3544        assert_eq!(SystemPromptOverride::Disable.as_set_prompt(), None);
3545    }
3546
3547    #[test]
3548    fn system_prompt_override_absent_field_parses_as_inherit() {
3549        // Lossless with the retired `Option<String>` shape: an omitted field
3550        // (old `None`) is `Inherit`.
3551        let w: SystemPromptWrapper = serde_json::from_str("{}").unwrap();
3552        assert_eq!(w.system_prompt, SystemPromptOverride::Inherit);
3553    }
3554
3555    #[test]
3556    fn system_prompt_override_null_parses_as_inherit() {
3557        let w: SystemPromptWrapper = serde_json::from_str(r#"{"system_prompt": null}"#).unwrap();
3558        assert_eq!(w.system_prompt, SystemPromptOverride::Inherit);
3559    }
3560
3561    #[test]
3562    fn system_prompt_override_string_round_trips_as_set() {
3563        // Lossless with the retired `Some(prompt)` shape.
3564        let w: SystemPromptWrapper =
3565            serde_json::from_str(r#"{"system_prompt": "You are helpful."}"#).unwrap();
3566        assert_eq!(
3567            w.system_prompt,
3568            SystemPromptOverride::Set("You are helpful.".to_string())
3569        );
3570        let json = serde_json::to_string(&w).unwrap();
3571        assert_eq!(json, r#"{"system_prompt":"You are helpful."}"#);
3572        let back: SystemPromptWrapper = serde_json::from_str(&json).unwrap();
3573        assert_eq!(back, w);
3574    }
3575
3576    #[test]
3577    fn system_prompt_override_disable_round_trips() {
3578        // The suppression fact survives serialize → deserialize: no lossy
3579        // Disable→Inherit collapse at any persist/wire boundary.
3580        let w = SystemPromptWrapper {
3581            system_prompt: SystemPromptOverride::Disable,
3582        };
3583        let json = serde_json::to_string(&w).unwrap();
3584        assert_eq!(json, r#"{"system_prompt":{"action":"disable"}}"#);
3585        let back: SystemPromptWrapper = serde_json::from_str(&json).unwrap();
3586        assert_eq!(back.system_prompt, SystemPromptOverride::Disable);
3587    }
3588
3589    #[test]
3590    fn system_prompt_override_inherit_is_omitted_when_serialized() {
3591        let w = SystemPromptWrapper {
3592            system_prompt: SystemPromptOverride::Inherit,
3593        };
3594        assert_eq!(serde_json::to_string(&w).unwrap(), "{}");
3595    }
3596
3597    #[test]
3598    fn system_prompt_override_unknown_action_rejected() {
3599        let err = serde_json::from_str::<SystemPromptWrapper>(
3600            r#"{"system_prompt": {"action": "clear"}}"#,
3601        )
3602        .expect_err("unknown action must be rejected");
3603        assert!(
3604            err.to_string()
3605                .contains("unknown system_prompt override action")
3606        );
3607    }
3608
3609    #[test]
3610    fn system_prompt_override_unknown_key_rejected() {
3611        serde_json::from_str::<SystemPromptWrapper>(r#"{"system_prompt": {"disable": true}}"#)
3612            .expect_err("object form must carry exactly the action key");
3613    }
3614
3615    #[test]
3616    fn system_prompt_override_non_string_rejected() {
3617        serde_json::from_str::<SystemPromptWrapper>(r#"{"system_prompt": 42}"#)
3618            .expect_err("numeric system_prompt must be rejected");
3619    }
3620
3621    #[test]
3622    fn retry_config_default_has_inherit_call_timeout() {
3623        let config = RetryConfig::default();
3624        assert_eq!(config.call_timeout_override, CallTimeoutOverride::Inherit);
3625    }
3626
3627    #[test]
3628    fn retry_config_from_toml_with_call_timeout_value() {
3629        let toml_str = r#"
3630[retry]
3631max_retries = 5
3632call_timeout = "60s"
3633"#;
3634        let config: Config = toml::from_str(toml_str).unwrap();
3635        assert_eq!(config.retry.max_retries, 5);
3636        assert_eq!(
3637            config.retry.call_timeout_override,
3638            CallTimeoutOverride::Value(Duration::from_secs(60))
3639        );
3640    }
3641
3642    #[test]
3643    fn retry_config_from_toml_with_call_timeout_disabled() {
3644        let toml_str = r#"
3645[retry]
3646call_timeout = "disabled"
3647"#;
3648        let config: Config = toml::from_str(toml_str).unwrap();
3649        assert_eq!(
3650            config.retry.call_timeout_override,
3651            CallTimeoutOverride::Disabled
3652        );
3653    }
3654
3655    #[test]
3656    fn retry_config_from_toml_omitted_is_inherit() {
3657        let toml_str = r"
3658[retry]
3659max_retries = 2
3660";
3661        let config: Config = toml::from_str(toml_str).unwrap();
3662        assert_eq!(
3663            config.retry.call_timeout_override,
3664            CallTimeoutOverride::Inherit
3665        );
3666    }
3667
3668    #[test]
3669    fn retry_policy_from_config_with_value_override() {
3670        let config = RetryConfig {
3671            call_timeout_override: CallTimeoutOverride::Value(Duration::from_secs(90)),
3672            ..RetryConfig::default()
3673        };
3674        let policy: crate::retry::RetryPolicy = config.into();
3675        assert_eq!(policy.call_timeout, Some(Duration::from_secs(90)));
3676    }
3677
3678    #[test]
3679    fn retry_policy_from_config_with_disabled_override() {
3680        let config = RetryConfig {
3681            call_timeout_override: CallTimeoutOverride::Disabled,
3682            ..RetryConfig::default()
3683        };
3684        let policy: crate::retry::RetryPolicy = config.into();
3685        // Disabled collapses to None — the agent loop treats None as "no timeout"
3686        assert_eq!(policy.call_timeout, None);
3687    }
3688
3689    #[test]
3690    fn retry_policy_from_config_with_inherit_override() {
3691        let config = RetryConfig {
3692            call_timeout_override: CallTimeoutOverride::Inherit,
3693            ..RetryConfig::default()
3694        };
3695        let policy: crate::retry::RetryPolicy = config.into();
3696        assert_eq!(policy.call_timeout, None);
3697    }
3698
3699    #[test]
3700    fn config_merge_preserves_call_timeout_override() {
3701        let toml_base = r"
3702[retry]
3703max_retries = 2
3704";
3705        let toml_overlay = r#"
3706[retry]
3707call_timeout = "30s"
3708"#;
3709        let mut config: Config = toml::from_str(toml_base).unwrap();
3710        let overlay: Config = toml::from_str(toml_overlay).unwrap();
3711        let overlay_parsed: toml::Value = toml::from_str(toml_overlay).unwrap();
3712        config.merge_retry_from_toml_presence(&overlay_parsed, &overlay.retry);
3713        assert_eq!(config.retry.max_retries, 2); // Not overridden
3714        assert_eq!(
3715            config.retry.call_timeout_override,
3716            CallTimeoutOverride::Value(Duration::from_secs(30))
3717        );
3718    }
3719
3720    // ---- Provider tools config tests ----
3721
3722    #[test]
3723    fn test_provider_tools_defaults_all_enabled() {
3724        let config = Config::default();
3725        assert!(config.provider_tools.anthropic.web_search);
3726        assert!(config.provider_tools.openai.web_search);
3727        assert!(config.provider_tools.gemini.google_search);
3728    }
3729
3730    #[test]
3731    fn test_provider_tools_roundtrip_toml() {
3732        let config = Config::default();
3733        let toml_str = toml::to_string(&config.provider_tools).unwrap();
3734        let parsed: ProviderToolsConfig = toml::from_str(&toml_str).unwrap();
3735        assert_eq!(parsed, config.provider_tools);
3736    }
3737
3738    #[test]
3739    fn test_model_fallback_defaults_enabled_with_catalog_chain() {
3740        let config = Config::default();
3741        assert!(config.model_fallback.enabled);
3742        assert!(config.model_fallback.chain.is_empty());
3743    }
3744
3745    #[test]
3746    fn test_model_fallback_chain_parses_typed_targets() {
3747        let config: Config = toml::from_str(
3748            r#"
3749[model_fallback]
3750enabled = true
3751
3752[[model_fallback.chain]]
3753model = "backup-openai"
3754provider = "openai"
3755
3756[[model_fallback.chain]]
3757model = "backup-anthropic"
3758provider = "anthropic"
3759"#,
3760        )
3761        .unwrap();
3762
3763        assert!(config.model_fallback.enabled);
3764        assert_eq!(config.model_fallback.chain.len(), 2);
3765        assert_eq!(config.model_fallback.chain[0].model, "backup-openai");
3766        assert_eq!(
3767            config.model_fallback.chain[0].provider,
3768            Some(crate::Provider::OpenAI)
3769        );
3770        assert_eq!(
3771            config.model_fallback.chain[1].provider,
3772            Some(crate::Provider::Anthropic)
3773        );
3774    }
3775
3776    #[test]
3777    fn test_model_fallback_catalog_default_reset_overrides_custom_layer() {
3778        let mut config: Config = toml::from_str(
3779            r#"
3780[model_fallback]
3781enabled = false
3782
3783[[model_fallback.chain]]
3784model = "backup-openai"
3785provider = "openai"
3786"#,
3787        )
3788        .unwrap();
3789        let reset: Config = toml::from_str(
3790            r"
3791[model_fallback]
3792use_catalog_default_chain = true
3793",
3794        )
3795        .unwrap();
3796
3797        config.merge(reset);
3798
3799        assert_eq!(config.model_fallback, ModelFallbackConfig::default());
3800    }
3801
3802    #[test]
3803    fn test_provider_tools_merge_preserves_when_absent() {
3804        let mut config = Config::default();
3805        config
3806            .merge_toml_str(
3807                r#"[agent]
3808model = "custom-model"
3809"#,
3810            )
3811            .unwrap();
3812        // provider_tools should be untouched
3813        assert!(config.provider_tools.anthropic.web_search);
3814        assert!(config.provider_tools.openai.web_search);
3815        assert!(config.provider_tools.gemini.google_search);
3816    }
3817
3818    #[test]
3819    fn test_provider_tools_merge_overrides_single_provider() {
3820        let mut config = Config::default();
3821        config
3822            .merge_toml_str("[provider_tools.anthropic]\nweb_search = false\n")
3823            .unwrap();
3824        // Only anthropic should be disabled
3825        assert!(!config.provider_tools.anthropic.web_search);
3826        // Others unchanged
3827        assert!(config.provider_tools.openai.web_search);
3828        assert!(config.provider_tools.gemini.google_search);
3829    }
3830
3831    // ---- AgentConfig.provider_params carrier serialization tests ----
3832
3833    #[test]
3834    fn test_provider_tool_defaults_not_serialized() {
3835        use crate::lifecycle::run_primitive::{
3836            AnthropicProviderTag, OpaqueProviderBody, ProviderParamsCarrier, ProviderTag,
3837        };
3838
3839        let agent_config = AgentConfig {
3840            provider_params: ProviderParamsCarrier {
3841                params: Default::default(),
3842                tool_defaults: Some(ProviderTag::Anthropic(AnthropicProviderTag {
3843                    web_search: Some(OpaqueProviderBody::from_value(&serde_json::json!({
3844                        "type": "web_search_20250305"
3845                    }))),
3846                    ..Default::default()
3847                })),
3848            },
3849            ..Default::default()
3850        };
3851        let json = serde_json::to_value(&agent_config).unwrap();
3852        // The carrier is transparent over `params`; build-derived tool
3853        // defaults never persist, and an empty params half serializes nothing.
3854        assert!(
3855            json.get("provider_params").is_none(),
3856            "build-derived tool defaults must not be serialized: {json}"
3857        );
3858    }
3859
3860    /// K2 invariant: malformed provider params are rejected at CONFIG PARSE,
3861    /// not deferred to the first LLM call.
3862    #[test]
3863    fn test_malformed_provider_params_rejected_at_config_parse() {
3864        // Unknown key inside the typed override shape.
3865        let unknown_key = serde_json::json!({
3866            "model": "test-anthropic-default",
3867            "provider_params": { "not_a_known_knob": true }
3868        });
3869        assert!(
3870            serde_json::from_value::<AgentConfig>(unknown_key).is_err(),
3871            "unknown provider-params key must fail closed at config ingress"
3872        );
3873
3874        // Wrong type for a known knob.
3875        let wrong_type = serde_json::json!({
3876            "model": "test-anthropic-default",
3877            "provider_params": { "temperature": "hot" }
3878        });
3879        assert!(
3880            serde_json::from_value::<AgentConfig>(wrong_type).is_err(),
3881            "mistyped provider-params knob must fail closed at config ingress"
3882        );
3883
3884        // Well-formed typed shape parses.
3885        let typed = serde_json::json!({
3886            "model": "test-anthropic-default",
3887            "provider_params": {
3888                "temperature": 0.2,
3889                "provider_tag": { "provider": "anthropic", "effort": "high" }
3890            }
3891        });
3892        let parsed: AgentConfig =
3893            serde_json::from_value(typed).expect("typed provider params parse");
3894        assert_eq!(parsed.provider_params.params.temperature, Some(0.2));
3895    }
3896}