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] crate::vault::VaultError),
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        if self.memory.graph.temporal_decay_rate < 0.0
141            || self.memory.graph.temporal_decay_rate > 10.0
142        {
143            return Err(ConfigError::Validation(format!(
144                "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
145                self.memory.graph.temporal_decay_rate
146            )));
147        }
148        self.experiments.validate()?;
149        Ok(())
150    }
151
152    /// Resolve sensitive configuration values through the vault.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the vault backend fails.
157    pub async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> {
158        use crate::vault::Secret;
159
160        if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? {
161            self.secrets.claude_api_key = Some(Secret::new(val));
162        }
163        if let Some(val) = vault.get_secret("ZEPH_OPENAI_API_KEY").await? {
164            self.secrets.openai_api_key = Some(Secret::new(val));
165        }
166        if let Some(val) = vault.get_secret("ZEPH_GEMINI_API_KEY").await? {
167            self.secrets.gemini_api_key = Some(Secret::new(val));
168        }
169        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await? {
170            let tg = self.telegram.get_or_insert(TelegramConfig {
171                token: None,
172                allowed_users: Vec::new(),
173            });
174            tg.token = Some(val);
175        }
176        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
177            self.a2a.auth_token = Some(val);
178        }
179        if let Some(ref entries) = self.llm.compatible {
180            for entry in entries {
181                let env_key = format!("ZEPH_COMPATIBLE_{}_API_KEY", entry.name.to_uppercase());
182                if let Some(val) = vault.get_secret(&env_key).await? {
183                    self.secrets
184                        .compatible_api_keys
185                        .insert(entry.name.clone(), Secret::new(val));
186                }
187            }
188        }
189        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
190            self.gateway.auth_token = Some(val);
191        }
192        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await? {
193            let dc = self.discord.get_or_insert(DiscordConfig {
194                token: None,
195                application_id: None,
196                allowed_user_ids: Vec::new(),
197                allowed_role_ids: Vec::new(),
198                allowed_channel_ids: Vec::new(),
199            });
200            dc.token = Some(val);
201        }
202        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
203            && let Some(dc) = self.discord.as_mut()
204        {
205            dc.application_id = Some(val);
206        }
207        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await? {
208            let sl = self.slack.get_or_insert(SlackConfig {
209                bot_token: None,
210                signing_secret: None,
211                webhook_host: "127.0.0.1".into(),
212                port: 3000,
213                allowed_user_ids: Vec::new(),
214                allowed_channel_ids: Vec::new(),
215            });
216            sl.bot_token = Some(val);
217        }
218        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
219            && let Some(sl) = self.slack.as_mut()
220        {
221            sl.signing_secret = Some(val);
222        }
223        for key in vault.list_keys() {
224            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
225                && !custom_name.is_empty()
226                && let Some(val) = vault.get_secret(&key).await?
227            {
228                // Canonical form uses underscores. Both `_` and `-` in vault key names
229                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
230                // both map to "my_key", matching SKILL.md requires-secrets parsing.
231                let normalized = custom_name.to_lowercase().replace('-', "_");
232                self.secrets.custom.insert(normalized, Secret::new(val));
233            }
234        }
235        Ok(())
236    }
237
238    fn normalize_legacy_runtime_defaults(&mut self) {
239        if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
240            self.memory.sqlite_path = default_sqlite_path();
241        }
242
243        for skill_path in &mut self.skills.paths {
244            if is_legacy_default_skills_path(skill_path) {
245                *skill_path = default_skills_dir();
246            }
247        }
248
249        if is_legacy_default_debug_dir(&self.debug.output_dir) {
250            self.debug.output_dir = default_debug_dir();
251        }
252
253        if is_legacy_default_log_file(&self.logging.file) {
254            self.logging.file = default_log_file_path();
255        }
256    }
257}