aegis_tools/
session_tool.rs1use 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 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 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}