1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use serde::{Deserialize, Serialize};
6
7use crate::types::ReasoningEffort;
8
9static DOTENV_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
10
11fn load_dotenv_once(path: &Path) -> &'static HashMap<String, String> {
14 DOTENV_VARS.get_or_init(|| {
15 let mut map = HashMap::new();
16 let Ok(content) = std::fs::read_to_string(path) else {
17 return map;
18 };
19 for line in content.lines() {
20 let line = line.trim();
21 if line.is_empty() || line.starts_with('#') {
22 continue;
23 }
24 if let Some((k, v)) = line.split_once('=') {
25 let k = k.trim().to_string();
26 let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
27 map.insert(k, v);
28 }
29 }
30 map
31 })
32}
33
34fn env_or_dotenv(key: &str, dotenv: &HashMap<String, String>) -> Option<String> {
36 std::env::var(key)
37 .ok()
38 .filter(|v| !v.is_empty())
39 .or_else(|| dotenv.get(key).filter(|v| !v.is_empty()).cloned())
40}
41
42pub fn get_secret(key: &str) -> Option<String> {
45 std::env::var(key)
46 .ok()
47 .filter(|v| !v.is_empty())
48 .or_else(|| {
49 DOTENV_VARS
50 .get()?
51 .get(key)
52 .filter(|v| !v.is_empty())
53 .cloned()
54 })
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct AgentConfig {
59 #[serde(skip)]
60 pub home_dir: PathBuf,
61 #[serde(default = "default_model")]
62 pub model: String,
63 #[serde(default = "default_max_iterations")]
64 pub max_iterations: u32,
65 #[serde(default)]
66 pub tool_delay_ms: u64,
67 #[serde(default = "default_provider")]
68 pub provider: String,
69 pub base_url: Option<String>,
70 #[serde(skip)]
71 pub api_key: Option<String>,
72 #[serde(default)]
73 pub compression: CompressionConfig,
74 #[serde(default)]
75 pub network: NetworkConfig,
76 #[serde(default)]
77 pub mcp_servers: Vec<McpServerConfig>,
78 #[serde(default)]
79 pub max_concurrent_requests: Option<usize>,
80 #[serde(default)]
81 pub security: SecurityConfig,
82 #[serde(default)]
83 pub memory_expiry: MemoryExpiryConfig,
84 #[serde(default = "default_nudge_interval")]
87 pub nudge_interval: u32,
88 #[serde(default = "default_llm_max_retries")]
90 pub llm_max_retries: u32,
91 #[serde(default = "default_llm_retry_base_ms")]
93 pub llm_retry_base_ms: u64,
94 #[serde(default)]
96 pub platform: PlatformConfig,
97 #[serde(default = "default_auto_skill_threshold")]
101 pub auto_skill_threshold: u32,
102 #[serde(default = "default_llm_timeout_secs")]
104 pub llm_timeout_secs: u64,
105 #[serde(default = "default_tool_timeout_secs")]
107 pub tool_timeout_secs: u64,
108 #[serde(default = "default_shutdown_timeout_secs")]
111 pub shutdown_timeout_secs: u64,
112 #[serde(default)]
116 pub max_tokens_per_task: Option<u32>,
117 #[serde(default)]
120 pub max_output_tokens: Option<u32>,
121 #[serde(default)]
124 pub reasoning_effort: Option<ReasoningEffort>,
125}
126
127fn default_model() -> String {
128 "anthropic/claude-sonnet-4-6".into()
129}
130fn default_provider() -> String {
131 "openrouter".into()
132}
133fn default_max_iterations() -> u32 {
134 90
135}
136fn default_nudge_interval() -> u32 {
137 5
138}
139fn default_auto_skill_threshold() -> u32 {
140 5
141}
142fn default_llm_max_retries() -> u32 {
143 3
144}
145fn default_llm_retry_base_ms() -> u64 {
146 1000
147}
148fn default_llm_timeout_secs() -> u64 {
149 120
150}
151fn default_tool_timeout_secs() -> u64 {
152 60
153}
154fn default_shutdown_timeout_secs() -> u64 {
155 30
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct MemoryExpiryConfig {
163 #[serde(default = "default_fact_days")]
165 pub fact_days: Option<u32>,
166 #[serde(default = "default_project_days")]
168 pub project_days: Option<u32>,
169 #[serde(default = "default_other_days")]
171 pub other_days: Option<u32>,
172 #[serde(default)]
174 pub preference_days: Option<u32>,
175 #[serde(default)]
177 pub skill_days: Option<u32>,
178}
179
180#[allow(clippy::unnecessary_wraps)]
181fn default_fact_days() -> Option<u32> {
182 Some(90)
183}
184#[allow(clippy::unnecessary_wraps)]
185fn default_project_days() -> Option<u32> {
186 Some(30)
187}
188#[allow(clippy::unnecessary_wraps)]
189fn default_other_days() -> Option<u32> {
190 Some(60)
191}
192
193impl Default for MemoryExpiryConfig {
194 fn default() -> Self {
195 Self {
196 fact_days: default_fact_days(),
197 project_days: default_project_days(),
198 other_days: default_other_days(),
199 preference_days: None,
200 skill_days: None,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
207#[serde(rename_all = "lowercase")]
208pub enum TerminalSandbox {
209 #[default]
211 None,
212 Docker,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SecurityConfig {
219 #[serde(skip)]
221 pub gateway_api_key: Option<String>,
222
223 #[serde(default)]
225 pub allowed_read_paths: Vec<PathBuf>,
226
227 #[serde(default)]
229 pub allowed_write_paths: Vec<PathBuf>,
230
231 #[serde(default = "default_approval_mode")]
233 pub approval_mode: String,
234
235 #[serde(default)]
237 pub rate_limit_rpm: Option<u32>,
238
239 #[serde(default)]
241 pub terminal_sandbox: TerminalSandbox,
242
243 #[serde(default = "default_sandbox_image")]
245 pub terminal_sandbox_image: String,
246
247 #[serde(default)]
250 pub terminal_sandbox_opts: Vec<String>,
251}
252
253fn default_approval_mode() -> String {
254 "smart".to_string()
255}
256
257fn default_sandbox_image() -> String {
258 "ubuntu:24.04".to_string()
259}
260
261impl Default for SecurityConfig {
262 fn default() -> Self {
263 Self {
264 gateway_api_key: None,
265 allowed_read_paths: Vec::new(),
266 allowed_write_paths: Vec::new(),
267 approval_mode: default_approval_mode(),
268 rate_limit_rpm: None,
269 terminal_sandbox: TerminalSandbox::None,
270 terminal_sandbox_image: default_sandbox_image(),
271 terminal_sandbox_opts: Vec::new(),
272 }
273 }
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct PlatformConfig {
279 #[serde(default)]
282 pub allowed_user_ids: Vec<String>,
283
284 #[serde(default)]
287 pub require_mention: bool,
288
289 #[serde(default)]
292 pub bot_username: String,
293
294 #[serde(default = "default_true")]
298 pub session_per_user: bool,
299}
300
301fn default_true() -> bool {
302 true
303}
304
305impl Default for PlatformConfig {
306 fn default() -> Self {
307 Self {
308 allowed_user_ids: Vec::new(),
309 require_mention: false,
310 bot_username: String::new(),
311 session_per_user: true,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct McpServerConfig {
318 pub name: String,
319 pub command: String,
320 #[serde(default)]
321 pub args: Vec<String>,
322}
323
324impl Default for AgentConfig {
325 fn default() -> Self {
326 let cwd = std::env::current_dir().unwrap_or_default();
327 let home = dirs::home_dir().unwrap_or_default();
328 Self {
329 home_dir: Self::garudust_dir(),
330 model: "anthropic/claude-sonnet-4-6".into(),
331 max_iterations: 90,
332 tool_delay_ms: 0,
333 provider: "openrouter".into(),
334 base_url: None,
335 api_key: None,
336 compression: CompressionConfig::default(),
337 network: NetworkConfig::default(),
338 mcp_servers: Vec::new(),
339 max_concurrent_requests: None,
340 security: SecurityConfig {
341 gateway_api_key: None,
342 allowed_read_paths: vec![cwd.clone(), home],
343 allowed_write_paths: vec![cwd],
344 approval_mode: default_approval_mode(),
345 rate_limit_rpm: None,
346 terminal_sandbox: TerminalSandbox::None,
347 terminal_sandbox_image: default_sandbox_image(),
348 terminal_sandbox_opts: Vec::new(),
349 },
350 memory_expiry: MemoryExpiryConfig::default(),
351 nudge_interval: default_nudge_interval(),
352 llm_max_retries: default_llm_max_retries(),
353 llm_retry_base_ms: default_llm_retry_base_ms(),
354 platform: PlatformConfig::default(),
355 auto_skill_threshold: default_auto_skill_threshold(),
356 llm_timeout_secs: default_llm_timeout_secs(),
357 tool_timeout_secs: default_tool_timeout_secs(),
358 shutdown_timeout_secs: default_shutdown_timeout_secs(),
359 max_tokens_per_task: None,
360 max_output_tokens: None,
361 reasoning_effort: None,
362 }
363 }
364}
365
366impl AgentConfig {
367 pub fn garudust_dir() -> PathBuf {
369 dirs::home_dir()
370 .unwrap_or_else(|| PathBuf::from("/tmp"))
371 .join(".garudust")
372 }
373
374 pub fn load() -> Self {
382 let home_dir = Self::garudust_dir();
383
384 let env_file = home_dir.join(".env");
386 let dotenv = load_dotenv_once(&env_file);
387
388 let yaml_path = home_dir.join("config.yaml");
390 let mut config: AgentConfig = if yaml_path.exists() {
391 let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
392 serde_yaml::from_str(&src).unwrap_or_default()
393 } else {
394 AgentConfig::default()
395 };
396
397 config.home_dir = home_dir;
398
399 if config.security.allowed_read_paths.is_empty() {
401 let cwd = std::env::current_dir().unwrap_or_default();
402 let home = dirs::home_dir().unwrap_or_default();
403 config.security.allowed_read_paths = vec![cwd.clone(), home];
404 config.security.allowed_write_paths = vec![cwd];
405 }
406
407 if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
409 config.api_key = Some(k);
410 config.provider = "anthropic".into();
411 } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
412 config.api_key = Some(k);
413 } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
414 config.provider = "ollama".into();
415 config.base_url = Some(url);
416 } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
417 config.provider = "vllm".into();
418 config.base_url = Some(url);
419 if let Some(k) = env_or_dotenv("VLLM_API_KEY", dotenv) {
420 config.api_key = Some(k);
421 }
422 }
423 if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
424 config.model = m;
425 }
426 if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
427 config.base_url = Some(u);
428 }
429 if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
430 config.security.gateway_api_key = Some(k);
431 }
432 if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
433 if let Ok(n) = v.parse::<u32>() {
434 config.security.rate_limit_rpm = Some(n);
435 }
436 }
437 if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
438 config.security.approval_mode = mode;
439 }
440 if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
441 config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
442 "docker" => TerminalSandbox::Docker,
443 _ => TerminalSandbox::None,
444 };
445 }
446 if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
447 config.security.terminal_sandbox_image = image;
448 }
449
450 config
451 }
452
453 pub fn save_yaml(&self) -> std::io::Result<()> {
455 std::fs::create_dir_all(&self.home_dir)?;
456 let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
457 std::fs::write(self.home_dir.join("config.yaml"), yaml)
458 }
459
460 pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
462 std::fs::create_dir_all(home_dir)?;
463 let env_path = home_dir.join(".env");
464 let existing = if env_path.exists() {
465 std::fs::read_to_string(&env_path)?
466 } else {
467 String::new()
468 };
469
470 let prefix = format!("{key}=");
471 let mut lines: Vec<String> = existing
472 .lines()
473 .filter(|l| !l.starts_with(&prefix))
474 .map(String::from)
475 .collect();
476 lines.push(format!("{key}={value}"));
477
478 std::fs::write(&env_path, lines.join("\n") + "\n")
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct CompressionConfig {
486 pub enabled: bool,
487 pub threshold_fraction: f32,
488 pub model: Option<String>,
489}
490
491impl Default for CompressionConfig {
492 fn default() -> Self {
493 Self {
494 enabled: true,
495 threshold_fraction: 0.8,
496 model: None,
497 }
498 }
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, Default)]
502pub struct NetworkConfig {
503 pub force_ipv4: bool,
504 pub proxy: Option<String>,
505}