oxi_agent/tools/
web_search.rs1use super::search_cache::{SearchCache, SearchResult};
2use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use async_trait::async_trait;
15use serde_json::{Value, json};
16use std::sync::Arc;
17use tokio::sync::oneshot;
18
19const DEFAULT_MAX_RESULTS: usize = 10;
21
22const MAX_RESULTS: usize = 30;
24
25const DEFAULT_ENGINES: &str = "ddg,wiki";
27
28const SEARCH_TIMEOUT_SECS: u64 = 15;
30
31pub struct WebSearchTool {
35 cache: Arc<SearchCache>,
36}
37
38impl WebSearchTool {
39 pub fn new(cache: Arc<SearchCache>) -> Self {
41 Self { cache }
42 }
43
44 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", engines, None, None, limit,
58 SEARCH_TIMEOUT_SECS,
59 )
60 .await
61 .map_err(|e| format!("Search failed: {}", e))?;
62
63 Ok(output.results)
64 }
65}
66
67fn 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#[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 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#[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}