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        // Startup identity (additive event): lets hosts log the exact binary
146        // version/path, so stale bundled copies are obvious.
147        emitter.runtime_info();
148        // (c) 2026 xiefujin <490021684@qq.com> — GPL-3.0
149
150        // Session: reuse current or create new.
151        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            // Ensure the task is recorded as a user message for this turn.
158            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        // Sync the todo store with the current session.
172        {
173            let mut sid = sid_holder.lock().unwrap();
174            *sid = session.current_session_id.clone();
175        }
176        let native = self.native_tools(&registry);
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(&registry, &skills_list);
202
203        // Build the message list: system + prior history + current task.
204        let mut messages = vec![ChatMessage::system(system_prompt)];
205
206        // If ended mid-tool previously, inject a recovery hint.
207        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        // Append history directly — skip the trailing duplicate current-task
213        // user message without allocating an intermediate Vec.
214        {
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        // Repair the tool_calls/tool pairing before anything reaches the API.
233        // A session interrupted between persisting assistant(tool_calls) and
234        // the tool results would otherwise poison every subsequent request
235        // (HTTP 400 "insufficient tool messages following tool_calls").
236        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        // Current task as the final user turn. Send the raw task **exactly** as
244        // it was persisted: any per-request rewrite (a "Task:" prefix or a
245        // fixed suffix) would make the replayed history differ from the prefix
246        // cached by the provider, breaking the KV/prefix cache at every task
247        // boundary. The equivalent guidance already lives in the system
248        // prompt (see `prompts::SYSTEM_PROMPT_FOR_MAIN_AGENT`).
249        messages.push(ChatMessage::user(task));
250
251        let loop_ = ReactLoop::new(self.llm.as_ref(), &registry, &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(&reg, "(no skills installed)");
290        assert!(p.contains("Working Directory"));
291        assert!(!p.contains("{skills_list}"));
292        // Static config belongs in messages[0].
293        assert!(p.contains("Project Init Instructions"));
294        // Dynamic project analysis is removed — not in messages[0], not appended.
295    }
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(&reg);
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(&reg, "(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(&reg, "(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(&reg, "(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        // Append an assistant reply, then a user dup (simulates a follow-up call).
377        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        // Re-create messages exactly as execute() does (without running the loop).
386        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        // Should have: sys + user(task) + assistant("done") = 3 messages
406        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        // Last is "task one" ≠ "different task" — should NOT be skipped.
451        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}