1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use serde::{Deserialize, Serialize};
6
7static DOTENV_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
8
9fn load_dotenv_once(path: &Path) -> &'static HashMap<String, String> {
12 DOTENV_VARS.get_or_init(|| {
13 let mut map = HashMap::new();
14 let Ok(content) = std::fs::read_to_string(path) else {
15 return map;
16 };
17 for line in content.lines() {
18 let line = line.trim();
19 if line.is_empty() || line.starts_with('#') {
20 continue;
21 }
22 if let Some((k, v)) = line.split_once('=') {
23 let k = k.trim().to_string();
24 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
25 map.insert(k, v);
26 }
27 }
28 map
29 })
30}
31
32fn env_or_dotenv(key: &str, dotenv: &HashMap<String, String>) -> Option<String> {
34 std::env::var(key)
35 .ok()
36 .filter(|v| !v.is_empty())
37 .or_else(|| dotenv.get(key).filter(|v| !v.is_empty()).cloned())
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AgentConfig {
42 #[serde(skip)]
43 pub home_dir: PathBuf,
44 pub model: String,
45 pub max_iterations: u32,
46 pub tool_delay_ms: u64,
47 pub provider: String,
48 pub base_url: Option<String>,
49 #[serde(skip)]
50 pub api_key: Option<String>,
51 pub compression: CompressionConfig,
52 pub network: NetworkConfig,
53 #[serde(default)]
54 pub mcp_servers: Vec<McpServerConfig>,
55 #[serde(default)]
56 pub max_concurrent_requests: Option<usize>,
57 #[serde(default)]
58 pub security: SecurityConfig,
59 #[serde(default)]
60 pub memory_expiry: MemoryExpiryConfig,
61 #[serde(default = "default_nudge_interval")]
64 pub nudge_interval: u32,
65 #[serde(default = "default_llm_max_retries")]
67 pub llm_max_retries: u32,
68 #[serde(default = "default_llm_retry_base_ms")]
70 pub llm_retry_base_ms: u64,
71 #[serde(default)]
73 pub platform: PlatformConfig,
74 #[serde(default = "default_auto_skill_threshold")]
78 pub auto_skill_threshold: u32,
79 #[serde(default = "default_llm_timeout_secs")]
81 pub llm_timeout_secs: u64,
82 #[serde(default = "default_tool_timeout_secs")]
84 pub tool_timeout_secs: u64,
85 #[serde(default = "default_shutdown_timeout_secs")]
88 pub shutdown_timeout_secs: u64,
89 #[serde(default)]
93 pub max_tokens_per_task: Option<u32>,
94}
95
96fn default_nudge_interval() -> u32 {
97 5
98}
99fn default_auto_skill_threshold() -> u32 {
100 5
101}
102fn default_llm_max_retries() -> u32 {
103 3
104}
105fn default_llm_retry_base_ms() -> u64 {
106 1000
107}
108fn default_llm_timeout_secs() -> u64 {
109 120
110}
111fn default_tool_timeout_secs() -> u64 {
112 60
113}
114fn default_shutdown_timeout_secs() -> u64 {
115 30
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct MemoryExpiryConfig {
123 #[serde(default = "default_fact_days")]
125 pub fact_days: Option<u32>,
126 #[serde(default = "default_project_days")]
128 pub project_days: Option<u32>,
129 #[serde(default = "default_other_days")]
131 pub other_days: Option<u32>,
132 #[serde(default)]
134 pub preference_days: Option<u32>,
135 #[serde(default)]
137 pub skill_days: Option<u32>,
138}
139
140#[allow(clippy::unnecessary_wraps)]
141fn default_fact_days() -> Option<u32> {
142 Some(90)
143}
144#[allow(clippy::unnecessary_wraps)]
145fn default_project_days() -> Option<u32> {
146 Some(30)
147}
148#[allow(clippy::unnecessary_wraps)]
149fn default_other_days() -> Option<u32> {
150 Some(60)
151}
152
153impl Default for MemoryExpiryConfig {
154 fn default() -> Self {
155 Self {
156 fact_days: default_fact_days(),
157 project_days: default_project_days(),
158 other_days: default_other_days(),
159 preference_days: None,
160 skill_days: None,
161 }
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
167#[serde(rename_all = "lowercase")]
168pub enum TerminalSandbox {
169 #[default]
171 None,
172 Docker,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SecurityConfig {
179 #[serde(skip)]
181 pub gateway_api_key: Option<String>,
182
183 #[serde(default)]
185 pub allowed_read_paths: Vec<PathBuf>,
186
187 #[serde(default)]
189 pub allowed_write_paths: Vec<PathBuf>,
190
191 #[serde(default = "default_approval_mode")]
193 pub approval_mode: String,
194
195 #[serde(default)]
197 pub rate_limit_rpm: Option<u32>,
198
199 #[serde(default)]
201 pub terminal_sandbox: TerminalSandbox,
202
203 #[serde(default = "default_sandbox_image")]
205 pub terminal_sandbox_image: String,
206
207 #[serde(default)]
210 pub terminal_sandbox_opts: Vec<String>,
211}
212
213fn default_approval_mode() -> String {
214 "smart".to_string()
215}
216
217fn default_sandbox_image() -> String {
218 "ubuntu:24.04".to_string()
219}
220
221impl Default for SecurityConfig {
222 fn default() -> Self {
223 Self {
224 gateway_api_key: None,
225 allowed_read_paths: Vec::new(),
226 allowed_write_paths: Vec::new(),
227 approval_mode: default_approval_mode(),
228 rate_limit_rpm: None,
229 terminal_sandbox: TerminalSandbox::None,
230 terminal_sandbox_image: default_sandbox_image(),
231 terminal_sandbox_opts: Vec::new(),
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct PlatformConfig {
239 #[serde(default)]
242 pub allowed_user_ids: Vec<String>,
243
244 #[serde(default)]
247 pub require_mention: bool,
248
249 #[serde(default)]
252 pub bot_username: String,
253
254 #[serde(default = "default_true")]
258 pub session_per_user: bool,
259}
260
261fn default_true() -> bool {
262 true
263}
264
265impl Default for PlatformConfig {
266 fn default() -> Self {
267 Self {
268 allowed_user_ids: Vec::new(),
269 require_mention: false,
270 bot_username: String::new(),
271 session_per_user: true,
272 }
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct McpServerConfig {
278 pub name: String,
279 pub command: String,
280 #[serde(default)]
281 pub args: Vec<String>,
282}
283
284impl Default for AgentConfig {
285 fn default() -> Self {
286 let cwd = std::env::current_dir().unwrap_or_default();
287 let home = dirs::home_dir().unwrap_or_default();
288 Self {
289 home_dir: Self::garudust_dir(),
290 model: "anthropic/claude-sonnet-4-6".into(),
291 max_iterations: 90,
292 tool_delay_ms: 0,
293 provider: "openrouter".into(),
294 base_url: None,
295 api_key: None,
296 compression: CompressionConfig::default(),
297 network: NetworkConfig::default(),
298 mcp_servers: Vec::new(),
299 max_concurrent_requests: None,
300 security: SecurityConfig {
301 gateway_api_key: None,
302 allowed_read_paths: vec![cwd.clone(), home],
303 allowed_write_paths: vec![cwd],
304 approval_mode: default_approval_mode(),
305 rate_limit_rpm: None,
306 terminal_sandbox: TerminalSandbox::None,
307 terminal_sandbox_image: default_sandbox_image(),
308 terminal_sandbox_opts: Vec::new(),
309 },
310 memory_expiry: MemoryExpiryConfig::default(),
311 nudge_interval: default_nudge_interval(),
312 llm_max_retries: default_llm_max_retries(),
313 llm_retry_base_ms: default_llm_retry_base_ms(),
314 platform: PlatformConfig::default(),
315 auto_skill_threshold: default_auto_skill_threshold(),
316 llm_timeout_secs: default_llm_timeout_secs(),
317 tool_timeout_secs: default_tool_timeout_secs(),
318 shutdown_timeout_secs: default_shutdown_timeout_secs(),
319 max_tokens_per_task: None,
320 }
321 }
322}
323
324impl AgentConfig {
325 pub fn garudust_dir() -> PathBuf {
327 dirs::home_dir()
328 .unwrap_or_else(|| PathBuf::from("/tmp"))
329 .join(".garudust")
330 }
331
332 pub fn load() -> Self {
340 let home_dir = Self::garudust_dir();
341
342 let env_file = home_dir.join(".env");
344 let dotenv = load_dotenv_once(&env_file);
345
346 let yaml_path = home_dir.join("config.yaml");
348 let mut config: AgentConfig = if yaml_path.exists() {
349 let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
350 serde_yaml::from_str(&src).unwrap_or_default()
351 } else {
352 AgentConfig::default()
353 };
354
355 config.home_dir = home_dir;
356
357 if config.security.allowed_read_paths.is_empty() {
359 let cwd = std::env::current_dir().unwrap_or_default();
360 let home = dirs::home_dir().unwrap_or_default();
361 config.security.allowed_read_paths = vec![cwd.clone(), home];
362 config.security.allowed_write_paths = vec![cwd];
363 }
364
365 if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
367 config.api_key = Some(k);
368 config.provider = "anthropic".into();
369 } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
370 config.api_key = Some(k);
371 } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
372 config.provider = "ollama".into();
373 config.base_url = Some(url);
374 } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
375 config.provider = "vllm".into();
376 config.base_url = Some(url);
377 if let Some(k) = env_or_dotenv("VLLM_API_KEY", dotenv) {
378 config.api_key = Some(k);
379 }
380 }
381 if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
382 config.model = m;
383 }
384 if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
385 config.base_url = Some(u);
386 }
387 if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
388 config.security.gateway_api_key = Some(k);
389 }
390 if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
391 if let Ok(n) = v.parse::<u32>() {
392 config.security.rate_limit_rpm = Some(n);
393 }
394 }
395 if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
396 config.security.approval_mode = mode;
397 }
398 if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
399 config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
400 "docker" => TerminalSandbox::Docker,
401 _ => TerminalSandbox::None,
402 };
403 }
404 if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
405 config.security.terminal_sandbox_image = image;
406 }
407
408 config
409 }
410
411 pub fn save_yaml(&self) -> std::io::Result<()> {
413 std::fs::create_dir_all(&self.home_dir)?;
414 let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
415 std::fs::write(self.home_dir.join("config.yaml"), yaml)
416 }
417
418 pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
420 std::fs::create_dir_all(home_dir)?;
421 let env_path = home_dir.join(".env");
422 let existing = if env_path.exists() {
423 std::fs::read_to_string(&env_path)?
424 } else {
425 String::new()
426 };
427
428 let prefix = format!("{key}=");
429 let mut lines: Vec<String> = existing
430 .lines()
431 .filter(|l| !l.starts_with(&prefix))
432 .map(String::from)
433 .collect();
434 lines.push(format!("{key}={value}"));
435
436 std::fs::write(&env_path, lines.join("\n") + "\n")
437 }
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct CompressionConfig {
444 pub enabled: bool,
445 pub threshold_fraction: f32,
446 pub model: Option<String>,
447}
448
449impl Default for CompressionConfig {
450 fn default() -> Self {
451 Self {
452 enabled: true,
453 threshold_fraction: 0.8,
454 model: None,
455 }
456 }
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, Default)]
460pub struct NetworkConfig {
461 pub force_ipv4: bool,
462 pub proxy: Option<String>,
463}