Skip to main content

aegis_tools/
session_tool.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use serde_json::{json, Value};
4
5use crate::{Tool, ToolContext};
6
7pub struct SessionTool;
8
9#[async_trait]
10impl Tool for SessionTool {
11    fn name(&self) -> &str {
12        "session"
13    }
14
15    fn description(&self) -> &str {
16        "Manage conversation sessions: list past sessions (with titles), search \
17         across session history, read a session's content, or view current session info. \
18         Use this when the user asks about past conversations, wants to recall what was \
19         discussed before, or asks for session/usage information."
20    }
21
22    fn parameters(&self) -> Value {
23        json!({
24            "type": "object",
25            "properties": {
26                "action": {
27                    "type": "string",
28                    "enum": ["list", "search", "read", "info"],
29                    "description": "list = recent sessions; search = full-text search; read = load a session's transcript; info = current session details"
30                },
31                "query": {
32                    "type": "string",
33                    "description": "Search query (action=search)"
34                },
35                "session_id": {
36                    "type": "string",
37                    "description": "Session ID or prefix (action=read)"
38                },
39                "limit": {
40                    "type": "integer",
41                    "description": "Max results (default 10 for list, 5 for search)"
42                }
43            },
44            "required": ["action"]
45        })
46    }
47
48    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
49        let action = args["action"].as_str().unwrap_or("list");
50        match action {
51            "list" => self.action_list(&args),
52            "search" => self.action_search(&args),
53            "read" => self.action_read(&args),
54            "info" => Ok(self.action_info(ctx)),
55            _ => Ok(format!(
56                "Unknown action '{action}'. Use: list, search, read, info."
57            )),
58        }
59    }
60}
61
62impl SessionTool {
63    fn open_store(&self) -> Result<aegis_record::SessionStore> {
64        let db_dir = aegis_types::paths::config_dir();
65        aegis_record::SessionStore::open(&db_dir.join("sessions.db"))
66    }
67
68    fn action_list(&self, args: &Value) -> Result<String> {
69        let limit = args["limit"].as_u64().unwrap_or(10) as u32;
70        let store = self.open_store()?;
71        let sessions = store.list_sessions(limit)?;
72
73        if sessions.is_empty() {
74            return Ok("No past sessions found.".to_string());
75        }
76
77        let mut out = String::from("Recent sessions:\n");
78        for (i, s) in sessions.iter().enumerate() {
79            let title = s.title.as_deref().unwrap_or("(untitled)");
80            let id_short = &s.id[..s.id.len().min(19)];
81            out.push_str(&format!(
82                "  {}. {} — {} ({} msgs, {})\n",
83                i + 1,
84                id_short,
85                title,
86                s.message_count,
87                s.started_at
88            ));
89        }
90        Ok(out)
91    }
92
93    fn action_search(&self, args: &Value) -> Result<String> {
94        let query = args["query"].as_str().unwrap_or("");
95        if query.trim().is_empty() {
96            return Ok("Error: 'query' is required for search.".to_string());
97        }
98        let limit = args["limit"].as_u64().unwrap_or(5) as u32;
99        let store = self.open_store()?;
100        let results = store.search(query, limit)?;
101
102        if results.is_empty() {
103            return Ok(format!("No results found for '{query}'."));
104        }
105
106        let mut out = format!("Search results for '{query}':\n");
107        for r in &results {
108            let sid = &r.session_id[..r.session_id.len().min(15)];
109            out.push_str(&format!("  [{}] {}: {}\n", sid, r.role, r.snippet));
110        }
111        Ok(out)
112    }
113
114    fn action_read(&self, args: &Value) -> Result<String> {
115        let id = match args["session_id"].as_str() {
116            Some(s) if !s.trim().is_empty() => s.trim(),
117            _ => return Ok("Error: 'session_id' is required (full id or prefix).".to_string()),
118        };
119
120        let store = self.open_store()?;
121
122        // Resolve prefix to full id
123        let full_id = if store
124            .get_messages(id)
125            .map(|m| !m.is_empty())
126            .unwrap_or(false)
127        {
128            id.to_string()
129        } else {
130            match store
131                .list_sessions(200)?
132                .into_iter()
133                .find(|s| s.id.starts_with(id))
134            {
135                Some(s) => s.id,
136                None => return Ok(format!("Session '{id}' not found.")),
137            }
138        };
139
140        let msgs = store.get_messages(&full_id)?;
141        if msgs.is_empty() {
142            return Ok(format!("Session '{id}' has no messages."));
143        }
144
145        let mut out = String::new();
146        let mut count = 0;
147        for m in &msgs {
148            let role = match m.role.as_str() {
149                "user" => "User",
150                "assistant" => "Assistant",
151                "tool" => continue,
152                _ => continue,
153            };
154            let content = m.content.as_deref().unwrap_or("");
155            // Cap per-message preview to avoid overwhelming context
156            let preview = if content.len() > 500 {
157                format!("{}...", &content[..content.floor_char_boundary(500)])
158            } else {
159                content.to_string()
160            };
161            out.push_str(&format!("{}: {}\n\n", role, preview));
162            count += 1;
163            if count >= 40 {
164                out.push_str(&format!(
165                    "... ({} more messages truncated)\n",
166                    msgs.len() - count
167                ));
168                break;
169            }
170        }
171        Ok(out)
172    }
173
174    fn action_info(&self, ctx: &ToolContext<'_>) -> String {
175        format!(
176            "Current session: {}\nWorking directory: {}",
177            ctx.session_id,
178            ctx.cwd.display()
179        )
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_info_action() {
189        let tool = SessionTool;
190        let ctx = ToolContext {
191            cwd: std::path::PathBuf::from("/tmp"),
192            session_id: "20260701-120000-abcd1234".to_string(),
193            approve_fn: &|_| true,
194            yolo: false,
195            identity: None,
196            sandbox_enabled: false,
197        };
198        let info = tool.action_info(&ctx);
199        assert!(info.contains("20260701-120000-abcd1234"));
200        assert!(info.contains("/tmp"));
201    }
202}