mermaid_cli/providers/tool/
web.rs1use std::sync::Arc;
9
10use async_trait::async_trait;
11
12use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
13
14use super::super::ctx::{ExecContext, ProgressEvent};
15use super::ToolExecutor;
16use super::web_client::{WebFetchResult, WebSearchClient};
17
18pub struct WebSearchTool {
22 client: Arc<WebSearchClient>,
23}
24
25impl WebSearchTool {
26 pub fn new(api_key: String) -> Self {
27 Self {
28 client: Arc::new(WebSearchClient::new(api_key)),
29 }
30 }
31}
32
33#[async_trait]
34impl ToolExecutor for WebSearchTool {
35 fn name(&self) -> &'static str {
36 "web_search"
37 }
38
39 fn schema(&self) -> ToolDefinition {
40 ToolDefinition {
41 name: "web_search".to_string(),
42 description:
43 "Search the web via Ollama Cloud's search API. Takes either a single `query` + `max_results`, or an array of `queries` for parallel fan-out."
44 .to_string(),
45 input_schema: serde_json::json!({
46 "type": "object",
47 "properties": {
48 "query": { "type": "string" },
49 "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 },
50 "queries": {
51 "type": "array",
52 "items": {
53 "type": "object",
54 "properties": {
55 "query": { "type": "string" },
56 "max_results": { "type": "integer", "minimum": 1, "maximum": 10 }
57 },
58 "required": ["query"]
59 }
60 }
61 }
62 }),
63 }
64 }
65
66 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
67 let queries = match parse_queries(&args) {
68 Ok(q) => q,
69 Err(e) => return ToolOutcome::error(e, 0.0),
70 };
71 if queries.is_empty() {
72 return ToolOutcome::error("web_search requires at least one query", 0.0);
73 }
74 if let Some(blocked) = super::policy_gate::gate_external(
75 &ctx,
76 "web_search",
77 crate::runtime::ToolCategory::Web,
78 format!("web_search ({} queries)", queries.len()),
79 &args,
80 )
81 .await
82 {
83 return blocked;
84 }
85
86 let start = std::time::Instant::now();
87 let mut combined = String::new();
88 let mut result_count = 0usize;
89 let mut sources = Vec::new();
90 for (idx, (query, count)) in queries.iter().enumerate() {
91 let _ = ctx
92 .progress
93 .send(ProgressEvent::Status(format!(
94 "searching {}/{}: {}",
95 idx + 1,
96 queries.len(),
97 query
98 )))
99 .await;
100
101 let search = self.client.search_query(query, *count);
102 tokio::select! {
103 biased;
104 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
105 result = search => {
106 match result {
107 Ok(results) => {
108 result_count += results.len();
109 sources.extend(results.iter().map(|result| result.url.clone()));
110 let formatted = self.client.format_results(&results);
111 if queries.len() > 1 {
112 combined.push_str(&format!("=== query: {} ===\n{}\n\n", query, formatted));
113 } else {
114 combined = formatted;
115 }
116 },
117 Err(e) => {
118 return ToolOutcome::error(
119 format!("web_search({}): {}", query, e),
120 start.elapsed().as_secs_f64(),
121 );
122 },
123 }
124 }
125 }
126 }
127
128 let combined = crate::utils::truncate_content(
132 &combined,
133 crate::constants::WEB_SEARCH_AGGREGATE_MAX_CHARS,
134 );
135
136 let duration_secs = start.elapsed().as_secs_f64();
137 let requested_count = queries.iter().map(|(_, count)| *count).sum();
138 let query_texts = queries.iter().map(|(query, _)| query.clone()).collect();
139 ToolOutcome::success(
140 combined,
141 format!(
142 "{} {} returned",
143 result_count,
144 if result_count == 1 {
145 "result"
146 } else {
147 "results"
148 }
149 ),
150 duration_secs,
151 )
152 .with_metadata(ToolRunMetadata {
153 detail: ToolMetadata::WebSearch {
154 queries: query_texts,
155 requested_count,
156 result_count,
157 sources,
158 },
159 result_count: Some(result_count),
160 ..ToolRunMetadata::default()
161 })
162 }
163}
164
165pub struct WebFetchTool {
168 client: Arc<WebSearchClient>,
169}
170
171impl WebFetchTool {
172 pub fn new(api_key: String) -> Self {
173 Self {
174 client: Arc::new(WebSearchClient::new(api_key)),
175 }
176 }
177}
178
179#[async_trait]
180impl ToolExecutor for WebFetchTool {
181 fn name(&self) -> &'static str {
182 "web_fetch"
183 }
184
185 fn schema(&self) -> ToolDefinition {
186 ToolDefinition {
187 name: "web_fetch".to_string(),
188 description: "Retrieve a single URL's main content as text (Ollama Cloud fetch API)."
189 .to_string(),
190 input_schema: serde_json::json!({
191 "type": "object",
192 "properties": { "url": { "type": "string" } },
193 "required": ["url"]
194 }),
195 }
196 }
197
198 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
199 let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
200 return ToolOutcome::error("web_fetch requires 'url' (string)", 0.0);
201 };
202 if let Err(reason) = validate_fetch_url(url) {
203 return ToolOutcome::error(format!("web_fetch: {reason}"), 0.0);
204 }
205 if let Some(blocked) = super::policy_gate::gate_external(
206 &ctx,
207 "web_fetch",
208 crate::runtime::ToolCategory::Web,
209 format!("web_fetch {}", url),
210 &args,
211 )
212 .await
213 {
214 return blocked;
215 }
216 let start = std::time::Instant::now();
217 let fetch = self.client.fetch_url(url);
218
219 tokio::select! {
220 biased;
221 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
222 result = fetch => match result {
223 Ok(page) => {
224 let output = format_fetch(url, &page);
225 let duration_secs = start.elapsed().as_secs_f64();
226 let line_count = output.lines().count();
227 let byte_count = output.len();
228 let title = if page.title.is_empty() {
229 None
230 } else {
231 Some(page.title)
232 };
233 ToolOutcome::success(
234 output,
235 format!("{} {} fetched", line_count, if line_count == 1 { "line" } else { "lines" }),
236 duration_secs,
237 )
238 .with_metadata(ToolRunMetadata {
239 detail: ToolMetadata::WebFetch {
240 url: url.to_string(),
241 title,
242 line_count,
243 byte_count,
244 },
245 line_count: Some(line_count),
246 byte_count: Some(byte_count),
247 ..ToolRunMetadata::default()
248 })
249 },
250 Err(e) => ToolOutcome::error(
251 format!("web_fetch({}): {}", url, e),
252 start.elapsed().as_secs_f64(),
253 ),
254 },
255 }
256 }
257}
258
259fn format_fetch(url: &str, page: &WebFetchResult) -> String {
260 let title = if page.title.is_empty() {
261 "(no title)"
262 } else {
263 page.title.as_str()
264 };
265 format!("# {}\n\nURL: {}\n\n{}", title, url, page.content)
266}
267
268fn parse_queries(args: &serde_json::Value) -> Result<Vec<(String, usize)>, String> {
269 if let Some(arr) = args.get("queries").and_then(|v| v.as_array()) {
270 if arr.len() > crate::constants::MAX_BATCH_TOOL_ITEMS {
271 return Err(format!(
272 "web_search: too many queries ({}); cap is {} per call — split the request",
273 arr.len(),
274 crate::constants::MAX_BATCH_TOOL_ITEMS
275 ));
276 }
277 let mut out = Vec::with_capacity(arr.len());
278 for v in arr {
279 let Some(obj) = v.as_object() else {
280 return Err(
281 "web_search: 'queries' must be an array of {query, max_results}".to_string(),
282 );
283 };
284 let Some(query) = obj.get("query").and_then(|x| x.as_str()) else {
285 return Err("web_search: each query entry needs 'query' (string)".to_string());
286 };
287 let count = obj
288 .get("max_results")
289 .or_else(|| obj.get("result_count"))
290 .and_then(|x| x.as_u64())
291 .unwrap_or(5)
292 .clamp(1, 10) as usize;
293 out.push((query.to_string(), count));
294 }
295 return Ok(out);
296 }
297 if let Some(query) = args.get("query").and_then(|v| v.as_str()) {
298 let count = args
299 .get("max_results")
300 .or_else(|| args.get("result_count"))
301 .and_then(|v| v.as_u64())
302 .unwrap_or(5)
303 .clamp(1, 10) as usize;
304 return Ok(vec![(query.to_string(), count)]);
305 }
306 Err("web_search requires 'query' (string) or 'queries' (array)".to_string())
307}
308
309fn validate_fetch_url(url: &str) -> Result<(), String> {
314 let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
315 match parsed.scheme() {
316 "http" | "https" => {},
317 other => {
318 return Err(format!(
319 "unsupported URL scheme '{other}' (only http/https allowed)"
320 ));
321 },
322 }
323 let host = parsed
324 .host_str()
325 .ok_or_else(|| "URL has no host".to_string())?;
326 if is_blocked_host(host) {
327 return Err(format!("refusing to fetch internal/loopback host '{host}'"));
328 }
329 Ok(())
330}
331
332fn is_blocked_host(host: &str) -> bool {
333 crate::utils::classify_host(host).is_internal()
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn validate_fetch_url_blocks_unsafe_targets() {
348 for bad in [
350 "file:///etc/passwd",
351 "ftp://example.com/x",
352 "http://localhost/admin",
353 "http://127.0.0.1:8080",
354 "http://169.254.169.254/latest/meta-data/",
355 "http://10.0.0.5/",
356 "http://192.168.1.1/",
357 "http://[::1]/",
358 "http://[::ffff:169.254.169.254]/latest/meta-data/",
360 "http://[fc00::1]/",
361 "http://[fe80::1]/",
362 "http://100.100.100.200/",
363 "not a url",
364 ] {
365 assert!(
366 validate_fetch_url(bad).is_err(),
367 "expected reject for {bad:?}",
368 );
369 }
370 for good in [
371 "https://example.com",
372 "http://example.com/page?x=1",
373 "https://docs.rs/serde",
374 ] {
375 assert!(
376 validate_fetch_url(good).is_ok(),
377 "expected accept for {good:?}",
378 );
379 }
380 }
381
382 #[test]
383 fn parse_queries_single_form() {
384 let args = serde_json::json!({"query": "rust async", "max_results": 3});
385 let q = parse_queries(&args).unwrap();
386 assert_eq!(q.len(), 1);
387 assert_eq!(q[0].0, "rust async");
388 assert_eq!(q[0].1, 3);
389 }
390
391 #[test]
392 fn parse_queries_array_form() {
393 let args = serde_json::json!({"queries": [
394 {"query": "a", "max_results": 2},
395 {"query": "b", "result_count": 5},
396 ]});
397 let q = parse_queries(&args).unwrap();
398 assert_eq!(q.len(), 2);
399 assert_eq!(q[1].1, 5);
400 }
401
402 #[test]
403 fn parse_queries_missing_errors() {
404 let args = serde_json::json!({});
405 assert!(parse_queries(&args).is_err());
406 }
407
408 #[test]
409 fn parse_queries_clamps_count() {
410 let args = serde_json::json!({"query": "q", "max_results": 999});
411 let q = parse_queries(&args).unwrap();
412 assert_eq!(q[0].1, 10);
413 let args = serde_json::json!({"query": "q", "max_results": 0});
414 let q = parse_queries(&args).unwrap();
415 assert_eq!(q[0].1, 1);
416 }
417
418 #[test]
419 fn parse_queries_rejects_excess_fan_out() {
420 let many: Vec<_> = (0..crate::constants::MAX_BATCH_TOOL_ITEMS + 1)
422 .map(|i| serde_json::json!({"query": format!("q{i}")}))
423 .collect();
424 let args = serde_json::json!({ "queries": many });
425 assert!(parse_queries(&args).is_err());
426
427 let at_cap: Vec<_> = (0..crate::constants::MAX_BATCH_TOOL_ITEMS)
429 .map(|i| serde_json::json!({"query": format!("q{i}")}))
430 .collect();
431 let args = serde_json::json!({ "queries": at_cap });
432 assert_eq!(
433 parse_queries(&args).unwrap().len(),
434 crate::constants::MAX_BATCH_TOOL_ITEMS
435 );
436 }
437}