Skip to main content

aacode_rs/
runtime.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Top-level orchestration: choose a shell backend, build the agent, run a task.
5//! Shared by the CLI (`bin/aacode`) and the FFI layer (`ffi.rs`).
6//!
7//! Backend selection (`config.shell_backend`):
8//!   * `Auto` (default) — native OS shell on desktop, fastshell sandbox on mobile.
9//!   * `Native` — force the real OS shell (`sh -c` / `cmd /C`).
10//!   * `Fastshell` — force the sandbox engine (VFS jail + embedded CPython).
11
12use crate::agent::{MainAgent, RunResult, RunStatus};
13use crate::config::{AgentConfig, ShellBackendChoice};
14use crate::error::{AacodeError, Result};
15use crate::llm::build_client;
16use crate::session::SessionManager;
17use crate::stream::EventSink;
18use crate::tools::backend::{BackendKind, FastshellBackend, NativeShell, ShellBackend};
19use fastshell::{Config, Fastshell};
20use std::path::PathBuf;
21use std::sync::atomic::AtomicBool;
22use std::sync::LazyLock;
23use std::sync::{Arc, Mutex};
24
25/// Lazily-initialized shared tokio runtime for async LLM calls.
26/// Created once on first use, shared across all FFI calls and validation.
27/// Uses 2 worker threads (enough for concurrent HTTP SSE reads) with a small
28/// blocking pool for shell execution — mobile devices benefit from fewer
29/// threads (~4 MB vs ~2 MB default on 6-core devices).
30pub(crate) static TOKIO_RT: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
31    tokio::runtime::Builder::new_multi_thread()
32        .worker_threads(2)
33        .max_blocking_threads(4)
34        .thread_name("aacode-tokio")
35        .enable_all()
36        .build()
37        .expect("failed to create tokio runtime for aacode-rs")
38});
39
40/// A prepared agent runtime bound to a project directory + shell backend.
41pub struct AgentRuntime {
42    pub config: AgentConfig,
43    pub project_path: PathBuf,
44    pub backend: Arc<dyn ShellBackend>,
45}
46
47/// Resolve the effective backend kind from config + platform.
48fn resolve_backend_kind(choice: ShellBackendChoice) -> BackendKind {
49    match choice {
50        ShellBackendChoice::Native => BackendKind::Native,
51        ShellBackendChoice::Fastshell => BackendKind::Fastshell,
52        ShellBackendChoice::Auto => BackendKind::platform_default(),
53    }
54}
55
56impl AgentRuntime {
57    /// Initialize the runtime for `project_path`, building the appropriate
58    /// shell backend. The native backend needs no setup; the fastshell backend
59    /// initializes a VFS sandbox + embedded CPython.
60    pub fn init(mut config: AgentConfig, project_path: PathBuf) -> Result<Self> {
61        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
62        // Wire config.timeouts.model_request into model's per-request deadline
63        if config.model.request_timeout_secs.is_none() {
64            config.model.request_timeout_secs = Some(config.timeouts.model_request);
65        }
66        if let Some(mm) = config.multimodal.as_mut() {
67            if mm.request_timeout_secs.is_none() {
68                mm.request_timeout_secs = Some(config.timeouts.model_request);
69            }
70        }
71        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
72        std::fs::create_dir_all(&project_path)
73            .map_err(|e| AacodeError::Io(format!("create project dir: {e}")))?;
74
75        let kind = resolve_backend_kind(config.shell_backend);
76        let backend: Arc<dyn ShellBackend> = match kind {
77            BackendKind::Native => Arc::new(NativeShell::new()),
78            BackendKind::Fastshell => {
79                let mut fs = Fastshell::new();
80                let mut fscfg = Config::default();
81                fscfg.sandbox_path = project_path.to_string_lossy().to_string();
82                // Python enabled so run_shell can execute `python3 ...` for testing.
83                fscfg.python_enabled = true;
84                fscfg.command_timeout_ms = config.timeouts.shell_command * 1000;
85                fs.init(fscfg).map_err(AacodeError::Other)?;
86                Arc::new(FastshellBackend::new(Arc::new(Mutex::new(fs))))
87            }
88        };
89
90        Ok(AgentRuntime {
91            config,
92            project_path,
93            backend,
94        })
95    }
96
97    /// Initialize reusing an already-constructed fastshell handle (FFI path
98    /// where the host already called fastshell_init on the same sandbox).
99    /// Forces the fastshell backend regardless of config.
100    pub fn with_fastshell(
101        mut config: AgentConfig,
102        project_path: PathBuf,
103        fs: Arc<Mutex<Fastshell>>,
104    ) -> Self {
105        if config.model.request_timeout_secs.is_none() {
106            config.model.request_timeout_secs = Some(config.timeouts.model_request);
107        }
108        if let Some(mm) = config.multimodal.as_mut() {
109            if mm.request_timeout_secs.is_none() {
110                mm.request_timeout_secs = Some(config.timeouts.model_request);
111            }
112        }
113        AgentRuntime {
114            config,
115            project_path,
116            backend: Arc::new(FastshellBackend::new(fs)),
117        }
118    }
119
120    /// Which backend is active (for diagnostics).
121    pub fn backend_kind(&self) -> &'static str {
122        self.backend.kind()
123    }
124
125    /// Validate the configured API key with a lightweight probe.
126    /// Uses a dedicated OS thread to avoid runtime nesting issues, reusing the
127    /// shared runtime handle (no per-call `Runtime::new()` thread churn).
128    pub fn validate_api_key(&self) -> Result<()> {
129        let client = build_client(&self.config.model);
130        let handle = TOKIO_RT.handle().clone();
131        std::thread::scope(|s| {
132            s.spawn(move || handle.block_on(async { client.validate().await }))
133                .join()
134                .unwrap()
135        })
136    }
137
138    /// Run a task to completion, streaming events to `emitter`.
139    /// `session_id` optionally continues an existing session.
140    pub async fn run_task(
141        &self,
142        task: &str,
143        session_id: Option<&str>,
144        emitter: &dyn EventSink,
145        cancel: &AtomicBool,
146    ) -> Result<RunResult> {
147        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
148        // Validate config early for a clear error.
149        let errs = self.config.validate();
150        if !errs.is_empty() {
151            emitter.error(&errs.join("; "));
152            return Ok(RunResult {
153                status: RunStatus::Error(errs.join("; ")),
154                iterations: 0,
155                final_text: String::new(),
156            });
157        }
158
159        let mut session = SessionManager::new(&self.project_path);
160        if let Some(id) = session_id {
161            let _ = session.switch_session(id);
162        }
163
164        let agent = MainAgent::new(
165            self.config.clone(),
166            self.project_path.clone(),
167            self.backend.clone(),
168        );
169        agent.execute(task, &mut session, emitter, cancel).await
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::config::ModelConfig;
177    use crate::stream::CollectingSink;
178
179    fn tmp() -> PathBuf {
180        std::env::temp_dir().join(format!(
181            "aacode_rt_{}_{}",
182            std::process::id(),
183            uuid::Uuid::new_v4().simple()
184        ))
185    }
186
187    #[test]
188    fn desktop_defaults_to_native_backend() {
189        let d = tmp();
190        let mut cfg = AgentConfig::default();
191        cfg.model.api_key = Some("x".into());
192        let rt = AgentRuntime::init(cfg, d.clone()).unwrap();
193        // On desktop, Auto → native.
194        #[cfg(not(any(target_os = "android", target_os = "ios")))]
195        assert_eq!(rt.backend_kind(), "native");
196    }
197
198    #[test]
199    fn native_backend_uses_real_cwd() {
200        let d = tmp();
201        let mut cfg = AgentConfig::default();
202        cfg.model.api_key = Some("x".into());
203        cfg.shell_backend = ShellBackendChoice::Native;
204        let rt = AgentRuntime::init(cfg, d.clone()).unwrap();
205        let out = rt
206            .backend
207            .run("echo hi > f.txt && cat f.txt", None, 10, 0, &d);
208        assert!(out.stdout.contains("hi"));
209        // File created at the real project dir (no VFS jail nesting).
210        assert!(d.join("f.txt").exists());
211    }
212
213    #[test]
214    fn force_fastshell_backend() {
215        let d = tmp();
216        let mut cfg = AgentConfig::default();
217        cfg.model.api_key = Some("x".into());
218        cfg.shell_backend = ShellBackendChoice::Fastshell;
219        let rt = AgentRuntime::init(cfg, d).unwrap();
220        assert_eq!(rt.backend_kind(), "fastshell");
221        let out = rt
222            .backend
223            .run("echo hi", None, 10, 0, std::path::Path::new("."));
224        assert!(out.stdout.contains("hi"));
225    }
226
227    #[tokio::test]
228    async fn run_task_missing_key_errors() {
229        let d = tmp();
230        let cfg = AgentConfig::default(); // no api key
231        let rt = AgentRuntime::init(cfg, d).unwrap();
232        let sink = CollectingSink::new(false);
233        let cancel = AtomicBool::new(false);
234        let res = rt.run_task("do x", None, &sink, &cancel).await.unwrap();
235        assert!(matches!(res.status, RunStatus::Error(_)));
236        assert!(sink.lines().iter().any(|l| l.contains("error")));
237    }
238
239    #[test]
240    fn multimodal_timeout_wired_to_model_request() {
241        let d = tmp();
242        let mut cfg = AgentConfig::default();
243        cfg.model.api_key = Some("sk-test".into());
244        cfg.multimodal = Some(ModelConfig {
245            name: "kimi-k2.5".into(),
246            api_key: Some("sk-mm".into()),
247            ..Default::default()
248        });
249        assert!(
250            cfg.multimodal
251                .as_ref()
252                .unwrap()
253                .request_timeout_secs
254                .is_none(),
255            "before init, request_timeout_secs should be None"
256        );
257        let rt = AgentRuntime::init(cfg, d).unwrap();
258        assert_eq!(
259            rt.config.multimodal.as_ref().unwrap().request_timeout_secs,
260            Some(rt.config.timeouts.model_request),
261            "multimodal request_timeout_secs must be wired to model_request after init"
262        );
263    }
264
265    #[test]
266    fn multimodal_timeout_wired_with_fastshell() {
267        let d = tmp();
268        let mut cfg = AgentConfig::default();
269        cfg.model.api_key = Some("sk-test".into());
270        cfg.multimodal = Some(ModelConfig {
271            name: "kimi-k2.5".into(),
272            api_key: Some("sk-mm".into()),
273            ..Default::default()
274        });
275        assert!(cfg
276            .multimodal
277            .as_ref()
278            .unwrap()
279            .request_timeout_secs
280            .is_none());
281        let mut fs = Fastshell::new();
282        let mut fscfg = Config::default();
283        fscfg.sandbox_path = d.to_string_lossy().to_string();
284        fscfg.python_enabled = true;
285        fs.init(fscfg).unwrap();
286        let rt = AgentRuntime::with_fastshell(cfg, d, Arc::new(Mutex::new(fs)));
287        assert_eq!(
288            rt.config.multimodal.as_ref().unwrap().request_timeout_secs,
289            Some(rt.config.timeouts.model_request),
290            "multimodal request_timeout_secs must be wired to model_request in with_fastshell too"
291        );
292    }
293}