Skip to main content

mermaid_cli/providers/tool/
web.rs

1//! Web tools: `web_search` and `web_fetch`.
2//!
3//! Both delegate to `web_client::WebSearchClient` — a thin HTTP
4//! client for Ollama Cloud's web API (bearer-token path, via
5//! `OLLAMA_API_KEY`). The wrapper's job is cancellation plumbing +
6//! multi-query fan-out.
7
8use 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
18/// `web_search` — query Ollama Cloud's web-search endpoint. Accepts a
19/// single `{query, max_results}` OR a list of `{queries: [{query,
20/// max_results}]}` for parallel fan-out.
21pub 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        // Cap the aggregate output. Per-result content is already truncated to
129        // WEB_CONTENT_MAX_CHARS, but many results across many queries can still
130        // bloat context (and memory) past what any single result's cap bounds (#28).
131        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
165/// `web_fetch` — retrieve a URL's readable content (Ollama Cloud's
166/// fetch endpoint). Single URL, single response.
167pub 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
309/// Reject obviously-unsafe fetch URLs client-side before handing them to the
310/// (server-side) Ollama fetch API: only `http`/`https`, and no loopback /
311/// link-local / private / metadata hosts. Defense-in-depth against SSRF-style
312/// abuse via model-supplied URLs.
313fn 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    // Block every non-public host (loopback, RFC-1918/ULA, link-local incl.
334    // cloud metadata 169.254.169.254, CGNAT, unspecified). The shared
335    // classifier covers the IPv4-mapped-IPv6 / ULA / link-local-IPv6 / CGNAT
336    // forms a hand-rolled IPv4 check missed. Lexical only: a DNS name resolving
337    // to an internal address can't be caught here (the fetch is performed
338    // server-side by Ollama, not from this process).
339    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        // #9: scheme + internal-host guards.
349        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            // #27/#80: IPv6/CGNAT bypasses the old IPv4-centric blocklist missed.
359            "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        // #90: a single call can't request unbounded fan-out.
421        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        // Exactly at the cap is still accepted.
428        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}