microclaw_engine/internal/tool_runtime/
web_search.rs1use std::collections::HashMap;
2use std::sync::{Mutex, OnceLock};
3
4use serde::{Deserialize, Serialize};
5
6use crate::internal::tool_runtime::web_html::{extract_ddg_results, SearchItem};
7
8fn http_client(timeout_secs: u64) -> reqwest::Client {
9 static CLIENTS: OnceLock<Mutex<HashMap<u64, reqwest::Client>>> = OnceLock::new();
10 let cache = CLIENTS.get_or_init(|| Mutex::new(HashMap::new()));
11 let mut cache = cache.lock().unwrap_or_else(|e| e.into_inner());
12 if let Some(client) = cache.get(&timeout_secs) {
13 return client.clone();
14 }
15 let client = reqwest::Client::builder()
16 .timeout(std::time::Duration::from_secs(timeout_secs))
17 .redirect(reqwest::redirect::Policy::limited(5))
18 .user_agent("MicroClaw/1.0")
19 .build()
20 .expect("failed to build HTTP client");
21 cache.insert(timeout_secs, client.clone());
22 client
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum SearchBackend {
31 #[default]
32 Duckduckgo,
33 Searxng,
36 Brave,
38 Tavily,
40}
41
42fn default_search_max_results() -> usize {
43 8
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SearchProviderConfig {
51 #[serde(default)]
52 pub backend: SearchBackend,
53 #[serde(default)]
55 pub searxng_base_url: String,
56 #[serde(default)]
57 pub brave_api_key: String,
58 #[serde(default)]
59 pub tavily_api_key: String,
60 #[serde(default = "default_search_max_results")]
61 pub max_results: usize,
62}
63
64impl Default for SearchProviderConfig {
65 fn default() -> Self {
66 Self {
67 backend: SearchBackend::default(),
68 searxng_base_url: String::new(),
69 brave_api_key: String::new(),
70 tavily_api_key: String::new(),
71 max_results: default_search_max_results(),
72 }
73 }
74}
75
76impl SearchProviderConfig {
77 pub fn normalize(&mut self) {
78 self.searxng_base_url = self
79 .searxng_base_url
80 .trim()
81 .trim_end_matches('/')
82 .to_string();
83 self.brave_api_key = self.brave_api_key.trim().to_string();
84 self.tavily_api_key = self.tavily_api_key.trim().to_string();
85 if self.max_results == 0 {
86 self.max_results = default_search_max_results();
87 }
88 self.max_results = self.max_results.min(20);
89 }
90
91 pub fn effective_backend(&self) -> SearchBackend {
95 match self.backend {
96 SearchBackend::Searxng if self.searxng_base_url.trim().is_empty() => {
97 SearchBackend::Duckduckgo
98 }
99 SearchBackend::Brave if self.brave_api_key.trim().is_empty() => {
100 SearchBackend::Duckduckgo
101 }
102 SearchBackend::Tavily if self.tavily_api_key.trim().is_empty() => {
103 SearchBackend::Duckduckgo
104 }
105 other => other,
106 }
107 }
108}
109
110pub fn format_search_hits(hits: &[SearchItem]) -> String {
112 let mut output = String::new();
113 for (i, item) in hits.iter().enumerate() {
114 output.push_str(&format!(
115 "{}. {}\n {}\n {}\n\n",
116 i + 1,
117 item.title,
118 item.url,
119 item.snippet
120 ));
121 }
122 output
123}
124
125pub async fn search_with_provider(
128 query: &str,
129 config: &SearchProviderConfig,
130 timeout_secs: u64,
131) -> Result<Vec<SearchItem>, String> {
132 let max = config.max_results.clamp(1, 20);
133 let t = timeout_secs.max(1);
134 match config.effective_backend() {
135 SearchBackend::Duckduckgo => ddg_hits(query, t, max).await,
136 SearchBackend::Searxng => searxng_hits(query, &config.searxng_base_url, t, max).await,
137 SearchBackend::Brave => brave_hits(query, &config.brave_api_key, t, max).await,
138 SearchBackend::Tavily => tavily_hits(query, &config.tavily_api_key, t, max).await,
139 }
140}
141
142async fn ddg_hits(query: &str, timeout_secs: u64, max: usize) -> Result<Vec<SearchItem>, String> {
143 let encoded = urlencoding::encode(query);
144 let url = format!("https://html.duckduckgo.com/html/?q={encoded}");
145 let client = http_client(timeout_secs);
146 let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
147 if !resp.status().is_success() {
148 return Err(format!("HTTP {}", resp.status()));
149 }
150 let body = resp.text().await.map_err(|e| e.to_string())?;
151 Ok(extract_ddg_results(&body, max))
152}
153
154#[derive(Deserialize)]
155struct SearxResponse {
156 #[serde(default)]
157 results: Vec<SearxResult>,
158}
159
160#[derive(Deserialize)]
161struct SearxResult {
162 #[serde(default)]
163 title: String,
164 #[serde(default)]
165 url: String,
166 #[serde(default)]
167 content: String,
168}
169
170async fn searxng_hits(
171 query: &str,
172 base_url: &str,
173 timeout_secs: u64,
174 max: usize,
175) -> Result<Vec<SearchItem>, String> {
176 let encoded = urlencoding::encode(query);
177 let url = format!("{base_url}/search?q={encoded}&format=json");
178 let client = http_client(timeout_secs);
179 let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
180 if !resp.status().is_success() {
181 return Err(format!("HTTP {}", resp.status()));
182 }
183 let parsed: SearxResponse = resp.json().await.map_err(|e| e.to_string())?;
184 Ok(parsed
185 .results
186 .into_iter()
187 .filter(|r| !r.url.is_empty())
188 .take(max)
189 .map(|r| SearchItem {
190 title: r.title,
191 url: r.url,
192 snippet: r.content,
193 })
194 .collect())
195}
196
197#[derive(Deserialize)]
198struct BraveResponse {
199 #[serde(default)]
200 web: Option<BraveWeb>,
201}
202
203#[derive(Deserialize)]
204struct BraveWeb {
205 #[serde(default)]
206 results: Vec<BraveResult>,
207}
208
209#[derive(Deserialize)]
210struct BraveResult {
211 #[serde(default)]
212 title: String,
213 #[serde(default)]
214 url: String,
215 #[serde(default)]
216 description: String,
217}
218
219async fn brave_hits(
220 query: &str,
221 api_key: &str,
222 timeout_secs: u64,
223 max: usize,
224) -> Result<Vec<SearchItem>, String> {
225 let encoded = urlencoding::encode(query);
226 let url = format!("https://api.search.brave.com/res/v1/web/search?q={encoded}&count={max}");
227 let client = http_client(timeout_secs);
228 let resp = client
229 .get(&url)
230 .header("Accept", "application/json")
231 .header("X-Subscription-Token", api_key)
232 .send()
233 .await
234 .map_err(|e| e.to_string())?;
235 if !resp.status().is_success() {
236 return Err(format!("HTTP {}", resp.status()));
237 }
238 let parsed: BraveResponse = resp.json().await.map_err(|e| e.to_string())?;
239 Ok(parsed
240 .web
241 .map(|w| w.results)
242 .unwrap_or_default()
243 .into_iter()
244 .filter(|r| !r.url.is_empty())
245 .take(max)
246 .map(|r| SearchItem {
247 title: r.title,
248 url: r.url,
249 snippet: r.description,
250 })
251 .collect())
252}
253
254#[derive(Serialize)]
255struct TavilyRequest<'a> {
256 api_key: &'a str,
257 query: &'a str,
258 max_results: usize,
259}
260
261#[derive(Deserialize)]
262struct TavilyResponse {
263 #[serde(default)]
264 results: Vec<TavilyResult>,
265}
266
267#[derive(Deserialize)]
268struct TavilyResult {
269 #[serde(default)]
270 title: String,
271 #[serde(default)]
272 url: String,
273 #[serde(default)]
274 content: String,
275}
276
277async fn tavily_hits(
278 query: &str,
279 api_key: &str,
280 timeout_secs: u64,
281 max: usize,
282) -> Result<Vec<SearchItem>, String> {
283 let client = http_client(timeout_secs);
284 let resp = client
285 .post("https://api.tavily.com/search")
286 .json(&TavilyRequest {
287 api_key,
288 query,
289 max_results: max,
290 })
291 .send()
292 .await
293 .map_err(|e| e.to_string())?;
294 if !resp.status().is_success() {
295 return Err(format!("HTTP {}", resp.status()));
296 }
297 let parsed: TavilyResponse = resp.json().await.map_err(|e| e.to_string())?;
298 Ok(parsed
299 .results
300 .into_iter()
301 .filter(|r| !r.url.is_empty())
302 .take(max)
303 .map(|r| SearchItem {
304 title: r.title,
305 url: r.url,
306 snippet: r.content,
307 })
308 .collect())
309}
310
311pub async fn search_ddg_with_timeout(query: &str, timeout_secs: u64) -> Result<String, String> {
312 let hits = ddg_hits(query, timeout_secs.max(1), 8).await?;
313 Ok(format_search_hits(&hits))
314}
315
316pub async fn search_ddg(query: &str) -> Result<String, String> {
317 search_ddg_with_timeout(query, 15).await
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn default_backend_is_duckduckgo() {
326 let cfg = SearchProviderConfig::default();
327 assert_eq!(cfg.backend, SearchBackend::Duckduckgo);
328 assert_eq!(cfg.effective_backend(), SearchBackend::Duckduckgo);
329 assert_eq!(cfg.max_results, 8);
330 }
331
332 #[test]
333 fn effective_backend_downgrades_when_unconfigured() {
334 let cfg = SearchProviderConfig {
335 backend: SearchBackend::Searxng,
336 ..Default::default()
337 };
338 assert_eq!(cfg.effective_backend(), SearchBackend::Duckduckgo);
339
340 let cfg = SearchProviderConfig {
341 backend: SearchBackend::Brave,
342 ..Default::default()
343 };
344 assert_eq!(cfg.effective_backend(), SearchBackend::Duckduckgo);
345
346 let cfg = SearchProviderConfig {
347 backend: SearchBackend::Tavily,
348 tavily_api_key: "tvly-xxx".into(),
349 ..Default::default()
350 };
351 assert_eq!(cfg.effective_backend(), SearchBackend::Tavily);
352 }
353
354 #[test]
355 fn normalize_trims_and_bounds() {
356 let mut cfg = SearchProviderConfig {
357 backend: SearchBackend::Searxng,
358 searxng_base_url: " http://localhost:8888/ ".into(),
359 brave_api_key: " k ".into(),
360 max_results: 0,
361 ..Default::default()
362 };
363 cfg.normalize();
364 assert_eq!(cfg.searxng_base_url, "http://localhost:8888");
365 assert_eq!(cfg.brave_api_key, "k");
366 assert_eq!(cfg.max_results, 8);
367 assert_eq!(cfg.effective_backend(), SearchBackend::Searxng);
368 }
369
370 #[test]
371 fn format_hits_numbers_results() {
372 let hits = vec![
373 SearchItem {
374 title: "A".into(),
375 url: "https://a.example".into(),
376 snippet: "sa".into(),
377 },
378 SearchItem {
379 title: "B".into(),
380 url: "https://b.example".into(),
381 snippet: "sb".into(),
382 },
383 ];
384 let out = format_search_hits(&hits);
385 assert!(out.contains("1. A"));
386 assert!(out.contains("2. B"));
387 assert!(out.contains("https://a.example"));
388 }
389}