codetether_agent/tool/
websearch.rs1use 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 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 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 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}