Skip to main content

codetether_agent/tool/
websearch.rs

1//! Web Search Tool - Search the web using DuckDuckGo or configurable search API.
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::{json, Value};
7use std::time::Duration;
8use super::{Tool, ToolResult};
9
10const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
11
12pub struct WebSearchTool {
13    client: reqwest::Client,
14}
15
16impl Default for WebSearchTool {
17    fn default() -> Self { Self::new() }
18}
19
20impl WebSearchTool {
21    pub fn new() -> Self {
22        let client = reqwest::Client::builder()
23            .timeout(REQUEST_TIMEOUT)
24            .user_agent("CodeTether-Agent/1.0")
25            .build()
26            .expect("Failed to build HTTP client");
27        Self { client }
28    }
29
30    async fn search_ddg(&self, query: &str, max_results: usize) -> Result<Vec<SearchResult>> {
31        // DuckDuckGo HTML search (no API key required)
32        let url = format!("https://html.duckduckgo.com/html/?q={}", urlencoding::encode(query));
33        let resp = self.client.get(&url).send().await?;
34        let html = resp.text().await?;
35        
36        let mut results = Vec::new();
37        // Parse result links from DDG HTML
38        let link_re = regex::Regex::new(r#"<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)</a>"#)?;
39        let snippet_re = regex::Regex::new(r#"<a[^>]+class="result__snippet"[^>]*>([^<]+)</a>"#)?;
40        
41        let links: Vec<_> = link_re.captures_iter(&html).collect();
42        let snippets: Vec<_> = snippet_re.captures_iter(&html).collect();
43        
44        for (i, cap) in links.iter().take(max_results).enumerate() {
45            let url = cap.get(1).map(|m| m.as_str()).unwrap_or("");
46            let title = cap.get(2).map(|m| m.as_str()).unwrap_or("");
47            let snippet = snippets.get(i).and_then(|c| c.get(1)).map(|m| m.as_str()).unwrap_or("");
48            
49            // DDG wraps URLs, extract actual URL
50            let actual_url = if url.contains("uddg=") {
51                url.split("uddg=").nth(1).and_then(|s| urlencoding::decode(s.split('&').next().unwrap_or("")).ok()).map(|s| s.into_owned()).unwrap_or_else(|| url.to_string())
52            } else {
53                url.to_string()
54            };
55            
56            results.push(SearchResult {
57                title: html_escape::decode_html_entities(title).to_string(),
58                url: actual_url,
59                snippet: html_escape::decode_html_entities(snippet).to_string(),
60            });
61        }
62        Ok(results)
63    }
64}
65
66#[derive(Debug, Clone)]
67struct SearchResult {
68    title: String,
69    url: String,
70    snippet: String,
71}
72
73#[derive(Deserialize)]
74struct Params {
75    query: String,
76    #[serde(default = "default_max")]
77    max_results: usize,
78}
79
80fn default_max() -> usize { 5 }
81
82#[async_trait]
83impl Tool for WebSearchTool {
84    fn id(&self) -> &str { "websearch" }
85    fn name(&self) -> &str { "Web Search" }
86    fn description(&self) -> &str { "Search the web for information. Returns titles, URLs, and snippets." }
87    fn parameters(&self) -> Value {
88        json!({
89            "type": "object",
90            "properties": {
91                "query": {"type": "string", "description": "Search query"},
92                "max_results": {"type": "integer", "default": 5, "description": "Max results to return"}
93            },
94            "required": ["query"]
95        })
96    }
97
98    async fn execute(&self, params: Value) -> Result<ToolResult> {
99        let p: Params = serde_json::from_value(params).context("Invalid params")?;
100        
101        if p.query.trim().is_empty() {
102            return Ok(ToolResult::error("Query cannot be empty"));
103        }
104        
105        let results = self.search_ddg(&p.query, p.max_results).await?;
106        
107        if results.is_empty() {
108            return Ok(ToolResult::success("No results found".to_string()));
109        }
110        
111        let output = results.iter().enumerate().map(|(i, r)| {
112            format!("{}. {}\n   URL: {}\n   {}", i + 1, r.title, r.url, r.snippet)
113        }).collect::<Vec<_>>().join("\n\n");
114        
115        Ok(ToolResult::success(output).with_metadata("count", json!(results.len())))
116    }
117}