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), while `web_search = "auto"` uses the
6//! managed local SearXNG bundle. Cloud routing is selected explicitly. This
7//! tool layer owns cancellation plumbing, snapshots, and multi-query fan-out;
8//! the backend owns the transport and destination policy.
9
10use mermaid_domain::ProgressEvent;
11use std::collections::VecDeque;
12use std::sync::{Arc, Mutex, OnceLock};
13
14use async_trait::async_trait;
15use futures::{StreamExt, stream};
16
17use mermaid_domain::{FetchBackend, SearchBackend, WebConfig};
18use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
19
20use super::super::ctx::ExecContext;
21use super::ToolExecutor;
22use super::web_client::{
23    FetchProvider, ManagedSearxngBackend, NativeFetchClient, OllamaWebClient, SearchProvider,
24    SearxngClient, ValidatedWebUrl, WebFetchError, WebFetchResult, format_results,
25};
26
27/// Where a backend's traffic terminates. `trust_destination` says this in
28/// prose for humans; this says it in a form callers can branch on, so
29/// disclosure decisions never depend on matching English strings that a later
30/// wording change would silently break.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Egress {
33    /// Terminates on this machine: direct sockets, or a local child process.
34    OnMachine,
35    /// Reaches a third party, or a host that cannot be proven to be loopback.
36    /// Configured-endpoint backends land here even when the operator points
37    /// them at localhost — over-disclosing is the safe direction to err.
38    OffMachine,
39}
40
41/// User-visible availability for one web capability. This contains no
42/// credentials and is safe for doctor/UI diagnostics.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct WebCapabilityStatus {
45    pub available: bool,
46    pub backend: &'static str,
47    pub trust_destination: &'static str,
48    pub egress: Egress,
49    pub reason: Option<String>,
50}
51
52/// Single source of truth for web backend selection and viability. Registry,
53/// doctor, provider adapters, and child registries consume this result instead
54/// of independently guessing from an Ollama environment variable.
55pub struct WebCapabilities {
56    pub fetch: WebCapabilityStatus,
57    pub search: WebCapabilityStatus,
58    fetch_backend: Option<Arc<dyn FetchProvider>>,
59    search_backend: Option<Arc<dyn SearchProvider>>,
60}
61
62impl WebCapabilities {
63    #[must_use]
64    pub fn resolve(web: &WebConfig) -> Self {
65        let needs_ollama_key = web.fetch_backend == FetchBackend::Ollama
66            || web.search_backend == SearchBackend::Ollama;
67        let ollama_key = needs_ollama_key
68            .then(|| mermaid_model::utils::resolve_provider_key("ollama", "OLLAMA_API_KEY", None))
69            .flatten();
70
71        let (fetch, fetch_backend): (_, Option<Arc<dyn FetchProvider>>) = match web.fetch_backend {
72            FetchBackend::Native => match NativeFetchClient::new() {
73                Ok(client) => (
74                    available("native", "direct from this machine", Egress::OnMachine),
75                    Some(Arc::new(client)),
76                ),
77                Err(error) => (
78                    unavailable(
79                        "native",
80                        "direct from this machine",
81                        Egress::OnMachine,
82                        error.to_string(),
83                    ),
84                    None,
85                ),
86            },
87            FetchBackend::Ollama => {
88                let (status, client) = ollama_cloud_backend(
89                    ollama_key.clone(),
90                    "Ollama Cloud (target redirects are provider-managed; final URL is not disclosed)",
91                );
92                (status, client.map(|c| c as Arc<dyn FetchProvider>))
93            },
94        };
95
96        let (search, search_backend): (_, Option<Arc<dyn SearchProvider>>) =
97            match web.search_backend {
98                // Auto is deliberately sovereign-only. Merely having a cloud
99                // credential must not silently change the egress destination;
100                // users opt into Ollama Cloud with `search_backend = "ollama"`.
101                SearchBackend::Auto => match crate::searxng::managed_backend_viability() {
102                    Ok(_) => (
103                        available(
104                            "managed_searxng",
105                            "local managed process",
106                            Egress::OnMachine,
107                        ),
108                        Some(Arc::new(ManagedSearxngBackend)),
109                    ),
110                    Err(reason) => (
111                        unavailable(
112                            "managed_searxng",
113                            "local managed process",
114                            Egress::OnMachine,
115                            reason,
116                        ),
117                        None,
118                    ),
119                },
120                SearchBackend::Ollama => {
121                    let (status, client) = ollama_cloud_backend(ollama_key, "Ollama Cloud");
122                    (status, client.map(|c| c as Arc<dyn SearchProvider>))
123                },
124                // A configured endpoint may well be loopback, but proving that
125                // means parsing an operator-supplied URL. Disclose instead.
126                SearchBackend::Searxng => match SearxngClient::new(web.searxng_url.clone()) {
127                    Ok(client) => (
128                        available("searxng", "configured SearXNG instance", Egress::OffMachine),
129                        Some(Arc::new(client)),
130                    ),
131                    Err(error) => (
132                        unavailable(
133                            "searxng",
134                            "configured SearXNG instance",
135                            Egress::OffMachine,
136                            error.to_string(),
137                        ),
138                        None,
139                    ),
140                },
141            };
142
143        Self {
144            fetch,
145            search,
146            fetch_backend,
147            search_backend,
148        }
149    }
150
151    /// Assemble a capability set from statuses alone, with no backends behind
152    /// them. Lets disclosure tests assert formatting for viability combinations
153    /// the test host cannot actually produce (e.g. a missing SearXNG bundle).
154    #[cfg(test)]
155    #[must_use]
156    pub fn from_statuses_for_test(fetch: WebCapabilityStatus, search: WebCapabilityStatus) -> Self {
157        Self {
158            fetch,
159            search,
160            fetch_backend: None,
161            search_backend: None,
162        }
163    }
164
165    #[must_use]
166    pub fn fetch_tool(&self) -> Option<WebFetchTool> {
167        self.fetch_backend
168            .clone()
169            .map(|backend| WebFetchTool::new(backend, self.fetch.backend))
170    }
171
172    #[must_use]
173    pub fn search_tool(&self) -> Option<WebSearchTool> {
174        self.search_backend.clone().map(|backend| WebSearchTool {
175            backend,
176            backend_name: self.search.backend,
177        })
178    }
179}
180
181/// Build the shared Ollama Cloud client. The fetch and search backends resolve
182/// the same credential into the same client and differ only in how they
183/// describe the destination they trust, so the failure arms live here once.
184fn ollama_cloud_backend(
185    key: Option<String>,
186    trust_destination: &'static str,
187) -> (WebCapabilityStatus, Option<Arc<OllamaWebClient>>) {
188    match key {
189        Some(key) => match OllamaWebClient::new(key) {
190            Ok(client) => (
191                available("ollama_cloud", trust_destination, Egress::OffMachine),
192                Some(Arc::new(client)),
193            ),
194            Err(error) => (
195                unavailable(
196                    "ollama_cloud",
197                    "Ollama Cloud",
198                    Egress::OffMachine,
199                    error.to_string(),
200                ),
201                None,
202            ),
203        },
204        None => (
205            unavailable(
206                "ollama_cloud",
207                "Ollama Cloud",
208                Egress::OffMachine,
209                "OLLAMA_API_KEY is not configured",
210            ),
211            None,
212        ),
213    }
214}
215
216fn available(
217    backend: &'static str,
218    trust_destination: &'static str,
219    egress: Egress,
220) -> WebCapabilityStatus {
221    WebCapabilityStatus {
222        available: true,
223        backend,
224        trust_destination,
225        egress,
226        reason: None,
227    }
228}
229
230fn unavailable(
231    backend: &'static str,
232    trust_destination: &'static str,
233    egress: Egress,
234    reason: impl Into<String>,
235) -> WebCapabilityStatus {
236    WebCapabilityStatus {
237        available: false,
238        backend,
239        trust_destination,
240        egress,
241        reason: Some(reason.into()),
242    }
243}
244
245/// `web_search` — query the configured search backend. Accepts a single
246/// `{query, max_results}` OR a list of `{queries: [{query, max_results}]}` for
247/// parallel fan-out.
248pub struct WebSearchTool {
249    backend: Arc<dyn SearchProvider>,
250    backend_name: &'static str,
251}
252
253const MAX_WEB_SEARCH_FAILURE_BYTES: usize = 1024;
254
255#[expect(
256    clippy::too_many_lines,
257    reason = "predates the lint; see .github/baselines/expect_budget.txt"
258)]
259#[async_trait]
260impl ToolExecutor for WebSearchTool {
261    fn name(&self) -> &'static str {
262        "web_search"
263    }
264
265    fn schema(&self) -> ToolDefinition {
266        ToolDefinition {
267            name: "web_search".to_string(),
268            description:
269                "Search the web. Takes either a single `query` + `max_results`, or an array of `queries` for parallel fan-out."
270                    .to_string(),
271            input_schema: serde_json::json!({
272                "type": "object",
273                "properties": {
274                    "query": { "type": "string", "minLength": 1, "maxLength": 2048 },
275                    "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 },
276                    "queries": {
277                        "type": "array",
278                        "minItems": 1,
279                        "maxItems": mermaid_model::constants::MAX_BATCH_TOOL_ITEMS,
280                        "items": {
281                            "type": "object",
282                            "properties": {
283                                "query": { "type": "string", "minLength": 1, "maxLength": 2048 },
284                                "max_results": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 }
285                            },
286                            "required": ["query"],
287                            "additionalProperties": false
288                        }
289                    }
290                },
291                "oneOf": [
292                    { "required": ["query"], "not": { "required": ["queries"] } },
293                    { "required": ["queries"], "not": { "required": ["query"] } }
294                ],
295                "additionalProperties": false
296            }),
297        }
298    }
299
300    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
301        let queries = match parse_queries(&args) {
302            Ok(q) => q,
303            Err(e) => return ToolOutcome::error(e, 0.0),
304        };
305        if queries.is_empty() {
306            return ToolOutcome::error("web_search requires at least one query", 0.0);
307        }
308        if let Some(blocked) = super::policy_gate::gate_external(
309            &ctx,
310            "web_search",
311            mermaid_runtime::ToolCategory::Web,
312            format!("web_search ({} queries)", queries.len()),
313            &args,
314        )
315        .await
316        {
317            return blocked;
318        }
319
320        let start = std::time::Instant::now();
321        let jobs = stream::iter(queries.iter().cloned().enumerate())
322            .map(|(idx, (query, count))| {
323                let backend = self.backend.clone();
324                let progress = ctx.progress.clone();
325                let budget = ctx.web_budget();
326                let total = queries.len();
327                async move {
328                    let display_query = mermaid_model::utils::redact_secrets(&query);
329                    let _ = progress
330                        .send(ProgressEvent::Status(format!(
331                            "searching {}/{}: {}",
332                            idx + 1,
333                            total,
334                            display_query
335                        )))
336                        .await;
337                    let result = backend.search(&query, count, budget).await;
338                    (idx, query, result)
339                }
340            })
341            .buffer_unordered(mermaid_model::constants::MAX_WEB_SEARCH_CONCURRENCY)
342            .collect::<Vec<_>>();
343        let mut completed = tokio::select! {
344            biased;
345            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
346            completed = jobs => completed,
347        };
348        completed.sort_by_key(|(idx, _, _)| *idx);
349
350        let mut combined = String::new();
351        let mut result_count = 0usize;
352        let mut sources = Vec::new();
353        let mut errors: Vec<mermaid_domain::WebSearchFailure> = Vec::new();
354        for (idx, query, result) in completed {
355            let display_query = mermaid_model::utils::redact_secrets(&query);
356            // A single query returning nothing or erroring does NOT abort the
357            // batch — record it and carry on so the other queries' results
358            // survive (a partial answer beats none).
359            let section =
360                match result {
361                    Ok(results) => {
362                        result_count += results.len();
363                        sources.extend(results.iter().map(|result| {
364                            mermaid_model::utils::sanitize_url_for_display(&result.url)
365                        }));
366                        if results.is_empty() {
367                            "[SEARCH_RESULTS]\n(no results found)\n[/SEARCH_RESULTS]\n".to_string()
368                        } else {
369                            format_results(&results)
370                        }
371                    },
372                    Err(e) => {
373                        let safe_error = mermaid_model::utils::truncate_middle_bytes(
374                            &mermaid_model::utils::redact_secrets(&format!("{e:#}")),
375                            MAX_WEB_SEARCH_FAILURE_BYTES,
376                        );
377                        errors.push(mermaid_domain::WebSearchFailure {
378                            query_index: idx,
379                            error: safe_error.clone(),
380                        });
381                        format!("(search failed: {safe_error})\n")
382                    },
383                };
384            if queries.len() > 1 {
385                combined.push_str(&format!("=== query: {display_query} ===\n{section}\n\n"));
386            } else {
387                combined = section;
388            }
389        }
390
391        // Only a total failure — every query hit a backend error — is a tool
392        // error. An empty-but-reachable search, or a partial success, returns
393        // normally so the model sees what did come back.
394        if errors.len() == queries.len() {
395            let summary = errors
396                .iter()
397                .map(|failure| format!("query {}: {}", failure.query_index + 1, failure.error))
398                .collect::<Vec<_>>()
399                .join("; ");
400            let message = format!("web_search via {} failed: {summary}", self.backend_name);
401            let message = mermaid_model::utils::truncate_middle_bytes(
402                &message,
403                mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES
404                    .saturating_sub("Error: ".len()),
405            );
406            return ToolOutcome::error(message, start.elapsed().as_secs_f64()).with_metadata(
407                ToolRunMetadata {
408                    detail: ToolMetadata::WebSearch {
409                        queries: queries.iter().map(|(query, _)| query.clone()).collect(),
410                        requested_count: queries.iter().map(|(_, count)| *count).sum(),
411                        result_count: 0,
412                        sources: Vec::new(),
413                        backend: self.backend_name.to_string(),
414                        succeeded_queries: 0,
415                        failed_queries: errors.len(),
416                        partial: false,
417                        truncated: false,
418                        failures: errors,
419                    },
420                    result_count: Some(0),
421                    ..ToolRunMetadata::default()
422                },
423            );
424        }
425
426        // Cap the aggregate output. Per-result content is already truncated to
427        // WEB_CONTENT_MAX_CHARS, but many results across many queries can still
428        // bloat context (and memory) past what any single result's cap bounds (#28).
429        let truncated = combined.len() > mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES;
430        let combined = mermaid_model::utils::truncate_middle_bytes(
431            &combined,
432            mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES,
433        );
434
435        let duration_secs = start.elapsed().as_secs_f64();
436        let requested_count = queries.iter().map(|(_, count)| *count).sum();
437        let query_texts = queries.iter().map(|(query, _)| query.clone()).collect();
438        ToolOutcome::success(
439            combined,
440            format!(
441                "{} {} returned",
442                result_count,
443                if result_count == 1 {
444                    "result"
445                } else {
446                    "results"
447                }
448            ),
449            duration_secs,
450        )
451        .with_metadata(ToolRunMetadata {
452            detail: ToolMetadata::WebSearch {
453                queries: query_texts,
454                requested_count,
455                result_count,
456                sources,
457                backend: self.backend_name.to_string(),
458                succeeded_queries: queries.len() - errors.len(),
459                failed_queries: errors.len(),
460                partial: !errors.is_empty(),
461                truncated,
462                failures: errors,
463            },
464            result_count: Some(result_count),
465            ..ToolRunMetadata::default()
466        })
467    }
468}
469
470/// `web_fetch` — retrieve a URL's readable content as markdown. Single URL,
471/// single response. Native by default (fetches + converts in-process, no key);
472/// can be backed by Ollama Cloud instead.
473pub struct WebFetchTool {
474    backend: Arc<dyn FetchProvider>,
475    backend_name: &'static str,
476    snapshots: Arc<Mutex<FetchSnapshotStore>>,
477}
478
479impl WebFetchTool {
480    fn new(backend: Arc<dyn FetchProvider>, backend_name: &'static str) -> Self {
481        Self {
482            backend,
483            backend_name,
484            snapshots: global_fetch_snapshot_store(),
485        }
486    }
487
488    #[cfg(test)]
489    fn new_with_test_snapshots(
490        backend: Arc<dyn FetchProvider>,
491        backend_name: &'static str,
492    ) -> Self {
493        Self {
494            backend,
495            backend_name,
496            snapshots: Arc::new(Mutex::new(FetchSnapshotStore::default())),
497        }
498    }
499}
500
501fn fetch_failure_outcome(
502    error: &WebFetchError,
503    requested_url: &str,
504    backend: &str,
505    duration_secs: f64,
506    pattern: Option<String>,
507    context_lines: usize,
508) -> ToolOutcome {
509    let requested_url = mermaid_model::utils::sanitize_url_for_display(requested_url);
510    let message = mermaid_model::utils::redact_secrets(&format!(
511        "web_fetch({requested_url}) via {backend}: {error}"
512    ));
513    let pattern_context = pattern.as_ref().map(|_| context_lines);
514    ToolOutcome::error(message, duration_secs).with_metadata(ToolRunMetadata {
515        detail: ToolMetadata::WebFetch {
516            url: requested_url,
517            final_url: None,
518            status: error.status(),
519            error_kind: Some(error.kind().to_string()),
520            media_type: None,
521            charset: None,
522            backend: backend.to_string(),
523            extraction: String::new(),
524            title: None,
525            line_count: 0,
526            byte_count: 0,
527            source_byte_count: 0,
528            output_byte_count: 0,
529            truncated: false,
530            pattern,
531            context_lines: pattern_context,
532            match_count: None,
533            snapshot_id: None,
534        },
535        line_count: Some(0),
536        byte_count: Some(0),
537        ..ToolRunMetadata::default()
538    })
539}
540
541const MAX_FETCH_SNAPSHOTS: usize = 4;
542const MAX_FETCH_SNAPSHOT_BYTES: usize = 32 * 1024 * 1024;
543const MAX_SNAPSHOT_TITLE_BYTES: usize = 300;
544const MAX_SNAPSHOT_URL_BYTES: usize = 8 * 1024;
545const MAX_SNAPSHOT_MEDIA_TYPE_BYTES: usize = 256;
546const MAX_SNAPSHOT_CHARSET_BYTES: usize = 64;
547
548static FETCH_SNAPSHOT_STORE: OnceLock<Arc<Mutex<FetchSnapshotStore>>> = OnceLock::new();
549
550#[derive(Clone, Debug, PartialEq, Eq)]
551struct FetchSnapshotScope {
552    session_id: Option<String>,
553    task_id: Option<String>,
554    fallback_turn: Option<u64>,
555}
556
557impl FetchSnapshotScope {
558    fn from_context(ctx: &ExecContext) -> Self {
559        let has_owner = ctx.session_id.is_some() || ctx.task_id.is_some();
560        Self {
561            session_id: ctx.session_id.as_deref().map(compact_string),
562            task_id: ctx.task_id.as_deref().map(compact_string),
563            // A context with neither durable owner must fail closed across
564            // turns: otherwise every anonymous caller would share one cache.
565            fallback_turn: (!has_owner).then_some(ctx.turn.0),
566        }
567    }
568
569    fn retained_string_bytes(&self) -> usize {
570        option_string_capacity(&self.session_id)
571            .saturating_add(option_string_capacity(&self.task_id))
572    }
573}
574
575#[derive(Clone)]
576struct FetchSnapshot {
577    id: String,
578    scope: FetchSnapshotScope,
579    page: Arc<WebFetchResult>,
580    retained_bytes: usize,
581}
582
583#[derive(Default)]
584struct FetchSnapshotStore {
585    entries: VecDeque<FetchSnapshot>,
586    bytes: usize,
587    next_id: u64,
588}
589
590impl FetchSnapshotStore {
591    fn insert(
592        &mut self,
593        scope: FetchSnapshotScope,
594        page: WebFetchResult,
595    ) -> Result<(String, Arc<WebFetchResult>), String> {
596        self.next_id = self.next_id.wrapping_add(1).max(1);
597        let mut id = format!("web-{}", self.next_id);
598        id.shrink_to_fit();
599
600        let fixed_bytes = id.capacity().saturating_add(scope.retained_string_bytes());
601        if fixed_bytes >= MAX_FETCH_SNAPSHOT_BYTES {
602            return Err("web_fetch: snapshot owner identity exceeds the cache budget".to_string());
603        }
604        let page = Arc::new(bound_snapshot_page(
605            page,
606            MAX_FETCH_SNAPSHOT_BYTES - fixed_bytes,
607        ));
608        let retained_bytes = fixed_bytes.saturating_add(page_retained_string_bytes(&page));
609        if retained_bytes > MAX_FETCH_SNAPSHOT_BYTES {
610            return Err("web_fetch: snapshot metadata exceeds the cache budget".to_string());
611        }
612
613        while !self.entries.is_empty()
614            && (self.entries.len() >= MAX_FETCH_SNAPSHOTS
615                || self.bytes.saturating_add(retained_bytes) > MAX_FETCH_SNAPSHOT_BYTES)
616        {
617            if let Some(removed) = self.entries.pop_front() {
618                self.bytes = self.bytes.saturating_sub(removed.retained_bytes);
619            }
620        }
621        self.bytes = self.bytes.saturating_add(retained_bytes);
622        self.entries.push_back(FetchSnapshot {
623            id: id.clone(),
624            scope,
625            page: page.clone(),
626            retained_bytes,
627        });
628        Ok((id, page))
629    }
630
631    fn get(&self, scope: &FetchSnapshotScope, id: &str) -> Option<Arc<WebFetchResult>> {
632        self.entries
633            .iter()
634            .find(|entry| entry.id == id && &entry.scope == scope)
635            .map(|entry| Arc::clone(&entry.page))
636    }
637}
638
639fn global_fetch_snapshot_store() -> Arc<Mutex<FetchSnapshotStore>> {
640    FETCH_SNAPSHOT_STORE
641        .get_or_init(|| Arc::new(Mutex::new(FetchSnapshotStore::default())))
642        .clone()
643}
644
645fn bound_snapshot_page(mut page: WebFetchResult, max_retained_bytes: usize) -> WebFetchResult {
646    page.title = bounded_title(&page.title);
647    page.title.shrink_to_fit();
648    bound_owned_string(&mut page.requested_url, MAX_SNAPSHOT_URL_BYTES);
649    if let Some(final_url) = page.final_url.as_mut() {
650        bound_owned_string(final_url, MAX_SNAPSHOT_URL_BYTES);
651    }
652    bound_optional_string(&mut page.media_type, MAX_SNAPSHOT_MEDIA_TYPE_BYTES);
653    bound_optional_string(&mut page.charset, MAX_SNAPSHOT_CHARSET_BYTES);
654
655    let metadata_bytes = page_retained_string_bytes_without_content(&page);
656    let content_budget = max_retained_bytes.saturating_sub(metadata_bytes);
657    if page.content.len() > content_budget {
658        let cut = page.content.floor_char_boundary(content_budget);
659        page.content.truncate(cut);
660        page.truncated = true;
661    }
662    page.content.shrink_to_fit();
663    page
664}
665
666fn compact_string(value: &str) -> String {
667    let mut value = value.to_string();
668    value.shrink_to_fit();
669    value
670}
671
672fn bound_owned_string(value: &mut String, max_bytes: usize) {
673    if value.len() > max_bytes {
674        value.truncate(value.floor_char_boundary(max_bytes));
675    }
676    value.shrink_to_fit();
677}
678
679fn bound_optional_string(value: &mut Option<String>, max_bytes: usize) {
680    if let Some(value) = value {
681        bound_owned_string(value, max_bytes);
682    }
683}
684
685fn option_string_capacity(value: &Option<String>) -> usize {
686    value.as_ref().map_or(0, String::capacity)
687}
688
689fn page_retained_string_bytes_without_content(page: &WebFetchResult) -> usize {
690    page.requested_url
691        .capacity()
692        .saturating_add(option_string_capacity(&page.final_url))
693        .saturating_add(option_string_capacity(&page.media_type))
694        .saturating_add(option_string_capacity(&page.charset))
695        .saturating_add(page.title.capacity())
696}
697
698fn page_retained_string_bytes(page: &WebFetchResult) -> usize {
699    page_retained_string_bytes_without_content(page).saturating_add(page.content.capacity())
700}
701
702async fn run_snapshot_blocking<T, F>(work: F) -> Result<T, String>
703where
704    T: Send + 'static,
705    F: FnOnce() -> T + Send + 'static,
706{
707    run_snapshot_blocking_with(super::web_client::extraction_semaphore(), work).await
708}
709
710async fn run_snapshot_blocking_with<T, F>(
711    limiter: Arc<tokio::sync::Semaphore>,
712    work: F,
713) -> Result<T, String>
714where
715    T: Send + 'static,
716    F: FnOnce() -> T + Send + 'static,
717{
718    let permit = limiter
719        .acquire_owned()
720        .await
721        .map_err(|_| "web snapshot renderer is closed".to_string())?;
722    tokio::task::spawn_blocking(move || {
723        // Dropping the JoinHandle cannot stop blocking work. Keep its permit in
724        // the closure so cancellation never creates an unaccounted renderer.
725        let _permit = permit;
726        work()
727    })
728    .await
729    .map_err(|error| format!("web snapshot renderer failed: {error}"))
730}
731
732#[expect(
733    clippy::too_many_lines,
734    reason = "predates the lint; see .github/baselines/expect_budget.txt"
735)]
736#[async_trait]
737impl ToolExecutor for WebFetchTool {
738    fn name(&self) -> &'static str {
739        "web_fetch"
740    }
741
742    fn schema(&self) -> ToolDefinition {
743        ToolDefinition {
744            name: "web_fetch".to_string(),
745            description: "Fetch a public HTTP(S) URL into a bounded session snapshot, or inspect \
746                          a prior snapshot without refetching. Use pattern for case-insensitive \
747                          matching, or start_line + line_count for stable continuation."
748                .to_string(),
749            input_schema: serde_json::json!({
750                "type": "object",
751                "properties": {
752                    "url": {
753                        "type": "string",
754                        "format": "uri",
755                        "maxLength": 8192,
756                        "description": "Public HTTP(S) URL to fetch"
757                    },
758                    "snapshot_id": {
759                        "type": "string",
760                        "pattern": "^web-[0-9]+$",
761                        "description": "Snapshot returned by an earlier web_fetch call"
762                    },
763                    "pattern": {
764                        "type": "string",
765                        "minLength": 1,
766                        "maxLength": 1024,
767                        "description": "Case-insensitive substring to find in the page (not a regex)"
768                    },
769                    "context_lines": {
770                        "type": "integer",
771                        "minimum": 0,
772                        "maximum": 10,
773                        "default": 2,
774                        "description": "Context lines around each match (default 2, max 10)"
775                    },
776                    "start_line": {
777                        "type": "integer",
778                        "minimum": 1,
779                        "description": "First 1-based snapshot line to return"
780                    },
781                    "line_count": {
782                        "type": "integer",
783                        "minimum": 1,
784                        "maximum": 500,
785                        "default": 200,
786                        "description": "Maximum snapshot lines to return"
787                    }
788                },
789                "oneOf": [
790                    { "required": ["url"], "not": { "required": ["snapshot_id"] } },
791                    { "required": ["snapshot_id"], "not": { "required": ["url"] } }
792                ],
793                "additionalProperties": false
794            }),
795        }
796    }
797
798    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
799        let request = match parse_fetch_args(&args) {
800            Ok(request) => request,
801            Err(error) => return ToolOutcome::error(error, 0.0),
802        };
803        let start = std::time::Instant::now();
804        let snapshot_scope = FetchSnapshotScope::from_context(&ctx);
805        let (page, snapshot_id) = match &request.target {
806            FetchTarget::Snapshot(snapshot_id) => {
807                let page = self
808                    .snapshots
809                    .lock()
810                    .unwrap_or_else(std::sync::PoisonError::into_inner)
811                    .get(&snapshot_scope, snapshot_id);
812                let Some(page) = page else {
813                    return ToolOutcome::error(
814                        format!(
815                            "web_fetch: snapshot '{snapshot_id}' is unavailable or was evicted"
816                        ),
817                        start.elapsed().as_secs_f64(),
818                    );
819                };
820                (page, snapshot_id.to_string())
821            },
822            FetchTarget::Url(url) => {
823                let safe_url = mermaid_model::utils::sanitize_url_for_display(url.as_str());
824                if let Some(blocked) = super::policy_gate::gate_external(
825                    &ctx,
826                    "web_fetch",
827                    mermaid_runtime::ToolCategory::Web,
828                    format!("web_fetch via {} {safe_url}", self.backend_name),
829                    &args,
830                )
831                .await
832                {
833                    return blocked;
834                }
835                let fetch = self.backend.fetch(url.as_str(), ctx.web_budget());
836                let page = tokio::select! {
837                    biased;
838                    _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
839                    result = fetch => match result {
840                        Ok(page) => page,
841                        Err(error) => {
842                            return fetch_failure_outcome(
843                                &error,
844                                url.as_str(),
845                                self.backend_name,
846                                start.elapsed().as_secs_f64(),
847                                request.pattern.clone(),
848                                request.context_lines,
849                            );
850                        },
851                    },
852                };
853                let inserted = self
854                    .snapshots
855                    .lock()
856                    .unwrap_or_else(std::sync::PoisonError::into_inner)
857                    .insert(snapshot_scope, page);
858                match inserted {
859                    Ok((snapshot_id, page)) => (page, snapshot_id),
860                    Err(error) => {
861                        return ToolOutcome::error(error, start.elapsed().as_secs_f64());
862                    },
863                }
864            },
865        };
866
867        let render_page = Arc::clone(&page);
868        let render_snapshot_id = snapshot_id.clone();
869        let render_pattern = request.pattern.clone();
870        let render_context_lines = request.context_lines;
871        let render_start_line = request.start_line;
872        let render_line_count = request.line_count;
873        let render = run_snapshot_blocking(move || {
874            format_fetch(
875                &render_page,
876                &render_snapshot_id,
877                render_pattern.as_deref(),
878                render_context_lines,
879                render_start_line,
880                render_line_count,
881            )
882        });
883        let formatted = tokio::select! {
884            biased;
885            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
886            result = render => match result {
887                Ok(formatted) => formatted,
888                Err(error) => {
889                    return ToolOutcome::error(error, start.elapsed().as_secs_f64());
890                },
891            },
892        };
893        let duration_secs = start.elapsed().as_secs_f64();
894        let line_count = formatted.output.lines().count();
895        let byte_count = formatted.output.len();
896        let title = (!page.title.is_empty()).then(|| bounded_title(&page.title));
897        let requested_url = mermaid_model::utils::sanitize_url_for_display(&page.requested_url);
898        let final_url = page
899            .final_url
900            .as_deref()
901            .map(mermaid_model::utils::sanitize_url_for_display);
902        let pattern_context = request.pattern.as_ref().map(|_| request.context_lines);
903        ToolOutcome::success(
904            formatted.output,
905            format!(
906                "{} {} fetched via {}",
907                line_count,
908                if line_count == 1 { "line" } else { "lines" },
909                page.backend.as_str()
910            ),
911            duration_secs,
912        )
913        .with_metadata(ToolRunMetadata {
914            detail: ToolMetadata::WebFetch {
915                url: requested_url,
916                final_url,
917                status: page.status,
918                error_kind: None,
919                media_type: page.media_type.clone(),
920                charset: page.charset.clone(),
921                backend: page.backend.as_str().to_string(),
922                extraction: page.extraction.as_str().to_string(),
923                title,
924                line_count,
925                byte_count,
926                source_byte_count: page.source_bytes,
927                output_byte_count: page.output_bytes,
928                truncated: page.truncated || formatted.truncated,
929                pattern: request.pattern,
930                context_lines: pattern_context,
931                match_count: formatted.match_count,
932                snapshot_id: Some(snapshot_id),
933            },
934            line_count: Some(line_count),
935            byte_count: Some(byte_count),
936            ..ToolRunMetadata::default()
937        })
938    }
939}
940
941/// Exactly one of the two ways to name a page. Modelled as an enum rather
942/// than two `Option`s so "both" and "neither" are unrepresentable past the
943/// parser — `execute` reads the target without re-checking the invariant.
944enum FetchTarget {
945    Url(ValidatedWebUrl),
946    Snapshot(String),
947}
948
949struct ParsedFetchArgs {
950    target: FetchTarget,
951    pattern: Option<String>,
952    context_lines: usize,
953    start_line: Option<usize>,
954    line_count: usize,
955}
956
957fn parse_fetch_args(args: &serde_json::Value) -> Result<ParsedFetchArgs, String> {
958    let obj = args
959        .as_object()
960        .ok_or_else(|| "web_fetch arguments must be an object".to_string())?;
961    for key in obj.keys() {
962        if !matches!(
963            key.as_str(),
964            "url" | "snapshot_id" | "pattern" | "context_lines" | "start_line" | "line_count"
965        ) {
966            return Err(format!("web_fetch: unknown argument '{key}'"));
967        }
968    }
969
970    let url = match obj.get("url") {
971        None => None,
972        Some(value) => {
973            let raw = value
974                .as_str()
975                .ok_or_else(|| "web_fetch: 'url' must be a string".to_string())?
976                .trim();
977            if raw.len() > 8192 {
978                return Err("web_fetch: URL exceeds 8192 bytes".to_string());
979            }
980            Some(ValidatedWebUrl::parse(raw).map_err(|error| format!("web_fetch: {error}"))?)
981        },
982    };
983    let snapshot_id = match obj.get("snapshot_id") {
984        None => None,
985        Some(value) => {
986            let id = value
987                .as_str()
988                .ok_or_else(|| "web_fetch: 'snapshot_id' must be a string".to_string())?;
989            let valid = id.strip_prefix("web-").is_some_and(|suffix| {
990                !suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())
991            });
992            if !valid {
993                return Err("web_fetch: invalid snapshot id".to_string());
994            }
995            Some(id.to_string())
996        },
997    };
998    let target = match (url, snapshot_id) {
999        (Some(url), None) => FetchTarget::Url(url),
1000        (None, Some(id)) => FetchTarget::Snapshot(id),
1001        _ => {
1002            return Err("web_fetch requires exactly one of 'url' or 'snapshot_id'".to_string());
1003        },
1004    };
1005
1006    let pattern = match obj.get("pattern") {
1007        None => None,
1008        Some(value) => {
1009            let pattern = value
1010                .as_str()
1011                .ok_or_else(|| "web_fetch: 'pattern' must be a string".to_string())?
1012                .trim();
1013            if pattern.is_empty() {
1014                return Err("web_fetch: 'pattern' must not be empty".to_string());
1015            }
1016            if pattern.contains(['\r', '\n']) {
1017                return Err("web_fetch: 'pattern' must be a single line".to_string());
1018            }
1019            if pattern.chars().count() > 1024 {
1020                return Err("web_fetch: 'pattern' exceeds 1024 characters".to_string());
1021            }
1022            Some(pattern.to_string())
1023        },
1024    };
1025    let context_lines = parse_bounded_usize(obj, "context_lines", 2, 0, 10)?;
1026    if pattern.is_none() && obj.contains_key("context_lines") {
1027        return Err("web_fetch: 'context_lines' requires 'pattern'".to_string());
1028    }
1029    let has_range = obj.contains_key("start_line") || obj.contains_key("line_count");
1030    if pattern.is_some() && has_range {
1031        return Err("web_fetch: use either 'pattern' or a line range, not both".to_string());
1032    }
1033    let start_line = has_range
1034        .then(|| parse_bounded_usize(obj, "start_line", 1, 1, usize::MAX))
1035        .transpose()?;
1036    let line_count = parse_bounded_usize(obj, "line_count", 200, 1, 500)?;
1037
1038    Ok(ParsedFetchArgs {
1039        target,
1040        pattern,
1041        context_lines,
1042        start_line,
1043        line_count,
1044    })
1045}
1046
1047fn parse_bounded_usize(
1048    obj: &serde_json::Map<String, serde_json::Value>,
1049    key: &str,
1050    default: usize,
1051    min: usize,
1052    max: usize,
1053) -> Result<usize, String> {
1054    let Some(value) = obj.get(key) else {
1055        return Ok(default);
1056    };
1057    let value = value
1058        .as_u64()
1059        .and_then(|value| usize::try_from(value).ok())
1060        .ok_or_else(|| format!("web_fetch: '{key}' must be an integer"))?;
1061    if value < min || value > max {
1062        return Err(format!("web_fetch: '{key}' must be from {min} to {max}"));
1063    }
1064    Ok(value)
1065}
1066
1067/// Cap on the complete model-visible `web_fetch` result, including provenance
1068/// headers and truncation marker.
1069const WEB_FETCH_MAX_BYTES: usize = mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES;
1070const FETCH_TRUNCATION_SUFFIX: &str = "\n\n...[content truncated]\n[/WEB_FETCH]";
1071
1072struct FormattedFetch {
1073    output: String,
1074    truncated: bool,
1075    match_count: Option<usize>,
1076}
1077
1078/// Cap on find-in-page match BLOCKS per call (merged context windows).
1079/// Matched lines beyond the included blocks are summarized as a
1080/// `(+N more matches)` tail so the model knows the page has more.
1081const MAX_PATTERN_MATCHES: usize = 20;
1082
1083fn format_fetch(
1084    page: &WebFetchResult,
1085    snapshot_id: &str,
1086    pattern: Option<&str>,
1087    ctx_lines: usize,
1088    start_line: Option<usize>,
1089    line_count: usize,
1090) -> FormattedFetch {
1091    let title = if page.title.trim().is_empty() {
1092        "(no title)".to_string()
1093    } else {
1094        bounded_title(&page.title)
1095    };
1096    let requested_url = bounded_url(&page.requested_url);
1097    let final_url = page
1098        .final_url
1099        .as_deref()
1100        .map(bounded_url)
1101        .unwrap_or_else(|| "(not disclosed by backend)".to_string());
1102    let status = page
1103        .status
1104        .map(|status| status.to_string())
1105        .unwrap_or_else(|| "unknown".to_string());
1106    let media = page.media_type.as_deref().unwrap_or("unknown");
1107    let charset = page.charset.as_deref().unwrap_or("unknown");
1108
1109    let (body, match_count) = if let Some(pattern) = pattern {
1110        match extract_matches(&page.content, pattern, ctx_lines, MAX_PATTERN_MATCHES) {
1111            Some((report, count)) => (report, Some(count)),
1112            None => (format!("No matching lines for \"{pattern}\"."), Some(0)),
1113        }
1114    } else if let Some(start_line) = start_line {
1115        (
1116            format_line_range(&page.content, start_line, line_count),
1117            None,
1118        )
1119    } else {
1120        (page.content.clone(), None)
1121    };
1122
1123    let mut output = format!(
1124        "[WEB_FETCH]\nTitle: {title}\nRequested URL: {requested_url}\nFinal URL: {final_url}\nStatus: {status}\nMedia-Type: {media}\nCharset: {charset}\nBackend: {}\nExtraction: {}\nSnapshot: {snapshot_id}\nSource bytes: {}\nExtracted bytes: {}\nSnapshot bytes: {}\nSource lines: {}\n\nContent:\n{body}\n[/WEB_FETCH]",
1125        page.backend.as_str(),
1126        page.extraction.as_str(),
1127        page.source_bytes,
1128        page.output_bytes,
1129        page.content.len(),
1130        page.content.lines().count(),
1131    );
1132    let truncated = output.len() > WEB_FETCH_MAX_BYTES;
1133    if truncated {
1134        let budget = WEB_FETCH_MAX_BYTES.saturating_sub(FETCH_TRUNCATION_SUFFIX.len());
1135        let cut = output.floor_char_boundary(budget);
1136        output.truncate(cut);
1137        output.push_str(FETCH_TRUNCATION_SUFFIX);
1138    }
1139    FormattedFetch {
1140        output,
1141        truncated,
1142        match_count,
1143    }
1144}
1145
1146fn bounded_title(title: &str) -> String {
1147    let mut bounded = String::with_capacity(title.len().min(MAX_SNAPSHOT_TITLE_BYTES));
1148    let content_budget = MAX_SNAPSHOT_TITLE_BYTES.saturating_sub(3);
1149    let mut truncated = false;
1150
1151    for word in title.split_whitespace() {
1152        let separator_bytes = usize::from(!bounded.is_empty());
1153        if bounded
1154            .len()
1155            .saturating_add(separator_bytes)
1156            .saturating_add(word.len())
1157            <= MAX_SNAPSHOT_TITLE_BYTES
1158        {
1159            if separator_bytes != 0 {
1160                bounded.push(' ');
1161            }
1162            bounded.push_str(word);
1163            continue;
1164        }
1165
1166        if bounded.len() > content_budget {
1167            bounded.truncate(bounded.floor_char_boundary(content_budget));
1168        }
1169        if bounded.len() < content_budget {
1170            if separator_bytes != 0 && bounded.len() < content_budget {
1171                bounded.push(' ');
1172            }
1173            let remaining = content_budget.saturating_sub(bounded.len());
1174            let cut = word.floor_char_boundary(remaining);
1175            bounded.push_str(&word[..cut]);
1176        }
1177        truncated = true;
1178        break;
1179    }
1180
1181    if truncated {
1182        bounded.push_str("...");
1183    }
1184    bounded
1185}
1186
1187fn bounded_url(url: &str) -> String {
1188    const MAX_DISPLAY_URL_BYTES: usize = 2048;
1189    let url = mermaid_model::utils::sanitize_url_for_display(url);
1190    if url.len() <= MAX_DISPLAY_URL_BYTES {
1191        return url;
1192    }
1193    let cut = url.floor_char_boundary(MAX_DISPLAY_URL_BYTES.saturating_sub(3));
1194    format!("{}...", &url[..cut])
1195}
1196
1197fn format_line_range(content: &str, start_line: usize, line_count: usize) -> String {
1198    let total = content.lines().count();
1199    if start_line > total {
1200        return format!("Requested line {start_line}, but the snapshot contains {total} lines.");
1201    }
1202    let mut output = format!(
1203        "Lines {start_line}-{} of {total}:\n",
1204        start_line
1205            .saturating_add(line_count)
1206            .saturating_sub(1)
1207            .min(total)
1208    );
1209    for (offset, line) in content
1210        .lines()
1211        .skip(start_line.saturating_sub(1))
1212        .take(line_count)
1213        .enumerate()
1214    {
1215        output.push_str(&format!("L{}: {line}\n", start_line + offset));
1216    }
1217    output
1218}
1219
1220/// Canonical caseless form for substring matching. NFD before and after the
1221/// full Unicode fold follows the Unicode canonical-caseless algorithm: it
1222/// equates composed/decomposed text and handles multi-character folds such as
1223/// `ß` -> `ss`, while leaving the original line untouched for display.
1224fn normalized_case_fold(value: &str) -> String {
1225    use caseless::Caseless;
1226    use unicode_normalization::UnicodeNormalization;
1227
1228    value.nfd().default_case_fold().nfd().collect()
1229}
1230
1231/// Find-in-page core: canonical Unicode-caseless SUBSTRING match per line (not
1232/// a regex — model-supplied metacharacters must mean themselves), each match
1233/// reported as a `L<n>:`-prefixed context block. Overlapping or adjacent
1234/// windows merge into one block; blocks are separated by `---` lines and capped
1235/// at `max_blocks`, with a `(+N more matches)` tail counting the matched lines
1236/// that didn't fit. Returns `None` when nothing matches.
1237fn extract_matches(
1238    content: &str,
1239    pattern: &str,
1240    context_lines: usize,
1241    max_blocks: usize,
1242) -> Option<(String, usize)> {
1243    let needle = normalized_case_fold(pattern);
1244    let lines: Vec<&str> = content.lines().collect();
1245    let matched: Vec<usize> = lines
1246        .iter()
1247        .enumerate()
1248        .filter(|(_, line)| normalized_case_fold(line).contains(&needle))
1249        .map(|(i, _)| i)
1250        .collect();
1251    if matched.is_empty() {
1252        return None;
1253    }
1254
1255    // Merge each match's [i-ctx, i+ctx] window with overlapping/adjacent
1256    // neighbors; count how many matched lines the included blocks cover.
1257    let mut blocks: Vec<(usize, usize)> = Vec::new();
1258    for &i in &matched {
1259        let start = i.saturating_sub(context_lines);
1260        let end = (i + context_lines).min(lines.len() - 1);
1261        match blocks.last_mut() {
1262            Some((_, last_end)) if start <= *last_end + 1 => *last_end = (*last_end).max(end),
1263            _ => blocks.push((start, end)),
1264        }
1265    }
1266    let included = &blocks[..blocks.len().min(max_blocks)];
1267    let cutoff = included.last().map(|&(_, end)| end).unwrap_or(0);
1268    let dropped = matched.iter().filter(|&&i| i > cutoff).count();
1269
1270    let mut out = format!(
1271        "{} match{} for \"{}\":\n",
1272        matched.len(),
1273        if matched.len() == 1 { "" } else { "es" },
1274        pattern
1275    );
1276    for (bi, &(start, end)) in included.iter().enumerate() {
1277        if bi > 0 {
1278            out.push_str("---\n");
1279        }
1280        for (offset, line) in lines[start..=end].iter().enumerate() {
1281            // 1-based line numbers, matching how editors and grep report.
1282            out.push_str(&format!("L{}: {}\n", start + offset + 1, line));
1283        }
1284    }
1285    if dropped > 0 {
1286        out.push_str(&format!(
1287            "(+{dropped} more match{})\n",
1288            if dropped == 1 { "" } else { "es" }
1289        ));
1290    }
1291    let match_count = matched.len();
1292    Some((out, match_count))
1293}
1294
1295fn parse_queries(args: &serde_json::Value) -> Result<Vec<(String, usize)>, String> {
1296    let obj = args
1297        .as_object()
1298        .ok_or_else(|| "web_search arguments must be an object".to_string())?;
1299    for key in obj.keys() {
1300        if !matches!(key.as_str(), "query" | "max_results" | "queries") {
1301            return Err(format!("web_search: unknown argument '{key}'"));
1302        }
1303    }
1304    if obj.contains_key("query") && obj.contains_key("queries") {
1305        return Err("web_search accepts either 'query' or 'queries', not both".to_string());
1306    }
1307
1308    if let Some(value) = obj.get("queries") {
1309        let Some(arr) = value.as_array() else {
1310            return Err("web_search: 'queries' must be an array".to_string());
1311        };
1312        if arr.is_empty() {
1313            return Err("web_search: 'queries' must contain at least one entry".to_string());
1314        }
1315        if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
1316            return Err(format!(
1317                "web_search: too many queries ({}); cap is {} per call — split the request",
1318                arr.len(),
1319                mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
1320            ));
1321        }
1322        let mut out = Vec::with_capacity(arr.len());
1323        for v in arr {
1324            let Some(obj) = v.as_object() else {
1325                return Err(
1326                    "web_search: 'queries' must be an array of {query, max_results}".to_string(),
1327                );
1328            };
1329            for key in obj.keys() {
1330                if !matches!(key.as_str(), "query" | "max_results") {
1331                    return Err(format!("web_search: unknown query argument '{key}'"));
1332                }
1333            }
1334            out.push(parse_query_entry(obj)?);
1335        }
1336        return Ok(out);
1337    }
1338    if obj.contains_key("query") {
1339        return Ok(vec![parse_query_entry(obj)?]);
1340    }
1341    Err("web_search requires 'query' (string) or 'queries' (array)".to_string())
1342}
1343
1344fn parse_query_entry(
1345    obj: &serde_json::Map<String, serde_json::Value>,
1346) -> Result<(String, usize), String> {
1347    let query = obj
1348        .get("query")
1349        .and_then(|value| value.as_str())
1350        .ok_or_else(|| "web_search: each query needs 'query' (string)".to_string())?
1351        .trim();
1352    if query.is_empty() {
1353        return Err("web_search: query must not be empty".to_string());
1354    }
1355    if query.contains(['\r', '\n', '\0']) {
1356        return Err("web_search: query must be a single text line".to_string());
1357    }
1358    if query.chars().count() > 2048 {
1359        return Err("web_search: query exceeds 2048 characters".to_string());
1360    }
1361    let count = match obj.get("max_results") {
1362        None => 5,
1363        Some(value) => {
1364            let count = value.as_u64().ok_or_else(|| {
1365                "web_search: 'max_results' must be an integer from 1 to 10".to_string()
1366            })?;
1367            if !(1..=10).contains(&count) {
1368                return Err("web_search: 'max_results' must be from 1 to 10".to_string());
1369            }
1370            count as usize
1371        },
1372    };
1373    Ok((query.to_string(), count))
1374}
1375
1376/// Reject obviously-unsafe fetch URLs before the backend runs: only
1377/// `http`/`https`, and no loopback / link-local / private / metadata hosts.
1378/// For the native backend this is the primary SSRF boundary (the request
1379/// leaves from this process, so `web_client::guard_resolved_ips` also checks
1380/// the resolved addresses); for the Ollama backend it's defense-in-depth ahead
1381/// of Ollama's own server-side fetch. Guards against model-supplied URLs.
1382/// Reject anything that isn't a plain `http(s)` URL. A `file:`, `javascript:`,
1383/// `data:`, or otherwise exotic scheme has no business reaching an HTTP fetch or
1384/// an OS browser launcher. Returns the parsed URL so callers can inspect the
1385/// host without re-parsing. Note: this deliberately does NOT block loopback —
1386/// `open_url` legitimately opens a just-started local dev server.
1387pub(crate) fn require_http_scheme(url: &str) -> Result<reqwest::Url, String> {
1388    let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid URL: {e}"))?;
1389    match parsed.scheme() {
1390        "http" | "https" => Ok(parsed),
1391        other => Err(format!(
1392            "unsupported URL scheme '{other}' (only http/https allowed)"
1393        )),
1394    }
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400
1401    fn page(content: impl Into<String>) -> WebFetchResult {
1402        let content = content.into();
1403        WebFetchResult {
1404            requested_url: "https://example.com/start".to_string(),
1405            final_url: Some("https://example.com/final".to_string()),
1406            status: Some(200),
1407            media_type: Some("text/html".to_string()),
1408            charset: Some("utf-8".to_string()),
1409            backend: super::super::web_client::FetchBackend::Native,
1410            extraction: super::super::web_client::ExtractionMode::Readability,
1411            source_bytes: content.len(),
1412            output_bytes: content.len(),
1413            truncated: false,
1414            title: "T".to_string(),
1415            content,
1416        }
1417    }
1418
1419    fn scope(session_id: &str) -> FetchSnapshotScope {
1420        FetchSnapshotScope {
1421            session_id: Some(compact_string(session_id)),
1422            task_id: None,
1423            fallback_turn: None,
1424        }
1425    }
1426
1427    #[test]
1428    fn require_http_scheme_accepts_http_rejects_exotic() {
1429        // http/https pass — including loopback, since `open_url` legitimately
1430        // opens a just-started local dev server (so this must NOT block localhost).
1431        for good in [
1432            "http://example.com",
1433            "https://example.com/path?a=1&b=2",
1434            "http://localhost:3000",
1435            "http://127.0.0.1:8080",
1436        ] {
1437            assert!(require_http_scheme(good).is_ok(), "{good} should pass");
1438        }
1439        // Non-http(s) schemes and unparseable input are rejected.
1440        for bad in [
1441            "file:///etc/passwd",
1442            "javascript:alert(1)",
1443            "data:text/html,<script>",
1444            "ftp://example.com",
1445            "not a url",
1446        ] {
1447            assert!(
1448                require_http_scheme(bad).is_err(),
1449                "{bad} should be rejected"
1450            );
1451        }
1452    }
1453
1454    #[test]
1455    fn format_fetch_caps_long_content() {
1456        // F46: a huge page body must be truncated with a marker, not dumped whole.
1457        let big = "z".repeat(WEB_FETCH_MAX_BYTES * 2);
1458        let big_page = page(big);
1459        let out = format_fetch(&big_page, "web-1", None, 2, None, 200);
1460        assert!(
1461            out.output.len() <= WEB_FETCH_MAX_BYTES,
1462            "content must be capped, got {} bytes",
1463            out.output.len()
1464        );
1465        assert!(
1466            out.output.contains("truncated"),
1467            "expected truncation marker"
1468        );
1469        assert!(out.truncated);
1470
1471        // A short page is emitted intact, with no marker.
1472        let small = page("hello world");
1473        let out = format_fetch(&small, "web-1", None, 2, None, 200);
1474        assert!(out.output.contains("hello world"));
1475        assert!(!out.output.contains("truncated"));
1476    }
1477
1478    #[test]
1479    fn format_fetch_caps_the_complete_envelope_and_sanitizes_provenance() {
1480        let mut page = page("body");
1481        page.title = format!("  {}\n{}  ", "title ".repeat(100), "tail");
1482        page.requested_url = format!(
1483            "https://alice:hunter2@example.com/page?token=opaque-secret&q={}",
1484            "x".repeat(10_000)
1485        );
1486        page.final_url = Some(page.requested_url.clone());
1487
1488        let out = format_fetch(&page, "web-1", None, 2, None, 200);
1489        assert!(out.output.len() <= WEB_FETCH_MAX_BYTES);
1490        assert!(
1491            !out.output.contains("alice"),
1492            "userinfo leaked: {}",
1493            out.output
1494        );
1495        assert!(
1496            !out.output.contains("hunter2"),
1497            "password leaked: {}",
1498            out.output
1499        );
1500        assert!(!out.output.contains("opaque-secret"), "query secret leaked");
1501        let title = out
1502            .output
1503            .lines()
1504            .find_map(|line| line.strip_prefix("Title: "))
1505            .expect("title header");
1506        assert!(title.len() <= 300);
1507    }
1508
1509    #[test]
1510    fn complete_output_budget_holds_for_multibyte_boundary_sizes() {
1511        for unit in ["a", "é", "界"] {
1512            for units in [0, 1, 14_900, 15_000, 15_100, 40_000] {
1513                let mut candidate = page(unit.repeat(units));
1514                candidate.title = unit.repeat(1_000);
1515                let formatted = format_fetch(&candidate, "web-99", None, 2, None, 200);
1516                assert!(
1517                    formatted.output.len() <= WEB_FETCH_MAX_BYTES,
1518                    "{} bytes escaped the complete-result cap",
1519                    formatted.output.len()
1520                );
1521                assert!(std::str::from_utf8(formatted.output.as_bytes()).is_ok());
1522                assert!(formatted.output.ends_with("[/WEB_FETCH]"));
1523            }
1524        }
1525    }
1526
1527    #[test]
1528    fn snapshot_store_accounts_for_and_bounds_every_retained_string() {
1529        let original_content = "é".repeat(MAX_FETCH_SNAPSHOT_BYTES / 2 + 1_000);
1530        let original_output_bytes = original_content.len();
1531        let mut oversized = page(original_content);
1532        oversized.output_bytes = original_output_bytes;
1533        oversized.title = "title ".repeat(10_000);
1534        oversized.requested_url = format!("https://example.com/{}", "r".repeat(20_000));
1535        oversized.final_url = Some(format!("https://example.com/{}", "f".repeat(20_000)));
1536        oversized.media_type = Some("m".repeat(1_000));
1537        oversized.charset = Some("c".repeat(1_000));
1538
1539        let owner = scope("session-retained-size");
1540        let mut store = FetchSnapshotStore::default();
1541        let (id, bounded) = store.insert(owner.clone(), oversized).unwrap();
1542        let entry = store.entries.back().expect("snapshot entry");
1543        let expected = entry
1544            .id
1545            .capacity()
1546            .saturating_add(entry.scope.retained_string_bytes())
1547            .saturating_add(page_retained_string_bytes(&entry.page));
1548
1549        assert_eq!(entry.id, id);
1550        assert_eq!(entry.retained_bytes, expected);
1551        assert_eq!(store.bytes, expected);
1552        assert!(store.bytes <= MAX_FETCH_SNAPSHOT_BYTES);
1553        assert_eq!(bounded.output_bytes, original_output_bytes);
1554        assert!(bounded.truncated);
1555        assert!(bounded.title.len() <= MAX_SNAPSHOT_TITLE_BYTES);
1556        assert!(bounded.requested_url.len() <= MAX_SNAPSHOT_URL_BYTES);
1557        assert!(bounded.final_url.as_ref().unwrap().len() <= MAX_SNAPSHOT_URL_BYTES);
1558        assert!(bounded.media_type.as_ref().unwrap().len() <= MAX_SNAPSHOT_MEDIA_TYPE_BYTES);
1559        assert!(bounded.charset.as_ref().unwrap().len() <= MAX_SNAPSHOT_CHARSET_BYTES);
1560        assert!(std::str::from_utf8(bounded.content.as_bytes()).is_ok());
1561    }
1562
1563    #[test]
1564    fn snapshot_store_isolates_session_and_task_owners() {
1565        let owner = FetchSnapshotScope {
1566            session_id: Some(compact_string("session-a")),
1567            task_id: Some(compact_string("task-a")),
1568            fallback_turn: None,
1569        };
1570        let mut store = FetchSnapshotStore::default();
1571        let (id, _) = store.insert(owner.clone(), page("private page")).unwrap();
1572
1573        assert!(store.get(&owner, &id).is_some());
1574        for outsider in [
1575            FetchSnapshotScope {
1576                session_id: Some(compact_string("session-b")),
1577                task_id: Some(compact_string("task-a")),
1578                fallback_turn: None,
1579            },
1580            FetchSnapshotScope {
1581                session_id: Some(compact_string("session-a")),
1582                task_id: Some(compact_string("task-b")),
1583                fallback_turn: None,
1584            },
1585        ] {
1586            assert!(store.get(&outsider, &id).is_none());
1587        }
1588    }
1589
1590    #[test]
1591    fn snapshot_store_evicts_the_oldest_entry_at_the_count_limit() {
1592        let mut store = FetchSnapshotStore::default();
1593        let owner = scope("session-eviction");
1594        let mut ids = Vec::new();
1595        for index in 0..=MAX_FETCH_SNAPSHOTS {
1596            ids.push(
1597                store
1598                    .insert(owner.clone(), page(format!("page {index}")))
1599                    .unwrap()
1600                    .0,
1601            );
1602        }
1603        assert!(
1604            store.get(&owner, &ids[0]).is_none(),
1605            "oldest snapshot was not evicted"
1606        );
1607        assert!(store.get(&owner, ids.last().unwrap()).is_some());
1608        assert_eq!(store.entries.len(), MAX_FETCH_SNAPSHOTS);
1609    }
1610
1611    #[test]
1612    fn extract_matches_finds_case_insensitive_with_context() {
1613        let content = "line one\nline two\nTARGET here\nline four\nline five";
1614        let (out, count) = extract_matches(content, "target", 1, 20).unwrap();
1615        assert_eq!(count, 1);
1616        assert!(out.starts_with("1 match for \"target\":"));
1617        assert!(out.contains("L2: line two"));
1618        assert!(out.contains("L3: TARGET here"));
1619        assert!(out.contains("L4: line four"));
1620        assert!(!out.contains("L1:"), "context clipped to 1 line: {out}");
1621        assert!(!out.contains("L5:"));
1622    }
1623
1624    #[test]
1625    fn extract_matches_merges_overlapping_windows() {
1626        // Matches on adjacent lines must merge into ONE block (no separator).
1627        let content = "a\nhit one\nhit two\nb\nc\nd\ne\nf\ng\nhit three\nz";
1628        let (out, count) = extract_matches(content, "hit", 1, 20).unwrap();
1629        assert_eq!(count, 3);
1630        assert!(out.starts_with("3 matches"));
1631        assert_eq!(out.matches("---").count(), 1, "two blocks: {out}");
1632        // No duplicated lines from the merged windows.
1633        assert_eq!(out.matches("hit one").count(), 1);
1634    }
1635
1636    #[test]
1637    fn extract_matches_caps_blocks_and_reports_tail() {
1638        // 25 matches spaced far apart -> 25 blocks, capped at 20 + tail note.
1639        let content = (0..25)
1640            .map(|i| format!("match {i}\nx\nx\nx\nx\nx"))
1641            .collect::<Vec<_>>()
1642            .join("\n");
1643        let (out, count) = extract_matches(&content, "match", 0, 20).unwrap();
1644        assert_eq!(count, 25);
1645        assert!(out.starts_with("25 matches"));
1646        assert_eq!(out.matches("---").count(), 19, "20 blocks: {out}");
1647        assert!(out.contains("(+5 more matches)"), "tail note: {out}");
1648    }
1649
1650    #[test]
1651    fn extract_matches_none_and_multibyte() {
1652        assert!(extract_matches("nothing here", "absent", 2, 20).is_none());
1653        // Multibyte content must not panic and must match case-insensitively.
1654        let content = "voil\u{e0} un r\u{e9}sultat\nplain line";
1655        let (out, count) = extract_matches(content, "R\u{c9}SULTAT", 0, 20).unwrap();
1656        assert_eq!(count, 1);
1657        assert!(out.contains("L1: voil\u{e0} un r\u{e9}sultat"));
1658        // Context 0 keeps only the matching line.
1659        assert!(!out.contains("plain line"));
1660    }
1661
1662    #[test]
1663    fn extract_matches_uses_full_unicode_case_folding() {
1664        let content = "Die Straße ist lang\nSTRASSE in capitals\nother";
1665        let (out, count) = extract_matches(content, "strasse", 0, 20).unwrap();
1666        assert!(out.starts_with("2 matches for \"strasse\":"), "{out}");
1667        assert!(out.contains("L1: Die Straße ist lang"), "{out}");
1668        assert!(out.contains("L2: STRASSE in capitals"), "{out}");
1669        assert_eq!(count, 2);
1670    }
1671
1672    #[test]
1673    fn extract_matches_normalizes_composed_and_decomposed_text() {
1674        let content = "Café noir\nCafe\u{301} blanc\nplain";
1675        let decomposed_pattern = "CAFE\u{301}";
1676        let (out, count) = extract_matches(content, decomposed_pattern, 0, 20).unwrap();
1677        assert!(out.starts_with("2 matches"), "{out}");
1678        assert!(out.contains("L1: Café noir"), "{out}");
1679        assert!(out.contains("L2: Cafe\u{301} blanc"), "{out}");
1680        assert_eq!(count, 2);
1681    }
1682
1683    #[test]
1684    fn format_fetch_pattern_paths() {
1685        let page = page("alpha\nbeta\ngamma");
1686        // Match -> report replaces the body.
1687        let out = format_fetch(&page, "web-1", Some("beta"), 1, None, 200);
1688        assert!(out.output.contains("1 match for \"beta\""));
1689        assert!(out.output.contains("L2: beta"));
1690        // No match is explicit and does not unexpectedly dump the full body.
1691        let out = format_fetch(&page, "web-1", Some("nope"), 1, None, 200);
1692        assert!(out.output.contains("No matching lines for \"nope\"."));
1693        assert!(!out.output.contains("alpha"));
1694    }
1695
1696    #[test]
1697    fn find_in_page_runs_before_the_cap() {
1698        // A match in the tail of a page longer than the cap must still be
1699        // found (matching runs pre-cap; only the report is capped).
1700        let mut content = "x\n".repeat(WEB_FETCH_MAX_BYTES / 2);
1701        content.push_str("needle in the tail\n");
1702        let page = page(content);
1703        let out = format_fetch(&page, "web-1", Some("needle"), 1, None, 200);
1704        assert!(
1705            out.output.contains("1 match for \"needle\""),
1706            "tail match found"
1707        );
1708        assert!(out.output.contains("needle in the tail"));
1709    }
1710
1711    #[test]
1712    fn parse_queries_single_form() {
1713        let args = serde_json::json!({"query": "rust async", "max_results": 3});
1714        let q = parse_queries(&args).unwrap();
1715        assert_eq!(q.len(), 1);
1716        assert_eq!(q[0].0, "rust async");
1717        assert_eq!(q[0].1, 3);
1718    }
1719
1720    #[test]
1721    fn parse_queries_array_form() {
1722        let args = serde_json::json!({"queries": [
1723            {"query": "a", "max_results": 2},
1724            {"query": "b", "max_results": 5},
1725        ]});
1726        let q = parse_queries(&args).unwrap();
1727        assert_eq!(q.len(), 2);
1728        assert_eq!(q[1].1, 5);
1729    }
1730
1731    #[test]
1732    fn parse_queries_missing_errors() {
1733        let args = serde_json::json!({});
1734        assert!(parse_queries(&args).is_err());
1735    }
1736
1737    #[test]
1738    fn parse_queries_rejects_out_of_range_count() {
1739        let args = serde_json::json!({"query": "q", "max_results": 999});
1740        assert!(parse_queries(&args).is_err());
1741        let args = serde_json::json!({"query": "q", "max_results": 0});
1742        assert!(parse_queries(&args).is_err());
1743        let args = serde_json::json!({"query": "q", "max_results": "5"});
1744        assert!(parse_queries(&args).is_err());
1745    }
1746
1747    #[test]
1748    fn parse_queries_rejects_ambiguous_and_unknown_arguments() {
1749        assert!(
1750            parse_queries(&serde_json::json!({"query":"a", "queries":[{"query":"b"}]})).is_err()
1751        );
1752        assert!(parse_queries(&serde_json::json!({"query":"a", "extra":true})).is_err());
1753        assert!(parse_queries(&serde_json::json!({"query":"   "})).is_err());
1754        assert!(parse_queries(&serde_json::json!({"query":"safe\n=== injected ==="})).is_err());
1755    }
1756
1757    #[test]
1758    fn parse_queries_rejects_excess_fan_out() {
1759        // #90: a single call can't request unbounded fan-out.
1760        let many: Vec<_> = (0..mermaid_model::constants::MAX_BATCH_TOOL_ITEMS + 1)
1761            .map(|i| serde_json::json!({"query": format!("q{i}")}))
1762            .collect();
1763        let args = serde_json::json!({ "queries": many });
1764        assert!(parse_queries(&args).is_err());
1765
1766        // Exactly at the cap is still accepted.
1767        let at_cap: Vec<_> = (0..mermaid_model::constants::MAX_BATCH_TOOL_ITEMS)
1768            .map(|i| serde_json::json!({"query": format!("q{i}")}))
1769            .collect();
1770        let args = serde_json::json!({ "queries": at_cap });
1771        assert_eq!(
1772            parse_queries(&args).unwrap().len(),
1773            mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
1774        );
1775    }
1776
1777    #[test]
1778    fn parse_fetch_args_is_strict_and_rejects_credentialed_urls() {
1779        for invalid in [
1780            serde_json::json!({}),
1781            serde_json::json!({"url": "https://example.com", "snapshot_id": "web-1"}),
1782            serde_json::json!({"url": "https://user:password@example.com"}),
1783            serde_json::json!({"url": "http://127.0.0.1/private"}),
1784            serde_json::json!({"snapshot_id": "bad"}),
1785            serde_json::json!({"snapshot_id": "web-1", "context_lines": 2}),
1786            serde_json::json!({"snapshot_id": "web-1", "pattern": "x", "start_line": 1}),
1787            serde_json::json!({"snapshot_id": "web-1", "unknown": true}),
1788        ] {
1789            assert!(parse_fetch_args(&invalid).is_err(), "accepted {invalid}");
1790        }
1791
1792        let parsed = parse_fetch_args(&serde_json::json!({
1793            "url": "https://example.com/page#fragment",
1794            "start_line": 4,
1795            "line_count": 2
1796        }))
1797        .unwrap();
1798        let FetchTarget::Url(url) = &parsed.target else {
1799            panic!("expected a URL target");
1800        };
1801        assert_eq!(url.as_str(), "https://example.com/page");
1802        assert_eq!(parsed.start_line, Some(4));
1803        assert_eq!(parsed.line_count, 2);
1804    }
1805
1806    #[tokio::test]
1807    async fn web_fetch_failure_retains_typed_backend_provenance() {
1808        use crate::providers::ctx::test_exec_context;
1809        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
1810
1811        struct FailingFetch;
1812
1813        #[async_trait]
1814        impl FetchProvider for FailingFetch {
1815            async fn fetch(
1816                &self,
1817                url: &str,
1818                _budget: crate::providers::ctx::WebByteBudget,
1819            ) -> Result<WebFetchResult, WebFetchError> {
1820                Err(WebFetchError::HttpStatus {
1821                    status: 503,
1822                    url: url.to_string(),
1823                })
1824            }
1825        }
1826
1827        let tool = WebFetchTool::new_with_test_snapshots(Arc::new(FailingFetch), "mock");
1828        let (ctx, _rx) =
1829            test_exec_context(TurnId(9), ToolCallId(9), std::path::PathBuf::from("/tmp"));
1830        let outcome = tool
1831            .execute(serde_json::json!({"url": "https://example.com/page"}), ctx)
1832            .await;
1833
1834        assert_eq!(outcome.status, ToolStatus::Error);
1835        match &outcome.metadata.detail {
1836            ToolMetadata::WebFetch {
1837                status,
1838                error_kind,
1839                backend,
1840                final_url,
1841                ..
1842            } => {
1843                assert_eq!(*status, Some(503));
1844                assert_eq!(error_kind.as_deref(), Some("http_status"));
1845                assert_eq!(backend, "mock");
1846                assert!(final_url.is_none());
1847            },
1848            other => panic!("expected web metadata, got {other:?}"),
1849        }
1850    }
1851
1852    #[tokio::test]
1853    async fn web_search_progress_redacts_without_changing_transport_query() {
1854        use crate::providers::ctx::test_exec_context;
1855        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
1856
1857        struct RecordingSearch {
1858            seen: Arc<Mutex<Option<String>>>,
1859        }
1860
1861        #[async_trait]
1862        impl SearchProvider for RecordingSearch {
1863            async fn search(
1864                &self,
1865                query: &str,
1866                _count: usize,
1867                _budget: crate::providers::ctx::WebByteBudget,
1868            ) -> anyhow::Result<Vec<crate::providers::tool::web_client::SearchResult>> {
1869                *self
1870                    .seen
1871                    .lock()
1872                    .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(query.to_string());
1873                Ok(Vec::new())
1874            }
1875        }
1876
1877        let seen = Arc::new(Mutex::new(None));
1878        let tool = WebSearchTool {
1879            backend: Arc::new(RecordingSearch { seen: seen.clone() }),
1880            backend_name: "mock",
1881        };
1882        let (ctx, mut progress) =
1883            test_exec_context(TurnId(91), ToolCallId(91), std::path::PathBuf::from("/tmp"));
1884        let query = "research OPENAI_API_KEY=abc";
1885        let outcome = tool.execute(serde_json::json!({"query": query}), ctx).await;
1886        assert_eq!(outcome.status, ToolStatus::Success);
1887        assert_eq!(
1888            seen.lock()
1889                .unwrap_or_else(std::sync::PoisonError::into_inner)
1890                .as_deref(),
1891            Some(query),
1892            "redaction must not alter the transport query"
1893        );
1894        let ProgressEvent::Status(status) = progress.recv().await.expect("search progress") else {
1895            panic!("expected search status progress");
1896        };
1897        assert!(
1898            !status.contains("abc"),
1899            "progress leaked query secret: {status}"
1900        );
1901        assert!(status.contains("OPENAI_API_KEY=[REDACTED]"));
1902    }
1903
1904    #[tokio::test]
1905    async fn snapshot_line_ranges_do_not_refetch_mutable_pages() {
1906        use crate::providers::ctx::test_exec_context;
1907        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
1908        use std::sync::atomic::{AtomicUsize, Ordering};
1909
1910        struct MockFetch {
1911            calls: Arc<AtomicUsize>,
1912        }
1913
1914        #[async_trait]
1915        impl FetchProvider for MockFetch {
1916            async fn fetch(
1917                &self,
1918                url: &str,
1919                _budget: crate::providers::ctx::WebByteBudget,
1920            ) -> Result<WebFetchResult, WebFetchError> {
1921                self.calls.fetch_add(1, Ordering::SeqCst);
1922                let mut result = page("one\ntwo\nthree");
1923                result.requested_url = url.to_string();
1924                result.final_url = Some(url.to_string());
1925                Ok(result)
1926            }
1927        }
1928
1929        let calls = Arc::new(AtomicUsize::new(0));
1930        let tool = WebFetchTool::new_with_test_snapshots(
1931            Arc::new(MockFetch {
1932                calls: calls.clone(),
1933            }),
1934            "mock",
1935        );
1936        let (mut ctx, _rx) =
1937            test_exec_context(TurnId(10), ToolCallId(10), std::path::PathBuf::from("/tmp"));
1938        ctx.session_id = Some("session-a".to_string());
1939        let first = tool
1940            .execute(serde_json::json!({"url": "https://example.com/page"}), ctx)
1941            .await;
1942        assert_eq!(first.status, ToolStatus::Success);
1943        let snapshot_id = match &first.metadata.detail {
1944            ToolMetadata::WebFetch { snapshot_id, .. } => snapshot_id.clone().expect("snapshot id"),
1945            other => panic!("expected web metadata, got {other:?}"),
1946        };
1947
1948        let (mut foreign_ctx, _rx) =
1949            test_exec_context(TurnId(11), ToolCallId(11), std::path::PathBuf::from("/tmp"));
1950        foreign_ctx.session_id = Some("session-b".to_string());
1951        let foreign = tool
1952            .execute(
1953                serde_json::json!({
1954                    "snapshot_id": snapshot_id.clone(),
1955                    "start_line": 2,
1956                    "line_count": 1
1957                }),
1958                foreign_ctx,
1959            )
1960            .await;
1961        assert_eq!(foreign.status, ToolStatus::Error);
1962        assert!(foreign.output().contains("unavailable or was evicted"));
1963
1964        let (mut ctx, _rx) =
1965            test_exec_context(TurnId(12), ToolCallId(12), std::path::PathBuf::from("/tmp"));
1966        ctx.session_id = Some("session-a".to_string());
1967        let continuation = tool
1968            .execute(
1969                serde_json::json!({
1970                    "snapshot_id": snapshot_id,
1971                    "start_line": 2,
1972                    "line_count": 1
1973                }),
1974                ctx,
1975            )
1976            .await;
1977        assert_eq!(continuation.status, ToolStatus::Success);
1978        assert!(continuation.output().contains("L2: two"));
1979        assert!(!continuation.output().contains("L1: one"));
1980        assert_eq!(
1981            calls.load(Ordering::SeqCst),
1982            1,
1983            "snapshot triggered a refetch"
1984        );
1985    }
1986
1987    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1988    async fn snapshot_blocking_work_respects_the_global_extractor_limit() {
1989        use std::sync::atomic::{AtomicUsize, Ordering};
1990
1991        let active = Arc::new(AtomicUsize::new(0));
1992        let peak = Arc::new(AtomicUsize::new(0));
1993        let mut jobs = Vec::new();
1994        for _ in 0..8 {
1995            let active = Arc::clone(&active);
1996            let peak = Arc::clone(&peak);
1997            jobs.push(tokio::spawn(async move {
1998                run_snapshot_blocking(move || {
1999                    let now = active.fetch_add(1, Ordering::SeqCst) + 1;
2000                    peak.fetch_max(now, Ordering::SeqCst);
2001                    std::thread::sleep(std::time::Duration::from_millis(20));
2002                    active.fetch_sub(1, Ordering::SeqCst);
2003                })
2004                .await
2005                .unwrap();
2006            }));
2007        }
2008        for job in jobs {
2009            job.await.unwrap();
2010        }
2011
2012        assert_eq!(active.load(Ordering::SeqCst), 0);
2013        assert!(
2014            peak.load(Ordering::SeqCst) <= mermaid_model::constants::MAX_WEB_EXTRACTION_CONCURRENCY
2015        );
2016    }
2017
2018    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2019    async fn cancelled_snapshot_waiter_keeps_its_permit_until_blocking_work_finishes() {
2020        let limiter = Arc::new(tokio::sync::Semaphore::new(1));
2021        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2022        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(0);
2023        let worker_limiter = limiter.clone();
2024        let worker = tokio::spawn(async move {
2025            run_snapshot_blocking_with(worker_limiter, move || {
2026                let _ = started_tx.send(());
2027                release_rx.recv().expect("test releases blocking worker");
2028            })
2029            .await
2030        });
2031        started_rx.await.expect("blocking worker started");
2032        worker.abort();
2033        let _ = worker.await;
2034        assert_eq!(
2035            limiter.available_permits(),
2036            0,
2037            "cancelling the async waiter released a still-running blocking job"
2038        );
2039
2040        release_tx.send(()).expect("release blocking worker");
2041        tokio::time::timeout(std::time::Duration::from_secs(1), async {
2042            while limiter.available_permits() == 0 {
2043                tokio::task::yield_now().await;
2044            }
2045        })
2046        .await
2047        .expect("blocking worker did not release its permit");
2048    }
2049
2050    #[tokio::test]
2051    async fn web_search_batch_survives_empty_and_failed_queries() {
2052        use crate::providers::ctx::test_exec_context;
2053        use crate::providers::tool::web_client::SearchResult;
2054        use async_trait::async_trait;
2055        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2056        use std::sync::Arc;
2057
2058        struct Mock;
2059        #[async_trait]
2060        impl SearchProvider for Mock {
2061            async fn search(
2062                &self,
2063                query: &str,
2064                _count: usize,
2065                _budget: crate::providers::ctx::WebByteBudget,
2066            ) -> anyhow::Result<Vec<SearchResult>> {
2067                match query {
2068                    "boom" => Err(anyhow::anyhow!("backend down")),
2069                    "empty" => Ok(Vec::new()),
2070                    _ => Ok(vec![SearchResult {
2071                        title: "Title".to_string(),
2072                        url: "https://example.com".to_string(),
2073                        snippet: "snip".to_string(),
2074                        full_content: "content".to_string(),
2075                    }]),
2076                }
2077            }
2078        }
2079
2080        let mk = || WebSearchTool {
2081            backend: Arc::new(Mock),
2082            backend_name: "mock",
2083        };
2084        let tmp = std::path::PathBuf::from("/tmp");
2085
2086        // Partial: one good, one empty, one erroring -> success, good kept.
2087        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), tmp.clone());
2088        let out = mk()
2089            .execute(
2090                serde_json::json!({"queries": [{"query":"good"},{"query":"empty"},{"query":"boom"}]}),
2091                ctx,
2092            )
2093            .await;
2094        assert_eq!(
2095            out.status,
2096            ToolStatus::Success,
2097            "a partial batch must not abort"
2098        );
2099        assert!(
2100            out.output().contains("https://example.com"),
2101            "keeps the good result"
2102        );
2103        match &out.metadata.detail {
2104            ToolMetadata::WebSearch {
2105                partial, failures, ..
2106            } => {
2107                assert!(*partial);
2108                assert_eq!(failures.len(), 1);
2109                assert_eq!(failures[0].query_index, 2);
2110                assert!(failures[0].error.contains("backend down"));
2111            },
2112            other => panic!("expected web search metadata, got {other:?}"),
2113        }
2114
2115        // A single empty query is "no results", not a hard error.
2116        let (ctx, _rx) = test_exec_context(TurnId(2), ToolCallId(2), tmp.clone());
2117        let out = mk()
2118            .execute(serde_json::json!({"query": "empty"}), ctx)
2119            .await;
2120        assert_eq!(out.status, ToolStatus::Success, "empty is not an error");
2121        assert!(out.output().contains("no results"));
2122
2123        // Every query failing IS a tool error.
2124        let (ctx, _rx) = test_exec_context(TurnId(3), ToolCallId(3), tmp);
2125        let out = mk()
2126            .execute(
2127                serde_json::json!({"queries": [{"query":"boom"},{"query":"boom"}]}),
2128                ctx,
2129            )
2130            .await;
2131        assert_eq!(out.status, ToolStatus::Error, "total failure is an error");
2132        match &out.metadata.detail {
2133            ToolMetadata::WebSearch {
2134                failed_queries,
2135                failures,
2136                ..
2137            } => {
2138                assert_eq!(*failed_queries, 2);
2139                assert_eq!(failures.len(), 2);
2140            },
2141            other => panic!("expected web search metadata, got {other:?}"),
2142        }
2143    }
2144
2145    #[tokio::test]
2146    async fn web_search_batch_caps_concurrency_and_restores_input_order() {
2147        use crate::providers::ctx::test_exec_context;
2148        use crate::providers::tool::web_client::SearchResult;
2149        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2150        use std::sync::atomic::{AtomicUsize, Ordering};
2151
2152        struct ConcurrencyMock {
2153            active: Arc<AtomicUsize>,
2154            peak: Arc<AtomicUsize>,
2155        }
2156
2157        #[async_trait]
2158        impl SearchProvider for ConcurrencyMock {
2159            async fn search(
2160                &self,
2161                query: &str,
2162                _count: usize,
2163                _budget: crate::providers::ctx::WebByteBudget,
2164            ) -> anyhow::Result<Vec<SearchResult>> {
2165                let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
2166                self.peak.fetch_max(active, Ordering::SeqCst);
2167                let delay = 5 + (6 - query.parse::<u64>().unwrap_or(0)) * 5;
2168                tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
2169                self.active.fetch_sub(1, Ordering::SeqCst);
2170                Ok(vec![SearchResult {
2171                    title: format!("result {query}"),
2172                    url: format!("https://example.com/{query}"),
2173                    snippet: String::new(),
2174                    full_content: format!("content {query}"),
2175                }])
2176            }
2177        }
2178
2179        let active = Arc::new(AtomicUsize::new(0));
2180        let peak = Arc::new(AtomicUsize::new(0));
2181        let tool = WebSearchTool {
2182            backend: Arc::new(ConcurrencyMock {
2183                active: active.clone(),
2184                peak: peak.clone(),
2185            }),
2186            backend_name: "mock",
2187        };
2188        let (ctx, _rx) =
2189            test_exec_context(TurnId(20), ToolCallId(20), std::path::PathBuf::from("/tmp"));
2190        let queries: Vec<_> = (0..6)
2191            .map(|index| serde_json::json!({"query": index.to_string()}))
2192            .collect();
2193        let outcome = tool
2194            .execute(serde_json::json!({"queries": queries}), ctx)
2195            .await;
2196        assert_eq!(outcome.status, ToolStatus::Success);
2197        assert_eq!(active.load(Ordering::SeqCst), 0);
2198        assert_eq!(
2199            peak.load(Ordering::SeqCst),
2200            mermaid_model::constants::MAX_WEB_SEARCH_CONCURRENCY
2201        );
2202        let mut cursor = 0;
2203        for index in 0..6 {
2204            let marker = format!("=== query: {index} ===");
2205            let position = outcome.output()[cursor..]
2206                .find(&marker)
2207                .map(|offset| cursor + offset)
2208                .expect("ordered query section");
2209            assert!(position >= cursor);
2210            cursor = position + marker.len();
2211        }
2212    }
2213
2214    #[tokio::test]
2215    async fn web_search_complete_output_budget_is_byte_exact_for_multibyte_text() {
2216        use crate::providers::ctx::test_exec_context;
2217        use crate::providers::tool::web_client::SearchResult;
2218        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2219
2220        struct MultibyteMock;
2221
2222        #[async_trait]
2223        impl SearchProvider for MultibyteMock {
2224            async fn search(
2225                &self,
2226                _query: &str,
2227                _count: usize,
2228                _budget: crate::providers::ctx::WebByteBudget,
2229            ) -> anyhow::Result<Vec<SearchResult>> {
2230                Ok(vec![SearchResult {
2231                    title: "界".repeat(5_000),
2232                    url: "https://example.com/result".to_string(),
2233                    snippet: String::new(),
2234                    full_content: "界".repeat(20_000),
2235                }])
2236            }
2237        }
2238
2239        let tool = WebSearchTool {
2240            backend: Arc::new(MultibyteMock),
2241            backend_name: "mock",
2242        };
2243        let (ctx, _rx) =
2244            test_exec_context(TurnId(21), ToolCallId(21), std::path::PathBuf::from("/tmp"));
2245        let outcome = tool
2246            .execute(serde_json::json!({"query": "multibyte"}), ctx)
2247            .await;
2248
2249        assert_eq!(outcome.status, ToolStatus::Success);
2250        assert!(
2251            outcome.output().len() <= mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES,
2252            "{} bytes escaped the complete search-result cap",
2253            outcome.output().len()
2254        );
2255        assert!(std::str::from_utf8(outcome.output().as_bytes()).is_ok());
2256    }
2257
2258    #[tokio::test]
2259    async fn web_search_total_failure_and_structured_errors_are_byte_bounded() {
2260        use crate::providers::ctx::test_exec_context;
2261        use mermaid_domain::{ToolCallId, ToolStatus, TurnId};
2262
2263        struct LargeFailure;
2264
2265        #[async_trait]
2266        impl SearchProvider for LargeFailure {
2267            async fn search(
2268                &self,
2269                _query: &str,
2270                _count: usize,
2271                _budget: crate::providers::ctx::WebByteBudget,
2272            ) -> anyhow::Result<Vec<crate::providers::tool::web_client::SearchResult>> {
2273                Err(anyhow::anyhow!("{}", "界".repeat(20_000)))
2274            }
2275        }
2276
2277        let tool = WebSearchTool {
2278            backend: Arc::new(LargeFailure),
2279            backend_name: "mock",
2280        };
2281        let (ctx, _rx) =
2282            test_exec_context(TurnId(22), ToolCallId(22), std::path::PathBuf::from("/tmp"));
2283        let outcome = tool
2284            .execute(
2285                serde_json::json!({"queries": [{"query": "one"}, {"query": "two"}]}),
2286                ctx,
2287            )
2288            .await;
2289
2290        assert_eq!(outcome.status, ToolStatus::Error);
2291        assert!(
2292            outcome.output().len() <= mermaid_model::constants::WEB_SEARCH_AGGREGATE_MAX_BYTES,
2293            "{} bytes escaped the complete search error cap",
2294            outcome.output().len()
2295        );
2296        let ToolMetadata::WebSearch { failures, .. } = &outcome.metadata.detail else {
2297            panic!("expected web search metadata");
2298        };
2299        assert_eq!(failures.len(), 2);
2300        assert!(
2301            failures
2302                .iter()
2303                .all(|failure| failure.error.len() <= MAX_WEB_SEARCH_FAILURE_BYTES)
2304        );
2305    }
2306}