1use async_trait::async_trait;
18use serde::Deserialize;
19
20use super::traits::*;
21use crate::text::truncate_chars;
22
23const MAX_RESULT_CHARS: usize = 4000;
25
26const DDG_LITE_URL: &str = "https://lite.duckduckgo.com/lite/";
28
29const DDG_BLOCK_MARKERS: &[&str] = &[
31 "anomaly-modal",
32 "challenge-platform",
33 "DDG.anomalyDetection",
34];
35
36const DDG_USER_AGENTS: &[&str] = &[
37 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
38 "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
39 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
40 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
41 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
42];
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Backend {
47 Searxng(String),
49 DuckDuckGo,
51 Perplexity(String),
53}
54
55pub struct WebSearchTool {
56 backend: Backend,
57}
58
59impl WebSearchTool {
60 pub fn new() -> Self {
61 Self {
62 backend: Self::detect_backend(),
63 }
64 }
65
66 fn detect_backend() -> Backend {
69 if let Ok(url) = std::env::var("SEARXNG_URL") {
70 let url = url.trim().trim_end_matches('/');
71 if !url.is_empty() {
72 return Backend::Searxng(url.to_string());
73 }
74 }
75 match std::env::var("PERPLEXITY_API_KEY") {
76 Ok(key) if !key.trim().is_empty() => Backend::Perplexity(key),
77 _ => Backend::DuckDuckGo,
78 }
79 }
80
81 pub fn with_backend(mut self, backend: Backend) -> Self {
83 self.backend = backend;
84 self
85 }
86
87 pub fn with_api_key(self, key: String) -> Self {
88 self.with_backend(Backend::Perplexity(key))
89 }
90
91 pub fn backend(&self) -> &Backend {
92 &self.backend
93 }
94
95 fn client() -> anyhow::Result<reqwest::Client> {
96 Ok(reqwest::Client::builder()
97 .timeout(std::time::Duration::from_secs(30))
98 .build()?)
99 }
100
101 async fn search_searxng(base_url: &str, query: &str) -> anyhow::Result<String> {
102 let resp = Self::client()?
103 .get(format!("{base_url}/search"))
104 .query(&[("q", query), ("format", "json")])
105 .send()
106 .await?;
107
108 if !resp.status().is_success() {
109 anyhow::bail!("searxng returned {}", resp.status());
110 }
111
112 let body: serde_json::Value = resp.json().await?;
113 let results = body
114 .get("results")
115 .and_then(|r| r.as_array())
116 .ok_or_else(|| anyhow::anyhow!("searxng response had no results array"))?;
117
118 Ok(format_results(results.iter().take(8).map(|r| SearchHit {
119 title: field(r, "title"),
120 url: field(r, "url"),
121 snippet: field(r, "content"),
122 })))
123 }
124
125 async fn search_duckduckgo(query: &str) -> anyhow::Result<String> {
127 match Self::scrape_duckduckgo(query).await {
128 Ok(text) => Ok(text),
129 Err(e) => {
130 tracing::debug!("duckduckgo result page unavailable ({e}), using instant answers");
131 Self::duckduckgo_instant(query).await
132 }
133 }
134 }
135
136 async fn scrape_duckduckgo(query: &str) -> anyhow::Result<String> {
137 let resp = Self::client()?
138 .post(DDG_LITE_URL)
139 .header(reqwest::header::USER_AGENT, pick_user_agent())
140 .header(
141 reqwest::header::ACCEPT,
142 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
143 )
144 .header(reqwest::header::ACCEPT_LANGUAGE, "en-US,en;q=0.9")
145 .header(reqwest::header::REFERER, "https://lite.duckduckgo.com/")
146 .form(&[("q", query), ("kl", "us-en"), ("safe", "1")])
147 .send()
148 .await?;
149
150 if resp.status().as_u16() == 202 {
152 anyhow::bail!("soft-blocked");
153 }
154 if !resp.status().is_success() {
155 anyhow::bail!("duckduckgo returned {}", resp.status());
156 }
157
158 let body = resp.text().await?;
159 if looks_blocked(&body) {
160 anyhow::bail!("challenge page");
161 }
162
163 let hits = parse_lite_results(&body);
164 if hits.is_empty() {
165 anyhow::bail!("no results parsed");
166 }
167 Ok(format_results(hits.into_iter().take(8)))
168 }
169
170 async fn duckduckgo_instant(query: &str) -> anyhow::Result<String> {
171 let resp = Self::client()?
172 .get("https://api.duckduckgo.com/")
173 .query(&[
174 ("q", query),
175 ("format", "json"),
176 ("no_html", "1"),
177 ("no_redirect", "1"),
178 ])
179 .send()
180 .await?;
181
182 if !resp.status().is_success() {
183 anyhow::bail!("duckduckgo returned {}", resp.status());
184 }
185
186 let body: serde_json::Value = serde_json::from_str(&resp.text().await?)?;
189 Ok(format_duckduckgo(&body))
190 }
191
192 async fn search_perplexity(api_key: &str, query: &str) -> anyhow::Result<String> {
193 let resp = Self::client()?
194 .post("https://api.perplexity.ai/chat/completions")
195 .header("Authorization", format!("Bearer {api_key}"))
196 .header("Content-Type", "application/json")
197 .json(&serde_json::json!({
198 "model": "sonar",
199 "messages": [{"role": "user", "content": query}],
200 }))
201 .send()
202 .await?;
203
204 if !resp.status().is_success() {
205 let status = resp.status();
206 let text = resp.text().await.unwrap_or_default();
207 anyhow::bail!("perplexity {}: {}", status, truncate_chars(&text, 200));
208 }
209
210 let data: serde_json::Value = resp.json().await?;
211 Ok(data["choices"][0]["message"]["content"]
212 .as_str()
213 .unwrap_or("No results found")
214 .to_string())
215 }
216}
217
218struct SearchHit {
219 title: String,
220 url: String,
221 snippet: String,
222}
223
224fn pick_user_agent() -> &'static str {
226 let nanos = std::time::SystemTime::now()
227 .duration_since(std::time::UNIX_EPOCH)
228 .map(|d| d.subsec_nanos() as usize)
229 .unwrap_or(0);
230 DDG_USER_AGENTS[nanos % DDG_USER_AGENTS.len()]
231}
232
233fn looks_blocked(body: &str) -> bool {
234 DDG_BLOCK_MARKERS.iter().any(|m| body.contains(m))
235}
236
237fn attr(tag: &str, name: &str) -> Option<String> {
239 let at = tag.find(&format!("{name}="))?;
240 let rest = &tag[at + name.len() + 1..];
241 let quote = rest.chars().next()?;
242 if quote != '"' && quote != '\'' {
243 return None;
244 }
245 let rest = &rest[quote.len_utf8()..];
246 let end = rest.find(quote)?;
247 Some(rest[..end].to_string())
248}
249
250fn clean_text(fragment: &str) -> String {
252 let mut out = String::with_capacity(fragment.len());
253 let mut in_tag = false;
254 for ch in fragment.chars() {
255 match ch {
256 '<' => in_tag = true,
257 '>' => in_tag = false,
258 c if !in_tag => out.push(c),
259 _ => {}
260 }
261 }
262 let decoded = out
264 .replace(" ", " ")
265 .replace("<", "<")
266 .replace(">", ">")
267 .replace(""", "\"")
268 .replace("'", "'")
269 .replace("'", "'")
270 .replace("&", "&");
271 decoded.split_whitespace().collect::<Vec<_>>().join(" ")
272}
273
274fn parse_lite_results(html: &str) -> Vec<SearchHit> {
277 let mut hits = Vec::new();
278 let mut rest = html;
279
280 while let Some(marker) = rest.find("result-link") {
281 let (before, after) = rest.split_at(marker);
282 let Some(gt) = after.find('>') else { break };
283 let body = &after[gt + 1..];
284 let Some(end) = body.find("</a>") else { break };
285 let tail = &body[end..];
286
287 let url = before
288 .rfind("<a ")
289 .and_then(|start| attr(&before[start..], "href"))
290 .unwrap_or_default();
291 let title = clean_text(&body[..end]);
292
293 let next = tail.find("result-link").unwrap_or(tail.len());
295 let snippet = tail[..next]
296 .find("result-snippet")
297 .and_then(|at| {
298 let segment = &tail[at..next];
299 let open = segment.find('>')?;
300 let close = segment[open..].find("</td>")?;
301 Some(clean_text(&segment[open + 1..open + close]))
302 })
303 .unwrap_or_default();
304
305 if !url.is_empty() && !title.is_empty() {
306 hits.push(SearchHit {
307 title,
308 url,
309 snippet,
310 });
311 }
312 rest = tail;
313 }
314
315 hits
316}
317
318fn field(value: &serde_json::Value, key: &str) -> String {
319 value
320 .get(key)
321 .and_then(|v| v.as_str())
322 .unwrap_or_default()
323 .to_string()
324}
325
326fn format_results(hits: impl Iterator<Item = SearchHit>) -> String {
327 let mut out = String::new();
328 for (i, hit) in hits.enumerate() {
329 if !out.is_empty() {
330 out.push_str("\n\n");
331 }
332 out.push_str(&format!("{}. {}\n {}", i + 1, hit.title, hit.url));
333 if !hit.snippet.trim().is_empty() {
334 out.push_str(&format!("\n {}", hit.snippet.trim()));
335 }
336 }
337 if out.is_empty() {
338 "No results found".to_string()
339 } else {
340 truncate_chars(&out, MAX_RESULT_CHARS)
341 }
342}
343
344fn format_duckduckgo(body: &serde_json::Value) -> String {
347 let mut out = String::new();
348
349 let abstract_text = field(body, "AbstractText");
350 if !abstract_text.trim().is_empty() {
351 out.push_str(abstract_text.trim());
352 let source = field(body, "AbstractURL");
353 if !source.is_empty() {
354 out.push_str(&format!("\n\nSource: {source}"));
355 }
356 }
357
358 let answer = field(body, "Answer");
359 if !answer.trim().is_empty() {
360 if !out.is_empty() {
361 out.push_str("\n\n");
362 }
363 out.push_str(answer.trim());
364 }
365
366 let mut topics: Vec<SearchHit> = Vec::new();
368 if let Some(list) = body.get("RelatedTopics").and_then(|t| t.as_array()) {
369 for entry in list {
370 match entry.get("Topics").and_then(|t| t.as_array()) {
371 Some(nested) => topics.extend(nested.iter().filter_map(related_topic)),
372 None => topics.extend(related_topic(entry)),
373 }
374 }
375 }
376
377 if !topics.is_empty() {
378 if !out.is_empty() {
379 out.push_str("\n\nRelated:\n");
380 }
381 out.push_str(&format_results(topics.into_iter().take(8)));
382 }
383
384 if out.trim().is_empty() {
385 "No results found".to_string()
386 } else {
387 truncate_chars(&out, MAX_RESULT_CHARS)
388 }
389}
390
391fn related_topic(entry: &serde_json::Value) -> Option<SearchHit> {
392 let text = field(entry, "Text");
393 if text.trim().is_empty() {
394 return None;
395 }
396 let title = text.split(" - ").next().unwrap_or(&text).to_string();
398 Some(SearchHit {
399 title,
400 url: field(entry, "FirstURL"),
401 snippet: String::new(),
402 })
403}
404
405impl Default for WebSearchTool {
406 fn default() -> Self {
407 Self::new()
408 }
409}
410
411#[derive(Deserialize)]
412struct SearchArgs {
413 query: String,
414}
415
416#[async_trait]
417impl Tool for WebSearchTool {
418 fn name(&self) -> &str {
419 "web_search"
420 }
421
422 fn spec(&self) -> ToolSpec {
423 ToolSpec {
424 name: "web_search".to_string(),
425 description: "Search the web for information. Returns relevant results with snippets."
426 .to_string(),
427 parameters: serde_json::json!({
428 "type": "object",
429 "properties": {
430 "query": {
431 "type": "string",
432 "description": "Search query"
433 }
434 },
435 "required": ["query"]
436 }),
437 }
438 }
439
440 async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
441 let args: SearchArgs = serde_json::from_str(arguments)?;
442 if args.query.trim().is_empty() {
443 return Ok(ToolResult::error("query must not be empty"));
444 }
445
446 let outcome = match &self.backend {
447 Backend::Searxng(url) => Self::search_searxng(url, &args.query).await,
448 Backend::DuckDuckGo => Self::search_duckduckgo(&args.query).await,
449 Backend::Perplexity(key) => Self::search_perplexity(key, &args.query).await,
450 };
451
452 match outcome {
453 Ok(text) => Ok(ToolResult::success(text)),
454 Err(e) if matches!(self.backend, Backend::Searxng(_)) => {
457 tracing::warn!("searxng search failed, falling back to duckduckgo: {e}");
458 match Self::search_duckduckgo(&args.query).await {
459 Ok(text) => Ok(ToolResult::success(text)),
460 Err(e) => Ok(ToolResult::error(format!("search failed: {e}"))),
461 }
462 }
463 Err(e) => Ok(ToolResult::error(format!("search failed: {e}"))),
464 }
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn duckduckgo_is_the_keyless_default() {
474 assert_eq!(
476 WebSearchTool::new().backend(),
477 &Backend::DuckDuckGo,
478 "expected the keyless backend when no env vars are set"
479 );
480 }
481
482 #[test]
483 fn formats_an_abstract_with_its_source() {
484 let body = serde_json::json!({
485 "AbstractText": "Rust is a general-purpose programming language.",
486 "AbstractURL": "https://en.wikipedia.org/wiki/Rust",
487 "RelatedTopics": [],
488 });
489 let out = format_duckduckgo(&body);
490 assert!(out.contains("general-purpose programming language"));
491 assert!(out.contains("Source: https://en.wikipedia.org/wiki/Rust"));
492 }
493
494 #[test]
495 fn flattens_grouped_related_topics() {
496 let body = serde_json::json!({
497 "AbstractText": "",
498 "RelatedTopics": [
499 {"Text": "Top level - a description", "FirstURL": "https://example.com/a"},
500 {"Topics": [
501 {"Text": "Nested one - detail", "FirstURL": "https://example.com/b"},
502 {"Text": "Nested two - detail", "FirstURL": "https://example.com/c"},
503 ]},
504 ],
505 });
506 let out = format_duckduckgo(&body);
507 assert!(out.contains("Top level"));
508 assert!(out.contains("https://example.com/b"));
509 assert!(out.contains("https://example.com/c"));
510 }
511
512 #[test]
513 fn empty_payload_reports_no_results() {
514 let body = serde_json::json!({"AbstractText": "", "RelatedTopics": []});
515 assert_eq!(format_duckduckgo(&body), "No results found");
516 }
517
518 #[test]
519 fn skips_related_topics_without_text() {
520 let body = serde_json::json!({
521 "AbstractText": "",
522 "RelatedTopics": [
523 {"FirstURL": "https://example.com/no-text"},
524 {"Text": "Has text", "FirstURL": "https://example.com/ok"},
525 ],
526 });
527 let out = format_duckduckgo(&body);
528 assert!(out.contains("Has text"));
529 assert!(!out.contains("no-text"));
530 }
531
532 const LITE_PAGE: &str = r#"
533 <table border="0">
534 <tr><td valign="top">1. </td>
535 <td><a rel="nofollow" href="https://tokio.rs/" class='result-link'>Tokio - An asynchronous <b>Rust</b> runtime</a></td></tr>
536 <tr><td> </td>
537 <td class='result-snippet'><b>Tokio</b> is a library for fast & reliable apps.</td></tr>
538 <tr><td valign="top">2. </td>
539 <td><a rel="nofollow" href="https://docs.rs/tokio" class='result-link'>tokio - Rust</a></td></tr>
540 </table>"#;
541
542 #[test]
543 fn parses_lite_result_page() {
544 let hits = parse_lite_results(LITE_PAGE);
545 assert_eq!(hits.len(), 2);
546 assert_eq!(hits[0].url, "https://tokio.rs/");
547 assert_eq!(hits[0].title, "Tokio - An asynchronous Rust runtime");
548 assert_eq!(
549 hits[0].snippet,
550 "Tokio is a library for fast & reliable apps."
551 );
552 assert_eq!(hits[1].url, "https://docs.rs/tokio");
553 }
554
555 #[test]
556 fn does_not_borrow_a_later_hits_snippet() {
557 let hits = parse_lite_results(LITE_PAGE);
559 assert!(hits[1].snippet.is_empty());
560 }
561
562 #[test]
563 fn parsing_a_page_without_results_yields_nothing() {
564 assert!(parse_lite_results("<html><body>nothing here</body></html>").is_empty());
565 }
566
567 #[test]
568 fn detects_challenge_pages() {
569 assert!(looks_blocked("<div id=\"anomaly-modal\">"));
570 assert!(looks_blocked("window.DDG.anomalyDetection = {}"));
571 assert!(!looks_blocked(LITE_PAGE));
572 }
573
574 #[test]
575 fn reads_attributes_in_either_quote_style() {
576 assert_eq!(attr("<a href=\"x\">", "href").as_deref(), Some("x"));
577 assert_eq!(attr("<a href='y'>", "href").as_deref(), Some("y"));
578 assert_eq!(attr("<a rel=nofollow>", "href"), None);
579 }
580
581 #[test]
582 fn user_agent_comes_from_the_pool() {
583 assert!(DDG_USER_AGENTS.contains(&pick_user_agent()));
584 }
585
586 #[tokio::test]
590 #[ignore]
591 async fn duckduckgo_still_serves_a_result_page() {
592 let out = WebSearchTool::scrape_duckduckgo("rust tokio runtime")
593 .await
594 .expect("lite endpoint refused or markup changed");
595 assert!(out.contains("http"), "no links in: {out}");
596 }
597
598 #[test]
599 fn formats_searxng_style_hits() {
600 let hits = vec![
601 SearchHit {
602 title: "First".into(),
603 url: "https://example.com/1".into(),
604 snippet: " a snippet ".into(),
605 },
606 SearchHit {
607 title: "Second".into(),
608 url: "https://example.com/2".into(),
609 snippet: String::new(),
610 },
611 ];
612 let out = format_results(hits.into_iter());
613 assert!(out.starts_with("1. First"));
614 assert!(out.contains(" a snippet"));
615 assert!(out.contains("2. Second"));
616 }
617}