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