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::{Value, json};
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!("{}. **{}**\n   {}\n   {}", i + 1, r.title, r.url, snippet)
85        })
86        .collect::<Vec<_>>()
87        .join("\n\n")
88}
89
90// ── AgentTool impl ────────────────────────────────────────────────
91
92#[async_trait]
93impl AgentTool for WebSearchTool {
94    fn name(&self) -> &str {
95        "web_search"
96    }
97
98    fn label(&self) -> &str {
99        "Web Search"
100    }
101
102    fn description(&self) -> &str {
103        "Search the web using multiple engines (DuckDuckGo, Wikipedia, Bing). No server or API key needed. Returns results with titles, URLs, and snippets."
104    }
105
106    fn parameters_schema(&self) -> Value {
107        json!({
108            "type": "object",
109            "properties": {
110                "query": {
111                    "type": "string",
112                    "description": "Search query string"
113                },
114                "engines": {
115                    "type": "string",
116                    "description": "Comma-separated engines (ddg,wiki,bing). Default: ddg,wiki",
117                    "default": "ddg,wiki"
118                },
119                "limit": {
120                    "type": "integer",
121                    "description": "Maximum number of results to return (default: 10, max: 30)",
122                    "default": 10
123                }
124            },
125            "required": ["query"]
126        })
127    }
128
129    async fn execute(
130        &self,
131        _tool_call_id: &str,
132        params: Value,
133        _signal: Option<oneshot::Receiver<()>>,
134        _ctx: &ToolContext,
135    ) -> Result<AgentToolResult, ToolError> {
136        let query = params["query"]
137            .as_str()
138            .ok_or_else(|| "Missing required parameter: query".to_string())?;
139
140        let engines = params["engines"].as_str().unwrap_or(DEFAULT_ENGINES);
141
142        let limit = params["limit"]
143            .as_u64()
144            .unwrap_or(DEFAULT_MAX_RESULTS as u64)
145            .min(MAX_RESULTS as u64) as usize;
146
147        let results = self.do_search(query, engines, limit).await?;
148
149        if results.is_empty() {
150            return Ok(AgentToolResult::success(format!(
151                "No results found for: {}",
152                query
153            )));
154        }
155
156        // Cache results and generate a search ID
157        let search_id = self.cache.insert(query, results.clone());
158
159        let output = format_results(&results);
160
161        let results_json: Vec<Value> = results
162            .iter()
163            .map(|r| {
164                json!({
165                    "title": r.title,
166                    "url": r.url,
167                    "snippet": r.snippet,
168                    "source": r.source,
169                })
170            })
171            .collect();
172
173        Ok(AgentToolResult::success(output).with_metadata(json!({
174            "results": results_json,
175            "query": query,
176            "searchId": search_id,
177            "resultCount": results.len()
178        })))
179    }
180}
181
182// ── Tests ─────────────────────────────────────────────────────────
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn test_format_results_empty() {
190        assert_eq!(format_results(&[]), "No results found.");
191    }
192
193    #[test]
194    fn test_format_results() {
195        let results = vec![SearchResult {
196            title: "Test".to_string(),
197            url: "https://example.com".to_string(),
198            snippet: "A snippet".to_string(),
199            source: "DuckDuckGo".to_string(),
200            extra: None,
201        }];
202        let formatted = format_results(&results);
203        assert!(formatted.contains("**Test**"));
204        assert!(formatted.contains("https://example.com"));
205    }
206
207    #[test]
208    fn test_schema() {
209        let cache = Arc::new(SearchCache::new());
210        let tool = WebSearchTool::new(cache);
211        let schema = tool.parameters_schema();
212        assert_eq!(schema["type"], "object");
213        assert!(schema["properties"]["query"].is_object());
214        assert!(schema["properties"]["engines"].is_object());
215        assert!(schema["properties"]["limit"].is_object());
216        assert!(
217            schema["required"]
218                .as_array()
219                .unwrap()
220                .contains(&json!("query"))
221        );
222    }
223}