oxi_agent/tools/
web_search.rs1use super::search_cache::{SearchCache, SearchResult};
2use 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
25const MAX_RESULTS: usize = 30;
27
28#[allow(dead_code)]
29const DEFAULT_ENGINES: &str = "ddg,wiki";
30
31const 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
51pub struct WebSearchTool {
55 cache: Arc<SearchCache>,
56}
57
58impl WebSearchTool {
59 pub fn new(cache: Arc<SearchCache>) -> Self {
61 Self { cache }
62 }
63
64 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", engines, None, None, limit,
78 SEARCH_TIMEOUT_SECS,
79 )
80 .await
81 .map_err(|e| format!("Search failed: {}", e))?;
82
83 Ok(output.results)
84 }
85}
86
87fn 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#[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#[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}