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};
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")
});
pub struct AgentRuntime {
pub config: AgentConfig,
pub project_path: PathBuf,
pub backend: Arc<dyn ShellBackend>,
}
fn resolve_backend_kind(choice: ShellBackendChoice) -> BackendKind {
match choice {
ShellBackendChoice::Native => BackendKind::Native,
ShellBackendChoice::Fastshell => BackendKind::Fastshell,
ShellBackendChoice::Auto => BackendKind::platform_default(),
}
}
impl AgentRuntime {
pub fn init(mut config: AgentConfig, project_path: PathBuf) -> Result<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);
}
}
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();
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,
})
}
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)),
}
}
pub fn backend_kind(&self) -> &'static str {
self.backend.kind()
}
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()
})
}
pub async fn run_task(
&self,
task: &str,
session_id: Option<&str>,
emitter: &dyn EventSink,
cancel: &AtomicBool,
) -> Result<RunResult> {
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();
#[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"));
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(); 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"
);
}
}