agent_code_lib/tools/
web_search.rs1use async_trait::async_trait;
4use serde_json::json;
5use std::time::Duration;
6
7use super::{Tool, ToolContext, ToolResult};
8use crate::error::ToolError;
9
10pub struct WebSearchTool;
11
12#[async_trait]
13impl Tool for WebSearchTool {
14 fn name(&self) -> &'static str {
15 "WebSearch"
16 }
17
18 fn description(&self) -> &'static str {
19 "Search the web for information using a search query."
20 }
21
22 fn input_schema(&self) -> serde_json::Value {
23 json!({
24 "type": "object",
25 "required": ["query"],
26 "properties": {
27 "query": {
28 "type": "string",
29 "description": "Search query"
30 },
31 "max_results": {
32 "type": "integer",
33 "description": "Maximum number of results (default: 5)",
34 "default": 5
35 }
36 }
37 })
38 }
39
40 fn is_read_only(&self) -> bool {
41 true
42 }
43
44 fn is_concurrency_safe(&self) -> bool {
45 true
46 }
47
48 async fn call(
49 &self,
50 input: serde_json::Value,
51 ctx: &ToolContext,
52 ) -> Result<ToolResult, ToolError> {
53 let query = input
54 .get("query")
55 .and_then(|v| v.as_str())
56 .ok_or_else(|| ToolError::InvalidInput("'query' is required".into()))?;
57
58 let encoded = urlencoded(query);
61 let search_url = format!("https://html.duckduckgo.com/html/?q={encoded}");
62
63 let client = reqwest::Client::builder()
64 .timeout(Duration::from_secs(30))
65 .user_agent("agent-code/0.2")
66 .build()
67 .map_err(|e| ToolError::ExecutionFailed(format!("HTTP client error: {e}")))?;
68
69 let response = tokio::select! {
70 r = client.get(&search_url).send() => {
71 r.map_err(|e| ToolError::ExecutionFailed(format!("Search failed: {e}")))?
72 }
73 _ = ctx.cancel.cancelled() => {
74 return Err(ToolError::Cancelled);
75 }
76 };
77
78 let body = response
79 .text()
80 .await
81 .map_err(|e| ToolError::ExecutionFailed(format!("Read failed: {e}")))?;
82
83 let results = extract_search_results(&body, 5);
85
86 if results.is_empty() {
87 Ok(ToolResult::success(format!(
88 "No results found for: {query}"
89 )))
90 } else {
91 let formatted: Vec<String> = results
92 .iter()
93 .enumerate()
94 .map(|(i, r)| format!("{}. {}\n {}", i + 1, r.title, r.snippet))
95 .collect();
96 Ok(ToolResult::success(format!(
97 "Search results for: {query}\n\n{}",
98 formatted.join("\n\n")
99 )))
100 }
101 }
102}
103
104struct SearchResult {
105 title: String,
106 snippet: String,
107}
108
109fn extract_search_results(html: &str, max: usize) -> Vec<SearchResult> {
110 let mut results = Vec::new();
111
112 for segment in html.split("class=\"result__a\"").skip(1).take(max) {
114 let title = segment
115 .split('>')
116 .nth(1)
117 .and_then(|s| s.split('<').next())
118 .unwrap_or("")
119 .trim()
120 .to_string();
121
122 let snippet = segment
123 .split("class=\"result__snippet\"")
124 .nth(1)
125 .and_then(|s| s.split('>').nth(1))
126 .and_then(|s| s.split('<').next())
127 .unwrap_or("")
128 .trim()
129 .to_string();
130
131 if !title.is_empty() {
132 results.push(SearchResult { title, snippet });
133 }
134 }
135
136 results
137}
138
139fn urlencoded(s: &str) -> String {
140 s.chars()
141 .map(|c| match c {
142 ' ' => '+'.to_string(),
143 c if c.is_alphanumeric() || "-_.~".contains(c) => c.to_string(),
144 c => format!("%{:02X}", c as u32),
145 })
146 .collect()
147}