1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use parking_lot::RwLock;
4use regex::Regex;
5use serde::Deserialize;
6use serde_json::json;
7use std::collections::{HashMap, HashSet};
8use std::sync::OnceLock;
9use std::time::{Duration, Instant};
10
11const CACHE_TTL: Duration = Duration::from_secs(15 * 60);
12const DEFAULT_MAX_RESULTS: usize = 10;
13const ABSOLUTE_MAX_RESULTS: usize = 20;
14const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
15
16#[derive(Debug, Deserialize)]
17struct WebSearchArgs {
18 query: String,
19 #[serde(default)]
20 allowed_domains: Option<Vec<String>>,
21 #[serde(default)]
22 blocked_domains: Option<Vec<String>>,
23 #[serde(default)]
24 max_results: Option<usize>,
25}
26
27struct CachedSearch {
28 results: serde_json::Value,
29 expires_at: Instant,
30}
31
32static SEARCH_CACHE: OnceLock<RwLock<HashMap<String, CachedSearch>>> = OnceLock::new();
33
34fn search_cache() -> &'static RwLock<HashMap<String, CachedSearch>> {
35 SEARCH_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
36}
37
38static LINK_RE: OnceLock<Regex> = OnceLock::new();
41static TAG_RE: OnceLock<Regex> = OnceLock::new();
42static SNIPPET_RE: OnceLock<Regex> = OnceLock::new();
43static HREF_RE: OnceLock<Regex> = OnceLock::new();
44
45pub struct WebSearchTool;
46
47impl WebSearchTool {
48 pub fn new() -> Self {
49 Self
50 }
51
52 fn cache_key(
53 query: &str,
54 allowed: &Option<Vec<String>>,
55 blocked: &Option<Vec<String>>,
56 ) -> String {
57 let mut key = query.to_string();
58 if let Some(domains) = allowed {
59 key.push('|');
60 key.push_str(&domains.join(","));
61 }
62 key.push('|');
63 if let Some(domains) = blocked {
64 key.push_str(&domains.join(","));
65 }
66 key
67 }
68
69 fn try_cache(key: &str) -> Option<serde_json::Value> {
70 let cache = search_cache().read();
71 let entry = cache.get(key)?;
72 if entry.expires_at > Instant::now() {
73 Some(entry.results.clone())
74 } else {
75 None
76 }
77 }
78
79 fn put_cache(key: String, results: serde_json::Value) {
80 let mut cache = search_cache().write();
81 cache.insert(
82 key,
83 CachedSearch {
84 results,
85 expires_at: Instant::now() + CACHE_TTL,
86 },
87 );
88 }
89
90 fn decode_duckduckgo_url(raw: &str) -> Option<String> {
91 if let Ok(url) = url::Url::parse(raw) {
92 if let Some(value) = url
93 .query_pairs()
94 .find(|(key, _)| key == "uddg")
95 .map(|(_, value)| value.to_string())
96 {
97 return Some(value);
98 }
99 }
100
101 Some(raw.to_string())
102 }
103
104 fn host_of(url: &str) -> Option<String> {
105 url::Url::parse(url)
106 .ok()
107 .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase()))
108 }
109
110 fn domain_matches(host: &str, domain: &str) -> bool {
111 host == domain || host.ends_with(&format!(".{}", domain))
112 }
113}
114
115impl Default for WebSearchTool {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121#[async_trait]
122impl Tool for WebSearchTool {
123 fn name(&self) -> &str {
124 "WebSearch"
125 }
126
127 fn description(&self) -> &str {
128 "Search DuckDuckGo and return up to 10 filtered results (title, url, domain, snippet) with optional allow/block domain filters."
129 }
130
131 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
132 ToolClass::READONLY_PARALLEL.promotable()
133 }
134
135 fn parameters_schema(&self) -> serde_json::Value {
136 json!({
137 "type": "object",
138 "properties": {
139 "query": {
140 "type": "string",
141 "minLength": 2,
142 "description": "The search query to use"
143 },
144 "allowed_domains": {
145 "type": "array",
146 "items": { "type": "string" },
147 "description": "Only include results from these domains"
148 },
149 "blocked_domains": {
150 "type": "array",
151 "items": { "type": "string" },
152 "description": "Never include results from these domains"
153 },
154 "max_results": {
155 "type": "number",
156 "description": "Maximum results to return (default 10, max 20)"
157 }
158 },
159 "required": ["query"],
160 "additionalProperties": false
161 })
162 }
163
164 async fn invoke(
165 &self,
166 args: serde_json::Value,
167 ctx: ToolCtx,
168 ) -> Result<ToolOutcome, ToolError> {
169 let parsed: WebSearchArgs = serde_json::from_value(args)
170 .map_err(|e| ToolError::InvalidArguments(format!("Invalid WebSearch args: {}", e)))?;
171
172 let query = parsed.query.trim();
173 if query.len() < 2 {
174 return Err(ToolError::InvalidArguments(
175 "query must be at least 2 characters".to_string(),
176 ));
177 }
178
179 let allowed_domains = parsed.allowed_domains.filter(|v| !v.is_empty());
180 let blocked_domains = parsed.blocked_domains.filter(|v| !v.is_empty());
181
182 if allowed_domains.is_some() && blocked_domains.is_some() {
184 return Err(ToolError::InvalidArguments(
185 "Cannot specify both allowed_domains and blocked_domains in the same request"
186 .to_string(),
187 ));
188 }
189
190 let max_results = parsed
191 .max_results
192 .unwrap_or(DEFAULT_MAX_RESULTS)
193 .min(ABSOLUTE_MAX_RESULTS);
194
195 let cache_key = Self::cache_key(query, &allowed_domains, &blocked_domains);
197 if let Some(cached) = Self::try_cache(&cache_key) {
198 ctx.emit_tool_token("Using cached search results\n").await;
199 return Ok(ToolOutcome::Completed(ToolResult {
200 success: true,
201 result: cached.to_string(),
202 display_preference: Some("Collapsible".to_string()),
203 images: Vec::new(),
204 }));
205 }
206
207 ctx.emit_tool_token(format!("Searching: {}\n", query)).await;
208
209 let client = reqwest::Client::builder()
210 .timeout(Duration::from_secs(30))
211 .build()
212 .map_err(|e| ToolError::Execution(format!("Failed to build HTTP client: {}", e)))?;
213
214 let response = client
215 .get("https://duckduckgo.com/html/")
216 .header("User-Agent", USER_AGENT)
217 .query(&[("q", query)])
218 .send()
219 .await
220 .map_err(|e| ToolError::Execution(format!("Web search request failed: {}", e)))?;
221
222 let html = response.text().await.map_err(|e| {
223 ToolError::Execution(format!("Failed to decode web search body: {}", e))
224 })?;
225
226 if html.contains("Unfortunately, bots use DuckDuckGo too") || html.contains("anomaly-modal")
228 {
229 return Err(ToolError::Execution(
230 "Search blocked by anti-bot protection. Please retry.".to_string(),
231 ));
232 }
233
234 let allowed: Option<HashSet<String>> = allowed_domains.map(|domains| {
235 domains
236 .into_iter()
237 .map(|value| value.to_ascii_lowercase())
238 .collect()
239 });
240 let blocked: HashSet<String> = blocked_domains
241 .unwrap_or_default()
242 .into_iter()
243 .map(|value| value.to_ascii_lowercase())
244 .collect();
245
246 let link_re = LINK_RE.get_or_init(|| {
247 Regex::new(r#"<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>"#)
248 .expect("valid static regex")
249 });
250 let tag_re =
251 TAG_RE.get_or_init(|| Regex::new(r"(?is)<[^>]+>").expect("valid static regex"));
252 let snippet_re = SNIPPET_RE.get_or_init(|| {
253 Regex::new(r#"<a[^>]*class="result__snippet"[^>]*href="[^"]*"[^>]*>(.*?)</a>"#)
254 .expect("valid static regex")
255 });
256
257 let href_re =
259 HREF_RE.get_or_init(|| Regex::new(r#"href="([^"]+)""#).expect("valid static regex"));
260 let mut snippets: HashMap<String, String> = HashMap::new();
261 for cap in snippet_re.captures_iter(&html) {
262 if let Some(href_cap) = cap.get(0) {
263 let href_text = href_cap.as_str();
264 if let Some(url_match) = href_re.find(href_text) {
266 let raw_href = &href_text[url_match.start() + 6..url_match.end() - 1];
267 if let Some(decoded) = Self::decode_duckduckgo_url(raw_href) {
268 let snippet_text = cap
269 .get(1)
270 .map(|m| tag_re.replace_all(m.as_str(), "").trim().to_string())
271 .unwrap_or_default();
272 if !snippet_text.is_empty() {
273 snippets.insert(decoded, snippet_text);
274 }
275 }
276 }
277 }
278 }
279
280 let mut results = Vec::new();
281 for capture in link_re.captures_iter(&html) {
282 let Some(raw_url) = capture.get(1).map(|m| m.as_str()) else {
283 continue;
284 };
285 let Some(url) = Self::decode_duckduckgo_url(raw_url) else {
286 continue;
287 };
288 let Some(host) = Self::host_of(&url) else {
289 continue;
290 };
291
292 if blocked
293 .iter()
294 .any(|blocked_domain| Self::domain_matches(&host, blocked_domain))
295 {
296 continue;
297 }
298 if let Some(allowed_set) = &allowed {
299 if !allowed_set
300 .iter()
301 .any(|allowed_domain| Self::domain_matches(&host, allowed_domain))
302 {
303 continue;
304 }
305 }
306
307 let title = capture
308 .get(2)
309 .map(|m| tag_re.replace_all(m.as_str(), "").trim().to_string())
310 .unwrap_or_else(|| url.clone());
311
312 let snippet = snippets.get(&url).cloned().unwrap_or_default();
313
314 let mut result = json!({
315 "title": title,
316 "url": url,
317 "domain": host,
318 });
319 if !snippet.is_empty() {
320 result["snippet"] = json!(snippet);
321 }
322 results.push(result);
323
324 if results.len() >= max_results {
325 break;
326 }
327 }
328
329 ctx.emit_tool_token(format!(
330 "Found {} results for \"{}\"\n",
331 results.len(),
332 query
333 ))
334 .await;
335
336 let result_value = if results.is_empty() {
337 json!({
338 "query": parsed.query,
339 "results": [],
340 "note": "No results found for this query.",
341 })
342 } else {
343 json!({
344 "query": parsed.query,
345 "results": results,
346 })
347 };
348
349 Self::put_cache(cache_key, result_value.clone());
351
352 let mut result_string = result_value.to_string();
353 result_string.push_str("\n\nREMINDER: You MUST include a Sources section at the end of your response, listing all relevant URLs as markdown hyperlinks: [Title](URL)");
354
355 Ok(ToolOutcome::Completed(ToolResult {
356 success: true,
357 result: result_string,
358 display_preference: Some("Collapsible".to_string()),
359 images: Vec::new(),
360 }))
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn domain_matches_supports_subdomains() {
370 assert!(WebSearchTool::domain_matches("example.com", "example.com"));
371 assert!(WebSearchTool::domain_matches(
372 "docs.example.com",
373 "example.com"
374 ));
375 assert!(!WebSearchTool::domain_matches(
376 "notexample.com",
377 "example.com"
378 ));
379 assert!(!WebSearchTool::domain_matches(
380 "evil-example.com",
381 "example.com"
382 ));
383 }
384
385 #[test]
386 fn host_of_normalizes_case() {
387 let host = WebSearchTool::host_of("https://Docs.Example.Com/path").unwrap();
388 assert_eq!(host, "docs.example.com");
389 }
390
391 #[test]
392 fn decode_duckduckgo_url_extracts_uddg_param() {
393 let raw = "https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage&rut=whatever";
394 let decoded = WebSearchTool::decode_duckduckgo_url(raw).unwrap();
395 assert_eq!(decoded, "https://example.com/page");
396 }
397
398 #[test]
399 fn cache_key_is_stable() {
400 let k1 =
401 WebSearchTool::cache_key("rust", &Some(vec!["doc.rust-lang.org".to_string()]), &None);
402 let k2 =
403 WebSearchTool::cache_key("rust", &Some(vec!["doc.rust-lang.org".to_string()]), &None);
404 assert_eq!(k1, k2);
405
406 let k3 = WebSearchTool::cache_key("rust", &None, &Some(vec!["bad.com".to_string()]));
407 assert_ne!(k1, k3);
408 }
409}