1mod 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 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 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 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 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}