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