microclaw_engine/tools/
web_search.rs1use 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}