Skip to main content

microclaw_engine/tools/
web_search.rs

1use async_trait::async_trait;
2use serde_json::json;
3
4use super::{schema_object, Tool, ToolResult};
5use crate::internal::tool_runtime::web_search::SearchProviderConfig;
6use microclaw_core::llm_types::ToolDefinition;
7
8pub struct WebSearchTool {
9    default_timeout_secs: u64,
10    provider: SearchProviderConfig,
11}
12
13const MIN_TIMEOUT_SECS: u64 = 1;
14const MAX_TIMEOUT_SECS: u64 = 60;
15
16impl WebSearchTool {
17    pub fn new(default_timeout_secs: u64) -> Self {
18        Self {
19            default_timeout_secs,
20            provider: SearchProviderConfig::default(),
21        }
22    }
23
24    pub fn with_provider(mut self, provider: SearchProviderConfig) -> Self {
25        self.provider = provider;
26        self
27    }
28}
29
30#[async_trait]
31impl Tool for WebSearchTool {
32    fn name(&self) -> &str {
33        "web_search"
34    }
35
36    fn definition(&self) -> ToolDefinition {
37        ToolDefinition {
38            name: "web_search".into(),
39            description:
40                "Search the web (DuckDuckGo by default; SearXNG/Brave/Tavily when configured). \
41                 Returns titles, URLs, and snippets."
42                    .into(),
43            input_schema: schema_object(
44                json!({
45                    "query": {
46                        "type": "string",
47                        "description": "The search query"
48                    },
49                    "timeout_secs": {
50                        "type": "integer",
51                        "description": "Timeout in seconds (defaults to configured tool timeout budget)"
52                    }
53                }),
54                &["query"],
55            ),
56        }
57    }
58
59    async fn execute(&self, input: serde_json::Value) -> ToolResult {
60        let query = match parse_query(&input) {
61            Ok(q) => q,
62            Err(msg) => return ToolResult::error(msg),
63        };
64        let timeout_secs = resolve_timeout_secs(&input, self.default_timeout_secs);
65
66        match crate::internal::tool_runtime::web_search::search_with_provider(
67            &query,
68            &self.provider,
69            timeout_secs,
70        )
71        .await
72        {
73            Ok(hits) => {
74                if hits.is_empty() {
75                    ToolResult::success("No results found.".into())
76                } else {
77                    ToolResult::success(
78                        crate::internal::tool_runtime::web_search::format_search_hits(&hits),
79                    )
80                }
81            }
82            Err(e) => ToolResult::error(format!("Search failed: {e}")),
83        }
84    }
85}
86
87fn parse_query(input: &serde_json::Value) -> Result<String, String> {
88    let query = input
89        .get("query")
90        .and_then(|v| v.as_str())
91        .ok_or_else(|| "Missing required parameter: query".to_string())?;
92    let trimmed = query.trim();
93    if trimmed.is_empty() {
94        return Err("Missing required parameter: query".to_string());
95    }
96    Ok(trimmed.to_string())
97}
98
99fn resolve_timeout_secs(input: &serde_json::Value, default_timeout_secs: u64) -> u64 {
100    input
101        .get("timeout_secs")
102        .and_then(|v| v.as_u64())
103        .unwrap_or(default_timeout_secs)
104        .clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use serde_json::json;
111
112    #[test]
113    fn test_web_search_definition() {
114        let tool = WebSearchTool::new(15);
115        assert_eq!(tool.name(), "web_search");
116        let def = tool.definition();
117        assert_eq!(def.name, "web_search");
118        assert!(def.description.contains("DuckDuckGo"));
119        assert!(def.input_schema["properties"]["query"].is_object());
120        let required = def.input_schema["required"].as_array().unwrap();
121        assert!(required.iter().any(|v| v == "query"));
122    }
123
124    #[tokio::test]
125    async fn test_web_search_missing_query() {
126        let tool = WebSearchTool::new(15);
127        let result = tool.execute(json!({})).await;
128        assert!(result.is_error);
129        assert!(result.content.contains("Missing required parameter: query"));
130    }
131
132    #[tokio::test]
133    async fn test_web_search_null_query() {
134        let tool = WebSearchTool::new(15);
135        let result = tool.execute(json!({"query": null})).await;
136        assert!(result.is_error);
137        assert!(result.content.contains("Missing required parameter: query"));
138    }
139
140    #[tokio::test]
141    async fn test_web_search_empty_query() {
142        let tool = WebSearchTool::new(15);
143        let result = tool.execute(json!({"query": "   " })).await;
144        assert!(result.is_error);
145        assert!(result.content.contains("Missing required parameter: query"));
146    }
147
148    #[test]
149    fn test_resolve_timeout_secs_clamps_bounds() {
150        assert_eq!(resolve_timeout_secs(&json!({"timeout_secs": 0}), 15), 1);
151        assert_eq!(resolve_timeout_secs(&json!({"timeout_secs": 1000}), 15), 60);
152        assert_eq!(resolve_timeout_secs(&json!({"timeout_secs": 5}), 15), 5);
153        assert_eq!(resolve_timeout_secs(&json!({}), 120), 60);
154    }
155}