Skip to main content

aacode_rs/agent/
main_agent.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! MainAgent — assembles the system prompt, builds the tool registry, and
5//! drives the ReAct loop for a task.
6//!
7//! Ported from Python `core/main_agent.py` (execute + tool assembly).
8
9use 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        // Wire config.timeouts.model_request into the LLM agent's per-request deadline
39        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        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
49        let context = ContextManager::new(&project_path);
50        MainAgent {
51            config,
52            project_path,
53            backend,
54            llm,
55            context,
56        }
57    }
58
59    /// Build the full tool registry with all concrete tools wired up,
60    /// plus the session-id holder for todo store synchronization.
61    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    /// Build the full tool registry with all concrete tools wired up.
75    pub fn build_registry(&self) -> ToolRegistry {
76        self.build_registry_with_holder().0
77    }
78
79    /// Assemble the fully-static system prompt (messages[0]).
80    /// All content here is configuration that rarely changes — init.md
81    /// is project-level rules, skills_list comes from the host, and
82    /// Working Directory + PLANNING_IN_THOUGHT are anchored per session.
83    /// Byte-identical across execute() calls → provider KV cache stays hot.
84    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        // Project init instructions (static config, rarely changes).
100        let init = self.context.load_init_instructions();
101        prompt.push_str(&format!("\n\n## Project Init Instructions\n\n{init}"));
102        // Browser capability (only when a backend is actually available).
103        #[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        // Planning guidance (static).
118        prompt.push_str(PLANNING_IN_THOUGHT);
119        // Plan-first mode: instruct the model to produce a plan before acting.
120        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    /// Native tools for the active gateway.
129    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    /// Execute a task: create/continue a session, build messages, run the loop.
137    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        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
146
147        // Session: reuse current or create new.
148        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            // Ensure the task is recorded as a user message for this turn.
155            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        // Sync the todo store with the current session.
169        {
170            let mut sid = sid_holder.lock().unwrap();
171            *sid = session.current_session_id.clone();
172        }
173        let native = self.native_tools(&registry);
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(&registry, &skills_list);
199
200        // Build the message list: system + prior history + current task.
201        let mut messages = vec![ChatMessage::system(system_prompt)];
202
203        // If ended mid-tool previously, inject a recovery hint.
204        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        // Append history directly — skip the trailing duplicate current-task
210        // user message without allocating an intermediate Vec.
211        {
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        // Repair the tool_calls/tool pairing before anything reaches the API.
230        // A session interrupted between persisting assistant(tool_calls) and
231        // the tool results would otherwise poison every subsequent request
232        // (HTTP 400 "insufficient tool messages following tool_calls").
233        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        // Current task as the final user turn.
241        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(), &registry, &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(&reg, "(no skills installed)");
284        assert!(p.contains("Working Directory"));
285        assert!(!p.contains("{skills_list}"));
286        // Static config belongs in messages[0].
287        assert!(p.contains("Project Init Instructions"));
288        // Dynamic project analysis is removed — not in messages[0], not appended.
289    }
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(&reg);
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(&reg, "(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(&reg, "(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(&reg, "(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        // Append an assistant reply, then a user dup (simulates a follow-up call).
371        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        // Re-create messages exactly as execute() does (without running the loop).
380        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        // Should have: sys + user(task) + assistant("done") = 3 messages
400        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        // Last is "task one" ≠ "different task" — should NOT be skipped.
445        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}