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