Skip to main content

agent_code_lib/tools/
web_search.rs

1//! WebSearch tool: search the web for information.
2
3use 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        // Use a search API if configured, otherwise fall back to a
59        // simple web fetch of a search engine results page.
60        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        // Extract search results from HTML (simplified parsing).
84        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    // Simple extraction: look for result link patterns in DuckDuckGo HTML.
113    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}