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 if session.current_session_id.is_none() {
149 let id = session.create_session(task, None)?;
150 emitter.session("session_created", &id);
151 emitter.session_origin(task, &id, true);
152 } else {
153 let id = session.current_session_id.clone().unwrap();
154 let already = session
156 .messages
157 .last()
158 .map(|m| m.role == "user" && m.content == task)
159 .unwrap_or(false);
160 if !already {
161 session.add_message(SessionMessage::from_chat(&ChatMessage::user(task)))?;
162 }
163 emitter.session("session_switched", &id);
164 emitter.session_origin(task, &id, false);
165 }
166
167 let (registry, sid_holder) = self.build_registry_with_holder();
168 {
170 let mut sid = sid_holder.lock().unwrap();
171 *sid = session.current_session_id.clone();
172 }
173 let native = self.native_tools(®istry);
174 let skills_user_dir = self
175 .config
176 .skills
177 .user_dir
178 .as_ref()
179 .map(std::path::PathBuf::from);
180 let skills_list = {
181 let project_path = self.project_path.clone();
182 let extra_builtins = self.config.skills.extra_builtins.clone();
183 let vfs_skills_dir = self.config.skills.vfs_skills_dir.clone();
184 tokio::task::spawn_blocking(move || {
185 crate::tools::skills::skills_list_for_prompt(
186 &project_path,
187 skills_user_dir.as_deref(),
188 &extra_builtins,
189 vfs_skills_dir.as_deref(),
190 )
191 })
192 .await
193 }
194 .unwrap_or_else(|e| {
195 eprintln!("skills discovery panicked or was cancelled: {e}");
196 "(no skills installed)".to_string()
197 });
198 let system_prompt = self.build_system_prompt(®istry, &skills_list);
199
200 let mut messages = vec![ChatMessage::system(system_prompt)];
202
203 if session.ended_mid_tool() {
205 messages.push(ChatMessage::system(
206 "[SYSTEM] Previous turn was interrupted mid tool execution. Continue based on existing results; do not repeat prior tool calls.",
207 ));
208 }
209 {
212 let history_len = session.messages.len();
213 let skip_last = session
214 .messages
215 .last()
216 .map(|m| m.role == "user" && m.content == task)
217 .unwrap_or(false);
218 let end = if skip_last {
219 history_len.saturating_sub(1)
220 } else {
221 history_len
222 };
223 messages.reserve(end);
224 for m in &session.messages[..end] {
225 messages.push(m.to_chat());
226 }
227 }
228
229 let repaired = crate::agent::sanitize::sanitize_history(&mut messages);
234 if repaired > 0 {
235 messages.push(ChatMessage::system(format!(
236 "[SYSTEM] Repaired {repaired} interrupted tool-call record(s) from the previous turn. Do not repeat prior tool calls; continue from the recorded results."
237 )));
238 }
239
240 messages.push(ChatMessage::user(format!(
242 "Task: {task}\n\nUse native function calls to execute tools. Output a final text summary (no tool calls) when done."
243 )));
244
245 let loop_ = ReactLoop::new(self.llm.as_ref(), ®istry, &self.config, native);
246 let result = loop_.run(messages, session, emitter, cancel).await;
247 let _ = session.flush();
248 result
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::tools::backend::{CmdOutput, NativeShell};
256
257 fn make_agent() -> (MainAgent, PathBuf) {
258 let dir = std::env::temp_dir().join(format!(
259 "aacode_main_{}_{}",
260 std::process::id(),
261 uuid::Uuid::new_v4().simple()
262 ));
263 std::fs::create_dir_all(&dir).unwrap();
264 let backend: Arc<dyn ShellBackend> = Arc::new(NativeShell::new());
265 let mut acfg = AgentConfig::default();
266 acfg.model.api_key = Some("sk-test".into());
267 (MainAgent::new(acfg, dir.clone(), backend), dir)
268 }
269
270 #[test]
271 fn builds_registry_with_core_tools() {
272 let (agent, _) = make_agent();
273 let reg = agent.build_registry();
274 assert!(reg.contains("run_shell"));
275 assert!(reg.contains("add_todo_item"));
276 assert!(reg.contains("search_web"));
277 }
278
279 #[test]
280 fn system_prompt_has_static_content() {
281 let (agent, _) = make_agent();
282 let reg = agent.build_registry();
283 let p = agent.build_system_prompt(®, "(no skills installed)");
284 assert!(p.contains("Working Directory"));
285 assert!(!p.contains("{skills_list}"));
286 assert!(p.contains("Project Init Instructions"));
288 }
290
291 #[test]
292 fn native_tools_switch_by_gateway() {
293 let (agent, _) = make_agent();
294 let reg = agent.build_registry();
295 let openai = agent.native_tools(®);
296 assert!(openai[0].get("function").is_some());
297 }
298
299 #[test]
300 fn plan_first_adds_instruction() {
301 let (mut agent, _) = make_agent();
302 agent.config.plan_first = true;
303 let reg = agent.build_registry();
304 let p = agent.build_system_prompt(®, "(no skills installed)");
305 assert!(p.contains("Plan-First Mode"));
306 }
307
308 struct MockFastshellBackend;
309 impl ShellBackend for MockFastshellBackend {
310 fn run(
311 &self,
312 _command: &str,
313 _stdin_input: Option<&str>,
314 _timeout_secs: u64,
315 _idle_timeout_secs: u64,
316 _cwd: &std::path::Path,
317 ) -> CmdOutput {
318 CmdOutput {
319 stdout: String::new(),
320 stderr: String::new(),
321 exit_code: 0,
322 }
323 }
324 fn kind(&self) -> &'static str {
325 "fastshell"
326 }
327 }
328
329 #[test]
330 fn fastshell_backend_adds_sandbox_hint() {
331 let dir =
332 std::env::temp_dir().join(format!("aacode_shint_{}", uuid::Uuid::new_v4().simple()));
333 std::fs::create_dir_all(&dir).unwrap();
334 let backend: Arc<dyn ShellBackend> = Arc::new(MockFastshellBackend);
335 let mut acfg = AgentConfig::default();
336 acfg.model.api_key = Some("sk-test".into());
337 let agent = MainAgent::new(acfg, dir, backend);
338 let reg = agent.build_registry();
339 let p = agent.build_system_prompt(®, "(no skills installed)");
340 assert!(
341 p.contains("sandbox root"),
342 "fastshell prompt must contain sandbox hint"
343 );
344 assert!(p.contains("avoid /tmp/"));
345 }
346
347 #[test]
348 fn native_backend_does_not_add_sandbox_hint() {
349 let (agent, _) = make_agent();
350 let reg = agent.build_registry();
351 let p = agent.build_system_prompt(®, "(no skills installed)");
352 assert!(
353 !p.contains("sandbox root"),
354 "native prompt must not contain sandbox hint"
355 );
356 assert!(!p.contains("avoid /tmp/"));
357 }
358
359 #[test]
360 fn history_dedup_skips_trailing_duplicate_user_message() {
361 let dir = std::env::temp_dir().join(format!(
362 "aacode_main_hist_{}_{}",
363 std::process::id(),
364 uuid::Uuid::new_v4().simple()
365 ));
366 std::fs::create_dir_all(&dir).unwrap();
367 let mut session = SessionManager::new(&dir);
368 let task = "do the thing";
369 session.create_session(task, None).unwrap();
370 session
372 .add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
373 .unwrap();
374 session
375 .add_message(SessionMessage::from_chat(&ChatMessage::user(task)))
376 .unwrap();
377 session.flush().unwrap();
378
379 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
381 {
382 let history_len = session.messages.len();
383 let skip_last = session
384 .messages
385 .last()
386 .map(|m| m.role == "user" && m.content == task)
387 .unwrap_or(false);
388 let end = if skip_last {
389 history_len.saturating_sub(1)
390 } else {
391 history_len
392 };
393 messages.reserve(end);
394 for m in &session.messages[..end] {
395 messages.push(m.to_chat());
396 }
397 }
398
399 assert_eq!(messages.len(), 3);
401 assert_eq!(messages[0].role, "system");
402 assert_eq!(messages[0].content, "sys");
403 assert_eq!(messages[1].role, "user");
404 assert_eq!(messages[1].content, task);
405 assert_eq!(messages[2].role, "assistant");
406 assert_eq!(messages[2].content, "done");
407 }
408
409 #[test]
410 fn history_no_dedup_when_last_message_differs() {
411 let dir = std::env::temp_dir().join(format!(
412 "aacode_main_hist2_{}_{}",
413 std::process::id(),
414 uuid::Uuid::new_v4().simple()
415 ));
416 std::fs::create_dir_all(&dir).unwrap();
417 let mut session = SessionManager::new(&dir);
418 session.create_session("task one", None).unwrap();
419 session
420 .add_message(SessionMessage::from_chat(&ChatMessage::assistant("ok")))
421 .unwrap();
422 session.flush().unwrap();
423
424 let task = "different task";
425 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
426 {
427 let history_len = session.messages.len();
428 let skip_last = session
429 .messages
430 .last()
431 .map(|m| m.role == "user" && m.content == task)
432 .unwrap_or(false);
433 let end = if skip_last {
434 history_len.saturating_sub(1)
435 } else {
436 history_len
437 };
438 messages.reserve(end);
439 for m in &session.messages[..end] {
440 messages.push(m.to_chat());
441 }
442 }
443
444 assert_eq!(
446 messages.len(),
447 3,
448 "should keep all history when last msg != task"
449 );
450 assert_eq!(messages[1].content, "task one");
451 assert_eq!(messages[2].content, "ok");
452 }
453
454 #[test]
455 fn history_preserves_system_messages() {
456 let dir = std::env::temp_dir().join(format!(
457 "aacode_main_hist3_{}_{}",
458 std::process::id(),
459 uuid::Uuid::new_v4().simple()
460 ));
461 std::fs::create_dir_all(&dir).unwrap();
462 let mut session = SessionManager::new(&dir);
463 session.create_session("task", None).unwrap();
464 session
465 .add_message(SessionMessage::from_chat(&ChatMessage::system(
466 "## Analysis\n2 files",
467 )))
468 .unwrap();
469 session
470 .add_message(SessionMessage::from_chat(&ChatMessage::assistant("done")))
471 .unwrap();
472 session.flush().unwrap();
473
474 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
475 {
476 let history_len = session.messages.len();
477 let end = history_len;
478 messages.reserve(end);
479 for m in &session.messages[..end] {
480 messages.push(m.to_chat());
481 }
482 }
483
484 let sys_msgs: Vec<_> = messages.iter().filter(|m| m.role == "system").collect();
485 assert_eq!(
486 sys_msgs.len(),
487 2,
488 "should preserve both system messages in history"
489 );
490 assert!(sys_msgs.iter().any(|m| m.content.contains("Analysis")));
491 }
492
493 #[test]
494 fn history_empty_session_no_messages() {
495 let dir = std::env::temp_dir().join(format!(
496 "aacode_main_hist4_{}_{}",
497 std::process::id(),
498 uuid::Uuid::new_v4().simple()
499 ));
500 std::fs::create_dir_all(&dir).unwrap();
501 let session = SessionManager::new(&dir);
502
503 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
504 {
505 let history_len = session.messages.len();
506 let end = history_len;
507 messages.reserve(end);
508 for m in &session.messages[..end] {
509 messages.push(m.to_chat());
510 }
511 }
512
513 assert_eq!(messages.len(), 1);
514 assert_eq!(messages[0].role, "system");
515 }
516
517 #[test]
518 fn history_tool_calls_roundtrip() {
519 let dir = std::env::temp_dir().join(format!(
520 "aacode_main_hist5_{}_{}",
521 std::process::id(),
522 uuid::Uuid::new_v4().simple()
523 ));
524 std::fs::create_dir_all(&dir).unwrap();
525 let mut session = SessionManager::new(&dir);
526 session.create_session("t", None).unwrap();
527 let msg = ChatMessage::assistant_with_tools(
528 "let me run that",
529 vec![crate::llm::types::ToolCall {
530 id: "c1".into(),
531 name: "run_shell".into(),
532 arguments: r#"{"command":"ls"}"#.into(),
533 }],
534 );
535 let mut sm = SessionMessage::from_chat(&msg);
536 sm.reasoning_content = Some("think hard".into());
537 session.add_message(sm).unwrap();
538 session.flush().unwrap();
539
540 let mut messages: Vec<ChatMessage> = vec![ChatMessage::system("sys")];
541 {
542 let end = session.messages.len();
543 messages.reserve(end);
544 for m in &session.messages[..end] {
545 messages.push(m.to_chat());
546 }
547 }
548
549 let asst = messages
550 .iter()
551 .find(|m| m.role == "assistant" && m.tool_calls.is_some())
552 .expect("assistant with tool_calls must be preserved");
553 assert_eq!(asst.content, "let me run that");
554 assert_eq!(asst.reasoning_content.as_deref(), Some("think hard"));
555 let tcs = asst.tool_calls.as_ref().unwrap();
556 assert_eq!(tcs.len(), 1);
557 assert_eq!(tcs[0].name, "run_shell");
558 assert_eq!(tcs[0].arguments, r#"{"command":"ls"}"#);
559 }
560}