Skip to main content

cookie_agent_config/
runtime.rs

1use std::{collections::BTreeMap, time::Duration};
2
3use cookie_agent_identity::{ModelKey, ProviderId};
4pub use cookie_agent_models::catalog::PicoUsdPerMillion;
5use cookie_agent_models::{
6    HeaderName, ProviderDefinition, SafeStaticHeaderValue, deserialize_headers,
7    validate_header_limits, validate_header_ownership,
8};
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11
12use crate::ConfigError;
13use crate::toml_values::{SensitiveProviderValues, zeroize_toml_value};
14use zeroize::Zeroize;
15
16const DEFAULT_HOST: &str = "127.0.0.1";
17const DEFAULT_PORT: u16 = 7419;
18
19#[derive(Clone, Debug, Default, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct McpConfig {
22    #[serde(default)]
23    pub servers: BTreeMap<String, McpServerConfig>,
24}
25
26#[derive(Clone, Debug, Default, Deserialize)]
27pub struct PluginsConfig {
28    #[serde(flatten)]
29    pub plugins: IndexMap<String, PluginConfig>,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct PluginConfig {
35    pub command: Option<String>,
36    #[serde(default)]
37    pub args: Vec<String>,
38    #[serde(default)]
39    pub env: BTreeMap<String, String>,
40    pub cwd: Option<String>,
41    #[serde(default = "yes")]
42    pub enabled: bool,
43    #[serde(default)]
44    pub producer_messaging: bool,
45    #[serde(default = "default_plugin_interception_timeout_ms")]
46    pub interception_timeout_ms: u64,
47    #[serde(default = "default_plugin_startup_timeout_ms")]
48    pub startup_timeout_ms: u64,
49    #[serde(default = "default_plugin_shutdown_grace_ms")]
50    pub shutdown_grace_ms: u64,
51    #[serde(default = "default_plugin_tool_timeout_ms")]
52    pub tool_timeout_ms: u64,
53}
54
55impl PluginConfig {
56    pub fn validate(&self, name: &str) -> Result<(), ConfigError> {
57        self.invalid_field(name).map_or(Ok(()), |field| {
58            Err(ConfigError::Plugin {
59                plugin: name.to_owned(),
60                reason: format!("field `{field}` is invalid"),
61            })
62        })
63    }
64
65    pub(crate) fn invalid_field(&self, name: &str) -> Option<&'static str> {
66        if name.is_empty() || name.len() > 128 || name.chars().any(char::is_control) {
67            Some("name")
68        } else if self.command.as_ref().is_none_or(String::is_empty) {
69            Some("command")
70        } else if self.cwd.as_ref().is_some_and(String::is_empty) {
71            Some("cwd")
72        } else if self.interception_timeout_ms == 0 {
73            Some("interception_timeout_ms")
74        } else if self.startup_timeout_ms == 0 {
75            Some("startup_timeout_ms")
76        } else if self.shutdown_grace_ms == 0 {
77            Some("shutdown_grace_ms")
78        } else if self.tool_timeout_ms == 0 {
79            Some("tool_timeout_ms")
80        } else {
81            None
82        }
83    }
84}
85
86const fn default_plugin_interception_timeout_ms() -> u64 {
87    2_000
88}
89
90const fn default_plugin_startup_timeout_ms() -> u64 {
91    10_000
92}
93
94const fn default_plugin_shutdown_grace_ms() -> u64 {
95    3_000
96}
97
98const fn default_plugin_tool_timeout_ms() -> u64 {
99    30_000
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(deny_unknown_fields)]
104pub struct McpServerConfig {
105    pub command: Option<String>,
106    #[serde(default)]
107    pub args: Vec<String>,
108    #[serde(default)]
109    pub env: BTreeMap<String, String>,
110    pub cwd: Option<String>,
111    pub url: Option<String>,
112    #[serde(default)]
113    pub headers: BTreeMap<String, String>,
114    #[serde(default, skip_serializing_if = "McpOAuthConfig::is_auto")]
115    pub oauth: McpOAuthConfig,
116    #[serde(default = "yes")]
117    pub enabled: bool,
118    #[serde(default)]
119    pub lazy: bool,
120    pub timeout_ms: Option<u64>,
121}
122
123#[derive(Clone, Default, Deserialize, Eq, PartialEq, Serialize)]
124#[serde(deny_unknown_fields)]
125pub struct McpOAuthSettings {
126    pub client_id: Option<String>,
127    pub client_secret: Option<String>,
128    pub client_metadata_url: Option<String>,
129    #[serde(default)]
130    pub scopes: Vec<String>,
131}
132
133impl std::fmt::Debug for McpOAuthSettings {
134    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        formatter
136            .debug_struct("McpOAuthSettings")
137            .field("client_id", &self.client_id)
138            .field(
139                "client_secret",
140                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
141            )
142            .field("client_metadata_url", &self.client_metadata_url)
143            .field("scopes", &self.scopes)
144            .finish()
145    }
146}
147
148#[derive(Clone, Default, Eq, PartialEq)]
149pub enum McpOAuthConfig {
150    #[default]
151    Auto,
152    Enabled,
153    Disabled,
154    Settings(McpOAuthSettings),
155}
156
157impl McpOAuthConfig {
158    #[must_use]
159    pub const fn is_auto(&self) -> bool {
160        matches!(self, Self::Auto)
161    }
162
163    #[must_use]
164    pub const fn enabled(&self) -> bool {
165        !matches!(self, Self::Disabled)
166    }
167
168    #[must_use]
169    pub fn settings(&self) -> McpOAuthSettings {
170        match self {
171            Self::Settings(settings) => settings.clone(),
172            Self::Auto | Self::Enabled | Self::Disabled => McpOAuthSettings::default(),
173        }
174    }
175}
176
177impl std::fmt::Debug for McpOAuthConfig {
178    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        match self {
180            Self::Auto => formatter.write_str("Auto"),
181            Self::Enabled => formatter.write_str("Enabled"),
182            Self::Disabled => formatter.write_str("Disabled"),
183            Self::Settings(settings) => formatter
184                .debug_struct("Settings")
185                .field("client_id", &settings.client_id)
186                .field(
187                    "client_secret",
188                    &settings.client_secret.as_ref().map(|_| "[REDACTED]"),
189                )
190                .field("client_metadata_url", &settings.client_metadata_url)
191                .field("scopes", &settings.scopes)
192                .finish(),
193        }
194    }
195}
196
197impl Serialize for McpOAuthConfig {
198    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
199    where
200        S: serde::Serializer,
201    {
202        match self {
203            Self::Auto | Self::Enabled => serializer.serialize_bool(true),
204            Self::Disabled => serializer.serialize_bool(false),
205            Self::Settings(settings) => settings.serialize(serializer),
206        }
207    }
208}
209
210impl<'de> Deserialize<'de> for McpOAuthConfig {
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: serde::Deserializer<'de>,
214    {
215        #[derive(Deserialize)]
216        #[serde(untagged)]
217        enum OAuthValue {
218            Bool(bool),
219            Settings(McpOAuthSettings),
220        }
221
222        Ok(match OAuthValue::deserialize(deserializer)? {
223            OAuthValue::Bool(true) => Self::Enabled,
224            OAuthValue::Bool(false) => Self::Disabled,
225            OAuthValue::Settings(settings) => Self::Settings(settings),
226        })
227    }
228}
229
230impl McpServerConfig {
231    pub fn validate(&self, name: &str) -> Result<(), ConfigError> {
232        let invalid_name =
233            name.is_empty() || name.len() > 128 || name.chars().any(char::is_control);
234        let transport_count = usize::from(self.command.is_some()) + usize::from(self.url.is_some());
235        let invalid_stdio = self.command.as_ref().is_some_and(String::is_empty)
236            || self.cwd.as_ref().is_some_and(String::is_empty);
237        let invalid_remote = self.url.as_ref().is_some_and(|url| {
238            url::Url::parse(url)
239                .map(|parsed| !matches!(parsed.scheme(), "http" | "https"))
240                .unwrap_or(true)
241        });
242        let mixed = self.command.is_some() && (!self.headers.is_empty() || !self.oauth.is_auto())
243            || self.url.is_some()
244                && (!self.args.is_empty() || !self.env.is_empty() || self.cwd.is_some());
245        let invalid_oauth = match &self.oauth {
246            McpOAuthConfig::Settings(settings) => {
247                settings.client_id.as_ref().is_some_and(String::is_empty)
248                    || settings
249                        .client_secret
250                        .as_ref()
251                        .is_some_and(String::is_empty)
252                    || settings.client_secret.is_some() && settings.client_id.is_none()
253                    || settings.scopes.iter().any(String::is_empty)
254                    || settings.client_metadata_url.as_ref().is_some_and(|value| {
255                        url::Url::parse(value)
256                            .map(|url| {
257                                url.scheme() != "https"
258                                    || url.host_str().is_none()
259                                    || url.path() == "/"
260                            })
261                            .unwrap_or(true)
262                    })
263            }
264            McpOAuthConfig::Auto | McpOAuthConfig::Enabled | McpOAuthConfig::Disabled => false,
265        };
266        if invalid_name
267            || transport_count != 1
268            || invalid_stdio
269            || invalid_remote
270            || mixed
271            || invalid_oauth
272            || self.timeout_ms == Some(0)
273        {
274            return Err(ConfigError::McpServer {
275                server: name.to_owned(),
276                reason: "configure exactly one of command or url; stdio-only and remote-only fields may not be mixed; OAuth client settings must be valid; and timeout_ms must be positive".into(),
277            });
278        }
279        Ok(())
280    }
281}
282
283/// The decoded settings tables of `config.toml`: one field per settings table
284/// (for example `[messaging]` → [`EngineConfig::messaging`]). `[mcp]` and
285/// `[plugins]` decode separately onto [`crate::LoadedConfiguration`]. Exposed
286/// on [`crate::LoadedConfiguration::runtime`].
287#[derive(Clone, Debug, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub struct EngineConfig {
290    #[serde(default)]
291    pub server: ServerConfig,
292    #[serde(default)]
293    pub tool_output: ToolOutputConfig,
294    #[serde(default)]
295    pub agent_md: AgentMdConfig,
296    #[serde(default)]
297    pub approval: ApprovalConfig,
298    #[serde(default)]
299    pub model_retry: ModelRetryConfig,
300    #[serde(default)]
301    pub context_compaction: ContextCompactionConfig,
302    #[serde(default)]
303    pub session_title: SessionTitleConfig,
304    #[serde(default)]
305    pub delegation: DelegationConfig,
306    #[serde(default)]
307    pub messaging: MessagingConfig,
308    #[serde(skip)]
309    pub pricing: PricingConfig,
310    #[serde(default, deserialize_with = "deserialize_headers")]
311    pub headers: BTreeMap<HeaderName, SafeStaticHeaderValue>,
312    #[serde(default)]
313    pub providers: BTreeMap<ProviderId, ProviderDefinition>,
314}
315
316#[derive(Debug, Deserialize)]
317#[serde(deny_unknown_fields)]
318pub(crate) struct RawRuntimeLayer {
319    pub(crate) server: Option<ServerConfig>,
320    pub(crate) tool_output: Option<ToolOutputConfig>,
321    pub(crate) agent_md: Option<AgentMdConfig>,
322    pub(crate) approval: Option<ApprovalConfig>,
323    pub(crate) model_retry: Option<ModelRetryConfig>,
324    pub(crate) context_compaction: Option<ContextCompactionConfig>,
325    pub(crate) session_title: Option<SessionTitleConfig>,
326    pub(crate) delegation: Option<DelegationConfig>,
327    pub(crate) messaging: Option<MessagingConfig>,
328    #[serde(default)]
329    #[serde(deserialize_with = "deserialize_optional_headers")]
330    pub(crate) headers: Option<BTreeMap<HeaderName, SafeStaticHeaderValue>>,
331    pub(crate) mcp: Option<McpConfig>,
332    pub(crate) plugins: Option<PluginsConfig>,
333    #[serde(default)]
334    pub(crate) providers: SensitiveProviderValues,
335}
336
337#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
338#[serde(deny_unknown_fields)]
339pub struct ModelRetryConfig {
340    #[serde(default = "default_standard_retries")]
341    pub standard_retries: i64,
342    #[serde(default = "default_overload_retries")]
343    pub overload_retries: i64,
344    #[serde(default = "default_backoff_ceiling_ms")]
345    pub backoff_ceiling_ms: u64,
346}
347
348impl Default for ModelRetryConfig {
349    fn default() -> Self {
350        Self {
351            standard_retries: default_standard_retries(),
352            overload_retries: default_overload_retries(),
353            backoff_ceiling_ms: default_backoff_ceiling_ms(),
354        }
355    }
356}
357
358const fn default_standard_retries() -> i64 {
359    3
360}
361
362const fn default_overload_retries() -> i64 {
363    5
364}
365
366const fn default_backoff_ceiling_ms() -> u64 {
367    60_000
368}
369
370#[derive(Clone, Debug, Default, Deserialize)]
371#[serde(deny_unknown_fields)]
372pub struct PricingConfig {
373    #[serde(default)]
374    pub models: BTreeMap<ModelKey, ModelPricing>,
375}
376
377pub use cookie_agent_models::authoring::ModelPricing;
378
379impl Drop for RawRuntimeLayer {
380    fn drop(&mut self) {
381        if let Some(server) = &mut self.server {
382            server.host.zeroize();
383        }
384        for value in self.providers.values_mut() {
385            zeroize_toml_value(value.value_mut());
386        }
387    }
388}
389
390#[derive(Clone, Debug, Deserialize)]
391#[serde(deny_unknown_fields)]
392pub struct ServerConfig {
393    #[serde(default = "default_host")]
394    pub host: String,
395    #[serde(default = "default_port")]
396    pub port: u16,
397}
398impl Default for ServerConfig {
399    fn default() -> Self {
400        Self {
401            host: default_host(),
402            port: default_port(),
403        }
404    }
405}
406fn default_host() -> String {
407    DEFAULT_HOST.into()
408}
409const fn default_port() -> u16 {
410    DEFAULT_PORT
411}
412
413#[derive(Clone, Debug, Deserialize)]
414#[serde(deny_unknown_fields)]
415pub struct ToolOutputConfig {
416    #[serde(default = "default_output_lines")]
417    pub max_lines: usize,
418    #[serde(default = "default_output_bytes")]
419    pub max_bytes: usize,
420}
421
422#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
423#[serde(deny_unknown_fields)]
424pub struct AgentMdConfig {
425    #[serde(default = "yes")]
426    pub enabled: bool,
427}
428impl Default for AgentMdConfig {
429    fn default() -> Self {
430        Self { enabled: true }
431    }
432}
433impl Default for ToolOutputConfig {
434    fn default() -> Self {
435        Self {
436            max_lines: default_output_lines(),
437            max_bytes: default_output_bytes(),
438        }
439    }
440}
441const fn default_output_lines() -> usize {
442    2_000
443}
444const fn default_output_bytes() -> usize {
445    50 * 1024
446}
447
448#[derive(Clone, Debug, Deserialize)]
449#[serde(deny_unknown_fields)]
450pub struct ApprovalConfig {
451    #[serde(default = "default_approval_timeout")]
452    pub timeout_ms: u64,
453}
454
455#[derive(Clone, Debug, Deserialize)]
456#[serde(deny_unknown_fields)]
457pub struct DelegationConfig {
458    #[serde(default = "default_delegation_depth")]
459    pub max_depth: u32,
460    #[serde(default = "default_delegation_concurrency")]
461    pub max_concurrency: Option<u32>,
462    #[serde(default = "default_max_resident_subagents")]
463    pub max_resident_subagents: usize,
464    #[serde(
465        default = "default_idle_eviction_after",
466        deserialize_with = "deserialize_duration"
467    )]
468    pub idle_eviction_after: Duration,
469}
470impl Default for DelegationConfig {
471    fn default() -> Self {
472        Self {
473            max_depth: default_delegation_depth(),
474            max_concurrency: default_delegation_concurrency(),
475            max_resident_subagents: default_max_resident_subagents(),
476            idle_eviction_after: default_idle_eviction_after(),
477        }
478    }
479}
480const fn default_delegation_depth() -> u32 {
481    3
482}
483const fn default_delegation_concurrency() -> Option<u32> {
484    Some(4)
485}
486const fn default_max_resident_subagents() -> usize {
487    20
488}
489const fn default_idle_eviction_after() -> Duration {
490    Duration::from_secs(60 * 60)
491}
492
493#[derive(Clone, Debug, Deserialize)]
494#[serde(deny_unknown_fields)]
495pub struct MessagingConfig {
496    #[serde(default = "default_messaging_enabled")]
497    pub enabled: bool,
498    /// Deepest accepted agent-mail chain depth. `0` (or any negative value)
499    /// disables the guard; there is no load-time validation beyond the type.
500    #[serde(default)]
501    pub max_hops: i32,
502    #[serde(default = "default_max_body_bytes")]
503    pub max_body_bytes: usize,
504    /// Pending agent mail one recipient may hold at once, from any sender.
505    #[serde(default = "default_max_pending_per_session")]
506    pub max_pending_per_session: usize,
507    /// Unacknowledged mail one directed sender→recipient pair may hold at
508    /// once. `0` disables the guard.
509    #[serde(default = "default_max_inflight_per_pair")]
510    pub max_inflight_per_pair: u32,
511}
512impl Default for MessagingConfig {
513    fn default() -> Self {
514        Self {
515            enabled: default_messaging_enabled(),
516            max_hops: 0,
517            max_body_bytes: default_max_body_bytes(),
518            max_pending_per_session: default_max_pending_per_session(),
519            max_inflight_per_pair: default_max_inflight_per_pair(),
520        }
521    }
522}
523const fn default_messaging_enabled() -> bool {
524    true
525}
526const fn default_max_body_bytes() -> usize {
527    32_768
528}
529const fn default_max_pending_per_session() -> usize {
530    32
531}
532const fn default_max_inflight_per_pair() -> u32 {
533    4
534}
535
536fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
537where
538    D: serde::Deserializer<'de>,
539{
540    let value = String::deserialize(deserializer)?;
541    parse_duration(&value).ok_or_else(|| {
542        serde::de::Error::custom("duration must be an integer followed by ms, s, m, h, or d")
543    })
544}
545
546fn parse_duration(value: &str) -> Option<Duration> {
547    let unit_start = value.find(|character: char| !character.is_ascii_digit())?;
548    let amount = value[..unit_start].parse::<u64>().ok()?;
549    let unit = &value[unit_start..];
550    let milliseconds = match unit {
551        "ms" => amount,
552        "s" => amount.checked_mul(1_000)?,
553        "m" => amount.checked_mul(60_000)?,
554        "h" => amount.checked_mul(3_600_000)?,
555        "d" => amount.checked_mul(86_400_000)?,
556        _ => return None,
557    };
558    Some(Duration::from_millis(milliseconds))
559}
560impl Default for ApprovalConfig {
561    fn default() -> Self {
562        Self {
563            timeout_ms: default_approval_timeout(),
564        }
565    }
566}
567const fn default_approval_timeout() -> u64 {
568    30_000
569}
570
571#[derive(Clone, Debug)]
572pub struct ContextCompactionConfig {
573    pub auto_compaction: bool,
574    pub trigger: ContextCompactionTrigger,
575    pub max_summary_bytes: usize,
576    /// Recent-history tail token budget; zero disables retention. Runtime caps it to available space.
577    pub keep_recent_tokens: u64,
578}
579
580#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
581#[serde(untagged, deny_unknown_fields)]
582pub enum ContextCompactionTrigger {
583    Percent { percent: u8 },
584    BufferTokens { buffer_tokens: u64 },
585}
586
587#[derive(Deserialize)]
588#[serde(deny_unknown_fields)]
589struct RawContextCompactionConfig {
590    #[serde(default = "yes", rename = "auto")]
591    auto_compaction: bool,
592    trigger: Option<ContextCompactionTrigger>,
593    buffer_tokens: Option<u64>,
594    #[serde(default = "default_summary")]
595    max_summary_bytes: usize,
596    #[serde(default = "default_keep_recent_tokens")]
597    keep_recent_tokens: u64,
598}
599
600impl<'de> Deserialize<'de> for ContextCompactionConfig {
601    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
602    where
603        D: serde::Deserializer<'de>,
604    {
605        let raw = RawContextCompactionConfig::deserialize(deserializer)?;
606        let trigger = match (raw.trigger, raw.buffer_tokens) {
607            (Some(_), Some(_)) => {
608                return Err(serde::de::Error::custom(
609                    "trigger and buffer_tokens cannot both be set",
610                ));
611            }
612            (Some(trigger), None) => trigger,
613            (None, Some(buffer_tokens)) => ContextCompactionTrigger::BufferTokens { buffer_tokens },
614            (None, None) => default_compaction_trigger(),
615        };
616        Ok(Self {
617            auto_compaction: raw.auto_compaction,
618            trigger,
619            max_summary_bytes: raw.max_summary_bytes,
620            keep_recent_tokens: raw.keep_recent_tokens,
621        })
622    }
623}
624
625impl Default for ContextCompactionConfig {
626    fn default() -> Self {
627        Self {
628            auto_compaction: true,
629            trigger: default_compaction_trigger(),
630            max_summary_bytes: default_summary(),
631            keep_recent_tokens: default_keep_recent_tokens(),
632        }
633    }
634}
635const fn default_compaction_trigger() -> ContextCompactionTrigger {
636    ContextCompactionTrigger::Percent { percent: 70 }
637}
638const fn default_summary() -> usize {
639    256 * 1024
640}
641const fn default_keep_recent_tokens() -> u64 {
642    16_384
643}
644
645#[derive(Clone, Debug, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct SessionTitleConfig {
648    #[serde(default = "default_title_chars")]
649    pub max_chars: usize,
650    #[serde(default = "default_title_messages")]
651    pub max_input_messages: usize,
652    #[serde(default = "yes")]
653    pub generate_on_first_turn: bool,
654    #[serde(default = "yes")]
655    pub fallback_to_input_excerpt: bool,
656}
657impl Default for SessionTitleConfig {
658    fn default() -> Self {
659        Self {
660            max_chars: default_title_chars(),
661            max_input_messages: default_title_messages(),
662            generate_on_first_turn: true,
663            fallback_to_input_excerpt: true,
664        }
665    }
666}
667const fn default_title_chars() -> usize {
668    80
669}
670const fn default_title_messages() -> usize {
671    4
672}
673const fn yes() -> bool {
674    true
675}
676
677pub(crate) fn apply_settings(runtime: &mut EngineConfig, layer: &RawRuntimeLayer) {
678    if let Some(value) = &layer.server {
679        runtime.server = value.clone();
680    }
681    if let Some(value) = &layer.tool_output {
682        runtime.tool_output = value.clone();
683    }
684    if let Some(value) = &layer.agent_md {
685        runtime.agent_md = value.clone();
686    }
687    if let Some(value) = &layer.approval {
688        runtime.approval = value.clone();
689    }
690    if let Some(value) = layer.model_retry {
691        runtime.model_retry = value;
692    }
693    if let Some(value) = &layer.context_compaction {
694        runtime.context_compaction = value.clone();
695    }
696    if let Some(value) = &layer.session_title {
697        runtime.session_title = value.clone();
698    }
699    if let Some(value) = &layer.delegation {
700        runtime.delegation = value.clone();
701    }
702    if let Some(value) = &layer.messaging {
703        runtime.messaging = value.clone();
704    }
705    if let Some(values) = &layer.headers {
706        for (name, value) in values {
707            if value.as_str().is_empty() {
708                runtime.headers.remove(name);
709            } else {
710                runtime.headers.insert(name.clone(), value.clone());
711            }
712        }
713    }
714}
715
716pub(crate) fn validate_runtime(runtime: &EngineConfig) -> Result<(), ConfigError> {
717    validate_header_ownership(&runtime.headers, "global").map_err(ConfigError::HeaderOwnership)?;
718    validate_header_limits(&runtime.headers).map_err(|_| {
719        ConfigError::InvalidRuntime("headers exceed the header count or size limits")
720    })?;
721    for (invalid, reason) in [
722        (
723            runtime.server.host.is_empty() || runtime.server.host.len() > 255,
724            "server.host must contain 1..255 bytes",
725        ),
726        (
727            runtime.tool_output.max_lines == 0,
728            "tool_output.max_lines must be positive",
729        ),
730        (
731            runtime.tool_output.max_bytes == 0,
732            "tool_output.max_bytes must be positive",
733        ),
734        (
735            runtime.approval.timeout_ms == 0,
736            "approval.timeout_ms must be positive",
737        ),
738        (
739            runtime.model_retry.backoff_ceiling_ms == 0,
740            "model_retry.backoff_ceiling_ms must be positive",
741        ),
742        (
743            runtime.delegation.max_depth == 0,
744            "delegation.max_depth must be positive",
745        ),
746        (
747            runtime.delegation.max_concurrency == Some(0),
748            "delegation.max_concurrency must be positive when set",
749        ),
750    ] {
751        if invalid {
752            return Err(ConfigError::InvalidRuntime(reason));
753        }
754    }
755    let context = &runtime.context_compaction;
756    let invalid_trigger = match &context.trigger {
757        ContextCompactionTrigger::Percent { percent } => !(1..=99).contains(percent),
758        ContextCompactionTrigger::BufferTokens { buffer_tokens } => *buffer_tokens == 0,
759    };
760    if invalid_trigger
761        || context.max_summary_bytes == 0
762        || context.max_summary_bytes > 2 * 1024 * 1024
763    {
764        return Err(ConfigError::InvalidRuntime(
765            "context_compaction.trigger requires percent 1..99 or positive buffer_tokens; max_summary_bytes must be 1..2097152",
766        ));
767    }
768    if runtime.session_title.max_chars == 0 || runtime.session_title.max_input_messages == 0 {
769        return Err(ConfigError::InvalidRuntime(
770            "session_title.max_chars and max_input_messages must be positive",
771        ));
772    }
773    for (id, provider) in &runtime.providers {
774        provider
775            .validate_for(id)
776            .map_err(|source| ConfigError::Provider {
777                provider: id.clone(),
778                source,
779            })?;
780    }
781    Ok(())
782}
783
784fn deserialize_optional_headers<'de, D>(
785    deserializer: D,
786) -> Result<Option<BTreeMap<HeaderName, SafeStaticHeaderValue>>, D::Error>
787where
788    D: serde::Deserializer<'de>,
789{
790    deserialize_headers(deserializer).map(Some)
791}