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}
90
91fn default_nudge_interval() -> u32 {
92 5
93}
94fn default_auto_skill_threshold() -> u32 {
95 5
96}
97fn default_llm_max_retries() -> u32 {
98 3
99}
100fn default_llm_retry_base_ms() -> u64 {
101 1000
102}
103fn default_llm_timeout_secs() -> u64 {
104 120
105}
106fn default_tool_timeout_secs() -> u64 {
107 60
108}
109fn default_shutdown_timeout_secs() -> u64 {
110 30
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct MemoryExpiryConfig {
118 #[serde(default = "default_fact_days")]
120 pub fact_days: Option<u32>,
121 #[serde(default = "default_project_days")]
123 pub project_days: Option<u32>,
124 #[serde(default = "default_other_days")]
126 pub other_days: Option<u32>,
127 #[serde(default)]
129 pub preference_days: Option<u32>,
130 #[serde(default)]
132 pub skill_days: Option<u32>,
133}
134
135#[allow(clippy::unnecessary_wraps)]
136fn default_fact_days() -> Option<u32> {
137 Some(90)
138}
139#[allow(clippy::unnecessary_wraps)]
140fn default_project_days() -> Option<u32> {
141 Some(30)
142}
143#[allow(clippy::unnecessary_wraps)]
144fn default_other_days() -> Option<u32> {
145 Some(60)
146}
147
148impl Default for MemoryExpiryConfig {
149 fn default() -> Self {
150 Self {
151 fact_days: default_fact_days(),
152 project_days: default_project_days(),
153 other_days: default_other_days(),
154 preference_days: None,
155 skill_days: None,
156 }
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
162#[serde(rename_all = "lowercase")]
163pub enum TerminalSandbox {
164 #[default]
166 None,
167 Docker,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct SecurityConfig {
174 #[serde(skip)]
176 pub gateway_api_key: Option<String>,
177
178 #[serde(default)]
180 pub allowed_read_paths: Vec<PathBuf>,
181
182 #[serde(default)]
184 pub allowed_write_paths: Vec<PathBuf>,
185
186 #[serde(default = "default_approval_mode")]
188 pub approval_mode: String,
189
190 #[serde(default)]
192 pub rate_limit_rpm: Option<u32>,
193
194 #[serde(default)]
196 pub terminal_sandbox: TerminalSandbox,
197
198 #[serde(default = "default_sandbox_image")]
200 pub terminal_sandbox_image: String,
201
202 #[serde(default)]
205 pub terminal_sandbox_opts: Vec<String>,
206}
207
208fn default_approval_mode() -> String {
209 "smart".to_string()
210}
211
212fn default_sandbox_image() -> String {
213 "ubuntu:24.04".to_string()
214}
215
216impl Default for SecurityConfig {
217 fn default() -> Self {
218 Self {
219 gateway_api_key: None,
220 allowed_read_paths: Vec::new(),
221 allowed_write_paths: Vec::new(),
222 approval_mode: default_approval_mode(),
223 rate_limit_rpm: None,
224 terminal_sandbox: TerminalSandbox::None,
225 terminal_sandbox_image: default_sandbox_image(),
226 terminal_sandbox_opts: Vec::new(),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct PlatformConfig {
234 #[serde(default)]
237 pub allowed_user_ids: Vec<String>,
238
239 #[serde(default)]
242 pub require_mention: bool,
243
244 #[serde(default)]
247 pub bot_username: String,
248
249 #[serde(default = "default_true")]
253 pub session_per_user: bool,
254}
255
256fn default_true() -> bool {
257 true
258}
259
260impl Default for PlatformConfig {
261 fn default() -> Self {
262 Self {
263 allowed_user_ids: Vec::new(),
264 require_mention: false,
265 bot_username: String::new(),
266 session_per_user: true,
267 }
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct McpServerConfig {
273 pub name: String,
274 pub command: String,
275 #[serde(default)]
276 pub args: Vec<String>,
277}
278
279impl Default for AgentConfig {
280 fn default() -> Self {
281 let cwd = std::env::current_dir().unwrap_or_default();
282 let home = dirs::home_dir().unwrap_or_default();
283 Self {
284 home_dir: Self::garudust_dir(),
285 model: "anthropic/claude-sonnet-4-6".into(),
286 max_iterations: 90,
287 tool_delay_ms: 0,
288 provider: "openrouter".into(),
289 base_url: None,
290 api_key: None,
291 compression: CompressionConfig::default(),
292 network: NetworkConfig::default(),
293 mcp_servers: Vec::new(),
294 max_concurrent_requests: None,
295 security: SecurityConfig {
296 gateway_api_key: None,
297 allowed_read_paths: vec![cwd.clone(), home],
298 allowed_write_paths: vec![cwd],
299 approval_mode: default_approval_mode(),
300 rate_limit_rpm: None,
301 terminal_sandbox: TerminalSandbox::None,
302 terminal_sandbox_image: default_sandbox_image(),
303 terminal_sandbox_opts: Vec::new(),
304 },
305 memory_expiry: MemoryExpiryConfig::default(),
306 nudge_interval: default_nudge_interval(),
307 llm_max_retries: default_llm_max_retries(),
308 llm_retry_base_ms: default_llm_retry_base_ms(),
309 platform: PlatformConfig::default(),
310 auto_skill_threshold: default_auto_skill_threshold(),
311 llm_timeout_secs: default_llm_timeout_secs(),
312 tool_timeout_secs: default_tool_timeout_secs(),
313 shutdown_timeout_secs: default_shutdown_timeout_secs(),
314 }
315 }
316}
317
318impl AgentConfig {
319 pub fn garudust_dir() -> PathBuf {
321 dirs::home_dir()
322 .unwrap_or_else(|| PathBuf::from("/tmp"))
323 .join(".garudust")
324 }
325
326 pub fn load() -> Self {
334 let home_dir = Self::garudust_dir();
335
336 let env_file = home_dir.join(".env");
338 let dotenv = load_dotenv_once(&env_file);
339
340 let yaml_path = home_dir.join("config.yaml");
342 let mut config: AgentConfig = if yaml_path.exists() {
343 let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
344 serde_yaml::from_str(&src).unwrap_or_default()
345 } else {
346 AgentConfig::default()
347 };
348
349 config.home_dir = home_dir;
350
351 if config.security.allowed_read_paths.is_empty() {
353 let cwd = std::env::current_dir().unwrap_or_default();
354 let home = dirs::home_dir().unwrap_or_default();
355 config.security.allowed_read_paths = vec![cwd.clone(), home];
356 config.security.allowed_write_paths = vec![cwd];
357 }
358
359 if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
361 config.api_key = Some(k);
362 config.provider = "anthropic".into();
363 } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
364 config.api_key = Some(k);
365 } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
366 config.provider = "ollama".into();
367 config.base_url = Some(url);
368 } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
369 config.provider = "vllm".into();
370 config.base_url = Some(url);
371 if let Some(k) = env_or_dotenv("VLLM_API_KEY", dotenv) {
372 config.api_key = Some(k);
373 }
374 }
375 if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
376 config.model = m;
377 }
378 if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
379 config.base_url = Some(u);
380 }
381 if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
382 config.security.gateway_api_key = Some(k);
383 }
384 if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
385 if let Ok(n) = v.parse::<u32>() {
386 config.security.rate_limit_rpm = Some(n);
387 }
388 }
389 if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
390 config.security.approval_mode = mode;
391 }
392 if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
393 config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
394 "docker" => TerminalSandbox::Docker,
395 _ => TerminalSandbox::None,
396 };
397 }
398 if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
399 config.security.terminal_sandbox_image = image;
400 }
401
402 config
403 }
404
405 pub fn save_yaml(&self) -> std::io::Result<()> {
407 std::fs::create_dir_all(&self.home_dir)?;
408 let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
409 std::fs::write(self.home_dir.join("config.yaml"), yaml)
410 }
411
412 pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
414 std::fs::create_dir_all(home_dir)?;
415 let env_path = home_dir.join(".env");
416 let existing = if env_path.exists() {
417 std::fs::read_to_string(&env_path)?
418 } else {
419 String::new()
420 };
421
422 let prefix = format!("{key}=");
423 let mut lines: Vec<String> = existing
424 .lines()
425 .filter(|l| !l.starts_with(&prefix))
426 .map(String::from)
427 .collect();
428 lines.push(format!("{key}={value}"));
429
430 std::fs::write(&env_path, lines.join("\n") + "\n")
431 }
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct CompressionConfig {
438 pub enabled: bool,
439 pub threshold_fraction: f32,
440 pub model: Option<String>,
441}
442
443impl Default for CompressionConfig {
444 fn default() -> Self {
445 Self {
446 enabled: true,
447 threshold_fraction: 0.8,
448 model: None,
449 }
450 }
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, Default)]
454pub struct NetworkConfig {
455 pub force_ipv4: bool,
456 pub proxy: Option<String>,
457}