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