1use 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
25pub(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
40pub struct AgentRuntime {
42 pub config: AgentConfig,
43 pub project_path: PathBuf,
44 pub backend: Arc<dyn ShellBackend>,
45}
46
47fn 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 pub fn init(mut config: AgentConfig, project_path: PathBuf) -> Result<Self> {
61 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 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 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 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 pub fn backend_kind(&self) -> &'static str {
122 self.backend.kind()
123 }
124
125 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 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 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 #[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 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(); 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}