1use crate::agent::prompts::{PLANNING_IN_THOUGHT, SYSTEM_PROMPT_FOR_MAIN_AGENT};
10use crate::agent::react_loop::{ReactLoop, RunResult};
11use crate::config::{AgentConfig, Gateway};
12use crate::context::ContextManager;
13use crate::error::Result;
14use crate::llm::types::ChatMessage;
15use crate::llm::{build_client, LlmClient};
16use crate::session::{SessionManager, SessionMessage};
17use crate::stream::EventSink;
18use crate::tools::registry::ToolRegistry;
19use crate::tools::ShellBackend;
20use std::path::PathBuf;
21use std::sync::atomic::AtomicBool;
22use std::sync::Arc;
23
24pub struct MainAgent {
25 pub config: AgentConfig,
26 project_path: PathBuf,
27 backend: Arc<dyn ShellBackend>,
28 llm: Box<dyn LlmClient>,
29 context: ContextManager,
30}
31
32impl MainAgent {
33 pub fn new(
34 mut config: AgentConfig,
35 project_path: PathBuf,
36 backend: Arc<dyn ShellBackend>,
37 ) -> Self {
38 if config.model.request_timeout_secs.is_none() {
40 config.model.request_timeout_secs = Some(config.timeouts.model_request);
41 }
42 if let Some(mm) = config.multimodal.as_mut() {
43 if mm.request_timeout_secs.is_none() {
44 mm.request_timeout_secs = Some(config.timeouts.model_request);
45 }
46 }
47 let llm = build_client(&config.model);
48 let context = ContextManager::new(&project_path);
50 MainAgent {
51 config,
52 project_path,
53 backend,
54 llm,
55 context,
56 }
57 }
58
59 pub fn build_registry_with_holder(
62 &self,
63 ) -> (
64 ToolRegistry,
65 std::sync::Arc<std::sync::Mutex<Option<String>>>,
66 ) {
67 crate::tools::build_default_registry_with_holder(
68 self.backend.clone(),
69 self.project_path.clone(),
70 &self.config,
71 )
72 }
73
74 pub fn build_registry(&self) -> ToolRegistry {
76 self.build_registry_with_holder().0
77 }
78
79 fn build_system_prompt(&self, _registry: &ToolRegistry, skills_list: &str) -> String {
85 let mut prompt = SYSTEM_PROMPT_FOR_MAIN_AGENT.replace("{skills_list}", skills_list);
86 prompt.push_str(&format!(
87 "\n\n## Working Directory\nYour current working directory is: {}\nFor project files, use paths relative to this directory.",
88 self.project_path.display()
89 ));
90 if self.backend.kind() == "fastshell" {
91 prompt.push_str(" Paths starting with / (e.g. /skills) are relative to the sandbox root, not the real filesystem. Paths without / are relative to your working directory. avoid /tmp/.");
92 #[cfg(any(target_os = "android", target_os = "ios"))]
93 {
94 prompt.push_str(" Python: embedded RustPython (only pure-Python packages work, no C extensions). Test with `python3 -m unittest`; syntax-check: `python3 -c \"import ast; ast.parse(open('f.py').read())\"`. Install missing pure-Python packages with `pip-install <name>`.");
95 prompt.push_str(" Built-in extras: `sqlite3` (embedded DB, no install), `node`/`js` (WebView JavaScript), `render` (HTML→screenshot), `jscheck` (JS/TS syntax check); full command list at https://github.com/kandada/fastshell.");
96 prompt.push_str(" The sandbox runs in-process: `bash`/`sh` and Python `subprocess` are unavailable — use built-in commands or `execute_python` instead.");
97 }
98 }
99 let init = self.context.load_init_instructions();
101 prompt.push_str(&format!("\n\n## Project Init Instructions\n\n{init}"));
102 #[cfg(feature = "browser")]
104 if crate::tools::browser::is_available() {
105 prompt.push_str(
106 "\n\n## Browser\nA full browser-automation engine (`fastbrowser`) is available. It can open pages and drive a real \
107browser: click / type / fill forms, scroll, run JavaScript, read cookies & localStorage, extract \
108text / links / tables, manage tabs, wait for elements, and block or intercept requests — including \
109JS-heavy and anti-bot pages. To read cluttered or JS-heavy pages, use the accessibility tree \
110(`get_accessibility_tree`: role / name / state), which also lets you target elements by `role=` / `name=`.\n\
111`browser_tools` lists the available \
112browser tools (names by default; `mode:\"help\", name:\"<tool>\"` gives one tool's full schema). \
113Call any of them with `browser_call(name, args)`; pass `compact:true` for cleaned \
114output (large results archived under `.aacode/extracts/`).",
115 );
116 }
117 prompt.push_str(PLANNING_IN_THOUGHT);
119 if self.config.plan_first {
121 prompt.push_str(
122 "\n\nPlan-First Mode: Before taking any action, first produce a concise numbered plan of the steps you will take. Only then begin executing tools.\n",
123 );
124 }
125 prompt
126 }
127
128 fn native_tools(&self, registry: &ToolRegistry) -> Vec<serde_json::Value> {
130 match self.config.model.gateway {
131 Gateway::Anthropic => registry.anthropic_tools(),
132 Gateway::Openai => registry.openai_tools(),
133 }
134 }
135
136 pub async fn execute(
138 &self,
139 task: &str,
140 session: &mut SessionManager,
141 emitter: &dyn EventSink,
142 cancel: &AtomicBool,
143 ) -> Result<RunResult> {
144 emitter.started(task);
145 emitter.runtime_info();
148 if session.current_session_id.is_none() {
152 let id = session.create_session(task, None)?;
153 emitter.session("session_created", &id);
154 emitter.session_origin(task, &id, true);
155 } else {
156 let id = session.current_session_id.clone().unwrap();
157 let already = session
159 .messages
160 .last()
161 .map(|m| m.role == "user" && m.content == task)
162 .unwrap_or(false);
163 if !already {
164 session.add_message(SessionMessage::from_chat(&ChatMessage::user(task)))?;
165 }
166 emitter.session("session_switched", &id);
167 emitter.session_origin(task, &id, false);
168 }
169
170 let (registry, sid_holder) = self.build_registry_with_holder();
171 {
173 let mut sid = sid_holder.lock().unwrap();
174 *sid = session.current_session_id.clone();
175 }
176 let native = self.native_tools(®istry);
177 let skills_user_dir = self
178 .config
179 .skills
180 .user_dir
181 .as_ref()
182 .map(std::path::PathBuf::from);
183 let skills_list = {
184 let project_path = self.project_path.clone();
185 let extra_builtins = self.config.skills.extra_builtins.clone();
186 let vfs_skills_dir = self.config.skills.vfs_skills_dir.clone();
187 tokio::task::spawn_blocking(move || {
188 crate::tools::skills::skills_list_for_prompt(
189 &project_path,
190 skills_user_dir.as_deref(),
191 &extra_builtins,
192 vfs_skills_dir.as_deref(),
193 )
194 })
195 .await
196 }
197 .unwrap_or_else(|e| {
198 eprintln!("skills discovery panicked or was cancelled: {e}");
199 "(no skills installed)".to_string()
200 });
201 let system_prompt = self.build_system_prompt(®istry, &skills_list);
202
203 let mut messages = vec![ChatMessage::system(system_prompt)];
205
206 if session.ended_mid_tool() {
208 messages.push(ChatMessage::system(
209 "[SYSTEM] Previous turn was interrupted mid tool execution. Continue based on existing results; do not repeat prior tool calls.",
210 ));
211 }
212 {
215 let history_len = session.messages.len();
216 let skip_last = session
217 .messages
218 .last()
219 .map(|m| m.role == "user" && m.content == task)
220 .unwrap_or(false);
221 let end = if skip_last {
222 history_len.saturating_sub(1)
223 } else {
224 history_len
225 };
226 messages.reserve(end);
227 for m in &session.messages[..end] {
228 messages.push(m.to_chat());
229 }
230 }
231
232 let repaired = crate::agent::sanitize::sanitize_history(&mut messages);
237 if repaired > 0 {
238 messages.push(ChatMessage::system(format!(
239 "[SYSTEM] Repaired {repaired} interrupted tool-call record(s) from the previous turn. Do not repeat prior tool calls; continue from the recorded results."
240 )));
241 }
242
243 messages.push(ChatMessage::user(task));
250
251 let loop_ = ReactLoop::new(self.llm.as_ref(), ®istry, &self.config, native);
252 let result = loop_.run(messages, session, emitter, cancel).await;
253 let _ = session.flush();
254 result
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::tools::backend::{CmdOutput, NativeShell};
262
263 fn make_agent() -> (MainAgent, PathBuf) {
264 let dir = std::env::temp_dir().join(format!(
265 "aacode_main_{}_{}",
266 std::process::id(),
267 uuid::Uuid::new_v4().simple()
268 ));
269 std::fs::create_dir_all(&dir).unwrap();
270 let backend: Arc<dyn ShellBackend> = Arc::new(NativeShell::new());
271 let mut acfg = AgentConfig::default();
272 acfg.model.api_key = Some("sk-test".into());
273 (MainAgent::new(acfg, dir.clone(), backend), dir)
274 }
275
276 #[test]
277 fn builds_registry_with_core_tools() {
278 let (agent, _) = make_agent();
279 let reg = agent.build_registry();
280 assert!(reg.contains("run_shell"));
281 assert!(reg.contains("add_todo_item"));
282 assert!(reg.contains("search_web"));
283 }
284
285 #[test]
286 fn system_prompt_has_static_content() {
287 let (agent, _) = make_agent();
288 let reg = agent.build_registry();
289 let p = agent.build_system_prompt(®, "(no skills installed)");
290 assert!(p.contains("Working Directory"));
291 assert!(!p.contains("{skills_list}"));
292 assert!(p.contains("Project Init Instructions"));
294 }
296
297 #[test]
298 fn native_tools_switch_by_gateway() {
299 let (agent, _) = make_agent();
300 let reg = agent.build_registry();
301 let openai = agent.native_tools(®);
302 assert!(openai[0].get("function").is_some());
303 }
304
305 #[test]
306 fn plan_first_adds_instruction() {
307 let (mut agent, _) = make_agent();
308 agent.config.plan_first = true;
309 let reg = agent.build_registry();
310 let p = agent.build_system_prompt(®, "(no skills installed)");
311 assert!(p.contains("Plan-First Mode"));
312 }
313
314 struct MockFastshellBackend;
315 impl ShellBackend for MockFastshellBackend {
316 fn run(
317 &self,
318 _command: &str,
319 _stdin_input: Option<&str>,
320 _timeout_secs: u64,
321 _idle_timeout_secs: u64,
322 _cwd: &std::path::Path,
323 ) -> CmdOutput {
324 CmdOutput {
325 stdout: String::new(),
326 stderr: String::new(),
327 exit_code: 0,
328 }
329 }
330 fn kind(&self) -> &'static str {
331 "fastshell"
332 }
333 }
334
335 #[test]
336 fn fastshell_backend_adds_sandbox_hint() {
337 let dir =
338 std::env::temp_dir().join(format!("aacode_shint_{}", uuid::Uuid::new_v4().simple()));
339 std::fs::create_dir_all(&dir).unwrap();
340 let backend: Arc<dyn ShellBackend> = Arc::new(MockFastshellBackend);
341 let mut acfg = AgentConfig::default();
342 acfg.model.api_key = Some("sk-test".into());
343 let agent = MainAgent::new(acfg, dir, backend);
344 let reg = agent.build_registry();
345 let p = agent.build_system_prompt(®, "(no skills installed)");
346 assert!(
347 p.contains("sandbox root"),
348 "fastshell prompt must contain sandbox hint"
349 );
350 assert!(p.contains("avoid /tmp/"));
351 }
352
353 #[test]
354 fn native_backend_does_not_add_sandbox_hint() {
355 let (agent, _) = make_agent();
356 let reg = agent.build_registry();
357 let p = agent.build_system_prompt(®, "(no skills installed)");
358 assert!(
359 !p.contains("sandbox root"),
360 "native prompt must not contain sandbox hint"
361 );
362 assert!(!p.contains("avoid /tmp/"));
363 }
364
365 #[test]
366 fn history_dedup_skips_trailing_duplicate_user_message() {
367 let dir = std::env::temp_dir().join(format!(
368 "aacode_main_hist_{}_{}",
369 std::process::id(),
370 uuid::Uuid::new_v4().simple()
371 ));
372 std::fs::create_dir_all(&dir).unwrap();
373 let mut session = SessionManager::new(&dir);
374 let task = "do the thing";
375 session.create_session(task, None).unwrap();
376 session
378 .add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
379 .unwrap();
380 session
381 .add_message(SessionMessage::from_chat(&ChatMessage::user(task)))
382 .unwrap();
383 session.flush().unwrap();
384
385 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
387 {
388 let history_len = session.messages.len();
389 let skip_last = session
390 .messages
391 .last()
392 .map(|m| m.role == "user" && m.content == task)
393 .unwrap_or(false);
394 let end = if skip_last {
395 history_len.saturating_sub(1)
396 } else {
397 history_len
398 };
399 messages.reserve(end);
400 for m in &session.messages[..end] {
401 messages.push(m.to_chat());
402 }
403 }
404
405 assert_eq!(messages.len(), 3);
407 assert_eq!(messages[0].role, "system");
408 assert_eq!(messages[0].content, "sys");
409 assert_eq!(messages[1].role, "user");
410 assert_eq!(messages[1].content, task);
411 assert_eq!(messages[2].role, "assistant");
412 assert_eq!(messages[2].content, "done");
413 }
414
415 #[test]
416 fn history_no_dedup_when_last_message_differs() {
417 let dir = std::env::temp_dir().join(format!(
418 "aacode_main_hist2_{}_{}",
419 std::process::id(),
420 uuid::Uuid::new_v4().simple()
421 ));
422 std::fs::create_dir_all(&dir).unwrap();
423 let mut session = SessionManager::new(&dir);
424 session.create_session("task one", None).unwrap();
425 session
426 .add_message(SessionMessage::from_chat(&ChatMessage::assistant("ok")))
427 .unwrap();
428 session.flush().unwrap();
429
430 let task = "different task";
431 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
432 {
433 let history_len = session.messages.len();
434 let skip_last = session
435 .messages
436 .last()
437 .map(|m| m.role == "user" && m.content == task)
438 .unwrap_or(false);
439 let end = if skip_last {
440 history_len.saturating_sub(1)
441 } else {
442 history_len
443 };
444 messages.reserve(end);
445 for m in &session.messages[..end] {
446 messages.push(m.to_chat());
447 }
448 }
449
450 assert_eq!(
452 messages.len(),
453 3,
454 "should keep all history when last msg != task"
455 );
456 assert_eq!(messages[1].content, "task one");
457 assert_eq!(messages[2].content, "ok");
458 }
459
460 #[test]
461 fn history_preserves_system_messages() {
462 let dir = std::env::temp_dir().join(format!(
463 "aacode_main_hist3_{}_{}",
464 std::process::id(),
465 uuid::Uuid::new_v4().simple()
466 ));
467 std::fs::create_dir_all(&dir).unwrap();
468 let mut session = SessionManager::new(&dir);
469 session.create_session("task", None).unwrap();
470 session
471 .add_message(SessionMessage::from_chat(&ChatMessage::system(
472 "## Analysis\n2 files",
473 )))
474 .unwrap();
475 session
476 .add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
477 .unwrap();
478 session.flush().unwrap();
479
480 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
481 {
482 let history_len = session.messages.len();
483 let end = history_len;
484 messages.reserve(end);
485 for m in &session.messages[..end] {
486 messages.push(m.to_chat());
487 }
488 }
489
490 let sys_msgs: Vec<_> = messages.iter().filter(|m| m.role == "system").collect();
491 assert_eq!(
492 sys_msgs.len(),
493 2,
494 "should preserve both system messages in history"
495 );
496 assert!(sys_msgs.iter().any(|m| m.content.contains("Analysis")));
497 }
498
499 #[test]
500 fn history_empty_session_no_messages() {
501 let dir = std::env::temp_dir().join(format!(
502 "aacode_main_hist4_{}_{}",
503 std::process::id(),
504 uuid::Uuid::new_v4().simple()
505 ));
506 std::fs::create_dir_all(&dir).unwrap();
507 let session = SessionManager::new(&dir);
508
509 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
510 {
511 let history_len = session.messages.len();
512 let end = history_len;
513 messages.reserve(end);
514 for m in &session.messages[..end] {
515 messages.push(m.to_chat());
516 }
517 }
518
519 assert_eq!(messages.len(), 1);
520 assert_eq!(messages[0].role, "system");
521 }
522
523 #[test]
524 fn history_tool_calls_roundtrip() {
525 let dir = std::env::temp_dir().join(format!(
526 "aacode_main_hist5_{}_{}",
527 std::process::id(),
528 uuid::Uuid::new_v4().simple()
529 ));
530 std::fs::create_dir_all(&dir).unwrap();
531 let mut session = SessionManager::new(&dir);
532 session.create_session("t", None).unwrap();
533 let msg = ChatMessage::assistant_with_tools(
534 "let me run that",
535 vec![crate::llm::types::ToolCall {
536 id: "c1".into(),
537 name: "run_shell".into(),
538 arguments: r#"{"command":"ls"}"#.into(),
539 }],
540 );
541 let mut sm = SessionMessage::from_chat(&msg);
542 sm.reasoning_content = Some("think hard".into());
543 session.add_message(sm).unwrap();
544 session.flush().unwrap();
545
546 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
547 {
548 let end = session.messages.len();
549 messages.reserve(end);
550 for m in &session.messages[..end] {
551 messages.push(m.to_chat());
552 }
553 }
554
555 let asst = messages
556 .iter()
557 .find(|m| m.role == "assistant" && m.tool_calls.is_some())
558 .expect("assistant with tool_calls must be preserved");
559 assert_eq!(asst.content, "let me run that");
560 assert_eq!(asst.reasoning_content.as_deref(), Some("think hard"));
561 let tcs = asst.tool_calls.as_ref().unwrap();
562 assert_eq!(tcs.len(), 1);
563 assert_eq!(tcs[0].name, "run_shell");
564 assert_eq!(tcs[0].arguments, r#"{"command":"ls"}"#);
565 }
566}