Skip to main content

microclaw_engine/tools/
session_search.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4
5use crate::internal::storage::db::Database;
6use microclaw_core::llm_types::ToolDefinition;
7
8use super::{auth_context_from_input, authorize_chat_access, schema_object, Tool, ToolResult};
9
10/// Session search tool — full-text search over the chatbot's stored messages.
11///
12/// Exposes SQLite FTS5 queries against the `messages` table. Returns the
13/// top-ranked matches with enough context (chat title, sender, timestamp,
14/// snippet) for the agent to reason about them without loading whole
15/// transcripts. Modeled after `session_search_tool.py` in
16/// `nousresearch/hermes-agent`, adapted to microclaw's SQLite schema.
17///
18/// Access model:
19/// - By default the search is scoped to the caller's own chat. The tool
20///   participates in `should_inject_default_chat_id`, so when the agent
21///   omits `chat_id` the runtime injects the caller's.
22/// - Explicit `chat_id = N` is gated by `authorize_chat_access` — only the
23///   caller or a control chat may search another chat's messages.
24/// - `all_chats = true` opts in to cross-chat search and is only honored for
25///   control chats. This keeps multi-tenant deployments (multiple DMs,
26///   groups, channels, SOUL-per-chat personalities) from leaking messages
27///   across conversations.
28pub struct SessionSearchTool {
29    db: Arc<Database>,
30}
31
32impl SessionSearchTool {
33    pub fn new(db: Arc<Database>) -> Self {
34        Self { db }
35    }
36}
37
38fn trim_snippet(content: &str, max_chars: usize) -> String {
39    if content.chars().count() <= max_chars {
40        return content.to_string();
41    }
42    let truncated: String = content.chars().take(max_chars).collect();
43    format!("{truncated}…")
44}
45
46#[async_trait]
47impl Tool for SessionSearchTool {
48    fn name(&self) -> &str {
49        "session_search"
50    }
51
52    fn definition(&self) -> ToolDefinition {
53        ToolDefinition {
54            name: self.name().into(),
55            description: "Search past conversation messages using SQLite FTS5. \
56                Use when the user references an earlier conversation, asks 'did we \
57                talk about X?', or you need cross-session recall. Returns ranked \
58                message snippets with chat metadata. Supports FTS5 syntax — simple \
59                words, quoted phrases, `NEAR`, `AND/OR/NOT`, and column prefix \
60                filters (e.g. `sender_name:alice content:refund`). \
61                By default the search is scoped to the current chat; \
62                pass `chat_id` to target a different chat the caller can access, \
63                or `all_chats: true` (control chats only) to search every chat."
64                .into(),
65            input_schema: schema_object(
66                json!({
67                    "query": {
68                        "type": "string",
69                        "description": "FTS5 query string. Plain words are OR-joined; quote phrases for exact match."
70                    },
71                    "chat_id": {
72                        "type": "integer",
73                        "description": "Optional chat scope. Defaults to the caller's chat. Accessing other chats requires either the same caller_chat_id or a control chat."
74                    },
75                    "all_chats": {
76                        "type": "boolean",
77                        "description": "Control-chat-only opt-in to search every chat. Ignored for non-control callers."
78                    },
79                    "since": {
80                        "type": "string",
81                        "description": "Optional RFC3339 timestamp; only messages at or after this time are returned."
82                    },
83                    "max_results": {
84                        "type": "integer",
85                        "description": "Maximum rows to return (default 10, max 50)."
86                    },
87                    "snippet_chars": {
88                        "type": "integer",
89                        "description": "Maximum characters of message content to include per result (default 240)."
90                    }
91                }),
92                &["query"],
93            ),
94        }
95    }
96
97    async fn execute(&self, input: serde_json::Value) -> ToolResult {
98        let query = match input.get("query").and_then(|v| v.as_str()) {
99            Some(q) if !q.trim().is_empty() => q.trim().to_string(),
100            _ => return ToolResult::error("Missing or empty required parameter: query".into()),
101        };
102
103        let Some(auth) = auth_context_from_input(&input) else {
104            return ToolResult::error(
105                "session_search requires an auth context (caller_chat_id) on the input".into(),
106            );
107        };
108
109        let all_chats = input
110            .get("all_chats")
111            .and_then(|v| v.as_bool())
112            .unwrap_or(false);
113        let explicit_chat_id = input.get("chat_id").and_then(|v| v.as_i64());
114
115        // Resolve the scope based on access policy.
116        //   1. all_chats=true is a control-only escape hatch → no chat_id filter.
117        //   2. explicit chat_id is gated by authorize_chat_access.
118        //   3. default → caller's own chat (runtime may have already injected this).
119        let scope_chat_id: Option<i64> = if all_chats {
120            if !auth.is_control_chat() {
121                return ToolResult::error(
122                    "all_chats=true is only available for control chats".into(),
123                );
124            }
125            None
126        } else {
127            let target = explicit_chat_id.unwrap_or(auth.caller_chat_id);
128            if let Err(e) = authorize_chat_access(&input, target) {
129                return ToolResult::error(e);
130            }
131            Some(target)
132        };
133
134        let since = input
135            .get("since")
136            .and_then(|v| v.as_str())
137            .map(|s| s.to_string());
138        let max_results = input
139            .get("max_results")
140            .and_then(|v| v.as_u64())
141            .map(|n| n as usize)
142            .unwrap_or(10)
143            .clamp(1, 50);
144        let snippet_chars = input
145            .get("snippet_chars")
146            .and_then(|v| v.as_u64())
147            .map(|n| n as usize)
148            .unwrap_or(240)
149            .clamp(40, 2_000);
150
151        let db = self.db.clone();
152        let query_for_task = query.clone();
153        let since_for_task = since.clone();
154        let search = tokio::task::spawn_blocking(move || {
155            db.search_messages_fts(
156                &query_for_task,
157                scope_chat_id,
158                since_for_task.as_deref(),
159                max_results,
160            )
161        })
162        .await;
163
164        let results = match search {
165            Ok(Ok(rows)) => rows,
166            Ok(Err(e)) => {
167                return ToolResult::error(format!("session_search failed: {e}"));
168            }
169            Err(e) => {
170                return ToolResult::error(format!("session_search task join error: {e}"));
171            }
172        };
173
174        let scope_label = match scope_chat_id {
175            Some(cid) => format!("chat {cid}"),
176            None => "all chats".to_string(),
177        };
178
179        if results.is_empty() {
180            return ToolResult::success(format!(
181                "No messages matched FTS5 query {query:?} in {scope_label}"
182            ));
183        }
184
185        let mut lines = Vec::with_capacity(results.len() + 1);
186        lines.push(format!(
187            "{} match(es) for FTS5 query {query:?} in {scope_label}",
188            results.len()
189        ));
190        for m in &results {
191            let role = if m.is_from_bot { "bot" } else { "user" };
192            lines.push(format!(
193                "- [chat {chat}] {ts} ({role}={sender}): {snippet}",
194                chat = m.chat_id,
195                ts = m.timestamp,
196                sender = m.sender_name,
197                snippet = trim_snippet(&m.content, snippet_chars),
198            ));
199        }
200        ToolResult::success(lines.join("\n"))
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn trim_snippet_truncates_long_content() {
210        let long = "a".repeat(1000);
211        let out = trim_snippet(&long, 50);
212        assert!(out.ends_with('…'));
213        assert_eq!(out.chars().count(), 51);
214    }
215
216    #[test]
217    fn trim_snippet_passes_short_content() {
218        let text = "hello";
219        assert_eq!(trim_snippet(text, 100), text);
220    }
221
222    fn auth_input(caller_chat_id: i64, control_chat_ids: Vec<i64>) -> serde_json::Value {
223        json!({
224            "query": "anything",
225            "__microclaw_auth": {
226                "caller_channel": "telegram",
227                "caller_chat_id": caller_chat_id,
228                "control_chat_ids": control_chat_ids,
229                "env_files": [],
230            }
231        })
232    }
233
234    struct ScopedDir(std::path::PathBuf);
235    impl Drop for ScopedDir {
236        fn drop(&mut self) {
237            let _ = std::fs::remove_dir_all(&self.0);
238        }
239    }
240
241    fn make_tmp_db() -> (Arc<Database>, ScopedDir) {
242        let dir = std::env::temp_dir().join(format!("microclaw_test_{}", uuid::Uuid::new_v4()));
243        let db = Database::new(dir.to_str().unwrap()).expect("open db");
244        (Arc::new(db), ScopedDir(dir))
245    }
246
247    #[tokio::test]
248    async fn cross_chat_without_permission_is_rejected() {
249        let (db, _dir) = make_tmp_db();
250        let tool = SessionSearchTool::new(db);
251        let mut input = auth_input(100, vec![]);
252        input["chat_id"] = json!(200);
253        let result = tool.execute(input).await;
254        assert!(result.is_error, "expected permission error, got ok");
255        assert!(
256            result.content.contains("Permission denied"),
257            "expected permission-denied message, got: {}",
258            result.content
259        );
260    }
261
262    #[tokio::test]
263    async fn all_chats_denied_for_non_control_caller() {
264        let (db, _dir) = make_tmp_db();
265        let tool = SessionSearchTool::new(db);
266        let mut input = auth_input(100, vec![]);
267        input["all_chats"] = json!(true);
268        let result = tool.execute(input).await;
269        assert!(result.is_error);
270        assert!(
271            result.content.contains("control chats"),
272            "expected control-only message, got: {}",
273            result.content
274        );
275    }
276
277    #[tokio::test]
278    async fn all_chats_allowed_for_control_caller() {
279        let (db, _dir) = make_tmp_db();
280        let tool = SessionSearchTool::new(db);
281        let mut input = auth_input(100, vec![100]);
282        input["all_chats"] = json!(true);
283        let result = tool.execute(input).await;
284        assert!(
285            !result.is_error,
286            "expected ok for control chat, got: {}",
287            result.content
288        );
289    }
290
291    #[tokio::test]
292    async fn default_scope_is_caller_chat() {
293        let (db, _dir) = make_tmp_db();
294        let tool = SessionSearchTool::new(db);
295        let input = auth_input(100, vec![]);
296        let result = tool.execute(input).await;
297        assert!(!result.is_error, "expected ok, got: {}", result.content);
298        assert!(
299            result.content.contains("chat 100") || result.content.contains("No messages matched"),
300            "expected scope label 'chat 100', got: {}",
301            result.content
302        );
303    }
304}