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