Skip to main content

mermaid_cli/providers/tool/
web.rs

1//! Web tools: `web_search` and `web_fetch`.
2//!
3//! Each tool holds a pluggable backend (`web_client::SearchProvider` /
4//! `FetchProvider`) selected from `[web]` config: `web_fetch` defaults to a
5//! native in-process fetch (no key), `web_search` to Ollama Cloud or a
6//! self-hosted SearXNG. This tool layer owns cancellation plumbing, the SSRF
7//! guard, and multi-query fan-out; the backend owns the transport.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12
13use crate::app::{FetchBackend, SearchBackend, WebConfig};
14use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
15
16use super::super::ctx::{ExecContext, ProgressEvent};
17use super::ToolExecutor;
18use super::web_client::{
19    FetchProvider, ManagedSearxngBackend, NativeFetchClient, OllamaWebClient, SearchProvider,
20    SearxngClient, WebFetchResult, format_results,
21};
22
23/// Build the `web_fetch` tool for the configured backend. `native` always
24/// yields a tool; `ollama` yields one only when `OLLAMA_API_KEY` resolves
25/// (otherwise the tool would 401 on every call, so we don't register it).
26pub fn web_fetch_tool(web: &WebConfig) -> Option<WebFetchTool> {
27    match web.fetch_backend {
28        FetchBackend::Native => Some(WebFetchTool::native()),
29        FetchBackend::Ollama => {
30            crate::utils::resolve_api_key("OLLAMA_API_KEY", None).map(WebFetchTool::ollama)
31        },
32    }
33}
34
35/// Build the `web_search` tool for the configured backend. `auto` (the default)
36/// and `searxng` always yield a tool; `ollama` yields one only when
37/// `OLLAMA_API_KEY` resolves.
38pub fn web_search_tool(web: &WebConfig) -> Option<WebSearchTool> {
39    match web.search_backend {
40        // Zero-config default: Ollama Cloud when a key is present, otherwise an
41        // auto-managed local SearXNG (started lazily on the first search).
42        SearchBackend::Auto => Some(
43            crate::utils::resolve_api_key("OLLAMA_API_KEY", None)
44                .map(WebSearchTool::ollama)
45                .unwrap_or_else(WebSearchTool::managed_searxng),
46        ),
47        SearchBackend::Ollama => {
48            crate::utils::resolve_api_key("OLLAMA_API_KEY", None).map(WebSearchTool::ollama)
49        },
50        SearchBackend::Searxng => Some(WebSearchTool::searxng(web.searxng_url.clone())),
51    }
52}
53
54/// `web_search` — query the configured search backend. Accepts a single
55/// `{query, max_results}` OR a list of `{queries: [{query, max_results}]}` for
56/// parallel fan-out.
57pub struct WebSearchTool {
58    backend: Arc<dyn SearchProvider>,
59}
60
61impl WebSearchTool {
62    /// Search via Ollama Cloud (bearer `OLLAMA_API_KEY`).
63    pub fn ollama(api_key: String) -> Self {
64        Self {
65            backend: Arc::new(OllamaWebClient::new(api_key)),
66        }
67    }
68
69    /// Search via a self-hosted SearXNG instance at `base_url`.
70    pub fn searxng(base_url: String) -> Self {
71        Self {
72            backend: Arc::new(SearxngClient::new(base_url)),
73        }
74    }
75
76    /// Search via an auto-managed local SearXNG container (zero-config default).
77    pub fn managed_searxng() -> Self {
78        Self {
79            backend: Arc::new(ManagedSearxngBackend),
80        }
81    }
82}
83
84#[async_trait]
85impl ToolExecutor for WebSearchTool {
86    fn name(&self) -> &'static str {
87        "web_search"
88    }
89
90    fn schema(&self) -> ToolDefinition {
91        ToolDefinition {
92            name: "web_search".to_string(),
93            description:
94                "Search the web. Takes either a single `query` + `max_results`, or an array of `queries` for parallel fan-out."
95                    .to_string(),
96            input_schema: serde_json::json!({
97                "type": "object",
98                "properties": {
99                    "query": { "type": "string" },
100                    "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 },
101                    "queries": {
102                        "type": "array",
103                        "items": {
104                            "type": "object",
105                            "properties": {
106                                "query": { "type": "string" },
107                                "max_results": { "type": "integer", "minimum": 1, "maximum": 10 }
108                            },
109                            "required": ["query"]
110                        }
111                    }
112                }
113            }),
114        }
115    }
116
117    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
118        let queries = match parse_queries(&args) {
119            Ok(q) => q,
120            Err(e) => return ToolOutcome::error(e, 0.0),
121        };
122        if queries.is_empty() {
123            return ToolOutcome::error("web_search requires at least one query", 0.0);
124        }
125        if let Some(blocked) = super::policy_gate::gate_external(
126            &ctx,
127            "web_search",
128            crate::runtime::ToolCategory::Web,
129            format!("web_search ({} queries)", queries.len()),
130            &args,
131        )
132        .await
133        {
134            return blocked;
135        }
136
137        let start = std::time::Instant::now();
138        let mut combined = String::new();
139        let mut result_count = 0usize;
140        let mut sources = Vec::new();
141        let mut errors: Vec<String> = Vec::new();
142        for (idx, (query, count)) in queries.iter().enumerate() {
143            let _ = ctx
144                .progress
145                .send(ProgressEvent::Status(format!(
146                    "searching {}/{}: {}",
147                    idx + 1,
148                    queries.len(),
149                    query
150                )))
151                .await;
152
153            let search = self.backend.search(query, *count);
154            let result = tokio::select! {
155                biased;
156                _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
157                result = search => result,
158            };
159            // A single query returning nothing or erroring does NOT abort the
160            // batch — record it and carry on so the other queries' results
161            // survive (a partial answer beats none).
162            let section = match result {
163                Ok(results) => {
164                    result_count += results.len();
165                    sources.extend(results.iter().map(|result| result.url.clone()));
166                    if results.is_empty() {
167                        "[SEARCH_RESULTS]\n(no results found)\n[/SEARCH_RESULTS]\n".to_string()
168                    } else {
169                        format_results(&results)
170                    }
171                },
172                Err(e) => {
173                    errors.push(format!("{query}: {e}"));
174                    format!("(search failed: {e})\n")
175                },
176            };
177            if queries.len() > 1 {
178                combined.push_str(&format!("=== query: {query} ===\n{section}\n\n"));
179            } else {
180                combined = section;
181            }
182        }
183
184        // Only a total failure — every query hit a backend error — is a tool
185        // error. An empty-but-reachable search, or a partial success, returns
186        // normally so the model sees what did come back.
187        if errors.len() == queries.len() {
188            return ToolOutcome::error(
189                format!("web_search failed: {}", errors.join("; ")),
190                start.elapsed().as_secs_f64(),
191            );
192        }
193
194        // Cap the aggregate output. Per-result content is already truncated to
195        // WEB_CONTENT_MAX_CHARS, but many results across many queries can still
196        // bloat context (and memory) past what any single result's cap bounds (#28).
197        let combined = crate::utils::truncate_content(
198            &combined,
199            crate::constants::WEB_SEARCH_AGGREGATE_MAX_CHARS,
200        );
201
202        let duration_secs = start.elapsed().as_secs_f64();
203        let requested_count = queries.iter().map(|(_, count)| *count).sum();
204        let query_texts = queries.iter().map(|(query, _)| query.clone()).collect();
205        ToolOutcome::success(
206            combined,
207            format!(
208                "{} {} returned",
209                result_count,
210                if result_count == 1 {
211                    "result"
212                } else {
213                    "results"
214                }
215            ),
216            duration_secs,
217        )
218        .with_metadata(ToolRunMetadata {
219            detail: ToolMetadata::WebSearch {
220                queries: query_texts,
221                requested_count,
222                result_count,
223                sources,
224            },
225            result_count: Some(result_count),
226            ..ToolRunMetadata::default()
227        })
228    }
229}
230
231/// `web_fetch` — retrieve a URL's readable content as markdown. Single URL,
232/// single response. Native by default (fetches + converts in-process, no key);
233/// can be backed by Ollama Cloud instead.
234pub struct WebFetchTool {
235    backend: Arc<dyn FetchProvider>,
236}
237
238impl WebFetchTool {
239    /// Fetch the URL in-process and convert its HTML to markdown (no API key).
240    pub fn native() -> Self {
241        Self {
242            backend: Arc::new(NativeFetchClient::new()),
243        }
244    }
245
246    /// Fetch via Ollama Cloud's server-side `/api/web_fetch`.
247    pub fn ollama(api_key: String) -> Self {
248        Self {
249            backend: Arc::new(OllamaWebClient::new(api_key)),
250        }
251    }
252}
253
254#[async_trait]
255impl ToolExecutor for WebFetchTool {
256    fn name(&self) -> &'static str {
257        "web_fetch"
258    }
259
260    fn schema(&self) -> ToolDefinition {
261        ToolDefinition {
262            name: "web_fetch".to_string(),
263            description: "Retrieve a single URL's main content as markdown.".to_string(),
264            input_schema: serde_json::json!({
265                "type": "object",
266                "properties": { "url": { "type": "string" } },
267                "required": ["url"]
268            }),
269        }
270    }
271
272    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
273        let Some(url) = args.get("url").and_then(|v| v.as_str()) else {
274            return ToolOutcome::error("web_fetch requires 'url' (string)", 0.0);
275        };
276        if let Err(reason) = validate_fetch_url(url) {
277            return ToolOutcome::error(format!("web_fetch: {reason}"), 0.0);
278        }
279        if let Some(blocked) = super::policy_gate::gate_external(
280            &ctx,
281            "web_fetch",
282            crate::runtime::ToolCategory::Web,
283            format!("web_fetch {}", url),
284            &args,
285        )
286        .await
287        {
288            return blocked;
289        }
290        let start = std::time::Instant::now();
291        let fetch = self.backend.fetch(url);
292
293        tokio::select! {
294            biased;
295            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
296            result = fetch => match result {
297                Ok(page) => {
298                    let output = format_fetch(url, &page);
299                    let duration_secs = start.elapsed().as_secs_f64();
300                    let line_count = output.lines().count();
301                    let byte_count = output.len();
302                    let title = if page.title.is_empty() {
303                        None
304                    } else {
305                        Some(page.title)
306                    };
307                    ToolOutcome::success(
308                        output,
309                        format!("{} {} fetched", line_count, if line_count == 1 { "line" } else { "lines" }),
310                        duration_secs,
311                    )
312                    .with_metadata(ToolRunMetadata {
313                        detail: ToolMetadata::WebFetch {
314                            url: url.to_string(),
315                            title,
316                            line_count,
317                            byte_count,
318                        },
319                        line_count: Some(line_count),
320                        byte_count: Some(byte_count),
321                        ..ToolRunMetadata::default()
322                    })
323                },
324                Err(e) => ToolOutcome::error(
325                    format!("web_fetch({}): {}", url, e),
326                    start.elapsed().as_secs_f64(),
327                ),
328            },
329        }
330    }
331}
332
333/// Cap on a single `web_fetch` body (#F46). The raw fetch is bounded only by the
334/// 16 MB HTTP body limit, so without this one URL could dump megabytes into model
335/// context. A full page warrants more room than a `web_search` snippet
336/// (`WEB_CONTENT_MAX_CHARS`), so this mirrors web_search's per-call aggregate
337/// budget. Applied as a byte cap; truncation is char-boundary safe.
338const WEB_FETCH_MAX_CHARS: usize = crate::constants::WEB_SEARCH_AGGREGATE_MAX_CHARS;
339
340/// Truncate a fetched page body to `WEB_FETCH_MAX_CHARS` bytes, char-boundary
341/// safe, appending a marker — consistent with how `web_search` bounds the
342/// content it returns (#F46). Borrows when no truncation is needed.
343fn cap_fetch_content(content: &str) -> std::borrow::Cow<'_, str> {
344    if content.len() <= WEB_FETCH_MAX_CHARS {
345        return std::borrow::Cow::Borrowed(content);
346    }
347    let cut = content.floor_char_boundary(WEB_FETCH_MAX_CHARS);
348    std::borrow::Cow::Owned(format!("{}\n\n...[content truncated]", &content[..cut]))
349}
350
351fn format_fetch(url: &str, page: &WebFetchResult) -> String {
352    let title = if page.title.is_empty() {
353        "(no title)"
354    } else {
355        page.title.as_str()
356    };
357    let content = cap_fetch_content(&page.content);
358    format!("# {}\n\nURL: {}\n\n{}", title, url, content)
359}
360
361fn parse_queries(args: &serde_json::Value) -> Result<Vec<(String, usize)>, String> {
362    if let Some(arr) = args.get("queries").and_then(|v| v.as_array()) {
363        if arr.len() > crate::constants::MAX_BATCH_TOOL_ITEMS {
364            return Err(format!(
365                "web_search: too many queries ({}); cap is {} per call — split the request",
366                arr.len(),
367                crate::constants::MAX_BATCH_TOOL_ITEMS
368            ));
369        }
370        let mut out = Vec::with_capacity(arr.len());
371        for v in arr {
372            let Some(obj) = v.as_object() else {
373                return Err(
374                    "web_search: 'queries' must be an array of {query, max_results}".to_string(),
375                );
376            };
377            let Some(query) = obj.get("query").and_then(|x| x.as_str()) else {
378                return Err("web_search: each query entry needs 'query' (string)".to_string());
379            };
380            let count = obj
381                .get("max_results")
382                .or_else(|| obj.get("result_count"))
383                .and_then(|x| x.as_u64())
384                .unwrap_or(5)
385                .clamp(1, 10) as usize;
386            out.push((query.to_string(), count));
387        }
388        return Ok(out);
389    }
390    if let Some(query) = args.get("query").and_then(|v| v.as_str()) {
391        let count = args
392            .get("max_results")
393            .or_else(|| args.get("result_count"))
394            .and_then(|v| v.as_u64())
395            .unwrap_or(5)
396            .clamp(1, 10) as usize;
397        return Ok(vec![(query.to_string(), count)]);
398    }
399    Err("web_search requires 'query' (string) or 'queries' (array)".to_string())
400}
401
402/// Reject obviously-unsafe fetch URLs before the backend runs: only
403/// `http`/`https`, and no loopback / link-local / private / metadata hosts.
404/// For the native backend this is the primary SSRF boundary (the request
405/// leaves from this process, so `web_client::guard_resolved_ips` also checks
406/// the resolved addresses); for the Ollama backend it's defense-in-depth ahead
407/// of Ollama's own server-side fetch. Guards against model-supplied URLs.
408fn validate_fetch_url(url: &str) -> Result<(), String> {
409    let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
410    match parsed.scheme() {
411        "http" | "https" => {},
412        other => {
413            return Err(format!(
414                "unsupported URL scheme '{other}' (only http/https allowed)"
415            ));
416        },
417    }
418    let host = parsed
419        .host_str()
420        .ok_or_else(|| "URL has no host".to_string())?;
421    if is_blocked_host(host) {
422        return Err(format!("refusing to fetch internal/loopback host '{host}'"));
423    }
424    Ok(())
425}
426
427/// Cloud-metadata DNS hostnames that resolve (inside the relevant cloud) to a
428/// link-local metadata IP — `169.254.169.254` and friends — but are LEXICALLY
429/// public, so the IP-only `classify_host` waves them through as
430/// [`crate::utils::HostClass::Public`]. We block them by name as well. Matched
431/// after lowercasing + trimming any surrounding `[]` and trailing FQDN dot.
432const METADATA_HOSTNAMES: &[&str] = &[
433    "metadata.google.internal",   // GCP (canonical)
434    "metadata.goog",              // GCP (alternate)
435    "metadata",                   // GCP/Azure short name (http://metadata/ responds)
436    "instance-data",              // AWS (cloud-init alias)
437    "instance-data.ec2.internal", // AWS
438];
439
440/// F57: client-side SSRF denylist for `web_fetch`.
441///
442/// For the NATIVE backend the request originates from this process, so this
443/// lexical check plus `web_client::guard_resolved_ips` (which classifies the
444/// resolved addresses) is the authoritative boundary — modulo the
445/// DNS-rebinding TOCTOU that no pre-connect check can fully close.
446///
447/// For the OLLAMA backend the URL is POSTed to Ollama's server-side
448/// `/api/web_fetch` and the in-process client only ever connects to
449/// `ollama.com`; there the authoritative boundary is server-side (Ollama) and
450/// this check is defense-in-depth so a model can't trivially aim the server at
451/// an obvious internal target.
452///
453/// Either way we reject:
454/// - every non-public IP form via the shared `classify_host` (loopback,
455///   RFC-1918/ULA, link-local incl. `169.254.169.254`, CGNAT, unspecified
456///   `0.0.0.0`/`::`, plus the IPv4-mapped-IPv6 / ULA / link-local-IPv6 / `[::1]`
457///   forms a hand-rolled IPv4 check would miss); and
458/// - the well-known cloud-metadata HOSTNAMES (`metadata.google.internal`, …)
459///   that are lexically public but front a metadata service.
460fn is_blocked_host(host: &str) -> bool {
461    let normalized = host
462        .trim_start_matches('[')
463        .trim_end_matches(']')
464        .trim_end_matches('.')
465        .to_ascii_lowercase();
466    if METADATA_HOSTNAMES.contains(&normalized.as_str()) {
467        return true;
468    }
469    crate::utils::classify_host(host).is_internal()
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn validate_fetch_url_blocks_unsafe_targets() {
478        // #9: scheme + internal-host guards.
479        for bad in [
480            "file:///etc/passwd",
481            "ftp://example.com/x",
482            "http://localhost/admin",
483            "http://127.0.0.1:8080",
484            "http://169.254.169.254/latest/meta-data/",
485            "http://10.0.0.5/",
486            "http://192.168.1.1/",
487            "http://[::1]/",
488            // #27/#80: IPv6/CGNAT bypasses the old IPv4-centric blocklist missed.
489            "http://[::ffff:169.254.169.254]/latest/meta-data/",
490            "http://[fc00::1]/",
491            "http://[fe80::1]/",
492            "http://100.100.100.200/",
493            // F57: cloud-metadata hostnames are lexically public (IP-only
494            // classify_host waves them through) but front a metadata service.
495            "http://metadata.google.internal/computeMetadata/v1/",
496            "http://metadata.goog/",
497            "http://metadata/",
498            "http://instance-data/latest/meta-data/",
499            "https://METADATA.GOOGLE.INTERNAL./",
500            "not a url",
501        ] {
502            assert!(
503                validate_fetch_url(bad).is_err(),
504                "expected reject for {bad:?}",
505            );
506        }
507        for good in [
508            "https://example.com",
509            "http://example.com/page?x=1",
510            "https://docs.rs/serde",
511        ] {
512            assert!(
513                validate_fetch_url(good).is_ok(),
514                "expected accept for {good:?}",
515            );
516        }
517    }
518
519    #[test]
520    fn is_blocked_host_covers_metadata_names_and_ip_forms() {
521        // F57: cloud-metadata HOSTNAMES (lexically public, IP-only
522        // classify_host misses them) are blocked, case/dot-insensitively.
523        for h in [
524            "metadata.google.internal",
525            "metadata.google.internal.", // trailing FQDN dot
526            "Metadata.Google.Internal",  // case-insensitive
527            "metadata.goog",
528            "metadata",
529            "instance-data",
530            "instance-data.ec2.internal",
531        ] {
532            assert!(is_blocked_host(h), "metadata host {h:?} must be blocked");
533        }
534        // Non-public IP forms still go through classify_host (incl. the ones
535        // the task lists as examples that must already be covered).
536        for h in ["0.0.0.0", "::1", "169.254.169.254", "127.0.0.1"] {
537            assert!(is_blocked_host(h), "internal IP {h:?} must be blocked");
538        }
539        // Legitimate public hosts are NOT blocked — including a real `.goog`
540        // domain that merely is not the metadata alias.
541        for h in ["example.com", "docs.rs", "abc.goog", "8.8.8.8"] {
542            assert!(!is_blocked_host(h), "public host {h:?} must be allowed");
543        }
544    }
545
546    #[test]
547    fn format_fetch_caps_long_content() {
548        // F46: a huge page body must be truncated with a marker, not dumped whole.
549        let big = "z".repeat(WEB_FETCH_MAX_CHARS * 2);
550        let page = WebFetchResult {
551            title: "T".to_string(),
552            content: big,
553        };
554        let out = format_fetch("https://example.com", &page);
555        assert!(
556            out.len() < WEB_FETCH_MAX_CHARS + 256,
557            "content must be capped, got {} bytes",
558            out.len()
559        );
560        assert!(out.contains("truncated"), "expected truncation marker");
561
562        // A short page is emitted intact, with no marker.
563        let small = WebFetchResult {
564            title: "T".to_string(),
565            content: "hello world".to_string(),
566        };
567        let out = format_fetch("https://example.com", &small);
568        assert!(out.contains("hello world"));
569        assert!(!out.contains("truncated"));
570    }
571
572    #[test]
573    fn parse_queries_single_form() {
574        let args = serde_json::json!({"query": "rust async", "max_results": 3});
575        let q = parse_queries(&args).unwrap();
576        assert_eq!(q.len(), 1);
577        assert_eq!(q[0].0, "rust async");
578        assert_eq!(q[0].1, 3);
579    }
580
581    #[test]
582    fn parse_queries_array_form() {
583        let args = serde_json::json!({"queries": [
584            {"query": "a", "max_results": 2},
585            {"query": "b", "result_count": 5},
586        ]});
587        let q = parse_queries(&args).unwrap();
588        assert_eq!(q.len(), 2);
589        assert_eq!(q[1].1, 5);
590    }
591
592    #[test]
593    fn parse_queries_missing_errors() {
594        let args = serde_json::json!({});
595        assert!(parse_queries(&args).is_err());
596    }
597
598    #[test]
599    fn parse_queries_clamps_count() {
600        let args = serde_json::json!({"query": "q", "max_results": 999});
601        let q = parse_queries(&args).unwrap();
602        assert_eq!(q[0].1, 10);
603        let args = serde_json::json!({"query": "q", "max_results": 0});
604        let q = parse_queries(&args).unwrap();
605        assert_eq!(q[0].1, 1);
606    }
607
608    #[test]
609    fn parse_queries_rejects_excess_fan_out() {
610        // #90: a single call can't request unbounded fan-out.
611        let many: Vec<_> = (0..crate::constants::MAX_BATCH_TOOL_ITEMS + 1)
612            .map(|i| serde_json::json!({"query": format!("q{i}")}))
613            .collect();
614        let args = serde_json::json!({ "queries": many });
615        assert!(parse_queries(&args).is_err());
616
617        // Exactly at the cap is still accepted.
618        let at_cap: Vec<_> = (0..crate::constants::MAX_BATCH_TOOL_ITEMS)
619            .map(|i| serde_json::json!({"query": format!("q{i}")}))
620            .collect();
621        let args = serde_json::json!({ "queries": at_cap });
622        assert_eq!(
623            parse_queries(&args).unwrap().len(),
624            crate::constants::MAX_BATCH_TOOL_ITEMS
625        );
626    }
627
628    #[tokio::test]
629    async fn web_search_batch_survives_empty_and_failed_queries() {
630        use crate::domain::{ToolCallId, ToolStatus, TurnId};
631        use crate::providers::ctx::test_exec_context;
632        use crate::providers::tool::web_client::SearchResult;
633        use async_trait::async_trait;
634        use std::sync::Arc;
635
636        struct Mock;
637        #[async_trait]
638        impl SearchProvider for Mock {
639            async fn search(
640                &self,
641                query: &str,
642                _count: usize,
643            ) -> anyhow::Result<Vec<SearchResult>> {
644                match query {
645                    "boom" => Err(anyhow::anyhow!("backend down")),
646                    "empty" => Ok(Vec::new()),
647                    _ => Ok(vec![SearchResult {
648                        title: "Title".to_string(),
649                        url: "https://example.com".to_string(),
650                        snippet: "snip".to_string(),
651                        full_content: "content".to_string(),
652                    }]),
653                }
654            }
655        }
656
657        let mk = || WebSearchTool {
658            backend: Arc::new(Mock),
659        };
660        let tmp = std::path::PathBuf::from("/tmp");
661
662        // Partial: one good, one empty, one erroring -> success, good kept.
663        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), tmp.clone());
664        let out = mk()
665            .execute(
666                serde_json::json!({"queries": [{"query":"good"},{"query":"empty"},{"query":"boom"}]}),
667                ctx,
668            )
669            .await;
670        assert_eq!(
671            out.status,
672            ToolStatus::Success,
673            "a partial batch must not abort"
674        );
675        assert!(
676            out.output().contains("https://example.com"),
677            "keeps the good result"
678        );
679
680        // A single empty query is "no results", not a hard error.
681        let (ctx, _rx) = test_exec_context(TurnId(2), ToolCallId(2), tmp.clone());
682        let out = mk()
683            .execute(serde_json::json!({"query": "empty"}), ctx)
684            .await;
685        assert_eq!(out.status, ToolStatus::Success, "empty is not an error");
686        assert!(out.output().contains("no results"));
687
688        // Every query failing IS a tool error.
689        let (ctx, _rx) = test_exec_context(TurnId(3), ToolCallId(3), tmp);
690        let out = mk()
691            .execute(
692                serde_json::json!({"queries": [{"query":"boom"},{"query":"boom"}]}),
693                ctx,
694            )
695            .await;
696        assert_eq!(out.status, ToolStatus::Error, "total failure is an error");
697    }
698}