Skip to main content

aacode_rs/
config.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Configuration for the agent.
5//!
6//! Resolution priority (highest first):
7//!   inline taskJson  >  environment variables  >  aacode_config.json  >  defaults
8//!
9//! Ported from the Python `config.py` (ModelConfig auto-detection of base_url
10//! and gateway based on the model name).
11
12use serde::{Deserialize, Serialize};
13
14/// LLM gateway flavor.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "lowercase")]
17pub enum Gateway {
18    Openai,
19    Anthropic,
20}
21
22impl Default for Gateway {
23    fn default() -> Self {
24        Gateway::Openai
25    }
26}
27
28impl Gateway {
29    pub fn as_str(&self) -> &'static str {
30        match self {
31            Gateway::Openai => "openai",
32            Gateway::Anthropic => "anthropic",
33        }
34    }
35
36    pub fn parse(s: &str) -> Gateway {
37        match s.trim().to_lowercase().as_str() {
38            "anthropic" => Gateway::Anthropic,
39            _ => Gateway::Openai,
40        }
41    }
42}
43
44/// Model / LLM connection configuration.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ModelConfig {
47    #[serde(default = "default_model_name")]
48    pub name: String,
49    #[serde(default)]
50    pub api_key: Option<String>,
51    #[serde(default)]
52    pub base_url: Option<String>,
53    #[serde(default)]
54    pub gateway: Gateway,
55    #[serde(default = "default_temperature")]
56    pub temperature: f32,
57    #[serde(default = "default_max_tokens")]
58    pub max_tokens: u32,
59    #[serde(default)]
60    pub multimodal: bool,
61    /// Per-request deadline (secs). Falls back to config.timeouts.model_request.
62    #[serde(default)]
63    pub request_timeout_secs: Option<u64>,
64}
65
66fn default_model_name() -> String {
67    "deepseek-chat".to_string()
68}
69fn default_temperature() -> f32 {
70    0.1
71}
72fn default_max_tokens() -> u32 {
73    8192
74}
75
76impl Default for ModelConfig {
77    fn default() -> Self {
78        ModelConfig {
79            name: default_model_name(),
80            api_key: None,
81            base_url: None,
82            gateway: Gateway::Openai,
83            temperature: default_temperature(),
84            max_tokens: default_max_tokens(),
85            multimodal: false,
86            request_timeout_secs: None,
87        }
88    }
89}
90
91impl ModelConfig {
92    /// Resolve the effective base URL. If explicitly set, use it. Otherwise
93    /// infer from the model name + gateway, mirroring the Python behavior.
94    pub fn resolved_base_url(&self) -> String {
95        if let Some(url) = &self.base_url {
96            if !url.trim().is_empty() {
97                return url.clone();
98            }
99        }
100        let m = self.name.to_lowercase();
101        let is_kimi = m.contains("kimi") || m.contains("moonshot");
102        let is_minimax = m.contains("minimax");
103        let is_deepseek = m.contains("deepseek");
104        let is_claude = m.contains("claude");
105
106        match self.gateway {
107            Gateway::Anthropic => {
108                if is_minimax {
109                    "https://api.minimax.chat/anthropic".into()
110                } else if is_deepseek {
111                    "https://api.deepseek.com/anthropic".into()
112                } else if is_kimi {
113                    "https://api.moonshot.cn/anthropic".into()
114                } else {
115                    "https://api.anthropic.com".into()
116                }
117            }
118            Gateway::Openai => {
119                if is_minimax {
120                    "https://api.minimax.chat/v1".into()
121                } else if is_deepseek {
122                    "https://api.deepseek.com/v1".into()
123                } else if is_kimi {
124                    "https://api.moonshot.cn/v1".into()
125                } else if is_claude {
126                    "https://api.openai.com/v1".into()
127                } else {
128                    "https://api.openai.com/v1".into()
129                }
130            }
131        }
132    }
133
134    /// Apply environment variable overrides (env wins over file/defaults).
135    pub fn apply_env(&mut self) {
136        if let Ok(k) = std::env::var("LLM_API_KEY").or_else(|_| std::env::var("OPENAI_API_KEY")) {
137            if !k.is_empty() {
138                self.api_key = Some(k);
139            }
140        }
141        if let Ok(u) = std::env::var("LLM_API_URL").or_else(|_| std::env::var("OPENAI_BASE_URL")) {
142            if !u.is_empty() {
143                self.base_url = Some(u);
144            }
145        }
146        if let Ok(n) = std::env::var("LLM_MODEL_NAME") {
147            if !n.is_empty() {
148                self.name = n;
149            }
150        }
151        if let Ok(g) = std::env::var("LLM_GATEWAY") {
152            if !g.is_empty() {
153                self.gateway = Gateway::parse(&g);
154            }
155        }
156        if let Ok(mm) = std::env::var("LLM_MULTIMODAL") {
157            let l = mm.to_lowercase();
158            self.multimodal = matches!(l.as_str(), "true" | "1" | "yes" | "on");
159        }
160    }
161}
162
163/// Timeouts (seconds).
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct Timeouts {
166    #[serde(default = "d_shell")]
167    pub shell_command: u64,
168    #[serde(default = "d_shell_idle")]
169    pub shell_idle: u64,
170    #[serde(default = "d_web")]
171    pub web_request: u64,
172    #[serde(default = "d_model")]
173    pub model_request: u64,
174}
175fn d_shell() -> u64 {
176    120
177}
178fn d_shell_idle() -> u64 {
179    30
180}
181fn d_web() -> u64 {
182    15
183}
184fn d_model() -> u64 {
185    300
186}
187impl Default for Timeouts {
188    fn default() -> Self {
189        Timeouts {
190            shell_command: d_shell(),
191            shell_idle: d_shell_idle(),
192            web_request: d_web(),
193            model_request: d_model(),
194        }
195    }
196}
197
198/// Output / truncation limits.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Limits {
201    #[serde(default = "d_tool_output")]
202    pub tool_output_chars: usize,
203    #[serde(default = "d_display_preview")]
204    pub display_preview_chars: usize,
205    #[serde(default = "d_max_retries")]
206    pub max_retries: u32,
207}
208fn d_tool_output() -> usize {
209    24000
210}
211fn d_display_preview() -> usize {
212    3000
213}
214fn d_max_retries() -> u32 {
215    2
216}
217impl Default for Limits {
218    fn default() -> Self {
219        Limits {
220            tool_output_chars: d_tool_output(),
221            display_preview_chars: d_display_preview(),
222            max_retries: d_max_retries(),
223        }
224    }
225}
226
227/// Dangerous-command handling policy.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "lowercase")]
230pub enum DangerAction {
231    Log,
232    Reject,
233    Ask,
234}
235impl Default for DangerAction {
236    fn default() -> Self {
237        DangerAction::Log
238    }
239}
240
241/// Safety configuration.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct SafetyConfig {
244    #[serde(default)]
245    pub dangerous_command_action: DangerAction,
246}
247impl Default for SafetyConfig {
248    fn default() -> Self {
249        SafetyConfig {
250            dangerous_command_action: DangerAction::Log,
251        }
252    }
253}
254
255/// Context / compaction configuration.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ContextConfig {
258    #[serde(default = "d_max_ctx")]
259    pub max_context_tokens: usize,
260    #[serde(default = "d_trigger")]
261    pub compact_trigger_tokens: usize,
262    #[serde(default = "d_protect_first")]
263    pub protect_first_rounds: usize,
264    #[serde(default = "d_keep_last")]
265    pub keep_last_rounds: usize,
266    #[serde(default = "d_protect_user")]
267    pub protect_last_user_rounds: usize,
268}
269fn d_max_ctx() -> usize {
270    262144
271}
272// Aligned with the Python aacode default (config.py: compact_trigger_tokens).
273fn d_trigger() -> usize {
274    256000
275}
276fn d_protect_first() -> usize {
277    1
278}
279fn d_keep_last() -> usize {
280    10
281}
282fn d_protect_user() -> usize {
283    2
284}
285impl Default for ContextConfig {
286    fn default() -> Self {
287        ContextConfig {
288            max_context_tokens: d_max_ctx(),
289            compact_trigger_tokens: d_trigger(),
290            protect_first_rounds: d_protect_first(),
291            keep_last_rounds: d_keep_last(),
292            protect_last_user_rounds: d_protect_user(),
293        }
294    }
295}
296
297/// Search engine configuration.
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
299pub struct SearchConfig {
300    #[serde(default)]
301    pub searxng_url: Option<String>,
302    #[serde(default)]
303    pub brave_api_key: Option<String>,
304    #[serde(default)]
305    pub google_cse_key: Option<String>,
306    #[serde(default)]
307    pub google_cse_cx: Option<String>,
308    #[serde(default)]
309    pub bing_api_key: Option<String>,
310    #[serde(default)]
311    pub serpapi_key: Option<String>,
312}
313
314impl SearchConfig {
315    pub fn apply_env(&mut self) {
316        if let Ok(u) = std::env::var("SEARCHXNG_URL").or_else(|_| std::env::var("SEARXNG_URL")) {
317            if !u.is_empty() {
318                self.searxng_url = Some(u);
319            }
320        }
321        if let Ok(k) = std::env::var("BRAVE_API_KEY") {
322            if !k.is_empty() {
323                self.brave_api_key = Some(k);
324            }
325        }
326        if let Ok(k) = std::env::var("SERPAPI_KEY") {
327            if !k.is_empty() {
328                self.serpapi_key = Some(k);
329            }
330        }
331    }
332}
333
334/// Shell execution backend selection.
335#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336#[serde(rename_all = "lowercase")]
337pub enum ShellBackendChoice {
338    /// Real OS shell (sh -c / cmd /C). Natural on desktop.
339    Native,
340    /// fastshell sandbox engine (VFS jail + built-in commands + embedded CPython).
341    Fastshell,
342    /// Pick based on target OS: native on desktop, fastshell on mobile.
343    Auto,
344}
345
346impl Default for ShellBackendChoice {
347    fn default() -> Self {
348        ShellBackendChoice::Auto
349    }
350}
351
352/// Skills discovery configuration (host-provided, platform-agnostic).
353///
354/// When `user_dir` is set (mobile hosts pass an absolute path inside their
355/// sandbox), discovery switches to "builtin + user dir" mode and project
356/// directories are NOT scanned. When unset, the legacy per-project scanning
357/// (`<project>/skills` + `<project>/.aacode/skills`) is preserved unchanged.
358#[derive(Debug, Clone, Default, Serialize, Deserialize)]
359pub struct SkillsConfig {
360    /// Absolute path of the user-managed skills directory.
361    #[serde(default)]
362    pub user_dir: Option<String>,
363    /// Host-declared extra builtins beyond skill_creator. These are compiled
364    /// into the binary but only injected when the host explicitly names them
365    /// here (e.g. `["agent_cron"]`). Empty by default — desktop/CLI hosts
366    /// that don't pass this field never see platform-specific builtins.
367    #[serde(default)]
368    pub extra_builtins: Vec<String>,
369    /// VFS-internal path for `{SKILLS_DIR}` substitution in skill prompts.
370    /// Computed at runtime from `user_dir` relative to the shell sandbox root.
371    ///
372    /// The path starts with `/` (e.g. `/skills`) because the VFS sandbox
373    /// interprets leading-`/` paths as relative to the VFS root rather than
374    /// the real filesystem root. This keeps prompts short and platform-neutral.
375    ///
376    /// When `None`, substitution falls back to `user_dir` (legacy absolute path).
377    /// Marked `#[serde(skip)]` because the host never sets it — the shell
378    /// root widening logic in ffi.rs computes it.
379    #[serde(skip, default)]
380    pub vfs_skills_dir: Option<String>,
381}
382
383/// The complete agent configuration.
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct AgentConfig {
386    #[serde(default)]
387    pub model: ModelConfig,
388    /// Optional dedicated multimodal (vision) model config.
389    #[serde(default)]
390    pub multimodal: Option<ModelConfig>,
391    #[serde(default = "d_max_iter")]
392    pub max_iterations: u32,
393    #[serde(default)]
394    pub plan_first: bool,
395    /// Which shell backend to use. Defaults to Auto (native on desktop,
396    /// fastshell sandbox on mobile).
397    #[serde(default)]
398    pub shell_backend: ShellBackendChoice,
399    #[serde(default)]
400    pub timeouts: Timeouts,
401    #[serde(default)]
402    pub limits: Limits,
403    #[serde(default)]
404    pub safety: SafetyConfig,
405    #[serde(default)]
406    pub context: ContextConfig,
407    #[serde(default)]
408    pub search: SearchConfig,
409    #[serde(default)]
410    pub skills: SkillsConfig,
411}
412
413fn d_max_iter() -> u32 {
414    300
415}
416
417impl Default for AgentConfig {
418    fn default() -> Self {
419        AgentConfig {
420            model: ModelConfig::default(),
421            multimodal: None,
422            max_iterations: d_max_iter(),
423            plan_first: false,
424            shell_backend: ShellBackendChoice::Auto,
425            timeouts: Timeouts::default(),
426            limits: Limits::default(),
427            safety: SafetyConfig::default(),
428            context: ContextConfig::default(),
429            search: SearchConfig::default(),
430            skills: SkillsConfig::default(),
431        }
432    }
433}
434
435impl AgentConfig {
436    /// Apply environment overrides to the model + search config.
437    pub fn apply_env(&mut self) {
438        self.model.apply_env();
439        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
440        self.search.apply_env();
441        if let Some(mm) = self.multimodal.as_mut() {
442            mm.apply_env();
443        }
444        // AACODE_SHELL_BACKEND = native | fastshell | auto
445        if let Ok(b) = std::env::var("AACODE_SHELL_BACKEND") {
446            self.shell_backend = match b.trim().to_lowercase().as_str() {
447                "native" => ShellBackendChoice::Native,
448                "fastshell" => ShellBackendChoice::Fastshell,
449                _ => ShellBackendChoice::Auto,
450            };
451        }
452        // AACODE_SKILLS_DIR = absolute path of the user skills directory.
453        // Gives desktop/CLI hosts the same "builtin + user dir" skills mode
454        // that mobile hosts enable via task_json {"skills":{"user_dir":…}}.
455        if let Ok(d) = std::env::var("AACODE_SKILLS_DIR") {
456            let d = d.trim();
457            if !d.is_empty() {
458                self.skills.user_dir = Some(d.to_string());
459            }
460        }
461    }
462
463    /// Validate required fields; returns a list of human-readable problems.
464    pub fn validate(&self) -> Vec<String> {
465        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
466        let mut errs = Vec::new();
467        let has_key = self
468            .model
469            .api_key
470            .as_ref()
471            .map(|k| !k.trim().is_empty())
472            .unwrap_or(false);
473        if !has_key {
474            errs.push(
475                "LLM API key not configured (set model.api_key or env LLM_API_KEY)".to_string(),
476            );
477        }
478        errs
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    #[test]
485    fn task_json_context_trigger_parses() {
486        // The Android app sends {"context":{"compact_trigger_tokens":N}} in
487        // the task JSON — must round-trip into ContextConfig.
488        let v: serde_json::Value = serde_json::json!({
489            "model": {"name": "m", "api_key": "k"},
490            "context": {"compact_trigger_tokens": 123456}
491        });
492        let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
493        assert_eq!(cfg.context.compact_trigger_tokens, 123456);
494        // Default aligned with the Python aacode config (256000).
495        assert_eq!(super::ContextConfig::default().compact_trigger_tokens, 256000);
496    }
497
498    #[test]
499    fn task_json_skills_user_dir_parses() {
500        // The Android app sends {"skills":{"user_dir":"/abs/path"}} — must
501        // round-trip into SkillsConfig; absent → None (legacy mode).
502        let v: serde_json::Value = serde_json::json!({
503            "model": {"name": "m", "api_key": "k"},
504            "skills": {"user_dir": "/data/data/app/files/sandbox/skills"}
505        });
506        let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
507        assert_eq!(
508            cfg.skills.user_dir.as_deref(),
509            Some("/data/data/app/files/sandbox/skills")
510        );
511        let v2: serde_json::Value = serde_json::json!({"model": {"name": "m", "api_key": "k"}});
512        let cfg2: super::AgentConfig = serde_json::from_value(v2).unwrap();
513        assert!(cfg2.skills.user_dir.is_none());
514    }
515
516    #[test]
517    fn env_skills_dir_applies() {
518        std::env::set_var("AACODE_SKILLS_DIR", "/tmp/aacode_env_skills");
519        let mut cfg = super::AgentConfig::default();
520        cfg.apply_env();
521        std::env::remove_var("AACODE_SKILLS_DIR");
522        assert_eq!(cfg.skills.user_dir.as_deref(), Some("/tmp/aacode_env_skills"));
523    }
524
525    use super::*;
526
527    #[test]
528    fn base_url_inference() {
529        let mut m = ModelConfig {
530            name: "deepseek-chat".into(),
531            gateway: Gateway::Openai,
532            ..Default::default()
533        };
534        assert_eq!(m.resolved_base_url(), "https://api.deepseek.com/v1");
535
536        m.name = "kimi-k2".into();
537        assert_eq!(m.resolved_base_url(), "https://api.moonshot.cn/v1");
538
539        m.name = "MiniMax-M2".into();
540        m.gateway = Gateway::Anthropic;
541        assert_eq!(m.resolved_base_url(), "https://api.minimax.chat/anthropic");
542
543        m.name = "claude-3".into();
544        m.gateway = Gateway::Anthropic;
545        assert_eq!(m.resolved_base_url(), "https://api.anthropic.com");
546    }
547
548    #[test]
549    fn explicit_base_url_wins() {
550        let m = ModelConfig {
551            name: "deepseek-chat".into(),
552            base_url: Some("http://localhost:9999/v1".into()),
553            ..Default::default()
554        };
555        assert_eq!(m.resolved_base_url(), "http://localhost:9999/v1");
556    }
557
558    #[test]
559    fn config_json_roundtrip() {
560        let cfg = AgentConfig::default();
561        let s = serde_json::to_string(&cfg).unwrap();
562        let back: AgentConfig = serde_json::from_str(&s).unwrap();
563        assert_eq!(back.max_iterations, 300);
564        assert_eq!(back.model.name, "deepseek-chat");
565    }
566
567    #[test]
568    fn partial_json_uses_defaults() {
569        let cfg: AgentConfig =
570            serde_json::from_str(r#"{"model":{"name":"gpt-4","api_key":"x"}}"#).unwrap();
571        assert_eq!(cfg.model.name, "gpt-4");
572        assert_eq!(cfg.max_iterations, 300);
573        assert_eq!(cfg.model.temperature, 0.1);
574    }
575
576    #[test]
577    fn validate_requires_key() {
578        let cfg = AgentConfig::default();
579        assert!(!cfg.validate().is_empty());
580        let cfg2: AgentConfig =
581            serde_json::from_str(r#"{"model":{"api_key":"sk-x"}}"#).unwrap();
582        assert!(cfg2.validate().is_empty());
583    }
584}