Skip to main content

zeph_core/config/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4mod env;
5mod types;
6
7#[cfg(test)]
8mod tests;
9
10pub use types::*;
11pub use zeph_tools::AutonomyLevel;
12
13use std::path::Path;
14
15use crate::vault::VaultProvider;
16
17#[derive(Debug, thiserror::Error)]
18pub enum ConfigError {
19    #[error("failed to read config file: {0}")]
20    Io(#[from] std::io::Error),
21    #[error("failed to parse config file: {0}")]
22    Parse(#[from] toml::de::Error),
23    #[error("config validation failed: {0}")]
24    Validation(String),
25    #[error("vault error: {0}")]
26    Vault(#[from] anyhow::Error),
27}
28
29impl Config {
30    /// Load configuration from a TOML file with env var overrides.
31    ///
32    /// Falls back to sensible defaults when the file does not exist.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if the file exists but cannot be read or parsed.
37    pub fn load(path: &Path) -> Result<Self, ConfigError> {
38        let mut config = if path.exists() {
39            let content = std::fs::read_to_string(path)?;
40            toml::from_str::<Self>(&content)?
41        } else {
42            Self::default()
43        };
44
45        config.apply_env_overrides();
46        Ok(config)
47    }
48
49    /// Validate configuration values are within sane bounds.
50    ///
51    /// # Errors
52    ///
53    /// Returns an error if any value is out of range.
54    pub fn validate(&self) -> Result<(), ConfigError> {
55        if self.memory.history_limit > 10_000 {
56            return Err(ConfigError::Validation(format!(
57                "history_limit must be <= 10000, got {}",
58                self.memory.history_limit
59            )));
60        }
61        if self.memory.context_budget_tokens > 1_000_000 {
62            return Err(ConfigError::Validation(format!(
63                "context_budget_tokens must be <= 1000000, got {}",
64                self.memory.context_budget_tokens
65            )));
66        }
67        if self.agent.max_tool_iterations > 100 {
68            return Err(ConfigError::Validation(format!(
69                "max_tool_iterations must be <= 100, got {}",
70                self.agent.max_tool_iterations
71            )));
72        }
73        if self.a2a.rate_limit == 0 {
74            return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
75        }
76        if self.gateway.rate_limit == 0 {
77            return Err(ConfigError::Validation(
78                "gateway.rate_limit must be > 0".into(),
79            ));
80        }
81        if self.gateway.max_body_size > 10_485_760 {
82            return Err(ConfigError::Validation(format!(
83                "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
84                self.gateway.max_body_size
85            )));
86        }
87        if self.memory.token_safety_margin <= 0.0 {
88            return Err(ConfigError::Validation(format!(
89                "token_safety_margin must be > 0.0, got {}",
90                self.memory.token_safety_margin
91            )));
92        }
93        if self.memory.tool_call_cutoff == 0 {
94            return Err(ConfigError::Validation(
95                "tool_call_cutoff must be >= 1".into(),
96            ));
97        }
98        if let crate::config::CompressionStrategy::Proactive {
99            threshold_tokens,
100            max_summary_tokens,
101        } = &self.memory.compression.strategy
102        {
103            if *threshold_tokens < 1_000 {
104                return Err(ConfigError::Validation(format!(
105                    "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
106                )));
107            }
108            if *max_summary_tokens < 128 {
109                return Err(ConfigError::Validation(format!(
110                    "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
111                )));
112            }
113        }
114        if !self.memory.compaction_threshold.is_finite()
115            || self.memory.compaction_threshold <= 0.0
116            || self.memory.compaction_threshold >= 1.0
117        {
118            return Err(ConfigError::Validation(format!(
119                "compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
120                self.memory.compaction_threshold
121            )));
122        }
123        if !self.memory.deferred_apply_threshold.is_finite()
124            || self.memory.deferred_apply_threshold <= 0.0
125            || self.memory.deferred_apply_threshold >= 1.0
126        {
127            return Err(ConfigError::Validation(format!(
128                "deferred_apply_threshold must be in (0.0, 1.0) exclusive, got {}",
129                self.memory.deferred_apply_threshold
130            )));
131        }
132        if self.memory.deferred_apply_threshold >= self.memory.compaction_threshold {
133            return Err(ConfigError::Validation(format!(
134                "deferred_apply_threshold ({}) must be less than compaction_threshold ({})",
135                self.memory.deferred_apply_threshold, self.memory.compaction_threshold,
136            )));
137        }
138        self.experiments.validate()?;
139        Ok(())
140    }
141
142    /// Resolve sensitive configuration values through the vault.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the vault backend fails.
147    pub async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> {
148        use crate::vault::Secret;
149
150        if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? {
151            self.secrets.claude_api_key = Some(Secret::new(val));
152        }
153        if let Some(val) = vault.get_secret("ZEPH_OPENAI_API_KEY").await? {
154            self.secrets.openai_api_key = Some(Secret::new(val));
155        }
156        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await? {
157            let tg = self.telegram.get_or_insert(TelegramConfig {
158                token: None,
159                allowed_users: Vec::new(),
160            });
161            tg.token = Some(val);
162        }
163        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
164            self.a2a.auth_token = Some(val);
165        }
166        if let Some(ref entries) = self.llm.compatible {
167            for entry in entries {
168                let env_key = format!("ZEPH_COMPATIBLE_{}_API_KEY", entry.name.to_uppercase());
169                if let Some(val) = vault.get_secret(&env_key).await? {
170                    self.secrets
171                        .compatible_api_keys
172                        .insert(entry.name.clone(), Secret::new(val));
173                }
174            }
175        }
176        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
177            self.gateway.auth_token = Some(val);
178        }
179        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await? {
180            let dc = self.discord.get_or_insert(DiscordConfig {
181                token: None,
182                application_id: None,
183                allowed_user_ids: Vec::new(),
184                allowed_role_ids: Vec::new(),
185                allowed_channel_ids: Vec::new(),
186            });
187            dc.token = Some(val);
188        }
189        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
190            && let Some(dc) = self.discord.as_mut()
191        {
192            dc.application_id = Some(val);
193        }
194        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await? {
195            let sl = self.slack.get_or_insert(SlackConfig {
196                bot_token: None,
197                signing_secret: None,
198                webhook_host: "127.0.0.1".into(),
199                port: 3000,
200                allowed_user_ids: Vec::new(),
201                allowed_channel_ids: Vec::new(),
202            });
203            sl.bot_token = Some(val);
204        }
205        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
206            && let Some(sl) = self.slack.as_mut()
207        {
208            sl.signing_secret = Some(val);
209        }
210        for key in vault.list_keys() {
211            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
212                && !custom_name.is_empty()
213                && let Some(val) = vault.get_secret(&key).await?
214            {
215                // Canonical form uses underscores. Both `_` and `-` in vault key names
216                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
217                // both map to "my_key", matching SKILL.md requires-secrets parsing.
218                let normalized = custom_name.to_lowercase().replace('-', "_");
219                self.secrets.custom.insert(normalized, Secret::new(val));
220            }
221        }
222        Ok(())
223    }
224}