Skip to main content

codetether_agent/tool/
websearch.rs

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