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)]
69 pub sub_agent_max_iterations: Option<u32>,
70 #[serde(default)]
71 pub tool_delay_ms: u64,
72 #[serde(default = "default_provider")]
73 pub provider: String,
74 pub base_url: Option<String>,
75 #[serde(skip)]
76 pub api_key: Option<String>,
77 #[serde(skip)]
80 pub fallback_api_keys: Vec<String>,
81 #[serde(default)]
82 pub compression: CompressionConfig,
83 #[serde(default)]
84 pub network: NetworkConfig,
85 #[serde(default)]
86 pub mcp_servers: Vec<McpServerConfig>,
87 #[serde(default)]
88 pub max_concurrent_requests: Option<usize>,
89 #[serde(default)]
90 pub security: SecurityConfig,
91 #[serde(default)]
92 pub memory_expiry: MemoryExpiryConfig,
93 #[serde(default = "default_nudge_interval")]
96 pub nudge_interval: u32,
97 #[serde(default = "default_llm_max_retries")]
99 pub llm_max_retries: u32,
100 #[serde(default = "default_llm_retry_base_ms")]
102 pub llm_retry_base_ms: u64,
103 #[serde(default)]
105 pub platform: PlatformConfig,
106 #[serde(default = "default_auto_skill_threshold")]
110 pub auto_skill_threshold: u32,
111 #[serde(default = "default_llm_timeout_secs")]
113 pub llm_timeout_secs: u64,
114 #[serde(default = "default_tool_timeout_secs")]
116 pub tool_timeout_secs: u64,
117 #[serde(default = "default_shutdown_timeout_secs")]
120 pub shutdown_timeout_secs: u64,
121 #[serde(default)]
125 pub max_tokens_per_task: Option<u32>,
126 #[serde(default)]
129 pub max_output_tokens: Option<u32>,
130 #[serde(default)]
133 pub reasoning_effort: Option<ReasoningEffort>,
134 #[serde(default)]
139 pub context_window: Option<usize>,
140 #[serde(default)]
147 pub disabled_toolsets: Vec<String>,
148 #[serde(default)]
153 pub disabled_tools: Vec<String>,
154 #[serde(default)]
158 pub show_usage_footer: bool,
159 #[serde(default)]
163 pub platforms: WebhookPlatformsConfig,
164 #[serde(default)]
167 pub server: ServerConfig,
168 #[serde(default)]
172 pub cron: CronConfig,
173}
174
175fn default_model() -> String {
176 "anthropic/claude-sonnet-4-6".into()
177}
178fn default_provider() -> String {
179 "openrouter".into()
180}
181fn default_max_iterations() -> u32 {
182 90
183}
184fn default_nudge_interval() -> u32 {
185 5
186}
187fn default_auto_skill_threshold() -> u32 {
188 5
189}
190fn default_llm_max_retries() -> u32 {
191 3
192}
193fn default_llm_retry_base_ms() -> u64 {
194 1000
195}
196fn default_llm_timeout_secs() -> u64 {
197 120
198}
199fn default_tool_timeout_secs() -> u64 {
200 60
201}
202fn default_shutdown_timeout_secs() -> u64 {
203 30
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct MemoryExpiryConfig {
211 #[serde(default = "default_fact_days")]
213 pub fact_days: Option<u32>,
214 #[serde(default = "default_project_days")]
216 pub project_days: Option<u32>,
217 #[serde(default = "default_other_days")]
219 pub other_days: Option<u32>,
220 #[serde(default)]
222 pub preference_days: Option<u32>,
223 #[serde(default)]
225 pub skill_days: Option<u32>,
226}
227
228#[allow(clippy::unnecessary_wraps)]
229fn default_fact_days() -> Option<u32> {
230 Some(90)
231}
232#[allow(clippy::unnecessary_wraps)]
233fn default_project_days() -> Option<u32> {
234 Some(30)
235}
236#[allow(clippy::unnecessary_wraps)]
237fn default_other_days() -> Option<u32> {
238 Some(60)
239}
240
241impl Default for MemoryExpiryConfig {
242 fn default() -> Self {
243 Self {
244 fact_days: default_fact_days(),
245 project_days: default_project_days(),
246 other_days: default_other_days(),
247 preference_days: None,
248 skill_days: None,
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
255#[serde(rename_all = "lowercase")]
256pub enum TerminalSandbox {
257 #[default]
259 None,
260 Docker,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct SecurityConfig {
267 #[serde(skip)]
269 pub gateway_api_key: Option<String>,
270
271 #[serde(default)]
273 pub allowed_read_paths: Vec<PathBuf>,
274
275 #[serde(default)]
277 pub allowed_write_paths: Vec<PathBuf>,
278
279 #[serde(default = "default_approval_mode")]
281 pub approval_mode: String,
282
283 #[serde(default)]
285 pub rate_limit_rpm: Option<u32>,
286
287 #[serde(default)]
289 pub terminal_sandbox: TerminalSandbox,
290
291 #[serde(default = "default_sandbox_image")]
293 pub terminal_sandbox_image: String,
294
295 #[serde(default)]
298 pub terminal_sandbox_opts: Vec<String>,
299}
300
301fn default_approval_mode() -> String {
302 "smart".to_string()
303}
304
305fn default_sandbox_image() -> String {
306 "ubuntu:24.04".to_string()
307}
308
309impl Default for SecurityConfig {
310 fn default() -> Self {
311 Self {
312 gateway_api_key: None,
313 allowed_read_paths: Vec::new(),
314 allowed_write_paths: Vec::new(),
315 approval_mode: default_approval_mode(),
316 rate_limit_rpm: None,
317 terminal_sandbox: TerminalSandbox::None,
318 terminal_sandbox_image: default_sandbox_image(),
319 terminal_sandbox_opts: Vec::new(),
320 }
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct PlatformConfig {
327 #[serde(default)]
330 pub allowed_user_ids: Vec<String>,
331
332 #[serde(default)]
335 pub require_mention: bool,
336
337 #[serde(default)]
340 pub bot_username: String,
341
342 #[serde(default = "default_true")]
346 pub session_per_user: bool,
347}
348
349fn default_true() -> bool {
350 true
351}
352
353impl Default for PlatformConfig {
354 fn default() -> Self {
355 Self {
356 allowed_user_ids: Vec::new(),
357 require_mention: false,
358 bot_username: String::new(),
359 session_per_user: true,
360 }
361 }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct McpServerConfig {
366 pub name: String,
367 pub command: String,
368 #[serde(default)]
369 pub args: Vec<String>,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct WebhookPlatformConfig {
377 #[serde(default)]
379 pub enabled: bool,
380 pub port: u16,
382 pub webhook_path: String,
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize, Default)]
388pub struct WebhookPlatformsConfig {
389 #[serde(default)]
390 pub line: Option<WebhookPlatformConfig>,
391 #[serde(default)]
392 pub whatsapp: Option<WebhookPlatformConfig>,
393 #[serde(default)]
394 pub webhook: Option<WebhookPlatformConfig>,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct ServerConfig {
400 #[serde(default = "default_server_port")]
402 pub port: u16,
403}
404
405fn default_server_port() -> u16 {
406 3000
407}
408
409fn parse_cron_jobs_str(s: &str) -> Vec<CronJob> {
412 s.split(',')
413 .filter_map(|entry| {
414 let (expr, task) = entry.trim().split_once('=')?;
415 Some(CronJob {
416 schedule: expr.trim().to_string(),
417 task: task.trim().to_string(),
418 })
419 })
420 .collect()
421}
422
423impl Default for ServerConfig {
424 fn default() -> Self {
425 Self {
426 port: default_server_port(),
427 }
428 }
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize)]
433pub struct CronJob {
434 pub schedule: String,
436 pub task: String,
438}
439
440#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442pub struct CronConfig {
443 #[serde(default)]
445 pub jobs: Vec<CronJob>,
446 #[serde(default)]
448 pub memory_consolidation: Option<String>,
449 #[serde(default)]
451 pub memory_expiry: Option<String>,
452}
453
454impl WebhookPlatformConfig {
455 pub fn default_webhook() -> Self {
458 Self {
459 enabled: true,
460 port: 3001,
461 webhook_path: "/webhook".to_string(),
462 }
463 }
464
465 pub fn default_line() -> Self {
469 Self {
470 enabled: true,
471 port: 3002,
472 webhook_path: "/line".to_string(),
473 }
474 }
475
476 pub fn default_whatsapp() -> Self {
478 Self {
479 enabled: true,
480 port: 3003,
481 webhook_path: "/whatsapp".to_string(),
482 }
483 }
484}
485
486impl Default for AgentConfig {
487 fn default() -> Self {
488 let cwd = std::env::current_dir().unwrap_or_default();
489 let home = dirs::home_dir().unwrap_or_default();
490 Self {
491 home_dir: Self::garudust_dir(),
492 model: "anthropic/claude-sonnet-4-6".into(),
493 max_iterations: 90,
494 sub_agent_max_iterations: None,
495 tool_delay_ms: 0,
496 provider: "openrouter".into(),
497 base_url: None,
498 api_key: None,
499 fallback_api_keys: Vec::new(),
500 compression: CompressionConfig::default(),
501 network: NetworkConfig::default(),
502 mcp_servers: Vec::new(),
503 max_concurrent_requests: None,
504 security: SecurityConfig {
505 gateway_api_key: None,
506 allowed_read_paths: vec![cwd.clone(), home],
507 allowed_write_paths: vec![cwd],
508 approval_mode: default_approval_mode(),
509 rate_limit_rpm: None,
510 terminal_sandbox: TerminalSandbox::None,
511 terminal_sandbox_image: default_sandbox_image(),
512 terminal_sandbox_opts: Vec::new(),
513 },
514 memory_expiry: MemoryExpiryConfig::default(),
515 nudge_interval: default_nudge_interval(),
516 llm_max_retries: default_llm_max_retries(),
517 llm_retry_base_ms: default_llm_retry_base_ms(),
518 platform: PlatformConfig::default(),
519 auto_skill_threshold: default_auto_skill_threshold(),
520 llm_timeout_secs: default_llm_timeout_secs(),
521 tool_timeout_secs: default_tool_timeout_secs(),
522 shutdown_timeout_secs: default_shutdown_timeout_secs(),
523 max_tokens_per_task: None,
524 max_output_tokens: None,
525 reasoning_effort: None,
526 context_window: None,
527 disabled_toolsets: Vec::new(),
528 disabled_tools: Vec::new(),
529 show_usage_footer: false,
530 platforms: WebhookPlatformsConfig {
531 webhook: Some(WebhookPlatformConfig::default_webhook()),
532 line: None,
533 whatsapp: None,
534 },
535 server: ServerConfig::default(),
536 cron: CronConfig::default(),
537 }
538 }
539}
540
541impl AgentConfig {
542 pub fn garudust_dir() -> PathBuf {
544 dirs::home_dir()
545 .unwrap_or_else(|| PathBuf::from("/tmp"))
546 .join(".garudust")
547 }
548
549 pub fn load() -> Self {
557 let home_dir = Self::garudust_dir();
558
559 let env_file = home_dir.join(".env");
561 let dotenv = load_dotenv_once(&env_file);
562
563 let yaml_path = home_dir.join("config.yaml");
565 let mut config: AgentConfig = if yaml_path.exists() {
566 let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
567 serde_yaml::from_str(&src).unwrap_or_default()
568 } else {
569 AgentConfig::default()
570 };
571
572 config.home_dir = home_dir;
573
574 if config.security.allowed_read_paths.is_empty() {
576 let cwd = std::env::current_dir().unwrap_or_default();
577 let home = dirs::home_dir().unwrap_or_default();
578 config.security.allowed_read_paths = vec![cwd.clone(), home];
579 config.security.allowed_write_paths = vec![cwd];
580 }
581
582 let yaml_authoritative = yaml_path.exists();
593
594 if yaml_authoritative {
595 if config.api_key.is_none() {
596 config.api_key = match config.provider.as_str() {
597 "vllm" => env_or_dotenv("VLLM_API_KEY", dotenv),
598 "anthropic" => env_or_dotenv("ANTHROPIC_API_KEY", dotenv),
599 "thaillm" => env_or_dotenv("THAILLM_API_KEY", dotenv),
600 "ollama" | "bedrock" | "codex" => None,
601 _ => env_or_dotenv("OPENROUTER_API_KEY", dotenv),
603 };
604 }
605 } else {
606 if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
608 config.api_key = Some(k);
609 config.provider = "anthropic".into();
610 } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
611 config.provider = "ollama".into();
612 config.base_url = Some(url);
613 } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
614 config.provider = "vllm".into();
615 config.base_url = Some(url);
616 config.api_key = env_or_dotenv("VLLM_API_KEY", dotenv);
617 } else if let Some(k) = env_or_dotenv("THAILLM_API_KEY", dotenv) {
618 config.api_key = Some(k);
619 config.provider = "thaillm".into();
620 } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
621 config.api_key = Some(k);
622 config.provider = "openrouter".into();
623 }
624 }
625 if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
626 config.model = m;
627 }
628 if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
629 config.base_url = Some(u);
630 }
631 if let Some(v) = env_or_dotenv("LLM_FALLBACK_API_KEYS", dotenv) {
632 config.fallback_api_keys = v
633 .split(',')
634 .map(str::trim)
635 .filter(|s| !s.is_empty())
636 .map(str::to_string)
637 .collect();
638 }
639 if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
640 config.security.gateway_api_key = Some(k);
641 }
642 if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
643 if let Ok(n) = v.parse::<u32>() {
644 config.security.rate_limit_rpm = Some(n);
645 }
646 }
647 if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
648 config.security.approval_mode = mode;
649 }
650 if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
651 config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
652 "docker" => TerminalSandbox::Docker,
653 _ => TerminalSandbox::None,
654 };
655 }
656 if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
657 config.security.terminal_sandbox_image = image;
658 }
659
660 if let Some(v) = env_or_dotenv("GARUDUST_PORT", dotenv) {
665 if let Ok(n) = v.parse::<u16>() {
666 config.server.port = n;
667 }
668 }
669 if let Some(v) = env_or_dotenv("GARUDUST_MEMORY_CRON", dotenv) {
670 config.cron.memory_consolidation = Some(v);
671 }
672 if let Some(v) = env_or_dotenv("GARUDUST_MEMORY_EXPIRY_CRON", dotenv) {
673 config.cron.memory_expiry = Some(v);
674 }
675 if let Some(v) = env_or_dotenv("GARUDUST_CRON_JOBS", dotenv) {
676 config.cron.jobs = parse_cron_jobs_str(&v);
677 }
678
679 config
680 }
681
682 pub fn save_yaml(&self) -> std::io::Result<()> {
684 std::fs::create_dir_all(&self.home_dir)?;
685 let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
686 std::fs::write(self.home_dir.join("config.yaml"), yaml)
687 }
688
689 pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
691 std::fs::create_dir_all(home_dir)?;
692 let env_path = home_dir.join(".env");
693 let existing = if env_path.exists() {
694 std::fs::read_to_string(&env_path)?
695 } else {
696 String::new()
697 };
698
699 let prefix = format!("{key}=");
700 let mut lines: Vec<String> = existing
701 .lines()
702 .filter(|l| !l.starts_with(&prefix))
703 .map(String::from)
704 .collect();
705 lines.push(format!("{key}={value}"));
706
707 std::fs::write(&env_path, lines.join("\n") + "\n")
708 }
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct CompressionConfig {
715 pub enabled: bool,
716 pub threshold_fraction: f32,
717 pub model: Option<String>,
718}
719
720impl Default for CompressionConfig {
721 fn default() -> Self {
722 Self {
723 enabled: true,
724 threshold_fraction: 0.8,
725 model: None,
726 }
727 }
728}
729
730#[derive(Debug, Clone, Serialize, Deserialize, Default)]
731pub struct NetworkConfig {
732 pub force_ipv4: bool,
733 pub proxy: Option<String>,
734}