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