aacode-rs 1.8.10

Rust port of the AACode ReAct coding agent, built on fastshell for mobile AI agents
Documentation
// Copyright (c) 2026 xiefujin <490021684@qq.com>
// Licensed under GPL-3.0, see LICENSE file for full license terms.

//! Top-level orchestration: choose a shell backend, build the agent, run a task.
//! Shared by the CLI (`bin/aacode`) and the FFI layer (`ffi.rs`).
//!
//! Backend selection (`config.shell_backend`):
//!   * `Auto` (default) — native OS shell on desktop, fastshell sandbox on mobile.
//!   * `Native` — force the real OS shell (`sh -c` / `cmd /C`).
//!   * `Fastshell` — force the sandbox engine (VFS jail + embedded CPython).

use crate::agent::{MainAgent, RunResult, RunStatus};
use crate::config::{AgentConfig, ShellBackendChoice};
use crate::error::{AacodeError, Result};
use crate::llm::build_client;
use crate::session::SessionManager;
use crate::stream::EventSink;
use crate::tools::backend::{BackendKind, FastshellBackend, NativeShell, ShellBackend};
use fastshell::{Config, Fastshell};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::LazyLock;
use std::sync::{Arc, Mutex};

/// Lazily-initialized shared tokio runtime for async LLM calls.
/// Created once on first use, shared across all FFI calls and validation.
/// Uses 2 worker threads (enough for concurrent HTTP SSE reads) with a small
/// blocking pool for shell execution — mobile devices benefit from fewer
/// threads (~4 MB vs ~2 MB default on 6-core devices).
pub(crate) static TOKIO_RT: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
    tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .max_blocking_threads(4)
        .thread_name("aacode-tokio")
        .enable_all()
        .build()
        .expect("failed to create tokio runtime for aacode-rs")
});

/// A prepared agent runtime bound to a project directory + shell backend.
pub struct AgentRuntime {
    pub config: AgentConfig,
    pub project_path: PathBuf,
    pub backend: Arc<dyn ShellBackend>,
}

/// Resolve the effective backend kind from config + platform.
fn resolve_backend_kind(choice: ShellBackendChoice) -> BackendKind {
    match choice {
        ShellBackendChoice::Native => BackendKind::Native,
        ShellBackendChoice::Fastshell => BackendKind::Fastshell,
        ShellBackendChoice::Auto => BackendKind::platform_default(),
    }
}

impl AgentRuntime {
    /// Initialize the runtime for `project_path`, building the appropriate
    /// shell backend. The native backend needs no setup; the fastshell backend
    /// initializes a VFS sandbox + embedded CPython.
    pub fn init(mut config: AgentConfig, project_path: PathBuf) -> Result<Self> {
        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
        // Wire config.timeouts.model_request into model's per-request deadline
        if config.model.request_timeout_secs.is_none() {
            config.model.request_timeout_secs = Some(config.timeouts.model_request);
        }
        if let Some(mm) = config.multimodal.as_mut() {
            if mm.request_timeout_secs.is_none() {
                mm.request_timeout_secs = Some(config.timeouts.model_request);
            }
        }
        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
        std::fs::create_dir_all(&project_path)
            .map_err(|e| AacodeError::Io(format!("create project dir: {e}")))?;

        let kind = resolve_backend_kind(config.shell_backend);
        let backend: Arc<dyn ShellBackend> = match kind {
            BackendKind::Native => Arc::new(NativeShell::new()),
            BackendKind::Fastshell => {
                let mut fs = Fastshell::new();
                let mut fscfg = Config::default();
                fscfg.sandbox_path = project_path.to_string_lossy().to_string();
                // Python enabled so run_shell can execute `python3 ...` for testing.
                fscfg.python_enabled = true;
                fscfg.command_timeout_ms = config.timeouts.shell_command * 1000;
                fs.init(fscfg).map_err(AacodeError::Other)?;
                Arc::new(FastshellBackend::new(Arc::new(Mutex::new(fs))))
            }
        };

        Ok(AgentRuntime {
            config,
            project_path,
            backend,
        })
    }

    /// Initialize reusing an already-constructed fastshell handle (FFI path
    /// where the host already called fastshell_init on the same sandbox).
    /// Forces the fastshell backend regardless of config.
    pub fn with_fastshell(
        mut config: AgentConfig,
        project_path: PathBuf,
        fs: Arc<Mutex<Fastshell>>,
    ) -> Self {
        if config.model.request_timeout_secs.is_none() {
            config.model.request_timeout_secs = Some(config.timeouts.model_request);
        }
        if let Some(mm) = config.multimodal.as_mut() {
            if mm.request_timeout_secs.is_none() {
                mm.request_timeout_secs = Some(config.timeouts.model_request);
            }
        }
        AgentRuntime {
            config,
            project_path,
            backend: Arc::new(FastshellBackend::new(fs)),
        }
    }

    /// Which backend is active (for diagnostics).
    pub fn backend_kind(&self) -> &'static str {
        self.backend.kind()
    }

    /// Validate the configured API key with a lightweight probe.
    /// Uses a dedicated OS thread to avoid runtime nesting issues, reusing the
    /// shared runtime handle (no per-call `Runtime::new()` thread churn).
    pub fn validate_api_key(&self) -> Result<()> {
        let client = build_client(&self.config.model);
        let handle = TOKIO_RT.handle().clone();
        std::thread::scope(|s| {
            s.spawn(move || handle.block_on(async { client.validate().await }))
                .join()
                .unwrap()
        })
    }

    /// Run a task to completion, streaming events to `emitter`.
    /// `session_id` optionally continues an existing session.
    pub async fn run_task(
        &self,
        task: &str,
        session_id: Option<&str>,
        emitter: &dyn EventSink,
        cancel: &AtomicBool,
    ) -> Result<RunResult> {
        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
        // Validate config early for a clear error.
        let errs = self.config.validate();
        if !errs.is_empty() {
            emitter.error(&errs.join("; "));
            return Ok(RunResult {
                status: RunStatus::Error(errs.join("; ")),
                iterations: 0,
                final_text: String::new(),
            });
        }

        let mut session = SessionManager::new(&self.project_path);
        if let Some(id) = session_id {
            let _ = session.switch_session(id);
        }

        let agent = MainAgent::new(
            self.config.clone(),
            self.project_path.clone(),
            self.backend.clone(),
        );
        agent.execute(task, &mut session, emitter, cancel).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::ModelConfig;
    use crate::stream::CollectingSink;

    fn tmp() -> PathBuf {
        std::env::temp_dir().join(format!(
            "aacode_rt_{}_{}",
            std::process::id(),
            uuid::Uuid::new_v4().simple()
        ))
    }

    #[test]
    fn desktop_defaults_to_native_backend() {
        let d = tmp();
        let mut cfg = AgentConfig::default();
        cfg.model.api_key = Some("x".into());
        let rt = AgentRuntime::init(cfg, d.clone()).unwrap();
        // On desktop, Auto → native.
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
        assert_eq!(rt.backend_kind(), "native");
    }

    #[test]
    fn native_backend_uses_real_cwd() {
        let d = tmp();
        let mut cfg = AgentConfig::default();
        cfg.model.api_key = Some("x".into());
        cfg.shell_backend = ShellBackendChoice::Native;
        let rt = AgentRuntime::init(cfg, d.clone()).unwrap();
        let out = rt
            .backend
            .run("echo hi > f.txt && cat f.txt", None, 10, 0, &d);
        assert!(out.stdout.contains("hi"));
        // File created at the real project dir (no VFS jail nesting).
        assert!(d.join("f.txt").exists());
    }

    #[test]
    fn force_fastshell_backend() {
        let d = tmp();
        let mut cfg = AgentConfig::default();
        cfg.model.api_key = Some("x".into());
        cfg.shell_backend = ShellBackendChoice::Fastshell;
        let rt = AgentRuntime::init(cfg, d).unwrap();
        assert_eq!(rt.backend_kind(), "fastshell");
        let out = rt
            .backend
            .run("echo hi", None, 10, 0, std::path::Path::new("."));
        assert!(out.stdout.contains("hi"));
    }

    #[tokio::test]
    async fn run_task_missing_key_errors() {
        let d = tmp();
        let cfg = AgentConfig::default(); // no api key
        let rt = AgentRuntime::init(cfg, d).unwrap();
        let sink = CollectingSink::new(false);
        let cancel = AtomicBool::new(false);
        let res = rt.run_task("do x", None, &sink, &cancel).await.unwrap();
        assert!(matches!(res.status, RunStatus::Error(_)));
        assert!(sink.lines().iter().any(|l| l.contains("error")));
    }

    #[test]
    fn multimodal_timeout_wired_to_model_request() {
        let d = tmp();
        let mut cfg = AgentConfig::default();
        cfg.model.api_key = Some("sk-test".into());
        cfg.multimodal = Some(ModelConfig {
            name: "kimi-k2.5".into(),
            api_key: Some("sk-mm".into()),
            ..Default::default()
        });
        assert!(
            cfg.multimodal
                .as_ref()
                .unwrap()
                .request_timeout_secs
                .is_none(),
            "before init, request_timeout_secs should be None"
        );
        let rt = AgentRuntime::init(cfg, d).unwrap();
        assert_eq!(
            rt.config.multimodal.as_ref().unwrap().request_timeout_secs,
            Some(rt.config.timeouts.model_request),
            "multimodal request_timeout_secs must be wired to model_request after init"
        );
    }

    #[test]
    fn multimodal_timeout_wired_with_fastshell() {
        let d = tmp();
        let mut cfg = AgentConfig::default();
        cfg.model.api_key = Some("sk-test".into());
        cfg.multimodal = Some(ModelConfig {
            name: "kimi-k2.5".into(),
            api_key: Some("sk-mm".into()),
            ..Default::default()
        });
        assert!(cfg
            .multimodal
            .as_ref()
            .unwrap()
            .request_timeout_secs
            .is_none());
        let mut fs = Fastshell::new();
        let mut fscfg = Config::default();
        fscfg.sandbox_path = d.to_string_lossy().to_string();
        fscfg.python_enabled = true;
        fs.init(fscfg).unwrap();
        let rt = AgentRuntime::with_fastshell(cfg, d, Arc::new(Mutex::new(fs)));
        assert_eq!(
            rt.config.multimodal.as_ref().unwrap().request_timeout_secs,
            Some(rt.config.timeouts.model_request),
            "multimodal request_timeout_secs must be wired to model_request in with_fastshell too"
        );
    }
}