Skip to main content

oxi_agent/tools/
web_search.rs

1use super::search_cache::{SearchCache, SearchResult};
2/// Web search tool — searches via oxibrowser's integrated search module.
3///
4/// Uses `oxibrowser::search::dispatch()` which provides multi-engine web
5/// search (DuckDuckGo, Wikipedia, Bing) and GitHub search, all powered by
6/// lightweight HTTP requests — no external binary or API keys needed.
7///
8/// Features:
9/// - Multiple search engines (ddg, wiki, bing)
10/// - Result caching with search IDs for later retrieval via `get_search_results`
11/// - Configurable engine selection and result count
12/// - Zero-config: no API keys, no external binary needed
13use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use async_trait::async_trait;
15use serde_json::{json, Value};
16use std::sync::Arc;
17use tokio::sync::oneshot;
18
19/// Maximum number of results to return by default.
20const DEFAULT_MAX_RESULTS: usize = 10;
21
22/// Maximum number of results allowed.
23const MAX_RESULTS: usize = 30;
24
25/// Default search engines.
26const DEFAULT_ENGINES: &str = "ddg,wiki";
27
28/// Search timeout in seconds.
29const SEARCH_TIMEOUT_SECS: u64 = 15;
30
31// ── WebSearchTool ─────────────────────────────────────────────────
32
33/// Multi-engine web search tool using oxibrowser's search module.
34pub struct WebSearchTool {
35    cache: Arc<SearchCache>,
36}
37
38impl WebSearchTool {
39    /// Create a new WebSearchTool with the given search cache.
40    pub fn new(cache: Arc<SearchCache>) -> Self {
41        Self { cache }
42    }
43
44    /// Execute search using oxibrowser's dispatch.
45    async fn do_search(
46        &self,
47        query: &str,
48        engines: &str,
49        limit: usize,
50    ) -> Result<Vec<SearchResult>, ToolError> {
51        let output = oxibrowser::search::dispatch(
52            query,
53            "web",       // source: web search
54            engines,     // "ddg,wiki,bing"
55            None,        // repo (not used for web)
56            None,        // token (not used for web)
57            limit,
58            SEARCH_TIMEOUT_SECS,
59        )
60        .await
61        .map_err(|e| format!("Search failed: {}", e))?;
62
63        Ok(output.results)
64    }
65}
66
67// ── Formatting ────────────────────────────────────────────────────
68
69/// Format search results for display.
70fn format_results(results: &[SearchResult]) -> String {
71    if results.is_empty() {
72        return "No results found.".to_string();
73    }
74    results
75        .iter()
76        .enumerate()
77        .map(|(i, r)| {
78            let snippet = if r.snippet.chars().count() > 200 {
79                let truncated: String = r.snippet.chars().take(200).collect();
80                format!("{}...", truncated)
81            } else {
82                r.snippet.clone()
83            };
84            format!(
85                "{}. **{}**\n   {}\n   {}",
86                i + 1,
87                r.title,
88                r.url,
89                snippet
90            )
91        })
92        .collect::<Vec<_>>()
93        .join("\n\n")
94}
95
96// ── AgentTool impl ────────────────────────────────────────────────
97
98#[async_trait]
99impl AgentTool for WebSearchTool {
100    fn name(&self) -> &str {
101        "web_search"
102    }
103
104    fn label(&self) -> &str {
105        "Web Search"
106    }
107
108    fn description(&self) -> &str {
109        "Search the web using multiple engines (DuckDuckGo, Wikipedia, Bing). No server or API key needed. Returns results with titles, URLs, and snippets."
110    }
111
112    fn parameters_schema(&self) -> Value {
113        json!({
114            "type": "object",
115            "properties": {
116                "query": {
117                    "type": "string",
118                    "description": "Search query string"
119                },
120                "engines": {
121                    "type": "string",
122                    "description": "Comma-separated engines (ddg,wiki,bing). Default: ddg,wiki",
123                    "default": "ddg,wiki"
124                },
125                "limit": {
126                    "type": "integer",
127                    "description": "Maximum number of results to return (default: 10, max: 30)",
128                    "default": 10
129                }
130            },
131            "required": ["query"]
132        })
133    }
134
135    async fn execute(
136        &self,
137        _tool_call_id: &str,
138        params: Value,
139        _signal: Option<oneshot::Receiver<()>>,
140        _ctx: &ToolContext,
141    ) -> Result<AgentToolResult, ToolError> {
142        let query = params["query"]
143            .as_str()
144            .ok_or_else(|| "Missing required parameter: query".to_string())?;
145
146        let engines = params["engines"].as_str().unwrap_or(DEFAULT_ENGINES);
147
148        let limit = params["limit"]
149            .as_u64()
150            .unwrap_or(DEFAULT_MAX_RESULTS as u64)
151            .min(MAX_RESULTS as u64) as usize;
152
153        let results = self.do_search(query, engines, limit).await?;
154
155        if results.is_empty() {
156            return Ok(AgentToolResult::success(format!(
157                "No results found for: {}",
158                query
159            )));
160        }
161
162        // Cache results and generate a search ID
163        let search_id = self.cache.insert(query, results.clone());
164
165        let output = format_results(&results);
166
167        let results_json: Vec<Value> = results
168            .iter()
169            .map(|r| {
170                json!({
171                    "title": r.title,
172                    "url": r.url,
173                    "snippet": r.snippet,
174                    "source": r.source,
175                })
176            })
177            .collect();
178
179        Ok(AgentToolResult::success(output).with_metadata(json!({
180            "results": results_json,
181            "query": query,
182            "searchId": search_id,
183            "resultCount": results.len()
184        })))
185    }
186}
187
188// ── Tests ─────────────────────────────────────────────────────────
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_format_results_empty() {
196        assert_eq!(format_results(&[]), "No results found.");
197    }
198
199    #[test]
200    fn test_format_results() {
201        let results = vec![SearchResult {
202            title: "Test".to_string(),
203            url: "https://example.com".to_string(),
204            snippet: "A snippet".to_string(),
205            source: "DuckDuckGo".to_string(),
206            extra: None,
207        }];
208        let formatted = format_results(&results);
209        assert!(formatted.contains("**Test**"));
210        assert!(formatted.contains("https://example.com"));
211    }
212
213    #[test]
214    fn test_schema() {
215        let cache = Arc::new(SearchCache::new());
216        let tool = WebSearchTool::new(cache);
217        let schema = tool.parameters_schema();
218        assert_eq!(schema["type"], "object");
219        assert!(schema["properties"]["query"].is_object());
220        assert!(schema["properties"]["engines"].is_object());
221        assert!(schema["properties"]["limit"].is_object());
222        assert!(schema["required"]
223            .as_array()
224            .unwrap()
225            .contains(&json!("query")));
226    }
227}