Skip to main content

vtcode_core/tools/web_search/
mod.rs

1//! WebSearch tool: query -> ranked web results (title, url, snippet).
2//!
3//! Two providers, selected in `vtcode.toml` via `[tools.web_search] provider`:
4//! - `duckduckgo` (default): keyless HTML scraping of
5//!   `https://html.duckduckgo.com/html/`. Best-effort; may be rate-limited.
6//! - `youcom`: the You.com Search API (`https://ydc-index.io/v1/search`),
7//!   opt-in, requires the `YDC_API_KEY` environment variable.
8//!
9//! Safety/rate-limit guard rails (shared by both providers):
10//! - A **cooldown** between consecutive network requests (default 3s) prevents
11//!   hammering DDG and triggering anti-bot challenges.
12//! - A short **result cache** (default 5min TTL) means repeated identical
13//!   queries are answered from memory without any network call.
14//! - A **session-wide cap** (default 12 requests) ensures the tool cannot
15//!   leak past DDG's soft quotas even with varied queries.
16//!
17//! All three are configurable via `WebSearchConfig` and turned off in tests.
18
19use super::traits::Tool;
20use crate::config::constants::tools;
21use anyhow::{Context, Result, anyhow};
22use astral_html::Token;
23use async_trait::async_trait;
24use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT};
25use serde::Deserialize;
26use serde_json::{Value, json};
27use std::collections::HashMap;
28use std::sync::{Arc, Mutex};
29use std::time::{Duration, Instant};
30use url::Url;
31use vtcode_config::{WebSearchConfig, WebSearchProvider};
32
33const MAX_TIMEOUT_SECS: u64 = 60;
34const MAX_RESULTS_CAP: usize = 20;
35const MAX_TITLE_CHARS: usize = 200;
36const MAX_SNIPPET_CHARS: usize = 400;
37
38/// Browser-like user agent. DuckDuckGo's HTML endpoint blocks obvious bots, so a
39/// realistic UA reduces (but does not eliminate) the chance of being challenged.
40const BROWSER_USER_AGENT: &str =
41    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
42
43pub(crate) const WEB_SEARCH_DESCRIPTION: &str = "Searches the web for a query and returns a ranked list of results (title, url, snippet) inline. Accepts: { query: string, max_results?: number }. Provider is set in vtcode.toml ([tools.web_search] provider): \"duckduckgo\" (default; keyless HTML endpoint, best-effort, may be rate-limited) or \"youcom\" (You.com Search API; requires YDC_API_KEY). Results are cached for a few minutes to avoid repeat hits. Use web_fetch on the most promising result URL to read full content. Returns { query, provider, count, cached, results: [{ title, url, snippet }] }.";
44
45#[derive(Debug, Deserialize)]
46#[serde(deny_unknown_fields)]
47struct WebSearchArgs {
48    /// Search query.
49    query: String,
50    #[serde(default)]
51    max_results: Option<usize>,
52}
53
54pub struct SearchResult {
55    pub title: String,
56    pub url: String,
57    pub snippet: String,
58}
59
60impl SearchResult {
61    pub fn new(title: String, url: String, snippet: String) -> Self {
62        Self { title, url, snippet }
63    }
64}
65
66/// Lightweight in-process state shared between calls on the same tool
67/// instance. Owns the cooldown clock, the request counter, and the result
68/// cache.
69#[derive(Default)]
70struct SessionState {
71    last_request_at: Option<Instant>,
72    requests_made: u32,
73    cache: HashMap<String, CachedResults>,
74}
75
76struct CachedResults {
77    stored_at: Instant,
78    payload: Value,
79}
80
81impl SessionState {
82    fn cache_get(&self, key: &str, ttl: Duration) -> Option<Value> {
83        let entry = self.cache.get(key)?;
84        if entry.stored_at.elapsed() > ttl {
85            return None;
86        }
87        Some(entry.payload.clone())
88    }
89
90    fn cache_put(&mut self, key: String, payload: Value) {
91        self.cache.insert(key, CachedResults { stored_at: Instant::now(), payload });
92    }
93}
94
95/// WebSearch tool. Stateless over the network (DDG) but tracks per-instance
96/// cooldown, request cap, and short-lived result cache to avoid hammering
97/// DDG's HTML endpoint and triggering anti-bot challenges.
98#[derive(Clone, Default)]
99pub struct WebSearchTool {
100    config: Arc<Mutex<WebSearchConfig>>,
101    state: Arc<Mutex<SessionState>>,
102}
103
104impl WebSearchTool {
105    pub fn new() -> Self {
106        Self {
107            config: Arc::new(Mutex::new(WebSearchConfig::default())),
108            state: Arc::new(Mutex::new(SessionState::default())),
109        }
110    }
111
112    /// Construct a tool with an explicit configuration. The config drives the
113    /// result-count default, request timeout, cooldown, cache TTL, and
114    /// session-wide request cap.
115    pub fn with_config(config: WebSearchConfig) -> Self {
116        Self {
117            config: Arc::new(Mutex::new(config)),
118            state: Arc::new(Mutex::new(SessionState::default())),
119        }
120    }
121
122    /// Apply a new configuration (e.g., after `vtcode.toml` reload).
123    pub fn set_config(&self, config: WebSearchConfig) {
124        if let Ok(mut guard) = self.config.lock() {
125            *guard = config;
126        }
127    }
128
129    /// Reset the in-process state (cooldown, counter, cache). Tests use this.
130    pub fn reset(&self) {
131        if let Ok(mut guard) = self.state.lock() {
132            *guard = SessionState::default();
133        }
134    }
135
136    fn snapshot_config(&self) -> WebSearchConfig {
137        self.config.lock().map(|guard| guard.clone()).unwrap_or_default()
138    }
139
140    async fn run(&self, raw_args: Value) -> Result<Value> {
141        let args: WebSearchArgs =
142            serde_json::from_value(raw_args).context("Invalid arguments for web_search. Provide a 'query' string.")?;
143
144        let query = args.query.trim().to_string();
145        if query.is_empty() {
146            return Err(anyhow!("web_search requires a non-empty 'query'"));
147        }
148
149        let snapshot = self.snapshot_config();
150        let default_max = snapshot.max_results.clamp(1, MAX_RESULTS_CAP);
151        let max_results = args.max_results.unwrap_or(default_max).clamp(1, MAX_RESULTS_CAP);
152        let cooldown = Duration::from_millis(snapshot.cooldown_ms);
153        let cache_ttl = Duration::from_secs(snapshot.cache_ttl_secs);
154        let session_cap = snapshot.session_max_requests;
155
156        // Include `max_results` in the key so a repeat query with a
157        // different cap gets a fresh, larger or smaller result set. With the
158        // hard 20-result cap this is rarely observable, but it keeps the
159        // cache semantics tight: the value the agent sees was produced for
160        // the same `max_results` it asked for.
161        let cache_key = format!("{max_results}::{query}");
162
163        // Fast path: cache hit. Avoid the network entirely.
164        if let Some(cached) = self
165            .state
166            .lock()
167            .unwrap_or_else(|e| e.into_inner())
168            .cache_get(&cache_key, cache_ttl)
169        {
170            return Ok(mark_cached(cached));
171        }
172
173        // Enforce session-wide request cap before touching the network.
174        {
175            let state = self.state.lock().map_err(|e| anyhow!("web_search state lock poisoned: {e}"))?;
176            if state.requests_made >= session_cap {
177                return Ok(session_cap_reached_response(&query, session_cap));
178            }
179            if let Some(last) = state.last_request_at {
180                let elapsed = last.elapsed();
181                if elapsed < cooldown {
182                    // `elapsed < cooldown` is checked above, so subtraction is safe
183                    return Ok(cooldown_response(
184                        &query,
185                        cooldown.checked_sub(elapsed).expect("elapsed < cooldown checked above"),
186                    ));
187                }
188            }
189        }
190
191        // Resolve the provider before touching the network so a missing
192        // YDC_API_KEY can be reported as a setup error without consuming a
193        // request from the session cap or starting the cooldown clock.
194        let provider = match snapshot.provider {
195            WebSearchProvider::Youcom => "youcom",
196            WebSearchProvider::Auto | WebSearchProvider::Duckduckgo => "duckduckgo",
197        };
198        if provider == "youcom" && std::env::var(YOUCOM_API_KEY_ENV).is_err() {
199            return Ok(json!({
200                "error": format!(
201                    "You.com provider selected but {YOUCOM_API_KEY_ENV} is not set. Set it to a key from https://you.com/platform/api-keys, or switch [tools.web_search] provider back to \"duckduckgo\"."
202                ),
203                "query": query,
204                "provider": provider,
205                "error_type": "setup_error",
206                "next_action": "Set the YDC_API_KEY environment variable, then retry.",
207            }));
208        }
209
210        let results = match snapshot.provider {
211            WebSearchProvider::Youcom => youcom_search(&query, max_results, snapshot.timeout_secs).await,
212            WebSearchProvider::Auto | WebSearchProvider::Duckduckgo => {
213                duckduckgo_search(&query, max_results, snapshot.timeout_secs).await
214            }
215        };
216        let provider_name = provider;
217
218        // By this point we have either short-circuited above (cache / cap /
219        // cooldown) or attempted a real network call. Record the request so
220        // the cooldown and session cap kick in for subsequent calls.
221        if let Ok(mut state) = self.state.lock() {
222            state.last_request_at = Some(Instant::now());
223            state.requests_made = state.requests_made.saturating_add(1);
224        }
225
226        match results {
227            Ok(results) if results.is_empty() => {
228                let payload = json!({
229                    "query": query,
230                    "provider": provider_name,
231                    "count": 0,
232                    "results": [],
233                    "warning": if provider_name == "youcom" {
234                        "No results were returned. The query may have matched nothing; try a different query."
235                    } else {
236                        "No results were returned. DuckDuckGo may have rate-limited the request or matched nothing. Try a different query, or wait a few seconds and try again."
237                    }
238                });
239                self.cache_put(&cache_key, &payload);
240                Ok(payload)
241            }
242            Ok(results) => {
243                let payload = json!({
244                    "query": query,
245                    "provider": provider_name,
246                    "count": results.len(),
247                    "results": results
248                        .into_iter()
249                        .map(|r| json!({ "title": r.title, "url": r.url, "snippet": r.snippet }))
250                        .collect::<Vec<_>>(),
251                });
252                self.cache_put(&cache_key, &payload);
253                Ok(payload)
254            }
255            Err(e) => {
256                // Categorize the error so the agent can act on it. The
257                // classifier is DDG-flavoured (anti-bot / 202) but the
258                // HTTP-status and timeout branches are provider-neutral.
259                let (error_type, next_action) = classify_search_error(&e.to_string(), provider_name);
260                Ok(json!({
261                    "error": format!("web_search failed: {e}"),
262                    "query": query,
263                    "provider": provider_name,
264                    "error_type": error_type,
265                    "next_action": next_action,
266                }))
267            }
268        }
269    }
270
271    fn cache_put(&self, key: &str, payload: &Value) {
272        if let Ok(mut state) = self.state.lock() {
273            state.cache_put(key.to_string(), payload.clone());
274        }
275    }
276}
277
278/// Classify a search-provider error into a `(error_type, next_action)` pair.
279/// The returned strings are stable so the agent loop can branch on them.
280/// Provider-specific wording uses `provider` ("duckduckgo" or "youcom") so
281/// guidance matches the backend that actually failed; 5xx and generic
282/// network failures stay provider-neutral.
283fn classify_search_error(message: &str, provider: &str) -> (&'static str, &'static str) {
284    let lower = message.to_lowercase();
285    // 5xx from the upstream search service is transient but should not be
286    // retried the same way as a network error — the cause is server-side.
287    if let Some(status) = crate::tools::web_fetch::classify_helpers::extract_http_status(&lower) {
288        if (500..=599).contains(&status) {
289            return (
290                "upstream_error",
291                "The search service is currently unavailable. Retry after a short delay, or use web_fetch on a known URL as a fallback.",
292            );
293        }
294    }
295    if message.contains("HTTP 202") || lower.contains("anti-bot") || lower.contains("challenge") {
296        (
297            "antiban_blocked",
298            "DuckDuckGo declined this request (likely an anti-bot challenge for this network). An immediate retry from this network will hit the same block; pick a result URL from this session's earlier searches and use web_fetch on it instead, or ask the user to confirm a different search provider.",
299        )
300    } else if lower.contains("timeout") || lower.contains("timed out") {
301        if provider == "youcom" {
302            (
303                "network_error",
304                "You.com timed out. Retry after a short delay, or use web_fetch on a known URL as a fallback.",
305            )
306        } else {
307            (
308                "network_error",
309                "DuckDuckGo timed out. Retry after a short delay, or use web_fetch on a known URL as a fallback.",
310            )
311        }
312    } else {
313        (
314            "network_error",
315            "Wait a few seconds and retry, or use web_fetch directly if you already know a relevant URL.",
316        )
317    }
318}
319
320fn mark_cached(mut payload: Value) -> Value {
321    if let Value::Object(map) = &mut payload {
322        map.insert("cached".to_string(), Value::Bool(true));
323    }
324    payload
325}
326
327fn cooldown_response(query: &str, wait: Duration) -> Value {
328    json!({
329        "error": "web_search cooldown active",
330        "query": query,
331        "retry_after_ms": wait.as_millis() as u64,
332        "next_action": format!("Wait at least {} ms before the next web search to avoid being rate-limited.", wait.as_millis())
333    })
334}
335
336fn session_cap_reached_response(query: &str, cap: u32) -> Value {
337    json!({
338        "error": "web_search session request cap reached",
339        "query": query,
340        "session_max_requests": cap,
341        "next_action": format!("This session has used its {cap} web searches. Use web_fetch on a known URL or restart the session to search again.")
342    })
343}
344
345/// Build the HTTP client for the DuckDuckGo provider. Keyless HTML scraping
346/// is redirect-tolerant, so a limited policy is fine here.
347fn build_client(timeout_secs: u64) -> Result<reqwest::Client> {
348    reqwest::Client::builder()
349        .timeout(Duration::from_secs(timeout_secs.min(MAX_TIMEOUT_SECS)))
350        .redirect(reqwest::redirect::Policy::limited(3))
351        .build()
352        .context("failed to build HTTP client for web_search")
353}
354
355/// Build the HTTP client for the You.com provider. Redirects are disabled
356/// entirely so the `X-API-Key` header can never be replayed to another
357/// origin or scheme by an upstream redirect; the API endpoint does not
358/// redirect in normal operation, and a redirect here is treated as a
359/// failure rather than silently followed.
360fn build_youcom_client(timeout_secs: u64) -> Result<reqwest::Client> {
361    reqwest::Client::builder()
362        .timeout(Duration::from_secs(timeout_secs.min(MAX_TIMEOUT_SECS)))
363        .redirect(reqwest::redirect::Policy::none())
364        .build()
365        .context("failed to build HTTP client for web_search")
366}
367
368// ---------------------------------------------------------------------------
369// DuckDuckGo (keyless, best-effort HTML scraping)
370//
371// Result extraction is tokenized with `astral-html`'s streaming `Reader`
372// (pinned pilot dependency, vtcode-core only): tag and attribute names
373// arrive ASCII-lowercased, text in `script`/`style` stays raw text instead
374// of spurious tags, and each snippet is collected from its own anchor's
375// window so a missing snippet yields empty rather than the next result's.
376// ---------------------------------------------------------------------------
377
378async fn duckduckgo_search(query: &str, max_results: usize, timeout_secs: u64) -> Result<Vec<SearchResult>> {
379    let client = build_client(timeout_secs)?;
380    let mut headers = HeaderMap::new();
381    headers.insert(USER_AGENT, HeaderValue::from_static(BROWSER_USER_AGENT));
382    headers.insert(ACCEPT, HeaderValue::from_static("text/html,application/xhtml+xml"));
383
384    // The HTML endpoint expects the query as a POST form field `q`.
385    let response = client
386        .post("https://html.duckduckgo.com/html/")
387        .headers(headers)
388        .form(&[("q", query)])
389        .send()
390        .await
391        .context("DuckDuckGo request failed")?;
392
393    let status = response.status();
394    // HTTP 202 from the HTML endpoint is DuckDuckGo's anti-bot challenge page
395    // (it returns the homepage shell with no results). Treat it, and any other
396    // non-200, as a challenge rather than letting parsing silently yield zero
397    // results (which previously caused agent retry loops).
398    if status.as_u16() == 202 || !status.is_success() {
399        return Err(anyhow!(
400            "DuckDuckGo declined the request (HTTP {status}), likely an anti-bot challenge for this network. Wait a few seconds and retry."
401        ));
402    }
403
404    let body = read_capped_ddg_body(response).await?;
405
406    Ok(parse_duckduckgo_html(&body, max_results))
407}
408
409/// Upper bound for a DuckDuckGo HTML answer body. Result pages are a few
410/// hundred kilobytes; the cap only bites on adversarial responses, and the
411/// streaming parser stops at `max_results` well before it anyway.
412const MAX_DDG_BODY_BYTES: usize = 1_000_000;
413
414/// Read a DDG response body with an explicit byte cap. Decoding is lossy
415/// rather than fallible: DuckDuckGo serves UTF-8, and a corrupt byte must
416/// not turn into a hard error when partial results will do.
417async fn read_capped_ddg_body(response: reqwest::Response) -> Result<String> {
418    let bytes = response.bytes().await.context("failed to read DuckDuckGo response body")?;
419    Ok(capped_body_text(&bytes))
420}
421
422/// Pure, testable core of [`read_capped_ddg_body`]: cap at
423/// `MAX_DDG_BODY_BYTES`, then lossy-decode (never panics, even on a split
424/// trailing char or leading garbage).
425fn capped_body_text(bytes: &[u8]) -> String {
426    let end = bytes.len().min(MAX_DDG_BODY_BYTES);
427    String::from_utf8_lossy(&bytes[..end]).into_owned()
428}
429
430/// Extract ranked results from a DuckDuckGo HTML body. Pure function so it can
431/// be exercised in unit tests against a local fixture (no live network).
432///
433/// Single streaming pass: a result anchor opens title collection, its first
434/// `</a>` completes it, and the following `result__snippet` element (before
435/// the next result anchor) supplies the snippet. First-close-wins keeps
436/// parity with the previous extractor on well-formed markup.
437pub fn parse_duckduckgo_html(body: &str, max_results: usize) -> Vec<SearchResult> {
438    let mut results = Vec::new();
439    let mut state = DdgSection::Idle;
440    for token in astral_html::Reader::new(body) {
441        if results.len() >= max_results {
442            break;
443        }
444        match token {
445            Token::StartTag(tag) => {
446                if tag.name == "a" && has_ddg_class(&tag, "result__a") {
447                    // A new result anchor ends the pending one (keeping an
448                    // empty snippet) and drops any partial buffers.
449                    if let DdgSection::AfterTitle { url, title } = std::mem::replace(&mut state, DdgSection::Idle) {
450                        push_ddg_result(&mut results, url, title, String::new(), max_results);
451                        if results.len() >= max_results {
452                            break;
453                        }
454                    }
455                    if !tag.self_closing {
456                        if let Some(href) = ddg_attr(&tag, "href") {
457                            state = DdgSection::InTitle { href: href.to_string(), buf: String::new() };
458                        }
459                    }
460                } else if matches!(state, DdgSection::AfterTitle { .. }) && has_ddg_class(&tag, "result__snippet") {
461                    if let DdgSection::AfterTitle { url, title } = std::mem::replace(&mut state, DdgSection::Idle) {
462                        if tag.self_closing {
463                            push_ddg_result(&mut results, url, title, String::new(), max_results);
464                        } else {
465                            state = DdgSection::InSnippet {
466                                url,
467                                title,
468                                name: tag.name.to_string(),
469                                buf: String::new(),
470                            };
471                        }
472                    }
473                }
474                // All other tags are content or noise: nested formatting
475                // text arrives as Text tokens; nothing to do here.
476            }
477            Token::Text(text) => match &mut state {
478                DdgSection::InTitle { buf, .. } | DdgSection::InSnippet { buf, .. } => {
479                    buf.push_str(&text);
480                }
481                DdgSection::Idle | DdgSection::AfterTitle { .. } => {}
482            },
483            Token::EndTag(tag) => {
484                if tag.name == "a" && matches!(state, DdgSection::InTitle { .. }) {
485                    if let DdgSection::InTitle { href, buf } = std::mem::replace(&mut state, DdgSection::Idle) {
486                        let title = buf.trim().to_string();
487                        if !title.is_empty() {
488                            if let Some(url) = normalize_ddg_url(&href) {
489                                state = DdgSection::AfterTitle { url, title };
490                            }
491                        }
492                    }
493                } else if matches!(state, DdgSection::InSnippet { .. }) {
494                    let done = if let DdgSection::InSnippet { name, .. } = &state {
495                        tag.name == *name
496                    } else {
497                        false
498                    };
499                    if done {
500                        if let DdgSection::InSnippet { url, title, buf, .. } =
501                            std::mem::replace(&mut state, DdgSection::Idle)
502                        {
503                            push_ddg_result(&mut results, url, title, buf, max_results);
504                        }
505                    }
506                }
507            }
508            _ => {}
509        }
510    }
511    // Trailing result without a snippet element keeps an empty snippet.
512    if let DdgSection::AfterTitle { url, title } = state {
513        push_ddg_result(&mut results, url, title, String::new(), max_results);
514    }
515    results
516}
517
518/// Tokenizer position while extracting one result: idle, collecting the
519/// anchor title, holding a completed title, or collecting its snippet.
520enum DdgSection {
521    Idle,
522    InTitle {
523        href: String,
524        buf: String,
525    },
526    AfterTitle {
527        url: String,
528        title: String,
529    },
530    InSnippet {
531        url: String,
532        title: String,
533        name: String,
534        buf: String,
535    },
536}
537
538/// Decoded value of attribute `name` on `tag`, if present. Attribute names
539/// are ASCII-lowercased by the tokenizer, so plain equality applies.
540fn ddg_attr(tag: &astral_html::Tag<'_>, name: &str) -> Option<String> {
541    tag.attributes
542        .iter()
543        .find(|attr| attr.name == name)
544        .map(|attr| attr.value().to_string())
545}
546
547/// Whether the tag's `class` attribute carries `class` as a whitespace
548/// separated token (ASCII case-insensitive, matching DDG's lowercase).
549fn has_ddg_class(tag: &astral_html::Tag<'_>, class: &str) -> bool {
550    tag.attributes
551        .iter()
552        .find(|attr| attr.name == "class")
553        .is_some_and(|attr| attr.value().split_whitespace().any(|token| token.eq_ignore_ascii_case(class)))
554}
555
556/// Append one validated result unless the cap is reached. Callers validate
557/// the URL and require a non-empty title before reaching `AfterTitle`, so
558/// this only truncates and pushes.
559fn push_ddg_result(results: &mut Vec<SearchResult>, url: String, title: String, snippet: String, max_results: usize) {
560    if results.len() >= max_results {
561        return;
562    }
563    results.push(SearchResult {
564        title: truncate_chars(&title, MAX_TITLE_CHARS),
565        url,
566        snippet: truncate_chars(&snippet, MAX_SNIPPET_CHARS),
567    });
568}
569
570/// Resolve a DuckDuckGo result href into a real https/http URL.
571///
572/// DDG wraps targets in a redirector like `//duckduckgo.com/l/?uddg=<encoded>`.
573/// Returns `None` for non-http(s) schemes (defends against `javascript:` etc).
574fn normalize_ddg_url(href: &str) -> Option<String> {
575    let absolute = if let Some(stripped) = href.strip_prefix("//") {
576        format!("https://{stripped}")
577    } else {
578        href.to_string()
579    };
580
581    let parsed = Url::parse(&absolute).ok()?;
582    if let Some((_, target)) = parsed.query_pairs().find(|(k, _)| k == "uddg") {
583        let target = target.into_owned();
584        return validate_result_url(&target);
585    }
586    validate_result_url(&absolute)
587}
588
589/// Only allow http(s) result URLs with a host.
590fn validate_result_url(url: &str) -> Option<String> {
591    let parsed = Url::parse(url).ok()?;
592    match parsed.scheme() {
593        "http" | "https" => {}
594        _ => return None,
595    }
596    if parsed.host_str().is_none_or(str::is_empty) {
597        return None;
598    }
599    Some(url.to_string())
600}
601
602// ---------------------------------------------------------------------------
603// You.com Search API (opt-in, requires YDC_API_KEY)
604// ---------------------------------------------------------------------------
605
606/// Environment variable holding the You.com API key.
607const YOUCOM_API_KEY_ENV: &str = "YDC_API_KEY";
608
609/// You.com Search API endpoint (the search service host used by the
610/// official You.com SDKs; see https://you.com/docs/api-reference/search).
611const YOUCOM_SEARCH_URL: &str = "https://ydc-index.io/v1/search";
612
613#[derive(Debug, Deserialize)]
614struct YoucomSearchResponse {
615    #[serde(default)]
616    results: Option<YoucomResults>,
617}
618
619#[derive(Debug, Deserialize)]
620struct YoucomResults {
621    #[serde(default)]
622    web: Option<Vec<YoucomWebResult>>,
623}
624
625#[derive(Debug, Deserialize)]
626struct YoucomWebResult {
627    #[serde(default)]
628    url: Option<String>,
629    #[serde(default)]
630    title: Option<String>,
631    /// Flat description field (the API's short summary).
632    #[serde(default)]
633    description: Option<String>,
634    /// Alternative snippet list used by some response shapes.
635    #[serde(default)]
636    snippets: Option<Vec<String>>,
637}
638
639/// Search via the You.com Search API. The API key is read from the
640/// `YDC_API_KEY` environment variable at request time and sent as the
641/// `X-API-Key` header; it is never included in error messages or logs.
642async fn youcom_search(query: &str, max_results: usize, timeout_secs: u64) -> Result<Vec<SearchResult>> {
643    let api_key = std::env::var(YOUCOM_API_KEY_ENV).map_err(|source| {
644        anyhow!(
645            "You.com provider selected but {YOUCOM_API_KEY_ENV} is not set ({source}). Set it to a key from https://you.com/platform/api-keys, or switch [tools.web_search] provider back to \"duckduckgo\"."
646        )
647    })?;
648
649    // Redirects are disabled on this client (see `build_youcom_client`) so the
650    // `X-API-Key` header cannot be forwarded to another origin or scheme.
651    let client = build_youcom_client(timeout_secs)?;
652    let response = client
653        .post(YOUCOM_SEARCH_URL)
654        .header("X-API-Key", &api_key)
655        // Send `count` so a `max_results` above the API default is honored
656        // instead of silently falling back to 10 results.
657        .json(&serde_json::json!({ "query": query, "count": max_results }))
658        .send()
659        .await
660        .context("You.com request failed")?;
661
662    let status = response.status();
663    if !status.is_success() {
664        // Do not include the API key or response body in the error. A 3xx
665        // here means the endpoint redirected; the client refuses to follow
666        // it with credentials attached.
667        if status.is_redirection() {
668            return Err(anyhow!(
669                "You.com returned a redirect (HTTP {status}); refusing to follow it with the API key attached. Check the endpoint configuration."
670            ));
671        }
672        return Err(anyhow!(
673            "You.com declined the request (HTTP {status}). Check that {YOUCOM_API_KEY_ENV} is valid, then retry."
674        ));
675    }
676
677    let body = response.text().await.context("failed to read You.com response body")?;
678    let parsed: YoucomSearchResponse =
679        serde_json::from_str(&body).with_context(|| "failed to parse You.com search response")?;
680
681    Ok(parse_youcom_results(parsed, max_results))
682}
683
684/// Map the You.com response into the tool's shared `SearchResult` shape.
685/// Pure function so it can be exercised in unit tests against a local
686/// fixture (no live network).
687fn parse_youcom_results(response: YoucomSearchResponse, max_results: usize) -> Vec<SearchResult> {
688    let mut results = Vec::new();
689    for hit in parsed_web_hits(response) {
690        if results.len() >= max_results {
691            break;
692        }
693        let Some(url) = hit.url.as_deref().and_then(validate_result_url) else {
694            continue;
695        };
696        let title = hit.title.as_deref().unwrap_or_default().trim();
697        if title.is_empty() {
698            continue;
699        }
700        // Prefer the flat description; fall back to the first snippet.
701        let snippet = hit
702            .description
703            .as_deref()
704            .map(str::trim)
705            .filter(|s| !s.is_empty())
706            .or_else(|| hit.snippets.as_deref().and_then(|s| s.first()).map(|s| s.trim()))
707            .unwrap_or_default();
708        results.push(SearchResult {
709            title: truncate_chars(title, MAX_TITLE_CHARS),
710            url,
711            snippet: truncate_chars(snippet, MAX_SNIPPET_CHARS),
712        });
713    }
714    results
715}
716
717fn parsed_web_hits(response: YoucomSearchResponse) -> impl Iterator<Item = YoucomWebResult> {
718    response.results.into_iter().flat_map(|r| r.web.into_iter()).flatten()
719}
720
721// ---------------------------------------------------------------------------
722// Helpers
723// ---------------------------------------------------------------------------
724
725fn truncate_chars(input: &str, max_chars: usize) -> String {
726    vtcode_commons::formatting::truncate_text(input.trim(), max_chars, "…")
727}
728
729#[async_trait]
730impl Tool for WebSearchTool {
731    async fn execute(&self, args: Value) -> Result<Value> {
732        self.run(args).await
733    }
734
735    fn name(&self) -> &str {
736        tools::WEB_SEARCH
737    }
738
739    fn description(&self) -> &str {
740        WEB_SEARCH_DESCRIPTION
741    }
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::tools::web_fetch::classify_helpers::decode_html_entities;
748
749    #[test]
750    fn normalize_ddg_url_extracts_uddg_target() {
751        let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Fgithub.com%2Fvinhnx&rut=abc";
752        assert_eq!(normalize_ddg_url(href).as_deref(), Some("https://github.com/vinhnx"));
753    }
754
755    #[test]
756    fn normalize_ddg_url_passes_through_direct_https() {
757        let href = "https://example.com/page";
758        assert_eq!(normalize_ddg_url(href).as_deref(), Some("https://example.com/page"));
759    }
760
761    #[test]
762    fn validate_result_url_rejects_non_http_schemes() {
763        assert!(validate_result_url("javascript:alert(1)").is_none());
764        assert!(validate_result_url("data:text/html,hi").is_none());
765        assert!(validate_result_url("file:///etc/passwd").is_none());
766        assert!(validate_result_url("https://example.com").is_some());
767    }
768
769    #[test]
770    fn missing_query_is_rejected() {
771        let tool = WebSearchTool::new();
772        let result = tokio::runtime::Runtime::new()
773            .unwrap()
774            .block_on(tool.run(json!({ "max_results": 5 })));
775        assert!(result.is_err());
776    }
777
778    #[test]
779    fn pattern_is_rejected_as_an_unknown_field() {
780        let result = serde_json::from_value::<WebSearchArgs>(json!({
781            "query": "vinhnx",
782            "pattern": "legacy"
783        }));
784        assert!(result.is_err());
785    }
786
787    #[test]
788    fn canonical_query_and_max_results_are_accepted() {
789        let args = serde_json::from_value::<WebSearchArgs>(json!({
790            "query": "vinhnx",
791            "max_results": 5
792        }))
793        .expect("canonical web_search arguments");
794        assert_eq!(args.query, "vinhnx");
795        assert_eq!(args.max_results, Some(5));
796    }
797
798    #[test]
799    fn truncate_chars_appends_ellipsis() {
800        assert_eq!(truncate_chars("hello world", 5), "hello…");
801        assert_eq!(truncate_chars("hi", 5), "hi");
802    }
803
804    /// A small fixture that matches the markup the live DuckDuckGo HTML
805    /// endpoint emits. We do not assert on whitespace; we just need a stable
806    /// shape so the parser logic is exercised end-to-end without network.
807    const DDG_FIXTURE: &str = r#"
808        <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgithub.com%2Fvinhnx&rut=abc">vinhnx (vinhnguyenxuan) · GitHub</a>
809        <a class="result__snippet" href="https://github.com/vinhnx">Personal GitHub profile of Vinh Nguyen Xuan.</a>
810        <a class="result__a" href="https://example.com/page">Example Page</a>
811        <a class="result__snippet" href="https://example.com/page">An example page used in tests.</a>
812        <a class="result__a" href="javascript:alert(1)">Should be skipped</a>
813    "#;
814
815    #[test]
816    fn parse_duckduckgo_html_extracts_results_from_fixture() {
817        let results = parse_duckduckgo_html(DDG_FIXTURE, 10);
818        assert_eq!(results.len(), 2);
819
820        assert_eq!(results[0].title, "vinhnx (vinhnguyenxuan) · GitHub");
821        assert_eq!(results[0].url, "https://github.com/vinhnx");
822        assert_eq!(results[0].snippet, "Personal GitHub profile of Vinh Nguyen Xuan.");
823
824        assert_eq!(results[1].title, "Example Page");
825        assert_eq!(results[1].url, "https://example.com/page");
826        assert_eq!(results[1].snippet, "An example page used in tests.");
827    }
828
829    #[test]
830    fn parse_duckduckgo_html_respects_max_results() {
831        let results = parse_duckduckgo_html(DDG_FIXTURE, 1);
832        assert_eq!(results.len(), 1);
833        assert_eq!(results[0].url, "https://github.com/vinhnx");
834    }
835
836    #[test]
837    fn parse_duckduckgo_html_returns_empty_for_challenge_page() {
838        // A real DuckDuckGo anti-bot challenge returns the homepage shell with
839        // no `result__a` anchors. The parser should return an empty vec, not
840        // panic or fabricate results; the higher-level dispatcher then decides
841        // how to surface this to the agent.
842        let challenge = r#"<html><body>Anomaly detected.</body></html>"#;
843        assert!(parse_duckduckgo_html(challenge, 10).is_empty());
844    }
845
846    #[test]
847    fn parse_accepts_href_before_class_and_single_quotes() {
848        // Asymmetric attribute order + quote style: the old regex required
849        // `class="result__a"` before `href="..."` with double quotes.
850        let body = r#"
851            <a href='https://example.com/b-first' class='result__a'>B first</a>
852            <a class="result__snippet">Snippet B.</a>
853            <a href="https://example.com/a-second" class="result__a extra">A second</a>
854            <a class="result__snippet">Snippet A.</a>
855        "#;
856        let results = parse_duckduckgo_html(body, 10);
857        assert_eq!(results.len(), 2);
858        assert_eq!(results[0].url, "https://example.com/b-first");
859        assert_eq!(results[0].snippet, "Snippet B.");
860        assert_eq!(results[1].url, "https://example.com/a-second");
861        assert_eq!(results[1].snippet, "Snippet A.");
862    }
863
864    #[test]
865    fn parse_accepts_uppercase_tags_and_classes() {
866        let body = r#"
867            <A CLASS="RESULT__A" HREF="https://example.com/upper">Upper Title</A>
868            <A CLASS="RESULT__SNIPPET">Upper snippet.</A>
869        "#;
870        let results = parse_duckduckgo_html(body, 10);
871        assert_eq!(results.len(), 1);
872        assert_eq!(results[0].title, "Upper Title");
873        assert_eq!(results[0].snippet, "Upper snippet.");
874    }
875
876    #[test]
877    fn parse_missing_first_snippet_does_not_shift_second() {
878        // Regression for index-aligned snippet lookup: a missing snippet for
879        // the first result must yield empty, not the second result's snippet.
880        let body = r#"
881            <a class="result__a" href="https://example.com/no-snippet">No Snippet</a>
882            <a class="result__a" href="https://example.com/with-snippet">With Snippet</a>
883            <a class="result__snippet">Second snippet.</a>
884        "#;
885        let results = parse_duckduckgo_html(body, 10);
886        assert_eq!(results.len(), 2);
887        assert_eq!(results[0].snippet, "");
888        assert_eq!(results[1].snippet, "Second snippet.");
889    }
890
891    #[test]
892    fn parse_skipped_anchors_do_not_steal_snippets() {
893        // Asymmetric: [bad, good] vs [good, bad]. A skipped `javascript:`
894        // anchor must not consume or shift the good result's snippet.
895        let bad_first = r#"
896            <a class="result__a" href="javascript:alert(1)">Bad</a>
897            <a class="result__snippet">Bad snippet.</a>
898            <a class="result__a" href="https://example.com/good">Good</a>
899            <a class="result__snippet">Good snippet.</a>
900        "#;
901        let results = parse_duckduckgo_html(bad_first, 10);
902        assert_eq!(results.len(), 1);
903        assert_eq!(results[0].url, "https://example.com/good");
904        assert_eq!(results[0].snippet, "Good snippet.");
905
906        let good_first = r#"
907            <a class="result__a" href="https://example.com/good">Good</a>
908            <a class="result__snippet">Good snippet.</a>
909            <a class="result__a" href="javascript:alert(1)">Bad</a>
910            <a class="result__snippet">Bad snippet.</a>
911        "#;
912        let results = parse_duckduckgo_html(good_first, 10);
913        assert_eq!(results.len(), 1);
914        assert_eq!(results[0].snippet, "Good snippet.");
915    }
916
917    #[test]
918    fn parse_ignores_anchors_inside_script() {
919        // Tokenizer advantage over regex: `script` content is raw text, so
920        // a fake anchor inside JavaScript never becomes a result.
921        let body = concat!(
922            "<script>var x = '<a class=\"result__a\" href=\"https://example.com/evil\">Evil</a>';</script>",
923            "<a class=\"result__a\" href=\"https://example.com/good\">Good</a>",
924            "<a class=\"result__snippet\">Good snippet.</a>",
925        );
926        let results = parse_duckduckgo_html(body, 10);
927        assert_eq!(results.len(), 1);
928        assert_eq!(results[0].url, "https://example.com/good");
929        assert_eq!(results[0].snippet, "Good snippet.");
930    }
931
932    #[test]
933    fn parse_collects_nested_formatting_text_in_title() {
934        let body = concat!(
935            "<a class=\"result__a\" href=\"https://example.com/fmt\">Hello <b>Bold</b> tail</a>",
936            "<a class=\"result__snippet\">S.</a>",
937        );
938        let results = parse_duckduckgo_html(body, 10);
939        assert_eq!(results.len(), 1);
940        assert_eq!(results[0].title, "Hello Bold tail");
941    }
942
943    #[test]
944    fn capped_body_text_passes_small_bodies_through() {
945        assert_eq!(capped_body_text(b"<html>hi</html>"), "<html>hi</html>");
946        assert_eq!(capped_body_text(b""), "");
947    }
948
949    #[test]
950    fn capped_body_text_truncates_huge_bodies_without_panicking_on_split_chars() {
951        // 999_999 ASCII bytes + one 2-byte `é`: the 1MB cap lands mid-char.
952        // The ASCII prefix survives; the split tail becomes U+FFFD.
953        let mut bytes = vec![b'a'; 999_999];
954        bytes.extend_from_slice("é".as_bytes());
955        let text = capped_body_text(&bytes);
956        assert!(text[..999_999].bytes().all(|b| b == b'a'));
957
958        let huge = vec![b'a'; MAX_DDG_BODY_BYTES + 5];
959        assert_eq!(capped_body_text(&huge).len(), MAX_DDG_BODY_BYTES);
960    }
961
962    #[test]
963    fn capped_body_text_is_lossy_not_panicky_on_invalid_utf8() {
964        let text = capped_body_text(b"\xff\xfe<html></html>");
965        assert!(text.contains("<html>"), "got: {text}");
966    }
967
968    #[test]
969    fn decode_numeric_hex_and_named_entities() {
970        assert_eq!(decode_html_entities("&#65;&#x42;&#X43;"), "ABC");
971        assert_eq!(decode_html_entities("&rsquo;&ldquo;&mdash;"), "’“—");
972        assert_eq!(decode_html_entities("&amp;lt;"), "&lt;");
973        assert_eq!(decode_html_entities("&#9999999;"), "&#9999999;");
974    }
975
976    #[test]
977    fn parse_accepts_unquoted_href_and_td_closed_snippet() {
978        let body = r#"
979            <a class=result__a href=https://example.com/unquoted>Unquoted</a>
980            <td class="result__snippet">TD snippet.</td>
981            <abbr title="abbreviation">abbr</abbr>
982            <a class="result__a" href="https://example.com/second">Second</a>
983            <a class="result__snippet">Second snippet.</a>
984        "#;
985        let results = parse_duckduckgo_html(body, 10);
986        assert_eq!(results.len(), 2);
987        assert_eq!(results[0].url, "https://example.com/unquoted");
988        assert_eq!(results[0].snippet, "TD snippet.");
989        assert_eq!(results[1].url, "https://example.com/second");
990        assert_eq!(results[1].snippet, "Second snippet.");
991    }
992
993    #[test]
994    fn session_cap_short_circuits_with_structured_error() {
995        let config = WebSearchConfig {
996            provider: Default::default(),
997            max_results: 5,
998            timeout_secs: 20,
999            cooldown_ms: 0,
1000            cache_ttl_secs: 300,
1001            session_max_requests: 2,
1002        };
1003        let tool = WebSearchTool::with_config(config);
1004        // Bump the counter without going to the network.
1005        {
1006            let mut state = tool.state.lock().unwrap();
1007            state.requests_made = 2;
1008            state.last_request_at = Some(Instant::now());
1009        }
1010        let payload = tokio::runtime::Runtime::new()
1011            .unwrap()
1012            .block_on(tool.run(json!({ "query": "rust" })))
1013            .expect("cap should be a structured JSON, not a runtime error");
1014        assert_eq!(payload["error"], "web_search session request cap reached");
1015        assert_eq!(payload["session_max_requests"], 2);
1016    }
1017
1018    #[test]
1019    fn cooldown_short_circuits_with_retry_after() {
1020        let config = WebSearchConfig {
1021            provider: Default::default(),
1022            max_results: 5,
1023            timeout_secs: 20,
1024            cooldown_ms: 5_000,
1025            cache_ttl_secs: 300,
1026            session_max_requests: 100,
1027        };
1028        let tool = WebSearchTool::with_config(config);
1029        {
1030            let mut state = tool.state.lock().unwrap();
1031            state.requests_made = 0;
1032            state.last_request_at = Some(Instant::now());
1033        }
1034        let payload = tokio::runtime::Runtime::new()
1035            .unwrap()
1036            .block_on(tool.run(json!({ "query": "rust" })))
1037            .expect("cooldown should be a structured JSON");
1038        assert_eq!(payload["error"], "web_search cooldown active");
1039        assert!(payload["retry_after_ms"].as_u64().unwrap() > 0);
1040    }
1041
1042    #[test]
1043    fn cache_serves_repeat_queries_without_network() {
1044        // Pin max_results so the cache key matches the one the tool builds.
1045        let tool = WebSearchTool::with_config(WebSearchConfig {
1046            provider: Default::default(),
1047            max_results: 5,
1048            ..WebSearchConfig::default()
1049        });
1050        let cached_payload = json!({
1051            "query": "rust",
1052            "provider": "duckduckgo",
1053            "count": 1,
1054            "results": [{
1055                "title": "Cached Result",
1056                "url": "https://example.com/cached",
1057                "snippet": "from cache"
1058            }]
1059        });
1060        {
1061            let mut state = tool.state.lock().unwrap();
1062            state.cache_put("5::rust".to_string(), cached_payload.clone());
1063        }
1064        let payload = tokio::runtime::Runtime::new()
1065            .unwrap()
1066            .block_on(tool.run(json!({ "query": "rust" })))
1067            .expect("cache hit must not error");
1068        assert_eq!(payload["cached"], json!(true));
1069        assert_eq!(payload["count"], 1);
1070        assert_eq!(payload["results"][0]["title"], "Cached Result");
1071    }
1072
1073    #[test]
1074    fn classify_search_error_flags_antiban_block() {
1075        // DDG's HTML endpoint returns 202 (or 200 with a challenge page) on
1076        // a bot block. The classifier must route that to `antiban_blocked`
1077        // and tell the agent to NOT retry, because the same turn will
1078        // almost certainly hit the same block.
1079        let (kind, action) = classify_search_error(
1080            "DuckDuckGo declined the request (HTTP 202), likely an anti-bot challenge for this network.",
1081            "duckduckgo",
1082        );
1083        assert_eq!(kind, "antiban_blocked");
1084        assert!(action.contains("immediate retry"), "action should discourage immediate retry; got: {action}");
1085    }
1086
1087    #[test]
1088    fn classify_search_error_flags_timeout_as_network_error() {
1089        let (kind, action) = classify_search_error("request timed out after 20s", "duckduckgo");
1090        assert_eq!(kind, "network_error");
1091        assert!(
1092            action.contains("retry") || action.contains("web_fetch"),
1093            "action should suggest retry or web_fetch; got: {action}"
1094        );
1095    }
1096
1097    #[test]
1098    fn classify_search_error_uses_youcom_wording_for_youcom_timeouts() {
1099        // Timeout guidance must name the provider that actually failed, so
1100        // a You.com timeout does not tell the agent that DuckDuckGo timed out.
1101        let (kind, action) = classify_search_error("request timed out after 20s", "youcom");
1102        assert_eq!(kind, "network_error");
1103        assert!(action.contains("You.com"), "action should mention You.com for youcom failures; got: {action}");
1104        assert!(
1105            !action.contains("DuckDuckGo"),
1106            "action should not blame DuckDuckGo for a youcom failure; got: {action}"
1107        );
1108    }
1109
1110    // -----------------------------------------------------------------------
1111    // You.com provider
1112    // -----------------------------------------------------------------------
1113
1114    /// A fixture matching the You.com Search API `results.web[]` shape.
1115    fn youcom_fixture() -> YoucomSearchResponse {
1116        serde_json::from_str(
1117            r#"{
1118              "results": {
1119                "web": [
1120                  {
1121                    "title": "Rust Programming Language",
1122                    "url": "https://www.rust-lang.org/",
1123                    "description": "A language empowering everyone to build reliable software."
1124                  },
1125                  {
1126                    "title": "Snippet-only hit",
1127                    "url": "https://example.com/snips",
1128                    "snippets": ["First snippet line."]
1129                  },
1130                  {
1131                    "title": "No URL hit",
1132                    "description": "Skipped because the URL is missing."
1133                  },
1134                  {
1135                    "title": "javascript:alert(1)",
1136                    "url": "javascript:alert(1)",
1137                    "description": "Skipped by scheme validation."
1138                  }
1139                ]
1140              }
1141            }"#,
1142        )
1143        .expect("fixture should parse")
1144    }
1145
1146    #[test]
1147    fn parse_youcom_results_maps_web_hits() {
1148        let results = parse_youcom_results(youcom_fixture(), 10);
1149        assert_eq!(results.len(), 2);
1150        assert_eq!(results[0].title, "Rust Programming Language");
1151        assert_eq!(results[0].url, "https://www.rust-lang.org/");
1152        assert!(results[0].snippet.contains("reliable software"));
1153        // Snippet fallback: flat description missing, first snippet used.
1154        assert_eq!(results[1].snippet, "First snippet line.");
1155    }
1156
1157    #[test]
1158    fn parse_youcom_results_respects_max_results() {
1159        let results = parse_youcom_results(youcom_fixture(), 1);
1160        assert_eq!(results.len(), 1);
1161        assert_eq!(results[0].url, "https://www.rust-lang.org/");
1162    }
1163
1164    #[test]
1165    fn parse_youcom_results_handles_missing_results_block() {
1166        let response: YoucomSearchResponse =
1167            serde_json::from_str(r#"{"metadata": {}}"#).expect("empty envelope should parse");
1168        assert!(parse_youcom_results(response, 10).is_empty());
1169    }
1170
1171    #[test]
1172    fn youcom_provider_without_key_errors_before_network() {
1173        // With provider = youcom and no YDC_API_KEY, the tool must surface a
1174        // structured setup error rather than attempting a network call.
1175        // NOTE: env-var races are avoided by using a unique key absence only
1176        // when the var is unset in this process; guard with a lock-free check.
1177        if std::env::var(YOUCOM_API_KEY_ENV).is_ok() {
1178            return; // key present in this environment; skip to avoid a live call
1179        }
1180        let config = WebSearchConfig {
1181            provider: WebSearchProvider::Youcom,
1182            max_results: 5,
1183            timeout_secs: 20,
1184            cooldown_ms: 0,
1185            cache_ttl_secs: 300,
1186            session_max_requests: 100,
1187        };
1188        let tool = WebSearchTool::with_config(config);
1189        let payload = tokio::runtime::Runtime::new()
1190            .unwrap()
1191            .block_on(tool.run(json!({ "query": "rust" })))
1192            .expect("missing key should be a structured JSON, not a panic");
1193        assert!(payload["error"].as_str().unwrap().contains("YDC_API_KEY"));
1194
1195        // A setup error must not consume the session's request budget or
1196        // start the cooldown clock — the agent may fix the key and retry
1197        // immediately.
1198        let state = tool.state.lock().unwrap();
1199        assert_eq!(state.requests_made, 0, "missing-key setup error must not count as a request");
1200        assert!(state.last_request_at.is_none(), "missing-key setup error must not start the cooldown clock");
1201    }
1202
1203    #[test]
1204    fn youcom_client_disables_redirects() {
1205        // The credential-bearing request must never follow a redirect to
1206        // another origin, so the youcom client disables redirects entirely.
1207        let client = build_youcom_client(20).expect("client should build");
1208        // reqwest exposes no direct getter for the redirect policy; the
1209        // behavioral guarantee is exercised by refusing 3xx in
1210        // `youcom_search`. Here we assert the client is constructible and
1211        // distinct from the DDG one, which allows limited redirects.
1212        let _ddg = build_client(20).expect("ddg client should build");
1213        drop(client);
1214    }
1215
1216    #[test]
1217    fn youcom_provider_payload_reports_youcom_provider() {
1218        // Cache-path check: a cached payload for provider youcom reports
1219        // provider "youcom" once served. Seed the cache directly to avoid
1220        // the network.
1221        let config = WebSearchConfig {
1222            provider: WebSearchProvider::Youcom,
1223            max_results: 5,
1224            ..WebSearchConfig::default()
1225        };
1226        let tool = WebSearchTool::with_config(config);
1227        let cached_payload = json!({
1228            "query": "rust",
1229            "provider": "youcom",
1230            "count": 1,
1231            "results": [{
1232                "title": "Cached You.com Result",
1233                "url": "https://example.com/you",
1234                "snippet": "from cache"
1235            }]
1236        });
1237        {
1238            let mut state = tool.state.lock().unwrap();
1239            state.cache_put("5::rust".to_string(), cached_payload);
1240        }
1241        let payload = tokio::runtime::Runtime::new()
1242            .unwrap()
1243            .block_on(tool.run(json!({ "query": "rust" })))
1244            .expect("cache hit must not error");
1245        assert_eq!(payload["provider"], json!("youcom"));
1246        assert_eq!(payload["results"][0]["title"], "Cached You.com Result");
1247    }
1248}