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 #[serde(default)]
130 pub context_window: Option<usize>,
131 #[serde(default)]
138 pub disabled_toolsets: Vec<String>,
139 #[serde(default)]
144 pub disabled_tools: Vec<String>,
145 #[serde(default)]
149 pub show_usage_footer: bool,
150 #[serde(default)]
154 pub platforms: WebhookPlatformsConfig,
155 #[serde(default)]
158 pub server: ServerConfig,
159 #[serde(default)]
163 pub cron: CronConfig,
164}
165
166fn default_model() -> String {
167 "anthropic/claude-sonnet-4-6".into()
168}
169fn default_provider() -> String {
170 "openrouter".into()
171}
172fn default_max_iterations() -> u32 {
173 90
174}
175fn default_nudge_interval() -> u32 {
176 5
177}
178fn default_auto_skill_threshold() -> u32 {
179 5
180}
181fn default_llm_max_retries() -> u32 {
182 3
183}
184fn default_llm_retry_base_ms() -> u64 {
185 1000
186}
187fn default_llm_timeout_secs() -> u64 {
188 120
189}
190fn default_tool_timeout_secs() -> u64 {
191 60
192}
193fn default_shutdown_timeout_secs() -> u64 {
194 30
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct MemoryExpiryConfig {
202 #[serde(default = "default_fact_days")]
204 pub fact_days: Option<u32>,
205 #[serde(default = "default_project_days")]
207 pub project_days: Option<u32>,
208 #[serde(default = "default_other_days")]
210 pub other_days: Option<u32>,
211 #[serde(default)]
213 pub preference_days: Option<u32>,
214 #[serde(default)]
216 pub skill_days: Option<u32>,
217}
218
219#[allow(clippy::unnecessary_wraps)]
220fn default_fact_days() -> Option<u32> {
221 Some(90)
222}
223#[allow(clippy::unnecessary_wraps)]
224fn default_project_days() -> Option<u32> {
225 Some(30)
226}
227#[allow(clippy::unnecessary_wraps)]
228fn default_other_days() -> Option<u32> {
229 Some(60)
230}
231
232impl Default for MemoryExpiryConfig {
233 fn default() -> Self {
234 Self {
235 fact_days: default_fact_days(),
236 project_days: default_project_days(),
237 other_days: default_other_days(),
238 preference_days: None,
239 skill_days: None,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
246#[serde(rename_all = "lowercase")]
247pub enum TerminalSandbox {
248 #[default]
250 None,
251 Docker,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SecurityConfig {
258 #[serde(skip)]
260 pub gateway_api_key: Option<String>,
261
262 #[serde(default)]
264 pub allowed_read_paths: Vec<PathBuf>,
265
266 #[serde(default)]
268 pub allowed_write_paths: Vec<PathBuf>,
269
270 #[serde(default = "default_approval_mode")]
272 pub approval_mode: String,
273
274 #[serde(default)]
276 pub rate_limit_rpm: Option<u32>,
277
278 #[serde(default)]
280 pub terminal_sandbox: TerminalSandbox,
281
282 #[serde(default = "default_sandbox_image")]
284 pub terminal_sandbox_image: String,
285
286 #[serde(default)]
289 pub terminal_sandbox_opts: Vec<String>,
290}
291
292fn default_approval_mode() -> String {
293 "smart".to_string()
294}
295
296fn default_sandbox_image() -> String {
297 "ubuntu:24.04".to_string()
298}
299
300impl Default for SecurityConfig {
301 fn default() -> Self {
302 Self {
303 gateway_api_key: None,
304 allowed_read_paths: Vec::new(),
305 allowed_write_paths: Vec::new(),
306 approval_mode: default_approval_mode(),
307 rate_limit_rpm: None,
308 terminal_sandbox: TerminalSandbox::None,
309 terminal_sandbox_image: default_sandbox_image(),
310 terminal_sandbox_opts: Vec::new(),
311 }
312 }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct PlatformConfig {
318 #[serde(default)]
321 pub allowed_user_ids: Vec<String>,
322
323 #[serde(default)]
326 pub require_mention: bool,
327
328 #[serde(default)]
331 pub bot_username: String,
332
333 #[serde(default = "default_true")]
337 pub session_per_user: bool,
338}
339
340fn default_true() -> bool {
341 true
342}
343
344impl Default for PlatformConfig {
345 fn default() -> Self {
346 Self {
347 allowed_user_ids: Vec::new(),
348 require_mention: false,
349 bot_username: String::new(),
350 session_per_user: true,
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct McpServerConfig {
357 pub name: String,
358 pub command: String,
359 #[serde(default)]
360 pub args: Vec<String>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct WebhookPlatformConfig {
368 #[serde(default)]
370 pub enabled: bool,
371 pub port: u16,
373 pub webhook_path: String,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, Default)]
379pub struct WebhookPlatformsConfig {
380 #[serde(default)]
381 pub line: Option<WebhookPlatformConfig>,
382 #[serde(default)]
383 pub whatsapp: Option<WebhookPlatformConfig>,
384 #[serde(default)]
385 pub webhook: Option<WebhookPlatformConfig>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ServerConfig {
391 #[serde(default = "default_server_port")]
393 pub port: u16,
394}
395
396fn default_server_port() -> u16 {
397 3000
398}
399
400impl Default for ServerConfig {
401 fn default() -> Self {
402 Self {
403 port: default_server_port(),
404 }
405 }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct CronJob {
411 pub schedule: String,
413 pub task: String,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, Default)]
419pub struct CronConfig {
420 #[serde(default)]
422 pub jobs: Vec<CronJob>,
423 #[serde(default)]
425 pub memory_consolidation: Option<String>,
426 #[serde(default)]
428 pub memory_expiry: Option<String>,
429}
430
431impl WebhookPlatformConfig {
432 pub fn default_webhook() -> Self {
435 Self {
436 enabled: true,
437 port: 3001,
438 webhook_path: "/webhook".to_string(),
439 }
440 }
441
442 pub fn default_line() -> Self {
446 Self {
447 enabled: true,
448 port: 3002,
449 webhook_path: "/line".to_string(),
450 }
451 }
452
453 pub fn default_whatsapp() -> Self {
455 Self {
456 enabled: true,
457 port: 3003,
458 webhook_path: "/whatsapp".to_string(),
459 }
460 }
461}
462
463impl Default for AgentConfig {
464 fn default() -> Self {
465 let cwd = std::env::current_dir().unwrap_or_default();
466 let home = dirs::home_dir().unwrap_or_default();
467 Self {
468 home_dir: Self::garudust_dir(),
469 model: "anthropic/claude-sonnet-4-6".into(),
470 max_iterations: 90,
471 tool_delay_ms: 0,
472 provider: "openrouter".into(),
473 base_url: None,
474 api_key: None,
475 compression: CompressionConfig::default(),
476 network: NetworkConfig::default(),
477 mcp_servers: Vec::new(),
478 max_concurrent_requests: None,
479 security: SecurityConfig {
480 gateway_api_key: None,
481 allowed_read_paths: vec![cwd.clone(), home],
482 allowed_write_paths: vec![cwd],
483 approval_mode: default_approval_mode(),
484 rate_limit_rpm: None,
485 terminal_sandbox: TerminalSandbox::None,
486 terminal_sandbox_image: default_sandbox_image(),
487 terminal_sandbox_opts: Vec::new(),
488 },
489 memory_expiry: MemoryExpiryConfig::default(),
490 nudge_interval: default_nudge_interval(),
491 llm_max_retries: default_llm_max_retries(),
492 llm_retry_base_ms: default_llm_retry_base_ms(),
493 platform: PlatformConfig::default(),
494 auto_skill_threshold: default_auto_skill_threshold(),
495 llm_timeout_secs: default_llm_timeout_secs(),
496 tool_timeout_secs: default_tool_timeout_secs(),
497 shutdown_timeout_secs: default_shutdown_timeout_secs(),
498 max_tokens_per_task: None,
499 max_output_tokens: None,
500 reasoning_effort: None,
501 context_window: None,
502 disabled_toolsets: Vec::new(),
503 disabled_tools: Vec::new(),
504 show_usage_footer: false,
505 platforms: WebhookPlatformsConfig {
506 webhook: Some(WebhookPlatformConfig::default_webhook()),
507 line: None,
508 whatsapp: None,
509 },
510 server: ServerConfig::default(),
511 cron: CronConfig::default(),
512 }
513 }
514}
515
516impl AgentConfig {
517 pub fn garudust_dir() -> PathBuf {
519 dirs::home_dir()
520 .unwrap_or_else(|| PathBuf::from("/tmp"))
521 .join(".garudust")
522 }
523
524 pub fn load() -> Self {
532 let home_dir = Self::garudust_dir();
533
534 let env_file = home_dir.join(".env");
536 let dotenv = load_dotenv_once(&env_file);
537
538 let yaml_path = home_dir.join("config.yaml");
540 let mut config: AgentConfig = if yaml_path.exists() {
541 let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
542 serde_yaml::from_str(&src).unwrap_or_default()
543 } else {
544 AgentConfig::default()
545 };
546
547 config.home_dir = home_dir;
548
549 if config.security.allowed_read_paths.is_empty() {
551 let cwd = std::env::current_dir().unwrap_or_default();
552 let home = dirs::home_dir().unwrap_or_default();
553 config.security.allowed_read_paths = vec![cwd.clone(), home];
554 config.security.allowed_write_paths = vec![cwd];
555 }
556
557 if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
560 config.api_key = Some(k);
561 config.provider = "anthropic".into();
562 } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
563 config.api_key = Some(k);
564 } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
565 config.provider = "ollama".into();
566 config.base_url = Some(url);
567 } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
568 config.provider = "vllm".into();
569 config.base_url = Some(url);
570 } else if let Some(k) = env_or_dotenv("THAILLM_API_KEY", dotenv) {
571 config.api_key = Some(k);
572 config.provider = "thaillm".into();
573 }
574 if config.api_key.is_none() {
577 config.api_key = match config.provider.as_str() {
578 "vllm" => env_or_dotenv("VLLM_API_KEY", dotenv),
579 "anthropic" => env_or_dotenv("ANTHROPIC_API_KEY", dotenv),
580 "thaillm" => env_or_dotenv("THAILLM_API_KEY", dotenv),
581 _ => env_or_dotenv("OPENROUTER_API_KEY", dotenv),
582 };
583 }
584 if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
585 config.model = m;
586 }
587 if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
588 config.base_url = Some(u);
589 }
590 if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
591 config.security.gateway_api_key = Some(k);
592 }
593 if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
594 if let Ok(n) = v.parse::<u32>() {
595 config.security.rate_limit_rpm = Some(n);
596 }
597 }
598 if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
599 config.security.approval_mode = mode;
600 }
601 if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
602 config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
603 "docker" => TerminalSandbox::Docker,
604 _ => TerminalSandbox::None,
605 };
606 }
607 if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
608 config.security.terminal_sandbox_image = image;
609 }
610
611 config
612 }
613
614 pub fn save_yaml(&self) -> std::io::Result<()> {
616 std::fs::create_dir_all(&self.home_dir)?;
617 let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
618 std::fs::write(self.home_dir.join("config.yaml"), yaml)
619 }
620
621 pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
623 std::fs::create_dir_all(home_dir)?;
624 let env_path = home_dir.join(".env");
625 let existing = if env_path.exists() {
626 std::fs::read_to_string(&env_path)?
627 } else {
628 String::new()
629 };
630
631 let prefix = format!("{key}=");
632 let mut lines: Vec<String> = existing
633 .lines()
634 .filter(|l| !l.starts_with(&prefix))
635 .map(String::from)
636 .collect();
637 lines.push(format!("{key}={value}"));
638
639 std::fs::write(&env_path, lines.join("\n") + "\n")
640 }
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize)]
646pub struct CompressionConfig {
647 pub enabled: bool,
648 pub threshold_fraction: f32,
649 pub model: Option<String>,
650}
651
652impl Default for CompressionConfig {
653 fn default() -> Self {
654 Self {
655 enabled: true,
656 threshold_fraction: 0.8,
657 model: None,
658 }
659 }
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, Default)]
663pub struct NetworkConfig {
664 pub force_ipv4: bool,
665 pub proxy: Option<String>,
666}