Skip to main content

aacode_rs/tools/
session_tools.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Session management tools — list/switch/delete/history/stats/new/continue.
5//!
6//! These operate on the on-disk session store via a fresh `SessionManager`
7//! bound to the project path. Switching the *active* session is coordinated at
8//! the FFI/CLI layer; these tools report information the model can act on.
9//!
10//! Ported from Python `core/main_agent.py` session tools.
11
12use super::registry::Tool;
13use super::schema::{ParamType, ToolParameter, ToolSchema};
14use crate::error::{AacodeError, Result};
15use crate::session::SessionManager;
16use serde_json::{json, Value};
17use std::path::PathBuf;
18use std::sync::atomic::AtomicBool;
19use std::sync::{Arc, Mutex};
20
21pub struct ListSessionsTool {
22    pub project_path: PathBuf,
23}
24#[async_trait::async_trait]
25impl Tool for ListSessionsTool {
26    fn schema(&self) -> ToolSchema {
27        ToolSchema::new("list_sessions", "List all conversation sessions.", vec![])
28    }
29    async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
30        let project_path = self.project_path.clone();
31        tokio::task::spawn_blocking(move || -> Result<String> {
32            let sm = SessionManager::new(&project_path);
33            let sessions: Vec<Value> = sm
34                .list_sessions()
35                .into_iter()
36                .map(|s| {
37                    json!({
38                        "session_id": s.session_id,
39                        "title": s.title,
40                        "created_at": s.created_at,
41                        "last_activity": s.last_activity,
42                        "total_messages": s.total_messages,
43                        "status": s.status,
44                    })
45                })
46                .collect();
47            Ok(json!({"success": true, "count": sessions.len(), "sessions": sessions}).to_string())
48        })
49        .await
50        .map_err(|e| AacodeError::Other(e.to_string()))?
51    }
52}
53
54pub struct GetConversationHistoryTool {
55    pub project_path: PathBuf,
56    pub active_session_id: Arc<Mutex<Option<String>>>,
57}
58#[async_trait::async_trait]
59impl Tool for GetConversationHistoryTool {
60    fn schema(&self) -> ToolSchema {
61        ToolSchema::new(
62            "get_conversation_history",
63            "Read messages from a session. Returns role, content, timestamp, tool_calls, tool_call_id, and reasoning_content for each message. Fields longer than max_content_chars chars are truncated with a length marker.",
64            vec![
65                ToolParameter::new("session_id", ParamType::String, false, "Session id (defaults to current)", &["id"]),
66                ToolParameter::new("range_from", ParamType::Integer, false, "Start index (0 = earliest message)", &["from"]),
67                ToolParameter::new("range_to", ParamType::Integer, false, "End index exclusive (default = latest)", &["to"]),
68                ToolParameter::new("max_content_chars", ParamType::Integer, false, "Max chars per field before truncation", &["limit"]),
69            ],
70        )
71    }
72    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
73        let id = args.get("session_id").and_then(|v| v.as_str()).map(|s| s.to_string());
74        let range_from = args.get("range_from").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
75        let range_to = args.get("range_to").and_then(|v| v.as_u64()).map(|n| n as usize);
76        let max_chars = args.get("max_content_chars").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
77        let project_path = self.project_path.clone();
78        let active_id = self.active_session_id.lock().unwrap().clone();
79        tokio::task::spawn_blocking(move || -> Result<String> {
80            let sm = SessionManager::new(&project_path);
81            let id = id
82                .or(active_id)
83                .or_else(|| sm.list_sessions().first().map(|s| s.session_id.clone()));
84            let id = match id {
85                Some(i) => i,
86                None => return Ok(json!({"success": false, "error": "no sessions"}).to_string()),
87            };
88            let msgs = sm.read_session_messages(&id);
89            let total = msgs.len();
90            let to = range_to.unwrap_or(total).min(total);
91            let from = range_from.min(to);
92            let sliced = &msgs[from..to];
93
94            let history: Vec<Value> = sliced
95                .iter()
96                .map(|m| {
97                    let mut obj = json!({
98                        "role": m.role,
99                        "content": truncate_str(&m.content, max_chars),
100                        "timestamp": m.timestamp,
101                    });
102                    if let Some(ref tcs) = m.tool_calls {
103                        let calls: Vec<Value> = tcs.iter().map(|tc| json!({
104                            "id": tc.id,
105                            "name": tc.name,
106                            "arguments": truncate_str(&tc.arguments, max_chars),
107                        })).collect();
108                        obj["tool_calls"] = json!(calls);
109                    }
110                    if let Some(ref tci) = m.tool_call_id {
111                        obj["tool_call_id"] = json!(tci);
112                    }
113                    if let Some(ref rc) = m.reasoning_content {
114                        obj["reasoning_content"] = json!(truncate_str(rc, max_chars));
115                    }
116                    obj
117                })
118                .collect();
119
120            Ok(json!({
121                "success": true,
122                "session_id": id,
123                "total_messages": total,
124                "history": history,
125            }).to_string())
126        })
127        .await
128        .map_err(|e| AacodeError::Other(e.to_string()))?
129    }
130}
131
132fn truncate_str(s: &str, max_chars: usize) -> String {
133    if max_chars == 0 || s.chars().count() <= max_chars {
134        return s.to_string();
135    }
136    let head: String = s.chars().take(max_chars).collect();
137    format!("{}…({} chars total)", head, s.chars().count())
138}
139
140pub struct GetSessionStatsTool {
141    pub project_path: PathBuf,
142}
143#[async_trait::async_trait]
144impl Tool for GetSessionStatsTool {
145    fn schema(&self) -> ToolSchema {
146        ToolSchema::new("get_session_stats", "Get statistics for sessions.", vec![])
147    }
148    async fn call(&self, _args: &Value, _c: &AtomicBool) -> Result<String> {
149        let project_path = self.project_path.clone();
150        tokio::task::spawn_blocking(move || -> Result<String> {
151            let sm = SessionManager::new(&project_path);
152            let sessions = sm.list_sessions();
153            let total_msgs: usize = sessions.iter().map(|s| s.total_messages).sum();
154            let total_tokens: usize = sessions.iter().map(|s| s.total_tokens).sum();
155            Ok(json!({
156                "success": true,
157                "stats": {
158                    "session_count": sessions.len(),
159                    "total_messages": total_msgs,
160                    "total_tokens": total_tokens,
161                }
162            })
163            .to_string())
164        })
165        .await
166        .map_err(|e| AacodeError::Other(e.to_string()))?
167    }
168}
169
170pub struct DeleteSessionTool {
171    pub project_path: PathBuf,
172}
173#[async_trait::async_trait]
174impl Tool for DeleteSessionTool {
175    fn schema(&self) -> ToolSchema {
176        ToolSchema::new(
177            "delete_session",
178            "Delete a conversation session by id.",
179            vec![ToolParameter::new("session_id", ParamType::String, true, "Session id", &["id"])],
180        )
181    }
182    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
183        let id = args.get("session_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
184        let project_path = self.project_path.clone();
185        tokio::task::spawn_blocking(move || -> Result<String> {
186            let mut sm = SessionManager::new(&project_path);
187            let ok = sm.delete_session(&id).unwrap_or(false);
188            Ok(json!({"success": ok, "session_id": id}).to_string())
189        })
190        .await
191        .map_err(|e| AacodeError::Other(e.to_string()))?
192    }
193}
194
195/// Informational stubs for new/continue/switch. Actual active-session change is
196/// coordinated by the caller (CLI/FFI). These acknowledge the request.
197pub struct NewSessionTool {
198    pub project_path: PathBuf,
199}
200#[async_trait::async_trait]
201impl Tool for NewSessionTool {
202    fn schema(&self) -> ToolSchema {
203        ToolSchema::new(
204            "new_session",
205            "Request a new conversation session for the next task.",
206            vec![ToolParameter::new("task", ParamType::String, false, "Initial task", &[])],
207        )
208    }
209    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
210        let task = args.get("task").and_then(|v| v.as_str()).unwrap_or("New task").to_string();
211        let title = args.get("title").and_then(|v| v.as_str()).map(|s| s.to_string());
212        let project_path = self.project_path.clone();
213        tokio::task::spawn_blocking(move || -> Result<String> {
214            let mut sm = SessionManager::new(&project_path);
215            match sm.create_session(&task, title.as_deref()) {
216                Ok(id) => Ok(json!({"success": true, "session_id": id, "message": "New session created"}).to_string()),
217                Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()),
218            }
219        })
220        .await
221        .map_err(|e| AacodeError::Other(e.to_string()))?
222    }
223}
224
225pub struct ContinueSessionTool {
226    pub project_path: PathBuf,
227}
228#[async_trait::async_trait]
229impl Tool for ContinueSessionTool {
230    fn schema(&self) -> ToolSchema {
231        ToolSchema::new(
232            "continue_session",
233            "Add a follow-up message to the current or specified session.",
234            vec![
235                ToolParameter::new("message", ParamType::String, true, "Follow-up message", &[]),
236                ToolParameter::new("session_id", ParamType::String, false, "Target session id", &["id"]),
237            ],
238        )
239    }
240    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
241        let msg = args.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
242        if msg.is_empty() {
243            return Ok(json!({"success": false, "error": "message required"}).to_string());
244        }
245        let sid = args.get("session_id").and_then(|v| v.as_str()).map(|s| s.to_string());
246        let project_path = self.project_path.clone();
247        tokio::task::spawn_blocking(move || -> Result<String> {
248            let mut sm = SessionManager::new(&project_path);
249            if let Some(ref id) = sid {
250                if !sm.switch_session(id).unwrap_or(false) {
251                    return Ok(json!({"success": false, "error": format!("session {id} not found")}).to_string());
252                }
253            }
254            if sm.current_session_id.is_none() {
255                let id = sm.create_session(&msg, None)?;
256                return Ok(json!({"success": true, "session_id": id}).to_string());
257            }
258            sm.add_message(crate::session::SessionMessage::from_chat(
259                &crate::llm::ChatMessage::user(msg),
260            ))?;
261            let id = sm.current_session_id.clone().unwrap_or_default();
262            Ok(json!({"success": true, "session_id": id}).to_string())
263        })
264        .await
265        .map_err(|e| AacodeError::Other(e.to_string()))?
266    }
267}
268
269pub struct SwitchSessionTool {
270    pub project_path: PathBuf,
271}
272#[async_trait::async_trait]
273impl Tool for SwitchSessionTool {
274    fn schema(&self) -> ToolSchema {
275        ToolSchema::new(
276            "switch_session",
277            "Switch to a specified conversation session.",
278            vec![ToolParameter::new("session_id", ParamType::String, true, "Session id", &["id"])],
279        )
280    }
281    async fn call(&self, args: &Value, _c: &AtomicBool) -> Result<String> {
282        let id = args.get("session_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
283        let project_path = self.project_path.clone();
284        tokio::task::spawn_blocking(move || -> Result<String> {
285            let mut sm = SessionManager::new(&project_path);
286            let ok = sm.switch_session(&id).unwrap_or(false);
287            Ok(json!({"success": ok, "session_id": id}).to_string())
288        })
289        .await
290        .map_err(|e| AacodeError::Other(e.to_string()))?
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::llm::types::ToolCall;
298    use crate::llm::ChatMessage;
299    use crate::session::SessionManager;
300
301    fn tmp() -> PathBuf {
302        let d = std::env::temp_dir().join(format!(
303            "aacode_sesstool_{}_{}",
304            std::process::id(),
305            uuid::Uuid::new_v4().simple()
306        ));
307        std::fs::create_dir_all(&d).unwrap();
308        d
309    }
310
311    fn history_tool(path: PathBuf) -> GetConversationHistoryTool {
312        GetConversationHistoryTool {
313            project_path: path,
314            active_session_id: Arc::new(Mutex::new(None)),
315        }
316    }
317
318    #[tokio::test]
319    async fn list_and_stats() {
320        let d = tmp();
321        {
322            let mut sm = SessionManager::new(&d);
323            sm.create_session("task one", None).unwrap();
324        }
325        let cancel = AtomicBool::new(false);
326        let out = ListSessionsTool { project_path: d.clone() }
327            .call(&json!({}), &cancel)
328            .await
329            .unwrap();
330        let v: Value = serde_json::from_str(&out).unwrap();
331        assert!(v["count"].as_u64().unwrap() >= 1);
332
333        let out = GetSessionStatsTool { project_path: d.clone() }
334            .call(&json!({}), &cancel)
335            .await
336            .unwrap();
337        assert!(serde_json::from_str::<Value>(&out).unwrap()["success"] == true);
338    }
339
340    #[tokio::test]
341    async fn delete_session() {
342        let d = tmp();
343        let id = {
344            let mut sm = SessionManager::new(&d);
345            sm.create_session("x", None).unwrap()
346        };
347        let cancel = AtomicBool::new(false);
348        let out = DeleteSessionTool { project_path: d.clone() }
349            .call(&json!({"session_id": id}), &cancel)
350            .await
351            .unwrap();
352        assert_eq!(serde_json::from_str::<Value>(&out).unwrap()["success"], true);
353    }
354
355    #[tokio::test]
356    async fn history_no_sessions() {
357        let d = tmp();
358        let cancel = AtomicBool::new(false);
359        let out = history_tool(d)
360            .call(&json!({}), &cancel)
361            .await
362            .unwrap();
363        assert_eq!(serde_json::from_str::<Value>(&out).unwrap()["success"], false);
364    }
365
366    #[tokio::test]
367    async fn history_includes_tool_calls_and_reasoning() {
368        let d = tmp();
369        let id = {
370            let mut sm = SessionManager::new(&d);
371            sm.create_session("task", None).unwrap();
372            let assistant = ChatMessage::assistant_with_tools(
373                "I will run a command",
374                vec![ToolCall {
375                    id: "call_abc".into(),
376                    name: "run_shell".into(),
377                    arguments: r#"{"command":"ls"}"#.into(),
378                }],
379            );
380            let mut msg = crate::session::SessionMessage::from_chat(&assistant);
381            msg.reasoning_content = Some("I should check the directory".into());
382            sm.add_message(msg).unwrap();
383
384            sm.add_message(crate::session::SessionMessage::from_chat(
385                &ChatMessage::tool_result("call_abc", "file1.txt\nfile2.txt\n"),
386            ))
387            .unwrap();
388            sm.flush().unwrap();
389            sm.current_session_id.clone().unwrap()
390        };
391
392        let cancel = AtomicBool::new(false);
393        let out = history_tool(d.clone())
394            .call(&json!({"session_id": id}), &cancel)
395            .await
396            .unwrap();
397        let v: Value = serde_json::from_str(&out).unwrap();
398        assert_eq!(v["success"], true);
399        let history = v["history"].as_array().unwrap();
400
401        let asst = history
402            .iter()
403            .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array())
404            .expect("assistant with tool_calls should be in history");
405
406        assert_eq!(asst["content"], "I will run a command");
407        assert_eq!(asst["timestamp"].as_str().map(|s| !s.is_empty()), Some(true));
408        let tcs = asst["tool_calls"].as_array().unwrap();
409        assert_eq!(tcs.len(), 1);
410        assert_eq!(tcs[0]["name"], "run_shell");
411        assert_eq!(tcs[0]["arguments"], r#"{"command":"ls"}"#);
412        assert_eq!(asst["reasoning_content"], "I should check the directory");
413
414        let tool = history
415            .iter()
416            .find(|m| m["role"] == "tool")
417            .expect("tool result should be in history");
418        assert_eq!(tool["content"], "file1.txt\nfile2.txt\n");
419        assert_eq!(tool["tool_call_id"], "call_abc");
420    }
421
422    #[tokio::test]
423    async fn history_truncates_long_content() {
424        let d = tmp();
425        let id = {
426            let mut sm = SessionManager::new(&d);
427            sm.create_session("task", None).unwrap();
428            let content = "A".repeat(500);
429            sm.add_message(crate::session::SessionMessage::from_chat(
430                &ChatMessage::assistant(content.clone()),
431            ))
432            .unwrap();
433            sm.flush().unwrap();
434            sm.current_session_id.clone().unwrap()
435        };
436
437        let cancel = AtomicBool::new(false);
438        let out = history_tool(d.clone())
439            .call(&json!({"session_id": id, "max_content_chars": 100}), &cancel)
440            .await
441            .unwrap();
442        let v: Value = serde_json::from_str(&out).unwrap();
443        let history = v["history"].as_array().unwrap();
444        let asst = history.iter().find(|m| m["role"] == "assistant").unwrap();
445        let content = asst["content"].as_str().unwrap();
446        assert!(content.contains("…(500 chars total)"), "should show truncation marker, got: {}", content);
447        assert_eq!(content.chars().take(100).collect::<String>().len(), 100);
448    }
449
450    #[tokio::test]
451    async fn history_truncates_tool_arguments() {
452        let d = tmp();
453        let id = {
454            let mut sm = SessionManager::new(&d);
455            sm.create_session("task", None).unwrap();
456            let long_args = format!(r#"{{"code":"{}"}}"#, "x".repeat(500));
457            let assistant = ChatMessage::assistant_with_tools(
458                "run",
459                vec![ToolCall {
460                    id: "c1".into(),
461                    name: "execute_python".into(),
462                    arguments: long_args.clone(),
463                }],
464            );
465            sm.add_message(crate::session::SessionMessage::from_chat(&assistant))
466                .unwrap();
467            sm.flush().unwrap();
468            sm.current_session_id.clone().unwrap()
469        };
470
471        let cancel = AtomicBool::new(false);
472        let out = history_tool(d.clone())
473            .call(&json!({"session_id": id, "max_content_chars": 100}), &cancel)
474            .await
475            .unwrap();
476        let v: Value = serde_json::from_str(&out).unwrap();
477        let history = v["history"].as_array().unwrap();
478        let asst = history
479            .iter()
480            .find(|m| m["role"] == "assistant" && m["tool_calls"].is_array())
481            .expect("assistant with tool_calls should be in history");
482        let tcs = asst["tool_calls"].as_array().unwrap();
483        let args = tcs[0]["arguments"].as_str().unwrap();
484        assert!(args.contains("…("), "tool_calls arguments should be truncated, got: {}", args);
485    }
486
487    #[tokio::test]
488    async fn history_range_from_and_to() {
489        let d = tmp();
490        let id = {
491            let mut sm = SessionManager::new(&d);
492            sm.create_session("task", None).unwrap();
493            for i in 0..10 {
494                sm.add_message(crate::session::SessionMessage::from_chat(
495                    &ChatMessage::user(format!("msg {}", i)),
496                ))
497                .unwrap();
498            }
499            sm.flush().unwrap();
500            sm.current_session_id.clone().unwrap()
501        };
502
503        let cancel = AtomicBool::new(false);
504        // range_from=2, range_to=5 should return messages 2,3,4 (3 messages)
505        let out = history_tool(d.clone())
506            .call(&json!({"session_id": id, "range_from": 2, "range_to": 5}), &cancel)
507            .await
508            .unwrap();
509        let v: Value = serde_json::from_str(&out).unwrap();
510        assert_eq!(v["success"], true);
511        assert_eq!(v["total_messages"], 11); // 1 user + 10 additions
512        let history = v["history"].as_array().unwrap();
513        assert_eq!(history.len(), 3, "range_from=2, range_to=5 should return 3 messages");
514        assert_eq!(history[0]["content"], "msg 1");
515        assert_eq!(history[2]["content"], "msg 3");
516    }
517
518    #[tokio::test]
519    async fn history_max_content_chars_zero_means_no_truncation() {
520        let d = tmp();
521        let id = {
522            let mut sm = SessionManager::new(&d);
523            sm.create_session("task", None).unwrap();
524            let content = "A".repeat(500);
525            sm.add_message(crate::session::SessionMessage::from_chat(
526                &ChatMessage::assistant(content.clone()),
527            ))
528            .unwrap();
529            sm.flush().unwrap();
530            sm.current_session_id.clone().unwrap()
531        };
532
533        let cancel = AtomicBool::new(false);
534        let out = history_tool(d.clone())
535            .call(&json!({"session_id": id, "max_content_chars": 0}), &cancel)
536            .await
537            .unwrap();
538        let v: Value = serde_json::from_str(&out).unwrap();
539        let history = v["history"].as_array().unwrap();
540        let asst = history.iter().find(|m| m["role"] == "assistant").unwrap();
541        let content = asst["content"].as_str().unwrap();
542        assert_eq!(content.len(), 500, "max_content_chars=0 should mean no truncation, got len {}", content.len());
543    }
544
545    #[tokio::test]
546    async fn new_and_continue_ack() {
547        let d = tmp();
548        let cancel = AtomicBool::new(false);
549        let nt = NewSessionTool { project_path: d.clone() };
550        let out = nt.call(&json!({"task": "test"}), &cancel).await.unwrap();
551        assert!(out.contains("New session created"));
552        let ct = ContinueSessionTool { project_path: d };
553        let out = ct.call(&json!({"message": "followup"}), &cancel).await.unwrap();
554        assert!(out.contains("true"));
555    }
556
557    #[tokio::test]
558    async fn history_uses_active_session_when_no_session_id() {
559        let d = tmp();
560        let active_id = {
561            let mut sm = SessionManager::new(&d);
562            sm.create_session("active session task", None).unwrap();
563            sm.current_session_id.clone().unwrap()
564        };
565
566        let cancel = AtomicBool::new(false);
567        let holder = Arc::new(Mutex::new(Some(active_id.clone())));
568        let tool = GetConversationHistoryTool {
569            project_path: d.clone(),
570            active_session_id: holder,
571        };
572        let out = tool.call(&json!({}), &cancel).await.unwrap();
573        let v: Value = serde_json::from_str(&out).unwrap();
574        assert_eq!(v["success"], true);
575        assert_eq!(v["session_id"], active_id);
576        assert_eq!(v["total_messages"], 1);
577    }
578}