Skip to main content

robit_agent/tool/
search_history.rs

1//! Search history tool — full-text search of chat messages.
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::Value;
6
7use crate::storage::{search_messages, MessageSearchFilter};
8use crate::tool::{Tool, ToolContext, ToolResult};
9use crate::error::Result;
10
11#[derive(Debug, Deserialize)]
12struct SearchHistoryArgs {
13    query: String,
14    role: Option<String>,
15    since: Option<String>,
16    until: Option<String>,
17    limit: Option<usize>,
18    #[serde(default)]
19    all_sessions: bool,
20}
21
22pub struct SearchHistoryTool;
23
24impl SearchHistoryTool {
25    pub fn new() -> Self {
26        Self
27    }
28}
29
30impl Default for SearchHistoryTool {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36#[async_trait]
37impl Tool for SearchHistoryTool {
38    fn name(&self) -> &str {
39        "search_history"
40    }
41
42    fn description(&self) -> &str {
43        "Search through chat history using full-text search. \
44         By default searches only the current session. \
45         Useful for finding past messages, references, or context from earlier in the conversation."
46    }
47
48    fn parameters_schema(&self) -> Value {
49        serde_json::json!({
50            "type": "object",
51            "properties": {
52                "query": {
53                    "type": "string",
54                    "description": "Search query. Supports phrase matching (\"quoted text\"), prefix queries (word*), and boolean operators (AND, OR, NOT)"
55                },
56                "role": {
57                    "type": "string",
58                    "description": "Filter by message role: user, assistant, or tool"
59                },
60                "since": {
61                    "type": "string",
62                    "description": "Only return messages after this ISO 8601 timestamp"
63                },
64                "until": {
65                    "type": "string",
66                    "description": "Only return messages before this ISO 8601 timestamp"
67                },
68                "limit": {
69                    "type": "integer",
70                    "description": "Maximum number of results to return",
71                    "default": 10
72                },
73                "all_sessions": {
74                    "type": "boolean",
75                    "description": "If true, search across all sessions. If false, search only the current session",
76                    "default": false
77                }
78            },
79            "required": ["query"]
80        })
81    }
82
83    fn requires_confirmation(&self) -> bool {
84        false
85    }
86
87    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
88        let parsed: SearchHistoryArgs = match serde_json::from_value(args) {
89            Ok(a) => a,
90            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
91        };
92
93        if parsed.query.trim().is_empty() {
94            return Ok(ToolResult::error("Search query cannot be empty".to_string()));
95        }
96
97        let db_path = match crate::storage::resolve_db_path(&ctx.working_dir, false) {
98            Ok(p) => p,
99            Err(e) => return Ok(ToolResult::error(format!("Failed to resolve DB path: {}", e))),
100        };
101
102        let conn = match rusqlite::Connection::open(&db_path) {
103            Ok(c) => c,
104            Err(e) => return Ok(ToolResult::error(format!("Failed to open DB: {}", e))),
105        };
106
107        let session_id = if parsed.all_sessions {
108            None
109        } else {
110            Some(ctx.session_id.as_str())
111        };
112
113        // Convert Option<String> to Option<&str> for the filter
114        let filter = MessageSearchFilter {
115            session_id,
116            role: parsed.role.as_deref(),
117            since: parsed.since.as_deref(),
118            until: parsed.until.as_deref(),
119        };
120
121        let limit = parsed.limit.unwrap_or(10).min(50); // Hard cap at 50
122
123        match search_messages(&conn, &parsed.query, &filter, limit) {
124            Ok(results) if results.is_empty() => {
125                Ok(ToolResult::success(
126                    "No messages found matching the search criteria.".to_string()
127                ))
128            }
129            Ok(results) => {
130                let mut output = format!(
131                    "Found {} message{} matching \"{}\":\n\n",
132                    results.len(),
133                    if results.len() == 1 { "" } else { "s" },
134                    parsed.query
135                );
136
137                let current_session = ctx.session_id.as_str();
138
139                for (i, msg) in results.iter().enumerate() {
140                    output.push_str(&format!(
141                        "{}. [{}] {}",
142                        i + 1,
143                        msg.role,
144                        msg.created_at
145                    ));
146
147                    // Show session info when searching across sessions
148                    if parsed.all_sessions && msg.session_id != current_session {
149                        output.push_str(&format!(
150                            " (Session: {})",
151                            msg.session_title
152                        ));
153                    }
154
155                    output.push_str("\n");
156
157                    // Indent snippet for readability
158                    for line in msg.content_snippet.lines() {
159                        output.push_str(&format!("   {}\n", line));
160                    }
161                    output.push_str("\n");
162                }
163
164                Ok(ToolResult::success(output))
165            }
166            Err(e) => Ok(ToolResult::error(format!("Search failed: {}", e))),
167        }
168    }
169}