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::{json, Value};
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!(
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#[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 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#[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}