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