Skip to main content

garudust_core/
config.rs

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
11/// Load ~/.garudust/.env once per process into an in-memory map.
12/// Never writes to process environment, so secrets are not visible to subprocesses.
13fn 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
34/// Read an env var: real environment takes priority, dotenv map is fallback.
35fn 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
42/// Read a secret from real env or ~/.garudust/.env (whichever is set first).
43/// Useful for Rust tools that don't go through script.rs env forwarding.
44pub 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    /// Inject a memory-save reminder every N tool-use iterations within a task.
85    /// 0 = disabled. Default: 5.
86    #[serde(default = "default_nudge_interval")]
87    pub nudge_interval: u32,
88    /// Max retry attempts on transient LLM API errors (429, 5xx, network). 0 = disabled.
89    #[serde(default = "default_llm_max_retries")]
90    pub llm_max_retries: u32,
91    /// Base delay in milliseconds for exponential backoff between retries.
92    #[serde(default = "default_llm_retry_base_ms")]
93    pub llm_retry_base_ms: u64,
94    /// Platform-level access controls (whitelist, mention gate, session isolation).
95    #[serde(default)]
96    pub platform: PlatformConfig,
97    /// Minimum tool-use iterations that trigger an automatic skill-reflection pass after a task.
98    /// The agent reviews the conversation and calls write_skill if the workflow is reusable.
99    /// Set to 0 to disable. Default: 5.
100    #[serde(default = "default_auto_skill_threshold")]
101    pub auto_skill_threshold: u32,
102    /// Timeout in seconds for a single LLM API call (chat or stream). 0 = no timeout. Default: 120.
103    #[serde(default = "default_llm_timeout_secs")]
104    pub llm_timeout_secs: u64,
105    /// Timeout in seconds applied to every non-terminal tool dispatch. 0 = no timeout. Default: 60.
106    #[serde(default = "default_tool_timeout_secs")]
107    pub tool_timeout_secs: u64,
108    /// Drain window in seconds for graceful shutdown — server waits this long for in-flight
109    /// requests to complete before forcing exit. Default: 30.
110    #[serde(default = "default_shutdown_timeout_secs")]
111    pub shutdown_timeout_secs: u64,
112    /// Hard cap on total tokens (input + output) consumed by a single task.
113    /// When exceeded the agent stops and returns what it has with a budget notice.
114    /// `None` means no limit.
115    #[serde(default)]
116    pub max_tokens_per_task: Option<u32>,
117    /// Maximum output tokens per LLM request. Default: 8192.
118    /// Lower this for models with small context windows (e.g. 4096 for a 27k-ctx model).
119    #[serde(default)]
120    pub max_output_tokens: Option<u32>,
121    /// Reasoning effort for supported models (Claude extended thinking, OpenAI o1/o3/o4).
122    /// Set via config.yaml: `reasoning_effort: medium`
123    #[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/// Per-category retention policy for memory entries.
159/// `None` means the category never expires.
160/// `preference` and `skill` default to `None` — they represent durable knowledge.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct MemoryExpiryConfig {
163    /// Max age in days for `fact` entries. Default: 90.
164    #[serde(default = "default_fact_days")]
165    pub fact_days: Option<u32>,
166    /// Max age in days for `project` entries. Default: 30.
167    #[serde(default = "default_project_days")]
168    pub project_days: Option<u32>,
169    /// Max age in days for `other` entries. Default: 60.
170    #[serde(default = "default_other_days")]
171    pub other_days: Option<u32>,
172    /// `preference` entries never expire by default.
173    #[serde(default)]
174    pub preference_days: Option<u32>,
175    /// `skill` entries never expire by default.
176    #[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/// Terminal execution sandbox mode.
206#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
207#[serde(rename_all = "lowercase")]
208pub enum TerminalSandbox {
209    /// Direct host execution (default). Hardline blocks still apply.
210    #[default]
211    None,
212    /// Wrap every command in `docker run --rm` with hardened flags.
213    Docker,
214}
215
216/// Security-related settings grouped together (mirrors CompressionConfig / NetworkConfig pattern).
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct SecurityConfig {
219    /// Bearer token required on /chat* endpoints. None = open (warn at startup).
220    #[serde(skip)]
221    pub gateway_api_key: Option<String>,
222
223    /// Allowed root paths for read_file tool. Defaults to cwd + home.
224    #[serde(default)]
225    pub allowed_read_paths: Vec<PathBuf>,
226
227    /// Allowed root paths for write_file tool. Defaults to cwd only.
228    #[serde(default)]
229    pub allowed_write_paths: Vec<PathBuf>,
230
231    /// Command approval mode: "auto" | "smart" | "deny". Default "smart".
232    #[serde(default = "default_approval_mode")]
233    pub approval_mode: String,
234
235    /// Per-IP rate limit in requests/minute. None = disabled.
236    #[serde(default)]
237    pub rate_limit_rpm: Option<u32>,
238
239    /// Terminal execution sandbox. Default "none" (direct host execution).
240    #[serde(default)]
241    pub terminal_sandbox: TerminalSandbox,
242
243    /// Docker image used when `terminal_sandbox = docker`. Default "ubuntu:24.04".
244    #[serde(default = "default_sandbox_image")]
245    pub terminal_sandbox_image: String,
246
247    /// Extra `docker run` flags appended after the hardened defaults.
248    /// Example: `["--network=none", "--memory=512m", "--cpus=0.5"]`
249    #[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/// Platform-level access and behaviour controls (whitelist, mention gate, session isolation).
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct PlatformConfig {
279    /// User IDs allowed to send messages to the agent.
280    /// Empty list means everyone is allowed.
281    #[serde(default)]
282    pub allowed_user_ids: Vec<String>,
283
284    /// Only respond in group chats when the bot is @mentioned.
285    /// Private / DM chats always get a response regardless of this flag.
286    #[serde(default)]
287    pub require_mention: bool,
288
289    /// Bot username used for @mention detection (without the @).
290    /// Example: set to "mybot" so @mybot triggers a response.
291    #[serde(default)]
292    pub bot_username: String,
293
294    /// Give each user their own conversation session (default: true).
295    /// Set to false only when you want all users in a channel to share one session.
296    /// Not applied to the webhook platform — webhook callers control session routing via payload.
297    #[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    /// Canonical ~/.garudust directory.
368    pub fn garudust_dir() -> PathBuf {
369        dirs::home_dir()
370            .unwrap_or_else(|| PathBuf::from("/tmp"))
371            .join(".garudust")
372    }
373
374    /// Load config from ~/.garudust/config.yaml + ~/.garudust/.env + environment.
375    ///
376    /// Priority (highest first):
377    ///   1. Environment variables already set in the shell
378    ///   2. ~/.garudust/.env  (set if not already present in env)
379    ///   3. ~/.garudust/config.yaml
380    ///   4. Built-in defaults
381    pub fn load() -> Self {
382        let home_dir = Self::garudust_dir();
383
384        // Load dotenv values into memory (never calls set_var — secrets stay out of process env)
385        let env_file = home_dir.join(".env");
386        let dotenv = load_dotenv_once(&env_file);
387
388        // Load config.yaml (non-secret settings)
389        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        // Populate default security paths if they came back empty from YAML
400        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        // Apply env/dotenv overrides (real env takes priority over dotenv)
408        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    /// Save non-secret settings to ~/.garudust/config.yaml.
454    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    /// Write or update a KEY=VALUE line in ~/.garudust/.env.
461    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// ── Sub-configs ──────────────────────────────────────────────────────────────
483
484#[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}