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    // 0 = unlimited. `run_shell` truncation is the caller's choice via its
210    // `max_output` param; the observation layer does NOT truncate plain text
211    // (only binary/base64 is externalized). Set a number here to impose a hard
212    // accident ceiling.
213    0
214}
215fn d_display_preview() -> usize {
216    3000
217}
218fn d_max_retries() -> u32 {
219    2
220}
221impl Default for Limits {
222    fn default() -> Self {
223        Limits {
224            tool_output_chars: d_tool_output(),
225            display_preview_chars: d_display_preview(),
226            max_retries: d_max_retries(),
227        }
228    }
229}
230
231/// Dangerous-command handling policy.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "lowercase")]
234pub enum DangerAction {
235    Log,
236    Reject,
237    Ask,
238}
239impl Default for DangerAction {
240    fn default() -> Self {
241        DangerAction::Log
242    }
243}
244
245/// Safety configuration.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct SafetyConfig {
248    #[serde(default)]
249    pub dangerous_command_action: DangerAction,
250}
251impl Default for SafetyConfig {
252    fn default() -> Self {
253        SafetyConfig {
254            dangerous_command_action: DangerAction::Log,
255        }
256    }
257}
258
259/// Context / compaction configuration.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct ContextConfig {
262    #[serde(default = "d_max_ctx")]
263    pub max_context_tokens: usize,
264    #[serde(default = "d_trigger")]
265    pub compact_trigger_tokens: usize,
266    #[serde(default = "d_protect_first")]
267    pub protect_first_rounds: usize,
268    #[serde(default = "d_keep_last")]
269    pub keep_last_rounds: usize,
270    #[serde(default = "d_protect_user")]
271    pub protect_last_user_rounds: usize,
272}
273fn d_max_ctx() -> usize {
274    262144
275}
276// Aligned with the Python aacode default (config.py: compact_trigger_tokens).
277fn d_trigger() -> usize {
278    256000
279}
280fn d_protect_first() -> usize {
281    1
282}
283fn d_keep_last() -> usize {
284    10
285}
286fn d_protect_user() -> usize {
287    2
288}
289impl Default for ContextConfig {
290    fn default() -> Self {
291        ContextConfig {
292            max_context_tokens: d_max_ctx(),
293            compact_trigger_tokens: d_trigger(),
294            protect_first_rounds: d_protect_first(),
295            keep_last_rounds: d_keep_last(),
296            protect_last_user_rounds: d_protect_user(),
297        }
298    }
299}
300
301/// Search engine configuration.
302#[derive(Debug, Clone, Default, Serialize, Deserialize)]
303pub struct SearchConfig {
304    #[serde(default)]
305    pub searxng_url: Option<String>,
306    #[serde(default)]
307    pub brave_api_key: Option<String>,
308    #[serde(default)]
309    pub google_cse_key: Option<String>,
310    #[serde(default)]
311    pub google_cse_cx: Option<String>,
312    #[serde(default)]
313    pub bing_api_key: Option<String>,
314    #[serde(default)]
315    pub serpapi_key: Option<String>,
316}
317
318impl SearchConfig {
319    pub fn apply_env(&mut self) {
320        if let Ok(u) = std::env::var("SEARCHXNG_URL").or_else(|_| std::env::var("SEARXNG_URL")) {
321            if !u.is_empty() {
322                self.searxng_url = Some(u);
323            }
324        }
325        if let Ok(k) = std::env::var("BRAVE_API_KEY") {
326            if !k.is_empty() {
327                self.brave_api_key = Some(k);
328            }
329        }
330        if let Ok(k) = std::env::var("SERPAPI_KEY") {
331            if !k.is_empty() {
332                self.serpapi_key = Some(k);
333            }
334        }
335    }
336}
337
338/// Shell execution backend selection.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
340#[serde(rename_all = "lowercase")]
341pub enum ShellBackendChoice {
342    /// Real OS shell (sh -c / cmd /C). Natural on desktop.
343    Native,
344    /// fastshell sandbox engine (VFS jail + built-in commands + embedded CPython).
345    Fastshell,
346    /// Pick based on target OS: native on desktop, fastshell on mobile.
347    Auto,
348}
349
350impl Default for ShellBackendChoice {
351    fn default() -> Self {
352        ShellBackendChoice::Auto
353    }
354}
355
356/// Skills discovery configuration (host-provided, platform-agnostic).
357///
358/// When `user_dir` is set (mobile hosts pass an absolute path inside their
359/// sandbox), discovery switches to "builtin + user dir" mode and project
360/// directories are NOT scanned. When unset, the legacy per-project scanning
361/// (`<project>/skills` + `<project>/.aacode/skills`) is preserved unchanged.
362#[derive(Debug, Clone, Default, Serialize, Deserialize)]
363pub struct SkillsConfig {
364    /// Absolute path of the user-managed skills directory.
365    #[serde(default)]
366    pub user_dir: Option<String>,
367    /// Host-declared extra builtins beyond skill_creator. These are compiled
368    /// into the binary but only injected when the host explicitly names them
369    /// here (e.g. `["agent_cron"]`). Empty by default — desktop/CLI hosts
370    /// that don't pass this field never see platform-specific builtins.
371    #[serde(default)]
372    pub extra_builtins: Vec<String>,
373    /// VFS-internal path for `{SKILLS_DIR}` substitution in skill prompts.
374    /// Computed at runtime from `user_dir` relative to the shell sandbox root.
375    ///
376    /// The path starts with `/` (e.g. `/skills`) because the VFS sandbox
377    /// interprets leading-`/` paths as relative to the VFS root rather than
378    /// the real filesystem root. This keeps prompts short and platform-neutral.
379    ///
380    /// When `None`, substitution falls back to `user_dir` (legacy absolute path).
381    /// Marked `#[serde(skip)]` because the host never sets it — the shell
382    /// root widening logic in ffi.rs computes it.
383    #[serde(skip, default)]
384    pub vfs_skills_dir: Option<String>,
385}
386
387/// The complete agent configuration.
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct AgentConfig {
390    #[serde(default)]
391    pub model: ModelConfig,
392    /// Optional dedicated multimodal (vision) model config.
393    #[serde(default)]
394    pub multimodal: Option<ModelConfig>,
395    #[serde(default = "d_max_iter")]
396    pub max_iterations: u32,
397    #[serde(default)]
398    pub plan_first: bool,
399    /// Which shell backend to use. Defaults to Auto (native on desktop,
400    /// fastshell sandbox on mobile).
401    #[serde(default)]
402    pub shell_backend: ShellBackendChoice,
403    #[serde(default)]
404    pub timeouts: Timeouts,
405    #[serde(default)]
406    pub limits: Limits,
407    #[serde(default)]
408    pub safety: SafetyConfig,
409    #[serde(default)]
410    pub context: ContextConfig,
411    #[serde(default)]
412    pub search: SearchConfig,
413    #[serde(default)]
414    pub skills: SkillsConfig,
415}
416
417fn d_max_iter() -> u32 {
418    300
419}
420
421impl Default for AgentConfig {
422    fn default() -> Self {
423        AgentConfig {
424            model: ModelConfig::default(),
425            multimodal: None,
426            max_iterations: d_max_iter(),
427            plan_first: false,
428            shell_backend: ShellBackendChoice::Auto,
429            timeouts: Timeouts::default(),
430            limits: Limits::default(),
431            safety: SafetyConfig::default(),
432            context: ContextConfig::default(),
433            search: SearchConfig::default(),
434            skills: SkillsConfig::default(),
435        }
436    }
437}
438
439impl AgentConfig {
440    /// Apply environment overrides to the model + search config.
441    pub fn apply_env(&mut self) {
442        self.model.apply_env();
443        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
444        self.search.apply_env();
445        if let Some(mm) = self.multimodal.as_mut() {
446            mm.apply_env();
447        }
448        // AACODE_SHELL_BACKEND = native | fastshell | auto
449        if let Ok(b) = std::env::var("AACODE_SHELL_BACKEND") {
450            self.shell_backend = match b.trim().to_lowercase().as_str() {
451                "native" => ShellBackendChoice::Native,
452                "fastshell" => ShellBackendChoice::Fastshell,
453                _ => ShellBackendChoice::Auto,
454            };
455        }
456        // AACODE_SKILLS_DIR = absolute path of the user skills directory.
457        // Gives desktop/CLI hosts the same "builtin + user dir" skills mode
458        // that mobile hosts enable via task_json {"skills":{"user_dir":…}}.
459        if let Ok(d) = std::env::var("AACODE_SKILLS_DIR") {
460            let d = d.trim();
461            if !d.is_empty() {
462                self.skills.user_dir = Some(d.to_string());
463            }
464        }
465    }
466
467    /// Validate required fields; returns a list of human-readable problems.
468    pub fn validate(&self) -> Vec<String> {
469        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
470        let mut errs = Vec::new();
471        let has_key = self
472            .model
473            .api_key
474            .as_ref()
475            .map(|k| !k.trim().is_empty())
476            .unwrap_or(false);
477        if !has_key {
478            errs.push(
479                "LLM API key not configured (set model.api_key or env LLM_API_KEY)".to_string(),
480            );
481        }
482        errs
483    }
484}
485
486#[cfg(test)]
487mod tests {
488    #[test]
489    fn task_json_context_trigger_parses() {
490        // The Android app sends {"context":{"compact_trigger_tokens":N}} in
491        // the task JSON — must round-trip into ContextConfig.
492        let v: serde_json::Value = serde_json::json!({
493            "model": {"name": "m", "api_key": "k"},
494            "context": {"compact_trigger_tokens": 123456}
495        });
496        let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
497        assert_eq!(cfg.context.compact_trigger_tokens, 123456);
498        // Default aligned with the Python aacode config (256000).
499        assert_eq!(
500            super::ContextConfig::default().compact_trigger_tokens,
501            256000
502        );
503    }
504
505    #[test]
506    fn task_json_skills_user_dir_parses() {
507        // The Android app sends {"skills":{"user_dir":"/abs/path"}} — must
508        // round-trip into SkillsConfig; absent → None (legacy mode).
509        let v: serde_json::Value = serde_json::json!({
510            "model": {"name": "m", "api_key": "k"},
511            "skills": {"user_dir": "/data/data/app/files/sandbox/skills"}
512        });
513        let cfg: super::AgentConfig = serde_json::from_value(v).unwrap();
514        assert_eq!(
515            cfg.skills.user_dir.as_deref(),
516            Some("/data/data/app/files/sandbox/skills")
517        );
518        let v2: serde_json::Value = serde_json::json!({"model": {"name": "m", "api_key": "k"}});
519        let cfg2: super::AgentConfig = serde_json::from_value(v2).unwrap();
520        assert!(cfg2.skills.user_dir.is_none());
521    }
522
523    #[test]
524    fn env_skills_dir_applies() {
525        std::env::set_var("AACODE_SKILLS_DIR", "/tmp/aacode_env_skills");
526        let mut cfg = super::AgentConfig::default();
527        cfg.apply_env();
528        std::env::remove_var("AACODE_SKILLS_DIR");
529        assert_eq!(
530            cfg.skills.user_dir.as_deref(),
531            Some("/tmp/aacode_env_skills")
532        );
533    }
534
535    use super::*;
536
537    #[test]
538    fn base_url_inference() {
539        let mut m = ModelConfig {
540            name: "deepseek-chat".into(),
541            gateway: Gateway::Openai,
542            ..Default::default()
543        };
544        assert_eq!(m.resolved_base_url(), "https://api.deepseek.com/v1");
545
546        m.name = "kimi-k2".into();
547        assert_eq!(m.resolved_base_url(), "https://api.moonshot.cn/v1");
548
549        m.name = "MiniMax-M2".into();
550        m.gateway = Gateway::Anthropic;
551        assert_eq!(m.resolved_base_url(), "https://api.minimax.chat/anthropic");
552
553        m.name = "claude-3".into();
554        m.gateway = Gateway::Anthropic;
555        assert_eq!(m.resolved_base_url(), "https://api.anthropic.com");
556    }
557
558    #[test]
559    fn explicit_base_url_wins() {
560        let m = ModelConfig {
561            name: "deepseek-chat".into(),
562            base_url: Some("http://localhost:9999/v1".into()),
563            ..Default::default()
564        };
565        assert_eq!(m.resolved_base_url(), "http://localhost:9999/v1");
566    }
567
568    #[test]
569    fn config_json_roundtrip() {
570        let cfg = AgentConfig::default();
571        let s = serde_json::to_string(&cfg).unwrap();
572        let back: AgentConfig = serde_json::from_str(&s).unwrap();
573        assert_eq!(back.max_iterations, 300);
574        assert_eq!(back.model.name, "deepseek-chat");
575    }
576
577    #[test]
578    fn partial_json_uses_defaults() {
579        let cfg: AgentConfig =
580            serde_json::from_str(r#"{"model":{"name":"gpt-4","api_key":"x"}}"#).unwrap();
581        assert_eq!(cfg.model.name, "gpt-4");
582        assert_eq!(cfg.max_iterations, 300);
583        assert_eq!(cfg.model.temperature, 0.1);
584    }
585
586    #[test]
587    fn validate_requires_key() {
588        let cfg = AgentConfig::default();
589        assert!(!cfg.validate().is_empty());
590        let cfg2: AgentConfig = serde_json::from_str(r#"{"model":{"api_key":"sk-x"}}"#).unwrap();
591        assert!(cfg2.validate().is_empty());
592    }
593}