返回 CodeWhale
web_search.rs
根目录 / crates / tui / src / tools / web_search.rs
1 //! Bounded provider-native/configured web search with explicit fallback receipts.
2 //! Adapters include Firecrawl, Tavily, Bocha, Metaso, SearXNG, Baidu,
3 //! Volcengine, Sofya, and Serply; browsing remains a separate `web.run` workflow.
4 //! `[search]` example:
5 //! provider = "firecrawl" # keyless on Firecrawl Cloud; optional api_key
6 //! base_url = `"https://search.example/"` # DDG-compatible URL or SearXNG instance
7
8 use super::spec::{
9 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_u64,
10 };
11 use crate::config::{SearchProvider, tavily_env_key, tavily_key_from};
12 use crate::network_policy::{Decision, NetworkPolicyDecider};
13 use async_trait::async_trait;
14 use regex::Regex;
15 use serde::{Deserialize, Serialize};
16 use serde_json::{Value, json};
17 use std::sync::OnceLock;
18 use std::time::{Duration, Instant};
19
20 use super::web::adapter::{self, AdapterFailure, AdapterResult};
21 use super::web::backend::SearchBackendChain;
22 use super::web::cache;
23 use super::web::contract::{
24 BackendId, BackendSearch, DEFAULT_SEARCH_RESULTS, DEFAULT_SEARCH_TIMEOUT_MS, DegradedReason,
25 HonoredQueryCapabilities, MAX_SEARCH_RESULTS, MAX_SEARCH_TIMEOUT_MS, QueryKnob, Recency,
26 SearchQuery, SearchReceipt, SearchResponse, SearchResult,
27 };
28 use super::web::scrape::{
29 BROWSER_USER_AGENT as USER_AGENT, ScrapedSearchResult, is_duckduckgo_challenge,
30 parse_bing_results as scrape_bing_results,
31 parse_duckduckgo_results as scrape_duckduckgo_results,
32 };
33
34 const DUCKDUCKGO_ENDPOINT: &str = "https://html.duckduckgo.com/html/";
35 const BING_HOST: &str = "www.bing.com";
36 const BING_ENDPOINT: &str = "https://www.bing.com/search";
37 const FIRECRAWL_ENDPOINT: &str = "https://api.firecrawl.dev/v2/search";
38 const TAVILY_ENDPOINT: &str = "https://api.tavily.com/search";
39 const BOCHA_ENDPOINT: &str = "https://api.bochaai.com/v1/web-search";
40 const METASO_ENDPOINT: &str = "https://metaso.cn/api/v1";
41 const BAIDU_ENDPOINT: &str = "https://qianfan.baidubce.com/v2/ai_search/web_search";
42 const VOLCENGINE_RESPONSES_ENDPOINT: &str = "https://ark.cn-beijing.volces.com/api/v3/responses";
43 const SOFYA_ENDPOINT: &str = "https://sofya.co/v1/search";
44 const SERPLY_ENDPOINT: &str = "https://api.serply.io/v1/search";
45 const ERROR_BODY_PREVIEW_BYTES: usize = 512;
46 const PROVIDER_NATIVE_MIN_TIMEOUT_MS: u64 = 45_000;
47 const KIMI_K3_FORMULA_MIN_TIMEOUT_MS: u64 = 180_000;
48 /// Time a native-search attempt allows for its search round-trips before the
49 /// answer is generated (#6508).
50 const NATIVE_SEARCH_ROUND_TRIP_ALLOWANCE_MS: u64 = 30_000;
51 /// Conservative generation rate for a native-search answer. The request is
52 /// non-streaming, so the attempt must outlast the whole generation or the
53 /// answer is lost to the timeout rather than returned whole.
54 const NATIVE_SEARCH_ASSUMED_TOKENS_PER_SEC: u64 = 40;
55 const VOLCENGINE_MIN_TIMEOUT_MS: u64 = 90_000;
56
57 /// The recency and locale knobs an adapter forwards to its backend.
58 ///
59 /// Recency is rounded *up* to the backend's nearest window (day, week, month,
60 /// year), so `recency = 10` days asks for the last month: results are never
61 /// cut tighter than requested, but may be older than asked. Locale is a BCP 47
62 /// style tag (`en`, `en-US`, `de_DE`); each backend takes the part it accepts.
63 #[derive(Debug, Clone, Copy, Default)]
64 struct QueryFilters<'a> {
65 recency: Option<Recency>,
66 locale: Option<&'a str>,
67 prepared: Option<&'a PreparedFilters>,
68 }
69
70 impl<'a> QueryFilters<'a> {
71 fn of(query: &'a SearchQuery) -> Self {
72 Self {
73 prepared: None,
74 recency: query.recency,
75 locale: query
76 .locale
77 .as_deref()
78 .map(str::trim)
79 .filter(|value| !value.is_empty()),
80 }
81 }
82
83 /// `day` / `week` / `month` / `year`, rounded up from the request.
84 fn window(self) -> Option<&'a str> {
85 if let Some(prepared) = self.prepared {
86 return prepared.window.as_deref();
87 }
88 self.recency.map(|recency| match recency.days() {
89 0..=1 => "day",
90 2..=7 => "week",
91 8..=31 => "month",
92 _ => "year",
93 })
94 }
95
96 /// Lowercase language subtag (`en` from `en-US`).
97 fn language(self) -> Option<String> {
98 if let Some(prepared) = self.prepared {
99 return prepared.language.clone();
100 }
101 let language = self.locale?.split(['-', '_']).next()?;
102 (matches!(language.len(), 2 | 3) && language.chars().all(|ch| ch.is_ascii_alphabetic()))
103 .then(|| language.to_ascii_lowercase())
104 }
105
106 /// Two-letter region subtag, uppercase (`US` from `en-US`).
107 fn region(self) -> Option<String> {
108 if let Some(prepared) = self.prepared {
109 return prepared.region.clone();
110 }
111 let region = self.locale?.split(['-', '_']).nth(1)?;
112 (region.len() == 2 && region.chars().all(|ch| ch.is_ascii_alphabetic()))
113 .then(|| region.to_ascii_uppercase())
114 }
115 }
116
117 #[derive(Debug, serde::Deserialize)]
118 #[serde(deny_unknown_fields)]
119 struct PreparedFilters {
120 kind: String,
121 window: Option<String>,
122 language: Option<String>,
123 region: Option<String>,
124 }
125
126 #[derive(Debug, serde::Deserialize)]
127 #[serde(deny_unknown_fields)]
128 struct RequestProposal {
129 kind: String,
130 payload: Value,
131 pairs: Vec<(String, String)>,
132 }
133
134 async fn host_request(
135 backend: &str,
136 query: &str,
137 filters: QueryFilters<'_>,
138 max_results: usize,
139 context: &ToolContext,
140 timeout_ms: u64,
141 ) -> AdapterResult<Option<RequestProposal>> {
142 if !adapter::search_selected(context) {
143 return Ok(None);
144 }
145 let proposal: RequestProposal = adapter::transform(
146 crate::extension_host::StockOperation::WebRequest,
147 json!({"backend":backend,"query":query,"max_results":max_results,
148 "locale":filters.locale,"filters":{"window":filters.window(),
149 "language":filters.language(),"region":filters.region()}}),
150 context,
151 Duration::from_millis(timeout_ms),
152 )
153 .await?;
154 let valid = proposal.kind == "web_request"
155 && match backend {
156 "serply" | "searxng" | "bing" | "duckduckgo" => {
157 proposal.payload.is_null()
158 && proposal.pairs.first() == Some(&("q".into(), query.into()))
159 && proposal
160 .pairs
161 .iter()
162 .enumerate()
163 .all(|(index, (key, value))| {
164 !proposal.pairs[..index]
165 .iter()
166 .any(|(prior, _)| prior == key)
167 && (key != "q" || value == query)
168 && (key != "num" || value == &max_results.to_string())
169 })
170 && proposal.pairs.iter().all(|(key, _)| {
171 matches!(
172 key.as_str(),
173 "q" | "num" | "hl" | "gl" | "format" | "time_range" | "language"
174 )
175 })
176 }
177 "volcengine" => {
178 proposal.pairs.is_empty()
179 && proposal
180 .payload
181 .as_object()
182 .is_some_and(|value| value.len() == 1 && value.contains_key("input"))
183 }
184 name => {
185 let (query_path, count_path) = match name {
186 "firecrawl" => ("/query", "/limit"),
187 "tavily" | "sofya" => ("/query", "/max_results"),
188 "bocha" => ("/query", "/count"),
189 "metaso" => ("/q", "/size"),
190 "baidu" => ("/messages/0/content", "/resource_type_filter/0/top_k"),
191 _ => ("", ""),
192 };
193 proposal.pairs.is_empty()
194 && proposal.payload.pointer(query_path).and_then(Value::as_str) == Some(query)
195 && proposal.payload.pointer(count_path).and_then(Value::as_u64)
196 == Some(max_results as u64)
197 && proposal.payload.as_object().is_some_and(|value| {
198 value.keys().all(|key| {
199 matches!(
200 key.as_str(),
201 "query"
202 | "q"
203 | "limit"
204 | "max_results"
205 | "count"
206 | "size"
207 | "sources"
208 | "tbs"
209 | "country"
210 | "search_depth"
211 | "time_range"
212 | "freshness"
213 | "scope"
214 | "messages"
215 | "search_source"
216 | "resource_type_filter"
217 )
218 })
219 })
220 }
221 };
222 if !valid {
223 return Err(AdapterFailure::host(ToolError::execution_failed(
224 "Web Host returned a request outside the captured query contract",
225 )));
226 }
227 Ok(Some(proposal))
228 }
229
230 #[derive(Default)]
231 pub(crate) struct OpaqueUrls(std::collections::HashMap<String, String>);
232 impl OpaqueUrls {
233 pub(crate) fn capture(&mut self, value: &str) -> String {
234 if value.trim().is_empty() {
235 return value.into();
236 }
237 let token = format!("web-url-{}", self.0.len());
238 let leading = value.len() - value.trim_start().len();
239 let trailing = value.trim_end().len();
240 let wire = format!("{}{}{}", &value[..leading], token, &value[trailing..]);
241 self.0.insert(token, value.trim().into());
242 self.0.insert(wire.clone(), value.into());
243 wire
244 }
245 pub(crate) fn restore(&self, value: &str) -> AdapterResult<String> {
246 if value.trim().is_empty() {
247 return Ok(value.into());
248 }
249 self.0.get(value).cloned().ok_or_else(|| {
250 AdapterFailure::host(ToolError::execution_failed(
251 "Web Host returned an unknown source handle",
252 ))
253 })
254 }
255 }
256
257 fn provider_projection(value: &Value, urls: &mut OpaqueUrls, secret: Option<&str>) -> Value {
258 match value {
259 Value::Object(object) => Value::Object(
260 object
261 .iter()
262 .filter_map(|(key, value)| {
263 // Only fields consumed by a registered adapter cross the process.
264 if !matches!(
265 key.as_str(),
266 "results"
267 | "title"
268 | "name"
269 | "url"
270 | "link"
271 | "content"
272 | "snippet"
273 | "summary"
274 | "description"
275 | "markdown"
276 | "data"
277 | "web"
278 | "webpages"
279 | "webPages"
280 | "value"
281 | "pages"
282 | "references"
283 | "success"
284 | "error"
285 | "message"
286 | "msg"
287 | "code"
288 | "error_code"
289 | "error_msg"
290 | "output"
291 | "type"
292 | "text"
293 | "score"
294 ) {
295 return None;
296 }
297 let projected = if matches!(key.as_str(), "url" | "link") && value.is_string() {
298 Value::String(urls.capture(value.as_str().expect("string")))
299 } else {
300 provider_projection(value, urls, secret)
301 };
302 Some((key.clone(), projected))
303 })
304 .collect(),
305 ),
306 Value::Array(array) => Value::Array(
307 array
308 .iter()
309 .map(|value| provider_projection(value, urls, secret))
310 .collect(),
311 ),
312 Value::String(text) => Value::String(match secret.filter(|key| !key.is_empty()) {
313 Some(key) => text.replace(key, "[redacted]"),
314 None => text.clone(),
315 }),
316 _ => value.clone(),
317 }
318 }
319
320 #[derive(serde::Deserialize)]
321 #[serde(deny_unknown_fields)]
322 struct ProviderProposal {
323 kind: String,
324 entries: Vec<WebSearchEntry>,
325 error: Option<String>,
326 }
327
328 async fn host_provider(
329 backend: &str,
330 parsed: &Value,
331 max_results: usize,
332 secret: Option<&str>,
333 context: &ToolContext,
334 timeout_ms: u64,
335 ) -> AdapterResult<Option<Vec<WebSearchEntry>>> {
336 if !adapter::search_selected(context) {
337 return Ok(None);
338 }
339 let mut urls = OpaqueUrls::default();
340 let captured;
341 let source = if backend == "volcengine" {
342 // Rust's existing JSON/scalar guard is also the privacy boundary: never
343 // forward the raw model body or an embedded credential-bearing URL.
344 captured = if let Some(error) = parsed.get("error") {
345 json!({"error":provider_projection(error,&mut urls,secret)})
346 } else if let Some(text) = volcengine_extract_text(parsed) {
347 let inner = serde_json::from_str::<Value>(extract_json_block(&text).unwrap_or(&text))
348 .ok()
349 .map(|value| provider_projection(&value, &mut urls, secret));
350 json!({"output":[{"type":"message","content":[{"text":inner.map(|value| value.to_string()).unwrap_or_default()}]}]})
351 } else {
352 json!({})
353 };
354 &captured
355 } else {
356 parsed
357 };
358 let projection = if backend == "volcengine" {
359 source.clone()
360 } else {
361 provider_projection(source, &mut urls, secret)
362 };
363 let mut numbers = serde_json::Map::new();
364 for key in ["code", "error_code"] {
365 if let Some(value) = parsed.get(key) {
366 numbers.insert(
367 format!("/{key}"),
368 json!({"i64":value.as_i64().map(|value| value.to_string())}),
369 );
370 }
371 }
372 if backend == "searxng"
373 && let Some(values) = parsed.get("results").and_then(Value::as_array)
374 {
375 for (index, value) in values.iter().enumerate() {
376 numbers.insert(
377 format!("/results/{index}/score"),
378 json!({"score":searxng_score(value).to_string()}),
379 );
380 }
381 }
382 let mut proposal: ProviderProposal = adapter::transform(crate::extension_host::StockOperation::WebProvider,
383 json!({"backend":backend,"max_results":max_results,"parsed":projection.as_object().map(|_| &projection).unwrap_or(&json!({})),"number_facts":numbers}),
384 context,Duration::from_millis(timeout_ms)).await?;
385 if proposal.kind != "web_provider" || proposal.entries.len() > max_results {
386 return Err(AdapterFailure::host(ToolError::execution_failed(
387 "Web Host returned an invalid provider proposal",
388 )));
389 }
390 if let Some(error) = proposal.error {
391 return Err(ToolError::execution_failed(error).into());
392 }
393 for entry in &mut proposal.entries {
394 entry.url = urls.restore(&entry.url)?;
395 }
396 Ok(Some(proposal.entries))
397 }
398
399 #[derive(serde::Deserialize)]
400 #[serde(deny_unknown_fields)]
401 struct EntriesProposal {
402 kind: String,
403 entries: Vec<super::web::contract::CapturedSearchEntry>,
404 }
405
406 pub(crate) async fn normalize_captured_entries(
407 entries: Vec<super::web::contract::CapturedSearchEntry>,
408 context: &ToolContext,
409 budget: Duration,
410 ) -> AdapterResult<Vec<super::web::contract::CapturedSearchEntry>> {
411 if !adapter::search_selected(context) {
412 return Ok(entries);
413 }
414 let mut urls = OpaqueUrls::default();
415 let mut captured = entries.clone();
416 for entry in &mut captured {
417 entry.url = urls.capture(&entry.url);
418 }
419 let proposal: EntriesProposal = adapter::transform(
420 crate::extension_host::StockOperation::WebEntries,
421 json!({"entries":captured}),
422 context,
423 budget,
424 )
425 .await?;
426 if proposal.kind != "web_entries" || proposal.entries.len() != entries.len() {
427 return Err(AdapterFailure::host(ToolError::execution_failed(
428 "Web Host changed the captured source count",
429 )));
430 }
431 proposal
432 .entries
433 .into_iter()
434 .map(|mut entry| {
435 entry.url = urls.restore(&entry.url)?;
436 Ok(entry)
437 })
438 .collect()
439 }
440
441 /// Credential-free endpoint selected for an explicit doctor reachability
442 /// probe. The ordinary search request builders remain the source of truth for
443 /// provider endpoints; doctor borrows those endpoints without constructing a
444 /// query or reading an API key.
445 #[derive(Debug, Clone, PartialEq, Eq)]
446 pub(crate) struct SearchProbeTarget {
447 pub(crate) url: reqwest::Url,
448 pub(crate) host: String,
449 }
450
451 /// Safe configuration failures for a search reachability probe.
452 ///
453 /// These variants deliberately carry no configured URL: userinfo, paths, and
454 /// query strings may contain credentials and must never be echoed by doctor.
455 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
456 pub(crate) enum SearchProbeTargetError {
457 Missing,
458 Unsupported,
459 Invalid,
460 }
461
462 /// Resolve the configured provider's transport endpoint for doctor.
463 ///
464 /// Built-in endpoints keep their known request path. User-configured
465 /// DuckDuckGo-compatible and SearXNG URLs are reduced to their HTTP(S)
466 /// authority so the probe cannot transmit userinfo, path/query credentials,
467 /// or a real search query.
468 pub(crate) fn search_probe_target(
469 provider: SearchProvider,
470 base_url: Option<&str>,
471 ) -> Result<SearchProbeTarget, SearchProbeTargetError> {
472 let configured_base_url = configured_search_base_url(base_url);
473 if configured_base_url.is_some()
474 && !matches!(
475 provider,
476 SearchProvider::DuckDuckGo | SearchProvider::Searxng
477 )
478 {
479 return Err(SearchProbeTargetError::Unsupported);
480 }
481
482 let (raw, configured) = match provider {
483 SearchProvider::Bing => (BING_ENDPOINT, false),
484 SearchProvider::DuckDuckGo => (
485 configured_base_url.unwrap_or(DUCKDUCKGO_ENDPOINT),
486 configured_base_url.is_some(),
487 ),
488 SearchProvider::Firecrawl => (FIRECRAWL_ENDPOINT, false),
489 SearchProvider::Tavily => (TAVILY_ENDPOINT, false),
490 SearchProvider::Bocha => (BOCHA_ENDPOINT, false),
491 SearchProvider::Metaso => (METASO_ENDPOINT, false),
492 SearchProvider::Searxng => (
493 configured_base_url.ok_or(SearchProbeTargetError::Missing)?,
494 true,
495 ),
496 SearchProvider::Baidu => (BAIDU_ENDPOINT, false),
497 SearchProvider::Volcengine => (VOLCENGINE_RESPONSES_ENDPOINT, false),
498 SearchProvider::Sofya => (SOFYA_ENDPOINT, false),
499 SearchProvider::Serply => (SERPLY_ENDPOINT, false),
500 };
501
502 let mut url = reqwest::Url::parse(raw).map_err(|_| SearchProbeTargetError::Invalid)?;
503 if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
504 return Err(SearchProbeTargetError::Invalid);
505 }
506
507 url.set_fragment(None);
508 url.set_query(None);
509 if configured {
510 url.set_username("")
511 .map_err(|_| SearchProbeTargetError::Invalid)?;
512 url.set_password(None)
513 .map_err(|_| SearchProbeTargetError::Invalid)?;
514 url.set_path("/");
515 }
516 let host = url
517 .host_str()
518 .ok_or(SearchProbeTargetError::Invalid)?
519 .to_string();
520
521 Ok(SearchProbeTarget { url, host })
522 }
523
524 /// Returns `Ok(())` if the policy allows the call, or a `ToolError` otherwise.
525 /// Falls through silently when no policy is attached (back-compat).
526 pub(crate) fn check_policy(
527 decider: Option<&NetworkPolicyDecider>,
528 host: &str,
529 ) -> Result<(), ToolError> {
530 let Some(decider) = decider else {
531 return Ok(());
532 };
533 match decider.evaluate(host, "web_search") {
534 Decision::Allow => Ok(()),
535 Decision::Deny => Err(ToolError::permission_denied(format!(
536 "web search to '{host}' blocked by network policy"
537 ))),
538 Decision::Prompt => Err(ToolError::permission_denied(format!(
539 "web search to '{host}' requires approval; \
540 re-run after `/network allow {host}` or set network.default = \"allow\" in config"
541 ))),
542 }
543 }
544
545 // Cached regex for secret redaction in error bodies
546 static BEARER_TOKEN_RE: OnceLock<Regex> = OnceLock::new();
547
548 fn get_bearer_token_re() -> &'static Regex {
549 BEARER_TOKEN_RE.get_or_init(|| {
550 Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
551 .expect("bearer token regex pattern is valid")
552 })
553 }
554
555 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556 #[serde(deny_unknown_fields)]
557 struct WebSearchEntry {
558 title: String,
559 url: String,
560 snippet: Option<String>,
561 }
562
563 pub struct WebSearchTool;
564
565 #[async_trait]
566 impl ToolSpec for WebSearchTool {
567 fn name(&self) -> &'static str {
568 "web_search"
569 }
570
571 fn model_visible(&self) -> bool {
572 false
573 }
574
575 fn description(&self) -> &'static str {
576 "Search the web and return ranked results with URLs, snippets, session-scoped ref_ids, and an execution receipt. Open a result ref_id with `web.run` when the short summary is not enough; fetch only the few sources needed. When the exact active route reports a documented first-party server-side search tool, it is tried first; otherwise keyless Firecrawl is the default. Configured API backends visibly degrade through DuckDuckGo then Bing when unavailable, and every hop is recorded. Configuration and network-policy errors fail closed. Explicit Bing and private DuckDuckGo-compatible routes do not cross providers. Set `[search] provider = \"firecrawl\" | \"bing\" | \"tavily\" | \"bocha\" | \"metaso\" | \"searxng\" | \"baidu\" | \"volcengine\" | \"sofya\" | \"serply\"` in config.toml. Firecrawl Cloud works keyless with a bounded quota. For a known canonical URL, prefer `fetch_url` directly."
577 }
578
579 fn input_schema(&self) -> Value {
580 json!({
581 "type": "object",
582 "properties": {
583 "query": {
584 "type": "string",
585 "description": "Search query. Compatibility aliases: q, or search_query[0].q."
586 },
587 "q": {
588 "type": "string",
589 "description": "Search query."
590 },
591 "search_query": {
592 "type": "array",
593 "description": "Array form for advanced queries: [{\"q\":\"...\", \"max_results\": 5}]",
594 "items": {
595 "type": "object",
596 "properties": {
597 "q": { "type": "string" },
598 "query": { "type": "string" },
599 "max_results": { "type": "integer" },
600 "recency": {
601 "oneOf": [
602 { "type": "string", "enum": ["day", "week", "month", "year"] },
603 { "type": "integer", "minimum": 1, "maximum": 3650 }
604 ]
605 },
606 "domains": { "type": "array", "items": { "type": "string" } },
607 "locale": { "type": "string" }
608 }
609 }
610 },
611 "max_results": {
612 "type": "integer",
613 "description": "Maximum number of results to return (default: 5, max: 10)"
614 },
615 "timeout_ms": {
616 "type": "integer",
617 "description": "Configured/local search timeout in milliseconds (default: 15000, max: 60000). Model-backed provider-native search has a separate bounded minimum before fallback."
618 },
619 "recency": {
620 "oneOf": [
621 { "type": "string", "enum": ["day", "week", "month", "year"] },
622 { "type": "integer", "minimum": 1, "maximum": 3650 }
623 ],
624 "description": "Requested freshness window. Unsupported backends report it as degraded instead of silently ignoring it."
625 },
626 "domains": {
627 "type": "array",
628 "items": { "type": "string" },
629 "description": "Restrict returned results to these domains. Backends without native support report post-filtering."
630 },
631 "locale": {
632 "type": "string",
633 "description": "Requested result locale. Unsupported backends report it as degraded."
634 }
635 }
636 })
637 }
638
639 fn capabilities(&self) -> Vec<ToolCapability> {
640 vec![ToolCapability::ReadOnly, ToolCapability::Network]
641 }
642
643 fn approval_requirement(&self) -> ApprovalRequirement {
644 // Read-only HTTP can still disclose local data through a URL or query.
645 // Host allowlisting controls reachability, not approval of this payload.
646 ApprovalRequirement::Required
647 }
648
649 fn supports_parallel(&self) -> bool {
650 true
651 }
652
653 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
654 let query = search_query_from_input(&input)?;
655 let timeout_ms = optional_u64(&input, "timeout_ms", DEFAULT_SEARCH_TIMEOUT_MS)?
656 .min(MAX_SEARCH_TIMEOUT_MS);
657 let response = execute_search(query, timeout_ms, context).await?;
658 ToolResult::json(&response).map_err(|error| ToolError::execution_failed(error.to_string()))
659 }
660 }
661
662 impl WebSearchTool {
663 async fn run_firecrawl_search(
664 &self,
665 query: &str,
666 filters: QueryFilters<'_>,
667 max_results: usize,
668 timeout_ms: u64,
669 context: &ToolContext,
670 ) -> AdapterResult<(Vec<WebSearchEntry>, String)> {
671 let env_key = std::env::var("FIRECRAWL_API_KEY").ok();
672 self.run_firecrawl_search_at_for_context(
673 FIRECRAWL_ENDPOINT,
674 query,
675 filters,
676 max_results,
677 timeout_ms,
678 context.search_api_key.as_deref().or(env_key.as_deref()),
679 context,
680 )
681 .await
682 }
683
684 #[cfg(test)]
685 async fn run_firecrawl_search_at(
686 &self,
687 endpoint: &str,
688 query: &str,
689 filters: QueryFilters<'_>,
690 max_results: usize,
691 timeout_ms: u64,
692 api_key: Option<&str>,
693 ) -> Result<(Vec<WebSearchEntry>, String), ToolError> {
694 let context = ToolContext::new(
695 std::env::current_dir()
696 .map_err(|error| ToolError::execution_failed(error.to_string()))?,
697 );
698 self.run_firecrawl_search_at_for_context(
699 endpoint,
700 query,
701 filters,
702 max_results,
703 timeout_ms,
704 api_key,
705 &context,
706 )
707 .await
708 .map_err(Into::into)
709 }
710
711 async fn run_firecrawl_search_at_for_context(
712 &self,
713 endpoint: &str,
714 query: &str,
715 filters: QueryFilters<'_>,
716 max_results: usize,
717 timeout_ms: u64,
718 api_key: Option<&str>,
719 context: &ToolContext,
720 ) -> AdapterResult<(Vec<WebSearchEntry>, String)> {
721 let client = crate::tls::reqwest_client_builder()
722 .timeout(Duration::from_millis(timeout_ms))
723 .user_agent(USER_AGENT)
724 .build()
725 .map_err(|e| {
726 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
727 })?;
728 let api_key = api_key.map(str::trim).filter(|key| !key.is_empty());
729 let payload = if let Some(plan) = host_request(
730 "firecrawl",
731 query,
732 filters,
733 max_results,
734 context,
735 timeout_ms,
736 )
737 .await?
738 {
739 plan.payload
740 } else {
741 {
742 let mut payload =
743 json!({"query":query,"limit":max_results,"sources":[{"type":"web"}]});
744 if let Some(window) = filters.window() {
745 payload["tbs"] = json!(format!("qdr:{}", &window[..1]));
746 }
747 if let Some(region) = filters.region() {
748 payload["country"] = json!(region);
749 }
750 payload
751 }
752 };
753 let mut request = client.post(endpoint).json(&payload);
754 if let Some(key) = api_key {
755 request = request.bearer_auth(key);
756 }
757 let response = request.send().await.map_err(|e| {
758 ToolError::execution_failed(format!("Firecrawl search request failed: {e}"))
759 })?;
760 let status = response.status();
761 let body = if adapter::search_selected(context) {
762 adapter::read_response(response, context).await?
763 } else {
764 response.text().await.map_err(|e| {
765 ToolError::execution_failed(format!("Failed to read Firecrawl response: {e}"))
766 })?
767 };
768 if !status.is_success() {
769 let message = match status.as_u16() {
770 401 | 403 if api_key.is_none() => "Firecrawl rejected keyless search; set `[search] api_key` or FIRECRAWL_API_KEY".to_string(),
771 401 | 403 => "Firecrawl authentication was rejected; check `[search] api_key` or FIRECRAWL_API_KEY".to_string(),
772 429 if api_key.is_none() => "Firecrawl keyless quota is exhausted; retry later or set `[search] api_key` / FIRECRAWL_API_KEY".to_string(),
773 429 => "Firecrawl quota is exhausted; retry later or check the configured account limits".to_string(),
774 code => format!("Firecrawl search failed: HTTP {code} — {}", truncate_error_body(&body)),
775 };
776 return Err((ToolError::execution_failed(message)).into());
777 }
778 let parsed: Value = serde_json::from_str(&body).map_err(|e| {
779 ToolError::execution_failed(format!("Failed to parse Firecrawl response: {e}"))
780 })?;
781 if let Some(entries) = host_provider(
782 "firecrawl",
783 &parsed,
784 max_results,
785 api_key,
786 context,
787 timeout_ms,
788 )
789 .await?
790 {
791 return Ok((
792 entries,
793 format!(
794 "Firecrawl {}",
795 if api_key.is_some() {
796 "authenticated"
797 } else {
798 "keyless"
799 }
800 ),
801 ));
802 }
803
804 if parsed.get("success").and_then(Value::as_bool) == Some(false) {
805 let detail = first_non_empty_string(&parsed, &["error", "message"])
806 .unwrap_or_else(|| "unknown API error".to_string());
807 return Err((ToolError::execution_failed(format!(
808 "Firecrawl search failed: {detail}"
809 )))
810 .into());
811 }
812 let mode = if api_key.is_some() {
813 "authenticated"
814 } else {
815 "keyless"
816 };
817 Ok((
818 parse_firecrawl_results(&parsed, max_results),
819 format!("Firecrawl {mode}"),
820 ))
821 }
822
823 /// Search a configured SearXNG JSON API; no public instance is assumed.
824 async fn run_searxng_search(
825 &self,
826 query: &str,
827 filters: QueryFilters<'_>,
828 max_results: usize,
829 timeout_ms: u64,
830 context: &ToolContext,
831 ) -> AdapterResult<(Vec<WebSearchEntry>, String)> {
832 let (url, host) = if let Some(plan) =
833 host_request("searxng", query, filters, max_results, context, timeout_ms).await?
834 {
835 // The configured origin/path/query are private Core authority.
836 let (base, host) = searxng_search_base(context.search_base_url.as_deref())?;
837 let mut url = base;
838 for (key, value) in plan.pairs {
839 url.query_pairs_mut().append_pair(&key, &value);
840 }
841 (url.to_string(), host)
842 } else {
843 searxng_search_url(context.search_base_url.as_deref(), query, filters)?
844 };
845 check_policy(context.network_policy.as_ref(), &host)?;
846
847 let client = crate::tls::reqwest_client_builder()
848 .timeout(Duration::from_millis(timeout_ms))
849 .user_agent(USER_AGENT)
850 .build()
851 .map_err(|e| {
852 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
853 })?;
854
855 let resp = client
856 .get(&url)
857 .header("Accept", "application/json")
858 .send()
859 .await
860 .map_err(|e| {
861 ToolError::execution_failed(format!("SearXNG search request to {host} failed: {e}"))
862 })?;
863
864 let status = resp.status();
865 let body = if adapter::search_selected(context) {
866 adapter::read_response(resp, context).await?
867 } else {
868 resp.text().await.map_err(|e| {
869 ToolError::execution_failed(format!(
870 "Failed to read SearXNG response from {host}: {e}"
871 ))
872 })?
873 };
874
875 if !status.is_success() {
876 let truncated = truncate_error_body(&body);
877 let msg = match status.as_u16() {
878 403 => format!(
879 "SearXNG search failed: HTTP 403 from {host}. Check that JSON output is enabled and this instance permits API access. {truncated}"
880 ),
881 429 => format!(
882 "SearXNG search failed: HTTP 429 from {host}. The configured instance is rate-limiting requests; use a trusted/self-hosted instance or retry later. {truncated}"
883 ),
884 code => format!("SearXNG search failed: HTTP {code} from {host}. {truncated}"),
885 };
886 return Err((ToolError::execution_failed(msg)).into());
887 }
888
889 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
890 ToolError::execution_failed(format!(
891 "Failed to parse SearXNG JSON response from {host}: {e}. Ensure the instance supports format=json and JSON output is enabled."
892 ))
893 })?;
894
895 if let Some(entries) =
896 host_provider("searxng", &parsed, max_results, None, context, timeout_ms).await?
897 {
898 return Ok((entries, host));
899 }
900
901 Ok((parse_searxng_results(&parsed, max_results), host))
902 }
903
904 /// Search via Tavily AI Search API (<https://tavily.com>).
905 async fn run_tavily_search(
906 &self,
907 query: &str,
908 filters: QueryFilters<'_>,
909 max_results: usize,
910 timeout_ms: u64,
911 context: &ToolContext,
912 ) -> AdapterResult<Vec<WebSearchEntry>> {
913 let api_key = tavily_key_from(context.search_api_key.as_deref())
914 .or_else(|| {
915 // An explicit `provider = "tavily"` still accepts any
916 // non-empty generic key, so a non-`tvly-` pin keeps working.
917 // Reaching this hop at all means Tavily was the resolved
918 // provider (pinned, or selected by a `tvly-` signal), so the
919 // generic fallback is never a Firecrawl/sentinel key.
920 context
921 .search_api_key
922 .as_deref()
923 .map(str::trim)
924 .filter(|value| !value.is_empty())
925 .map(str::to_string)
926 })
927 .ok_or_else(|| {
928 ToolError::execution_failed(
929 "Tavily search requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml or the `TAVILY_API_KEY` env var.",
930 )
931 })?;
932
933 let client = crate::tls::reqwest_client_builder()
934 .timeout(Duration::from_millis(timeout_ms))
935 .build()
936 .map_err(|e| {
937 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
938 })?;
939
940 let mut payload = if let Some(plan) =
941 host_request("tavily", query, filters, max_results, context, timeout_ms).await?
942 {
943 plan.payload
944 } else {
945 tavily_search_payload(&api_key, query, filters, max_results)
946 };
947 // The credential never enters a transform snapshot or its grant.
948 payload["api_key"] = json!(api_key);
949
950 let resp = client
951 .post(TAVILY_ENDPOINT)
952 .header("Content-Type", "application/json")
953 .json(&payload)
954 .send()
955 .await
956 .map_err(|e| {
957 ToolError::execution_failed(format!("Tavily search request failed: {e}"))
958 })?;
959
960 let status = resp.status();
961 let body = if adapter::search_selected(context) {
962 adapter::read_response(resp, context).await?
963 } else {
964 resp.text().await.map_err(|e| {
965 ToolError::execution_failed(format!("Failed to read Tavily response: {e}"))
966 })?
967 };
968
969 if !status.is_success() {
970 let truncated = truncate_error_body(&body);
971 return Err((ToolError::execution_failed(format!(
972 "Tavily search failed: HTTP {} — {truncated}",
973 status.as_u16()
974 )))
975 .into());
976 }
977
978 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
979 ToolError::execution_failed(format!("Failed to parse Tavily response: {e}"))
980 })?;
981
982 if let Some(entries) = host_provider(
983 "tavily",
984 &parsed,
985 max_results,
986 Some(&api_key),
987 context,
988 timeout_ms,
989 )
990 .await?
991 {
992 return Ok(entries);
993 }
994
995 Ok(parse_tavily_results(&parsed, max_results))
996 }
997
998 /// Search Sofya; it returns extracted content and accepts `SOFYA_API_KEY`.
999 async fn run_sofya_search(
1000 &self,
1001 query: &str,
1002 max_results: usize,
1003 timeout_ms: u64,
1004 context: &ToolContext,
1005 ) -> AdapterResult<Vec<WebSearchEntry>> {
1006 let env_key = std::env::var("SOFYA_API_KEY").ok();
1007 let api_key = context
1008 .search_api_key
1009 .as_deref()
1010 .or(env_key.as_deref())
1011 .ok_or_else(|| {
1012 ToolError::execution_failed(
1013 "Sofya search requires an API key. Set `[search] api_key = \"ay_live_...\"` in config.toml or the SOFYA_API_KEY env var.",
1014 )
1015 })?;
1016
1017 let client = crate::tls::reqwest_client_builder()
1018 .timeout(Duration::from_millis(timeout_ms))
1019 .build()
1020 .map_err(|e| {
1021 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
1022 })?;
1023
1024 let payload = if let Some(plan) = host_request(
1025 "sofya",
1026 query,
1027 QueryFilters::default(),
1028 max_results,
1029 context,
1030 timeout_ms,
1031 )
1032 .await?
1033 {
1034 plan.payload
1035 } else {
1036 json!({
1037 "query": query,
1038 "max_results": max_results,
1039 })
1040 };
1041
1042 let resp = client
1043 .post(SOFYA_ENDPOINT)
1044 .header("Content-Type", "application/json")
1045 .bearer_auth(api_key)
1046 .json(&payload)
1047 .send()
1048 .await
1049 .map_err(|e| {
1050 ToolError::execution_failed(format!("Sofya search request failed: {e}"))
1051 })?;
1052
1053 let status = resp.status();
1054 let body = if adapter::search_selected(context) {
1055 adapter::read_response(resp, context).await?
1056 } else {
1057 resp.text().await.map_err(|e| {
1058 ToolError::execution_failed(format!("Failed to read Sofya response: {e}"))
1059 })?
1060 };
1061
1062 if !status.is_success() {
1063 let truncated = truncate_error_body(&body);
1064 return Err((ToolError::execution_failed(format!(
1065 "Sofya search failed: HTTP {} — {truncated}",
1066 status.as_u16()
1067 )))
1068 .into());
1069 }
1070
1071 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
1072 ToolError::execution_failed(format!("Failed to parse Sofya response: {e}"))
1073 })?;
1074
1075 if let Some(entries) = host_provider(
1076 "sofya",
1077 &parsed,
1078 max_results,
1079 Some(api_key),
1080 context,
1081 timeout_ms,
1082 )
1083 .await?
1084 {
1085 return Ok(entries);
1086 }
1087
1088 Ok(parse_sofya_results(&parsed, max_results))
1089 }
1090
1091 /// Search Serply (<https://serply.io>); it returns Google organic results and
1092 /// accepts `SERPLY_API_KEY`.
1093 async fn run_serply_search(
1094 &self,
1095 query: &str,
1096 filters: QueryFilters<'_>,
1097 max_results: usize,
1098 timeout_ms: u64,
1099 context: &ToolContext,
1100 ) -> AdapterResult<Vec<WebSearchEntry>> {
1101 let env_key = std::env::var("SERPLY_API_KEY").ok();
1102 let api_key = context
1103 .search_api_key
1104 .as_deref()
1105 .or(env_key.as_deref())
1106 .ok_or_else(|| {
1107 ToolError::invalid_input(
1108 "Serply search requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.",
1109 )
1110 })?;
1111
1112 let client = crate::tls::reqwest_client_builder()
1113 .timeout(Duration::from_millis(timeout_ms))
1114 .build()
1115 .map_err(|e| {
1116 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
1117 })?;
1118
1119 let url = if let Some(plan) =
1120 host_request("serply", query, filters, max_results, context, timeout_ms).await?
1121 {
1122 let mut url = reqwest::Url::parse(SERPLY_ENDPOINT)
1123 .map_err(|error| ToolError::invalid_input(error.to_string()))?;
1124 for (key, value) in plan.pairs {
1125 url.query_pairs_mut().append_pair(&key, &value);
1126 }
1127 url
1128 } else {
1129 serply_search_url(query, filters, max_results)?
1130 };
1131 let resp = client
1132 .get(url)
1133 .header("X-Api-Key", api_key)
1134 .header("Accept", "application/json")
1135 .send()
1136 .await
1137 .map_err(|e| {
1138 ToolError::execution_failed(format!("Serply search request failed: {e}"))
1139 })?;
1140
1141 let status = resp.status();
1142 let body = if adapter::search_selected(context) {
1143 adapter::read_response(resp, context).await?
1144 } else {
1145 resp.text().await.map_err(|e| {
1146 ToolError::execution_failed(format!("Failed to read Serply response: {e}"))
1147 })?
1148 };
1149
1150 if !status.is_success() {
1151 let truncated = truncate_error_body(&body);
1152 return Err((ToolError::execution_failed(format!(
1153 "Serply search failed: HTTP {}: {truncated}",
1154 status.as_u16()
1155 )))
1156 .into());
1157 }
1158
1159 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
1160 ToolError::execution_failed(format!("Failed to parse Serply response: {e}"))
1161 })?;
1162
1163 if let Some(entries) = host_provider(
1164 "serply",
1165 &parsed,
1166 max_results,
1167 Some(api_key),
1168 context,
1169 timeout_ms,
1170 )
1171 .await?
1172 {
1173 return Ok(entries);
1174 }
1175
1176 Ok(parse_serply_results(&parsed, max_results))
1177 }
1178
1179 /// Search via Bocha AI Search API (<https://bochaai.com>).
1180 async fn run_bocha_search(
1181 &self,
1182 query: &str,
1183 max_results: usize,
1184 timeout_ms: u64,
1185 context: &ToolContext,
1186 ) -> AdapterResult<Vec<WebSearchEntry>> {
1187 let api_key = context
1188 .search_api_key
1189 .as_deref()
1190 .ok_or_else(|| {
1191 ToolError::execution_failed(
1192 "Bocha search requires an API key. Set `[search] api_key = \"sk-...\"` in config.toml.",
1193 )
1194 })?;
1195
1196 let client = crate::tls::reqwest_client_builder()
1197 .timeout(Duration::from_millis(timeout_ms))
1198 .build()
1199 .map_err(|e| {
1200 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
1201 })?;
1202
1203 let payload = if let Some(plan) = host_request(
1204 "bocha",
1205 query,
1206 QueryFilters::default(),
1207 max_results,
1208 context,
1209 timeout_ms,
1210 )
1211 .await?
1212 {
1213 plan.payload
1214 } else {
1215 json!({
1216 "query": query,
1217 "freshness": "noLimit",
1218 "count": max_results,
1219 })
1220 };
1221
1222 let resp = client
1223 .post(BOCHA_ENDPOINT)
1224 .header("Content-Type", "application/json")
1225 .header("Authorization", format!("Bearer {api_key}"))
1226 .json(&payload)
1227 .send()
1228 .await
1229 .map_err(|e| {
1230 ToolError::execution_failed(format!("Bocha search request failed: {e}"))
1231 })?;
1232
1233 let status = resp.status();
1234 let body = if adapter::search_selected(context) {
1235 adapter::read_response(resp, context).await?
1236 } else {
1237 resp.text().await.map_err(|e| {
1238 ToolError::execution_failed(format!("Failed to read Bocha response: {e}"))
1239 })?
1240 };
1241
1242 if !status.is_success() {
1243 let truncated = truncate_error_body(&body);
1244 return Err((ToolError::execution_failed(format!(
1245 "Bocha search failed: HTTP {} — {truncated}",
1246 status.as_u16()
1247 )))
1248 .into());
1249 }
1250
1251 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
1252 ToolError::execution_failed(format!("Failed to parse Bocha response: {e}"))
1253 })?;
1254
1255 if let Some(entries) = host_provider(
1256 "bocha",
1257 &parsed,
1258 max_results,
1259 Some(api_key),
1260 context,
1261 timeout_ms,
1262 )
1263 .await?
1264 {
1265 return Ok(entries);
1266 }
1267
1268 if let Some(error) = bocha_error_message(&parsed) {
1269 return Err((ToolError::execution_failed(error)).into());
1270 }
1271
1272 Ok(parse_bocha_results(&parsed, max_results))
1273 }
1274
1275 /// Search via Metaso AI Search API (<https://metaso.cn>). Falls back to
1276 /// `METASO_API_KEY` when no config key is set.
1277 async fn run_metaso_search(
1278 &self,
1279 query: &str,
1280 max_results: usize,
1281 timeout_ms: u64,
1282 context: &ToolContext,
1283 ) -> AdapterResult<Vec<WebSearchEntry>> {
1284 let env_key = std::env::var("METASO_API_KEY").ok();
1285 let api_key = context
1286 .search_api_key
1287 .as_deref()
1288 .or(env_key.as_deref())
1289 .ok_or_else(|| {
1290 ToolError::execution_failed(
1291 "Metaso search requires an API key. Set `METASO_API_KEY` or `[search] api_key` in config.toml.",
1292 )
1293 })?;
1294
1295 let client = crate::tls::reqwest_client_builder()
1296 .timeout(Duration::from_millis(timeout_ms))
1297 .build()
1298 .map_err(|e| {
1299 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
1300 })?;
1301
1302 let size = max_results.clamp(1, 100);
1303 let payload = if let Some(plan) = host_request(
1304 "metaso",
1305 query,
1306 QueryFilters::default(),
1307 max_results,
1308 context,
1309 timeout_ms,
1310 )
1311 .await?
1312 {
1313 plan.payload
1314 } else {
1315 json!({
1316 "q": query,
1317 "scope": "webpage",
1318 "size": size,
1319 })
1320 };
1321
1322 let resp = client
1323 .post(format!("{METASO_ENDPOINT}/search"))
1324 .header("Content-Type", "application/json")
1325 .header("Authorization", format!("Bearer {api_key}"))
1326 .json(&payload)
1327 .send()
1328 .await
1329 .map_err(|e| {
1330 ToolError::execution_failed(format!("Metaso search request failed: {e}"))
1331 })?;
1332
1333 let status = resp.status();
1334 let body = if adapter::search_selected(context) {
1335 adapter::read_response(resp, context).await?
1336 } else {
1337 resp.text().await.map_err(|e| {
1338 ToolError::execution_failed(format!("Failed to read Metaso response: {e}"))
1339 })?
1340 };
1341
1342 if !status.is_success() {
1343 let msg = match status.as_u16() {
1344 401 | 403 => "Metaso API key rejected — check METASO_API_KEY or set `[search] api_key` in config.toml, or get one at https://metaso.cn/search-api/playground".to_string(),
1345 429 => "Metaso rate-limited — wait and retry, or get your own API key at https://metaso.cn/search-api/playground".to_string(),
1346 _ => {
1347 let truncated = truncate_error_body(&body);
1348 format!("Metaso server error (HTTP {status}) — {truncated}")
1349 }
1350 };
1351 return Err((ToolError::execution_failed(msg)).into());
1352 }
1353
1354 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
1355 ToolError::execution_failed(format!("Failed to parse Metaso response: {e}"))
1356 })?;
1357
1358 if let Some(entries) =
1359 host_provider("metaso", &parsed, size, Some(api_key), context, timeout_ms).await?
1360 {
1361 return Ok(entries);
1362 }
1363
1364 // Check business-logic error codes in the response body.
1365 if let Some(code) = parsed.get("code").and_then(|v| v.as_i64())
1366 && code != 0
1367 {
1368 let msg = parsed
1369 .get("message")
1370 .and_then(|v| v.as_str())
1371 .unwrap_or("unknown error");
1372 return Err((ToolError::execution_failed(match code {
1373 3003 => "Metaso: daily search limit reached — set METASO_API_KEY or get one at https://metaso.cn/search-api/playground".to_string(),
1374 2005 => "Metaso API key rejected — check METASO_API_KEY or set `[search] api_key` in config.toml".to_string(),
1375 _ => format!("Metaso API error (code {code}: {msg})"),
1376 })).into());
1377 }
1378
1379 Ok(parse_metaso_results(&parsed, size))
1380 }
1381
1382 /// Search via Baidu AI Search API (<https://qianfan.baidubce.com>).
1383 async fn run_baidu_search(
1384 &self,
1385 query: &str,
1386 max_results: usize,
1387 timeout_ms: u64,
1388 context: &ToolContext,
1389 ) -> AdapterResult<Vec<WebSearchEntry>> {
1390 let env_key = std::env::var("BAIDU_SEARCH_API_KEY").ok();
1391 let api_key = context
1392 .search_api_key
1393 .as_deref()
1394 .or(env_key.as_deref())
1395 .ok_or_else(|| {
1396 ToolError::execution_failed(
1397 "Baidu search requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.",
1398 )
1399 })?;
1400
1401 let client = crate::tls::reqwest_client_builder()
1402 .timeout(Duration::from_millis(timeout_ms))
1403 .build()
1404 .map_err(|e| {
1405 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
1406 })?;
1407
1408 let payload = if let Some(plan) = host_request(
1409 "baidu",
1410 query,
1411 QueryFilters::default(),
1412 max_results,
1413 context,
1414 timeout_ms,
1415 )
1416 .await?
1417 {
1418 plan.payload
1419 } else {
1420 baidu_search_payload(query, max_results)
1421 };
1422
1423 // Baidu's AI Search endpoint accepts conversational messages rather
1424 // than an index-only query. Treat the entire request/response decode
1425 // as model-backed for attached-run ownership; a false negative here
1426 // could overlap Runtime Chat, while the conservative read lease only
1427 // serializes work that already belongs to the same interactive run.
1428 let _inference = acquire_model_backed_search_inference_participant().await;
1429
1430 let resp = client
1431 .post(BAIDU_ENDPOINT)
1432 .header("Authorization", format!("Bearer {api_key}"))
1433 .json(&payload)
1434 .send()
1435 .await
1436 .map_err(|e| {
1437 ToolError::execution_failed(format!("Baidu search request failed: {e}"))
1438 })?;
1439
1440 let status = resp.status();
1441 let body = if adapter::search_selected(context) {
1442 adapter::read_response(resp, context).await?
1443 } else {
1444 resp.text().await.map_err(|e| {
1445 ToolError::execution_failed(format!("Failed to read Baidu response: {e}"))
1446 })?
1447 };
1448
1449 if !status.is_success() {
1450 let msg = match status.as_u16() {
1451 401 | 403 => "Baidu search API key rejected — check BAIDU_SEARCH_API_KEY or `[search] api_key` in config.toml".to_string(),
1452 429 => "Baidu search rate-limited — wait and retry, or check your Baidu AI Search quota".to_string(),
1453 _ => {
1454 let truncated = truncate_error_body(&body);
1455 format!("Baidu search failed: HTTP {} — {truncated}", status.as_u16())
1456 }
1457 };
1458 return Err((ToolError::execution_failed(msg)).into());
1459 }
1460
1461 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
1462 ToolError::execution_failed(format!("Failed to parse Baidu response: {e}"))
1463 })?;
1464
1465 if let Some(entries) = host_provider(
1466 "baidu",
1467 &parsed,
1468 max_results,
1469 Some(api_key),
1470 context,
1471 timeout_ms,
1472 )
1473 .await?
1474 {
1475 return Ok(entries);
1476 }
1477
1478 if let Some(error) = baidu_error_message(&parsed) {
1479 return Err((ToolError::execution_failed(error)).into());
1480 }
1481
1482 Ok(parse_baidu_results(&parsed, max_results))
1483 }
1484
1485 /// Search via Volcengine Ark; it needs a 90s floor and retries transport failures.
1486 async fn run_volcengine_search(
1487 &self,
1488 query: &str,
1489 max_results: usize,
1490 timeout_ms: u64,
1491 context: &ToolContext,
1492 ) -> AdapterResult<Vec<WebSearchEntry>> {
1493 let volc_key = std::env::var("VOLCENGINE_API_KEY").ok();
1494 let volc_ark_key = std::env::var("VOLCENGINE_ARK_API_KEY").ok();
1495 let ark_key = std::env::var("ARK_API_KEY").ok();
1496 let api_key = context
1497 .search_api_key
1498 .as_deref()
1499 .or(volc_key.as_deref())
1500 .or(volc_ark_key.as_deref())
1501 .or(ark_key.as_deref())
1502 .ok_or_else(|| {
1503 ToolError::execution_failed(
1504 "Volcengine search requires an API key. Set `[search] api_key`, \
1505 or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY env var.",
1506 )
1507 })?;
1508
1509 let effective_timeout = timeout_ms.max(90_000);
1510
1511 let client = crate::tls::reqwest_client_builder()
1512 .connect_timeout(Duration::from_secs(15))
1513 .timeout(Duration::from_millis(effective_timeout))
1514 .tcp_keepalive(Some(Duration::from_secs(30)))
1515 .http2_keep_alive_interval(Some(Duration::from_secs(15)))
1516 .http2_keep_alive_timeout(Duration::from_secs(20))
1517 .user_agent(USER_AGENT)
1518 .build()
1519 .map_err(|e| {
1520 ToolError::execution_failed(format!("Failed to build HTTP client: {e}"))
1521 })?;
1522
1523 let mut payload = if let Some(plan) = host_request(
1524 "volcengine",
1525 query,
1526 QueryFilters::default(),
1527 max_results,
1528 context,
1529 timeout_ms,
1530 )
1531 .await?
1532 {
1533 plan.payload
1534 } else {
1535 volcengine_search_payload(query, max_results)
1536 };
1537 // The fixed Core route/model/tool selection is never Host authority.
1538 payload["model"] = json!("doubao-seed-2-0-lite-260428");
1539 payload["stream"] = json!(false);
1540 payload["tools"] = json!([{"type":"web_search"}]);
1541
1542 // Unlike the ordinary index-search backends, Volcengine's Responses
1543 // endpoint runs a named model and returns model-generated text. Keep
1544 // that provider lifecycle inside the attached CWC run's shared read
1545 // ownership through retries and response decoding, so an isolated
1546 // Runtime Chat turn cannot be projected alongside it.
1547 let _inference = acquire_model_backed_search_inference_participant().await;
1548
1549 let mut last_err: Option<ToolError> = None;
1550 for attempt in 0..3 {
1551 if attempt > 0 {
1552 tokio::time::sleep(Duration::from_millis(1000 * (1 << (attempt - 1)))).await;
1553 }
1554
1555 match client
1556 .post(VOLCENGINE_RESPONSES_ENDPOINT)
1557 .header("Authorization", format!("Bearer {api_key}"))
1558 .json(&payload)
1559 .send()
1560 .await
1561 {
1562 Ok(resp) => {
1563 let status = resp.status();
1564 let body = if adapter::search_selected(context) {
1565 adapter::read_response(resp, context).await?
1566 } else {
1567 resp.text().await.map_err(|e| {
1568 ToolError::execution_failed(format!(
1569 "Failed to read Volcengine response: {e}"
1570 ))
1571 })?
1572 };
1573
1574 if !status.is_success() {
1575 let msg = match status.as_u16() {
1576 401 | 403 => "Volcengine API key rejected — check `[search] api_key` in config.toml or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY".to_string(),
1577 429 => "Volcengine API rate-limited — wait and retry, or check your quota".to_string(),
1578 _ => {
1579 let truncated = truncate_error_body(&body);
1580 format!("Volcengine search failed: HTTP {} — {truncated}", status.as_u16())
1581 }
1582 };
1583 return Err((ToolError::execution_failed(msg)).into());
1584 }
1585
1586 let parsed: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
1587 ToolError::execution_failed(format!(
1588 "Failed to parse Volcengine response: {e}"
1589 ))
1590 })?;
1591
1592 if let Some(entries) = host_provider(
1593 "volcengine",
1594 &parsed,
1595 max_results,
1596 Some(api_key),
1597 context,
1598 timeout_ms,
1599 )
1600 .await?
1601 {
1602 return Ok(entries);
1603 }
1604
1605 if let Some(error) = volcengine_error_message(&parsed) {
1606 return Err((ToolError::execution_failed(error)).into());
1607 }
1608
1609 let response_text = volcengine_extract_text(&parsed).ok_or_else(|| {
1610 ToolError::execution_failed("Volcengine response contains no output text")
1611 })?;
1612
1613 return Ok(parse_volcengine_results(&response_text, max_results));
1614 }
1615 Err(e) => {
1616 let is_transient = e.is_timeout() || e.is_connect();
1617 if !is_transient || attempt == 2 {
1618 return Err((ToolError::execution_failed(format!(
1619 "Volcengine search request failed: {e}"
1620 )))
1621 .into());
1622 }
1623 last_err = Some(ToolError::execution_failed(format!(
1624 "Volcengine search request failed (attempt {}/3): {e}",
1625 attempt + 1
1626 )));
1627 }
1628 }
1629 }
1630
1631 // Unreachable — the final iteration always returns above.
1632 Err(last_err
1633 .unwrap_or_else(|| {
1634 ToolError::execution_failed("Volcengine search: unexpected retry exit")
1635 })
1636 .into())
1637 }
1638 }
1639
1640 pub(crate) async fn execute_search(
1641 query: SearchQuery,
1642 timeout_ms: u64,
1643 context: &ToolContext,
1644 ) -> Result<SearchResponse, ToolError> {
1645 if configured_search_base_url(context.search_base_url.as_deref()).is_some()
1646 && !matches!(
1647 context.search_provider,
1648 SearchProvider::DuckDuckGo | SearchProvider::Searxng
1649 )
1650 {
1651 return Err(ToolError::invalid_input(format!(
1652 "[search].base_url is only supported with provider = \"duckduckgo\" or \"searxng\"; current provider is \"{}\"",
1653 context.search_provider.as_str()
1654 )));
1655 }
1656
1657 let chain = SearchBackendChain::from_context(context);
1658 let initial_backend = chain.initial_backend();
1659 if initial_backend != BackendId::ProviderNative {
1660 debug_assert_eq!(initial_backend.as_str(), context.search_provider.as_str());
1661 preflight_search_provider(context)?;
1662 }
1663 let cache_scope = if initial_backend == BackendId::ProviderNative {
1664 context
1665 .provider_native_search
1666 .as_ref()
1667 .map(crate::client::ProviderNativeSearchClient::cache_identity)
1668 } else {
1669 normalized_search_base_url(context.search_base_url.as_deref())
1670 };
1671
1672 if let Some(mut cached) = cache::get_search(
1673 &context.state_namespace,
1674 initial_backend,
1675 cache_scope.as_deref(),
1676 &query,
1677 ) {
1678 validate_cached_search_policy(&cached, context)?;
1679 register_search_citations(&mut cached, context);
1680 cached.receipt.cache_hit = true;
1681 cached.receipt.latency_ms = 0;
1682 return Ok(cached);
1683 }
1684
1685 let started = Instant::now();
1686 let requested_timeout = Duration::from_millis(timeout_ms.max(1));
1687 let provider_native_timeout_floor = context
1688 .provider_native_search
1689 .as_ref()
1690 .and_then(provider_native_timeout_floor);
1691 let (total_timeout, first_attempt_budget, fallback_budget_after_first) = search_timeout_budgets(
1692 initial_backend,
1693 requested_timeout,
1694 provider_native_timeout_floor,
1695 );
1696 let deadline = started + total_timeout;
1697 let chained = chain
1698 .search(
1699 &query,
1700 deadline,
1701 first_attempt_budget,
1702 fallback_budget_after_first,
1703 )
1704 .await?;
1705 let mut response = finalize_search_response_for_context(
1706 query.clone(),
1707 chained.capabilities,
1708 chained.raw,
1709 started,
1710 context,
1711 deadline.saturating_duration_since(Instant::now()),
1712 )
1713 .await?;
1714 register_search_citations(&mut response, context);
1715 cache::insert_search(
1716 &context.state_namespace,
1717 initial_backend,
1718 cache_scope.as_deref(),
1719 &query,
1720 response.clone(),
1721 );
1722 Ok(response)
1723 }
1724
1725 fn search_timeout_budgets(
1726 initial_backend: BackendId,
1727 requested_timeout: Duration,
1728 provider_native_timeout_floor: Option<Duration>,
1729 ) -> (Duration, Option<Duration>, Option<Duration>) {
1730 match initial_backend {
1731 BackendId::Volcengine => {
1732 let provider_budget = Duration::from_millis(VOLCENGINE_MIN_TIMEOUT_MS);
1733 (
1734 provider_budget + requested_timeout,
1735 Some(provider_budget),
1736 None,
1737 )
1738 }
1739 BackendId::ProviderNative => {
1740 // Provider-native search performs a model-backed request. Give it
1741 // a dedicated minimum without donating unused time to the
1742 // configured/local fallback selected by the caller.
1743 let provider_budget = requested_timeout
1744 .max(Duration::from_millis(PROVIDER_NATIVE_MIN_TIMEOUT_MS))
1745 .max(provider_native_timeout_floor.unwrap_or_default());
1746 (
1747 provider_budget.saturating_add(requested_timeout),
1748 Some(provider_budget),
1749 Some(requested_timeout),
1750 )
1751 }
1752 _ => (requested_timeout, None, None),
1753 }
1754 }
1755
1756 fn provider_native_timeout_floor(
1757 client: &crate::client::ProviderNativeSearchClient,
1758 ) -> Option<Duration> {
1759 let k3_formula = crate::config::is_exact_direct_moonshot_k3_route(
1760 client.provider(),
1761 client.base_url(),
1762 client.model(),
1763 )
1764 .then_some(Duration::from_millis(KIMI_K3_FORMULA_MIN_TIMEOUT_MS));
1765 let answer = client
1766 .requested_answer_output_tokens()
1767 .map(native_answer_time_budget);
1768 k3_formula.max(answer)
1769 }
1770
1771 /// Time a native-search attempt needs to return an answer of `output_tokens`
1772 /// whole: the search round-trips plus generation at a conservative rate
1773 /// (#6508). Without it, raising the requested answer length only moved the
1774 /// cut from the provider's token limit to this tool's timeout.
1775 fn native_answer_time_budget(output_tokens: u32) -> Duration {
1776 let generation_ms =
1777 u64::from(output_tokens).saturating_mul(1_000) / NATIVE_SEARCH_ASSUMED_TOKENS_PER_SEC;
1778 Duration::from_millis(NATIVE_SEARCH_ROUND_TRIP_ALLOWANCE_MS.saturating_add(generation_ms))
1779 }
1780
1781 fn register_search_citations(response: &mut SearchResponse, context: &ToolContext) {
1782 let mut seen = std::collections::HashSet::new();
1783 response.results.retain_mut(|result| {
1784 let Some(citation) = super::web::citations::register(
1785 &context.state_namespace,
1786 &result.url,
1787 Some(&result.title),
1788 ) else {
1789 return false;
1790 };
1791 result.ref_id = citation.ref_id;
1792 result.url = citation.url;
1793 seen.insert(result.ref_id.clone())
1794 });
1795 if response.count != response.results.len() {
1796 rerank(&mut response.results);
1797 response.count = response.results.len();
1798 response.message = if response.count == 0 {
1799 "No usable web citations found".to_string()
1800 } else {
1801 format!("Found {} result(s)", response.count)
1802 };
1803 }
1804 }
1805
1806 /// Reject misconfiguration before cache lookup or network access.
1807 fn preflight_search_provider(context: &ToolContext) -> Result<(), ToolError> {
1808 let configured_key = context
1809 .search_api_key
1810 .as_deref()
1811 .is_some_and(|key| !key.trim().is_empty());
1812 let env_key = |name: &str| std::env::var_os(name).is_some_and(|value| !value.is_empty());
1813 let not_configured = |message: &str| Err(ToolError::invalid_input(message));
1814
1815 match context.search_provider {
1816 SearchProvider::Tavily if !configured_key && tavily_env_key().is_none() => not_configured(
1817 "Tavily search is not configured: it requires an API key. Set `[search] api_key = \"tvly-...\"` in config.toml or the `TAVILY_API_KEY` env var.",
1818 ),
1819 SearchProvider::Bocha if !configured_key => not_configured(
1820 "Bocha search is not configured: it requires an API key. Set `[search] api_key = \"sk-...\"` in config.toml.",
1821 ),
1822 SearchProvider::Metaso if !configured_key && !env_key("METASO_API_KEY") => not_configured(
1823 "Metaso search is not configured: it requires an API key. Set `METASO_API_KEY` or `[search] api_key` in config.toml.",
1824 ),
1825 SearchProvider::Baidu if !configured_key && !env_key("BAIDU_SEARCH_API_KEY") => {
1826 not_configured(
1827 "Baidu search is not configured: it requires an API key. Set `BAIDU_SEARCH_API_KEY` or `[search] api_key` in config.toml.",
1828 )
1829 }
1830 SearchProvider::Volcengine
1831 if !configured_key
1832 && !env_key("VOLCENGINE_API_KEY")
1833 && !env_key("VOLCENGINE_ARK_API_KEY")
1834 && !env_key("ARK_API_KEY") =>
1835 {
1836 not_configured(
1837 "Volcengine search is not configured: it requires an API key. Set `[search] api_key`, or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY env var.",
1838 )
1839 }
1840 SearchProvider::Sofya if !configured_key && !env_key("SOFYA_API_KEY") => not_configured(
1841 "Sofya search is not configured: it requires an API key. Set `[search] api_key = \"ay_live_...\"` in config.toml or the SOFYA_API_KEY env var.",
1842 ),
1843 SearchProvider::Serply if !configured_key && !env_key("SERPLY_API_KEY") => not_configured(
1844 "Serply search is not configured: it requires an API key. Set `[search] api_key` in config.toml or the SERPLY_API_KEY env var.",
1845 ),
1846 SearchProvider::Searxng
1847 if configured_search_base_url(context.search_base_url.as_deref()).is_none() =>
1848 {
1849 not_configured(
1850 "SearXNG search requires [search] base_url = \"https://your-searxng.example\"; no public instance is used by default.",
1851 )
1852 }
1853 _ => Ok(()),
1854 }
1855 }
1856
1857 fn normalized_search_base_url(base_url: Option<&str>) -> Option<String> {
1858 let raw = configured_search_base_url(base_url)?;
1859 let Ok(mut url) = reqwest::Url::parse(raw) else {
1860 return Some(raw.to_string());
1861 };
1862 url.set_fragment(None);
1863 Some(url.to_string())
1864 }
1865
1866 fn validate_cached_search_policy(
1867 response: &SearchResponse,
1868 context: &ToolContext,
1869 ) -> Result<(), ToolError> {
1870 let host = response
1871 .receipt
1872 .backend_detail
1873 .as_deref()
1874 .or_else(|| default_backend_host(response.receipt.backend))
1875 .ok_or_else(|| {
1876 ToolError::execution_failed("cached search receipt did not identify its backend host")
1877 })?;
1878 check_policy(context.network_policy.as_ref(), host)
1879 }
1880
1881 const fn default_backend_host(backend: BackendId) -> Option<&'static str> {
1882 match backend {
1883 BackendId::ProviderNative => None,
1884 BackendId::Bing => Some(BING_HOST),
1885 BackendId::DuckDuckGo => Some("html.duckduckgo.com"),
1886 BackendId::Firecrawl => Some("api.firecrawl.dev"),
1887 BackendId::Tavily => Some("api.tavily.com"),
1888 BackendId::Bocha => Some("api.bochaai.com"),
1889 BackendId::Metaso => Some("metaso.cn"),
1890 BackendId::Searxng => None,
1891 BackendId::Baidu => Some("qianfan.baidubce.com"),
1892 BackendId::Volcengine => Some("ark.cn-beijing.volces.com"),
1893 BackendId::Sofya => Some("sofya.co"),
1894 BackendId::Serply => Some("api.serply.io"),
1895 }
1896 }
1897
1898 async fn finalize_search_response_for_context(
1899 query: SearchQuery,
1900 capabilities: super::web::contract::QueryCapabilities,
1901 mut raw: BackendSearch,
1902 started: Instant,
1903 context: &ToolContext,
1904 budget: Duration,
1905 ) -> AdapterResult<SearchResponse> {
1906 if !adapter::search_selected(context) {
1907 return Ok(finalize_search_response(query, capabilities, raw, started));
1908 }
1909 #[derive(serde::Deserialize)]
1910 #[serde(deny_unknown_fields)]
1911 struct Proposal {
1912 kind: String,
1913 honored: HonoredQueryCapabilities,
1914 degraded: Vec<DegradedReason>,
1915 prefix: String,
1916 suffix: String,
1917 }
1918 let initial = raw.degraded.clone();
1919 apply_domain_constraints(&query, capabilities, &mut raw);
1920 let domain_extra = raw
1921 .degraded
1922 .iter()
1923 .filter(|reason| !initial.contains(reason))
1924 .cloned()
1925 .collect::<Vec<_>>();
1926 raw.results.truncate(usize::from(query.max_results));
1927 rerank(&mut raw.results);
1928 let proposal:Proposal=adapter::transform(crate::extension_host::StockOperation::WebFinalize,
1929 json!({"requested":{"recency":query.recency.is_some(),"domains":!query.domains.is_empty(),"locale":query.locale.is_some()},
1930 "capabilities":capabilities,"count":raw.results.len(),"degraded":initial,"domain_extra":domain_extra,"has_note":raw.note.is_some()}),context,budget).await?;
1931 let known = |reason: &DegradedReason| {
1932 initial.contains(reason)
1933 || domain_extra.contains(reason)
1934 || matches!(
1935 reason,
1936 DegradedReason::KnobIgnored {
1937 knob: QueryKnob::Recency | QueryKnob::Locale
1938 }
1939 )
1940 };
1941 if proposal.kind != "web_finalize"
1942 || proposal.prefix.len() > 128
1943 || proposal.suffix.len() > 128
1944 || !proposal.degraded.iter().all(known)
1945 || !initial
1946 .iter()
1947 .chain(&domain_extra)
1948 .all(|reason| proposal.degraded.contains(reason))
1949 || proposal.honored.domains != !query.domains.is_empty()
1950 || proposal.honored.max_results
1951 != matches!(
1952 capabilities.max_results,
1953 super::web::contract::CapabilityState::Supported
1954 )
1955 || (proposal.honored.recency
1956 && (!query.recency.is_some()
1957 || !matches!(
1958 capabilities.recency,
1959 super::web::contract::CapabilityState::Supported
1960 )))
1961 || (proposal.honored.locale
1962 && (!query.locale.is_some()
1963 || !matches!(
1964 capabilities.locale,
1965 super::web::contract::CapabilityState::Supported
1966 )))
1967 {
1968 return Err(AdapterFailure::host(ToolError::execution_failed(
1969 "Web Host returned an inconsistent final receipt",
1970 )));
1971 }
1972 let count = raw.results.len();
1973 let message = format!(
1974 "{}{}{}",
1975 proposal.prefix,
1976 raw.note.as_deref().unwrap_or_default(),
1977 proposal.suffix
1978 );
1979 Ok(SearchResponse {
1980 query: query.query.clone(),
1981 source: raw.source,
1982 count,
1983 message,
1984 results: raw.results,
1985 receipt: SearchReceipt {
1986 backend: raw.backend,
1987 backend_detail: raw.backend_detail,
1988 requested: query,
1989 capabilities,
1990 honored: proposal.honored,
1991 degraded: proposal.degraded,
1992 latency_ms: u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX),
1993 cache_hit: false,
1994 },
1995 })
1996 }
1997
1998 fn finalize_search_response(
1999 query: SearchQuery,
2000 capabilities: super::web::contract::QueryCapabilities,
2001 mut raw: BackendSearch,
2002 started: Instant,
2003 ) -> SearchResponse {
2004 let mut honored = HonoredQueryCapabilities {
2005 max_results: matches!(
2006 capabilities.max_results,
2007 super::web::contract::CapabilityState::Supported
2008 ),
2009 ..HonoredQueryCapabilities::default()
2010 };
2011
2012 let adapter_ignored = |raw: &BackendSearch, knob: QueryKnob| {
2013 raw.degraded.contains(&DegradedReason::KnobIgnored { knob })
2014 };
2015 if query.recency.is_some() && !adapter_ignored(&raw, QueryKnob::Recency) {
2016 if matches!(
2017 capabilities.recency,
2018 super::web::contract::CapabilityState::Supported
2019 ) {
2020 honored.recency = true;
2021 } else {
2022 raw.degraded.push(DegradedReason::KnobIgnored {
2023 knob: QueryKnob::Recency,
2024 });
2025 }
2026 }
2027 if !query.domains.is_empty() {
2028 // The backend chain applies this before deciding whether an attempt
2029 // produced usable results. Keep finalization defensive for cached or
2030 // directly constructed responses; the helper is idempotent.
2031 apply_domain_constraints(&query, capabilities, &mut raw);
2032 honored.domains = true;
2033 }
2034 if query.locale.is_some() && !adapter_ignored(&raw, QueryKnob::Locale) {
2035 if matches!(
2036 capabilities.locale,
2037 super::web::contract::CapabilityState::Supported
2038 ) {
2039 honored.locale = true;
2040 } else {
2041 raw.degraded.push(DegradedReason::KnobIgnored {
2042 knob: QueryKnob::Locale,
2043 });
2044 }
2045 }
2046
2047 raw.results.truncate(usize::from(query.max_results));
2048 rerank(&mut raw.results);
2049 let latency_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
2050 let receipt = SearchReceipt {
2051 backend: raw.backend,
2052 backend_detail: raw.backend_detail,
2053 requested: query.clone(),
2054 capabilities,
2055 honored,
2056 degraded: raw.degraded,
2057 latency_ms,
2058 cache_hit: false,
2059 };
2060 let count = raw.results.len();
2061 let mut message = match (count, raw.note.as_deref()) {
2062 (0, Some(note)) => format!("No results found. {note}"),
2063 (0, None) => "No results found".to_string(),
2064 (_, Some(note)) => format!("Found {count} result(s). {note}"),
2065 (_, None) => format!("Found {count} result(s)"),
2066 };
2067 // The answer sits in the message; say right next to it when the provider
2068 // cut it short (#6508).
2069 if let Some(cut) = receipt
2070 .degraded
2071 .iter()
2072 .find(|reason| matches!(reason, DegradedReason::AnswerCutByProvider))
2073 {
2074 message.push_str(&format!("\n[{}]", cut.message()));
2075 }
2076
2077 SearchResponse {
2078 query: query.query,
2079 source: raw.source,
2080 count,
2081 message,
2082 results: raw.results,
2083 receipt,
2084 }
2085 }
2086
2087 pub(crate) fn apply_domain_constraints(
2088 query: &SearchQuery,
2089 capabilities: super::web::contract::QueryCapabilities,
2090 raw: &mut BackendSearch,
2091 ) {
2092 if query.domains.is_empty() {
2093 return;
2094 }
2095
2096 let before = raw.results.len();
2097 raw.results
2098 .retain(|result| domain_matches(&result.url, &query.domains));
2099 rerank(&mut raw.results);
2100 let provider_honored = matches!(
2101 capabilities.domains,
2102 super::web::contract::CapabilityState::Supported
2103 );
2104 let filtered_any = raw.results.len() != before;
2105 if raw.backend == BackendId::ProviderNative && (!provider_honored || filtered_any) {
2106 // Post-filtering constrains returned citations but cannot prove that a
2107 // provider-generated answer did not rely on a removed source.
2108 raw.note = None;
2109 }
2110 let already_recorded = raw.degraded.iter().any(|reason| {
2111 matches!(
2112 reason,
2113 DegradedReason::PostFiltered {
2114 knob: QueryKnob::Domains
2115 }
2116 )
2117 });
2118 if (!provider_honored || filtered_any) && !already_recorded {
2119 raw.degraded.push(DegradedReason::PostFiltered {
2120 knob: QueryKnob::Domains,
2121 });
2122 }
2123 }
2124
2125 pub(crate) async fn run_backend_search(
2126 provider: SearchProvider,
2127 query: &SearchQuery,
2128 deadline: Instant,
2129 context: &ToolContext,
2130 ) -> AdapterResult<BackendSearch> {
2131 let timeout_ms = u64::try_from(
2132 deadline
2133 .saturating_duration_since(Instant::now())
2134 .as_millis()
2135 .max(1),
2136 )
2137 .unwrap_or(u64::MAX);
2138 let max_results = usize::from(query.max_results);
2139 let prepared = if adapter::search_selected(context) {
2140 let value: PreparedFilters = adapter::transform(
2141 crate::extension_host::StockOperation::WebFilters,
2142 json!({"recency_days":query.recency.map(Recency::days),"locale":query.locale}),
2143 context,
2144 Duration::from_millis(timeout_ms),
2145 )
2146 .await?;
2147 if value.kind != "web_filters"
2148 || value
2149 .window
2150 .as_deref()
2151 .is_some_and(|value| !matches!(value, "day" | "week" | "month" | "year"))
2152 || value.language.as_deref().is_some_and(|value| {
2153 !matches!(value.len(), 2 | 3) || !value.chars().all(|c| c.is_ascii_lowercase())
2154 })
2155 || value.region.as_deref().is_some_and(|value| {
2156 value.len() != 2 || !value.chars().all(|c| c.is_ascii_uppercase())
2157 })
2158 {
2159 return Err(AdapterFailure::host(ToolError::execution_failed(
2160 "Web Host returned malformed filters",
2161 )));
2162 }
2163 Some(value)
2164 } else {
2165 None
2166 };
2167 let mut filters = QueryFilters::of(query);
2168 filters.prepared = prepared.as_ref();
2169 let tool = WebSearchTool;
2170 let simple = |backend, entries: Vec<WebSearchEntry>| BackendSearch {
2171 backend,
2172 source: backend.as_str().to_string(),
2173 backend_detail: None,
2174 results: normalize_entries(entries),
2175 degraded: Vec::new(),
2176 note: None,
2177 };
2178
2179 match provider {
2180 SearchProvider::Firecrawl => {
2181 check_policy(context.network_policy.as_ref(), "api.firecrawl.dev")?;
2182 let (results, note) = tool
2183 .run_firecrawl_search(&query.query, filters, max_results, timeout_ms, context)
2184 .await?;
2185 // Firecrawl targets a country, not a language: a bare `en` has
2186 // nothing to send and is reported as ignored.
2187 let degraded = (filters.locale.is_some() && filters.region().is_none())
2188 .then_some(DegradedReason::KnobIgnored {
2189 knob: QueryKnob::Locale,
2190 })
2191 .into_iter()
2192 .collect();
2193 Ok(BackendSearch {
2194 backend: BackendId::Firecrawl,
2195 source: "firecrawl".to_string(),
2196 backend_detail: Some("api.firecrawl.dev".to_string()),
2197 results: normalize_entries(results),
2198 degraded,
2199 note: Some(note),
2200 })
2201 }
2202 SearchProvider::Tavily => {
2203 check_policy(context.network_policy.as_ref(), "api.tavily.com")?;
2204 Ok(simple(
2205 BackendId::Tavily,
2206 tool.run_tavily_search(&query.query, filters, max_results, timeout_ms, context)
2207 .await?,
2208 ))
2209 }
2210 SearchProvider::Bocha => {
2211 check_policy(context.network_policy.as_ref(), "api.bochaai.com")?;
2212 Ok(simple(
2213 BackendId::Bocha,
2214 tool.run_bocha_search(&query.query, max_results, timeout_ms, context)
2215 .await?,
2216 ))
2217 }
2218 SearchProvider::Metaso => {
2219 check_policy(context.network_policy.as_ref(), "metaso.cn")?;
2220 Ok(simple(
2221 BackendId::Metaso,
2222 tool.run_metaso_search(&query.query, max_results, timeout_ms, context)
2223 .await?,
2224 ))
2225 }
2226 SearchProvider::Searxng => {
2227 let (entries, host) = tool
2228 .run_searxng_search(&query.query, filters, max_results, timeout_ms, context)
2229 .await?;
2230 let note = format!("Backend: searxng at {host}");
2231 Ok(BackendSearch {
2232 backend: BackendId::Searxng,
2233 source: "searxng".to_string(),
2234 backend_detail: Some(host),
2235 results: normalize_entries(entries),
2236 degraded: Vec::new(),
2237 note: Some(note),
2238 })
2239 }
2240 SearchProvider::Baidu => {
2241 check_policy(context.network_policy.as_ref(), "qianfan.baidubce.com")?;
2242 Ok(simple(
2243 BackendId::Baidu,
2244 tool.run_baidu_search(&query.query, max_results, timeout_ms, context)
2245 .await?,
2246 ))
2247 }
2248 SearchProvider::Volcengine => {
2249 check_policy(context.network_policy.as_ref(), "ark.cn-beijing.volces.com")?;
2250 let mut response = simple(
2251 BackendId::Volcengine,
2252 tool.run_volcengine_search(&query.query, max_results, timeout_ms, context)
2253 .await?,
2254 );
2255 response.degraded.push(DegradedReason::SynthesizedResults);
2256 Ok(response)
2257 }
2258 SearchProvider::Sofya => {
2259 check_policy(context.network_policy.as_ref(), "sofya.co")?;
2260 Ok(simple(
2261 BackendId::Sofya,
2262 tool.run_sofya_search(&query.query, max_results, timeout_ms, context)
2263 .await?,
2264 ))
2265 }
2266 SearchProvider::Serply => {
2267 check_policy(context.network_policy.as_ref(), "api.serply.io")?;
2268 Ok(simple(
2269 BackendId::Serply,
2270 tool.run_serply_search(&query.query, filters, max_results, timeout_ms, context)
2271 .await?,
2272 ))
2273 }
2274 SearchProvider::Bing | SearchProvider::DuckDuckGo => {
2275 run_scrape_search(provider, query, timeout_ms, context).await
2276 }
2277 }
2278 }
2279
2280 /// Share of the search budget a DuckDuckGo request may use when Bing is allowed
2281 /// to answer after it (#6746).
2282 const DUCKDUCKGO_BUDGET_SHARE: f64 = 0.6;
2283
2284 #[derive(Clone, Copy)]
2285 struct ScrapeEndpoints<'a> {
2286 bing: &'a str,
2287 allow_bing_fallback: Option<bool>,
2288 }
2289
2290 impl Default for ScrapeEndpoints<'static> {
2291 fn default() -> Self {
2292 Self {
2293 bing: BING_ENDPOINT,
2294 allow_bing_fallback: None,
2295 }
2296 }
2297 }
2298
2299 async fn run_scrape_search(
2300 provider: SearchProvider,
2301 query: &SearchQuery,
2302 timeout_ms: u64,
2303 context: &ToolContext,
2304 ) -> AdapterResult<BackendSearch> {
2305 let fallback_context = (provider == SearchProvider::DuckDuckGo
2306 && context.search_provider != SearchProvider::DuckDuckGo)
2307 .then(|| {
2308 let mut cloned = context.clone();
2309 cloned.search_base_url = None;
2310 cloned
2311 });
2312 let context = fallback_context.as_ref().unwrap_or(context);
2313 run_scrape_search_with_endpoints(
2314 provider,
2315 query,
2316 timeout_ms,
2317 context,
2318 ScrapeEndpoints::default(),
2319 )
2320 .await
2321 }
2322
2323 async fn run_scrape_search_with_endpoints(
2324 provider: SearchProvider,
2325 query: &SearchQuery,
2326 timeout_ms: u64,
2327 context: &ToolContext,
2328 endpoints: ScrapeEndpoints<'_>,
2329 ) -> AdapterResult<BackendSearch> {
2330 let started = Instant::now();
2331 let budget = Duration::from_millis(timeout_ms);
2332 let decider = context.network_policy.as_ref();
2333 let client = crate::tls::reqwest_client_builder()
2334 .timeout(budget)
2335 .user_agent(USER_AGENT)
2336 .build()
2337 .map_err(|error| {
2338 ToolError::execution_failed(format!("Failed to build HTTP client: {error}"))
2339 })?;
2340 let max_results = usize::from(query.max_results);
2341 let mut degraded = Vec::new();
2342
2343 if provider == SearchProvider::Bing {
2344 check_policy(decider, BING_HOST)?;
2345 let results = run_bing_search(
2346 &client,
2347 &query.query,
2348 max_results,
2349 endpoints.bing,
2350 budget,
2351 context,
2352 )
2353 .await?;
2354 return Ok(BackendSearch {
2355 backend: BackendId::Bing,
2356 source: "bing".to_string(),
2357 backend_detail: None,
2358 results: normalize_entries(results),
2359 degraded,
2360 note: None,
2361 });
2362 }
2363
2364 let (url, duckduckgo_host) = if let Some(plan) = host_request(
2365 "duckduckgo",
2366 &query.query,
2367 QueryFilters::default(),
2368 max_results,
2369 context,
2370 timeout_ms,
2371 )
2372 .await?
2373 {
2374 let (mut url, host) = duckduckgo_search_base(context.search_base_url.as_deref())?;
2375 for (key, value) in plan.pairs {
2376 url.query_pairs_mut().append_pair(&key, &value);
2377 }
2378 (url.to_string(), host)
2379 } else {
2380 duckduckgo_search_url(context.search_base_url.as_deref(), &query.query)?
2381 };
2382 let allow_bing_fallback = endpoints
2383 .allow_bing_fallback
2384 .unwrap_or_else(|| duckduckgo_allows_bing_fallback(context.search_base_url.as_deref()));
2385 check_policy(decider, &duckduckgo_host)?;
2386 // #6746: a hanging DuckDuckGo must leave the Bing fallback time to answer.
2387 // When Bing may follow, DuckDuckGo gets a share of the budget and Bing
2388 // the remainder; without the fallback DuckDuckGo keeps the whole budget.
2389 let duckduckgo_budget = if allow_bing_fallback {
2390 budget.mul_f64(DUCKDUCKGO_BUDGET_SHARE)
2391 } else {
2392 budget
2393 };
2394 let fetched = fetch_duckduckgo_html(&client, &url, duckduckgo_budget, context).await;
2395 let bing_budget = || {
2396 budget
2397 .saturating_sub(started.elapsed())
2398 .max(Duration::from_millis(1))
2399 };
2400 let body = match fetched {
2401 Ok(body) => body,
2402 // #6746: an unreachable DuckDuckGo (connection error, timeout, or a
2403 // non-2xx status) must still reach the Bing fallback, not end the
2404 // chain. Only a Bing answer with results replaces the DuckDuckGo
2405 // error; otherwise the original failure is reported.
2406 Err(error)
2407 if !error.content()
2408 || matches!(
2409 error.error,
2410 ToolError::Cancelled { .. } | ToolError::Timeout { seconds: 0 }
2411 ) =>
2412 {
2413 return Err(error);
2414 }
2415 Err(error) if allow_bing_fallback => {
2416 let error = match error.error {
2417 ToolError::ExecutionFailed { message, .. } => message,
2418 other => other.to_string(),
2419 };
2420 check_policy(decider, BING_HOST)?;
2421 return match run_bing_search(
2422 &client,
2423 &query.query,
2424 max_results,
2425 endpoints.bing,
2426 bing_budget(),
2427 context,
2428 )
2429 .await
2430 {
2431 Ok(results) if !results.is_empty() => {
2432 degraded.push(DegradedReason::BackendUnavailable {
2433 backend: BackendId::DuckDuckGo,
2434 });
2435 degraded.push(DegradedReason::BackendFallback {
2436 from: BackendId::DuckDuckGo,
2437 to: BackendId::Bing,
2438 });
2439 Ok(BackendSearch {
2440 backend: BackendId::Bing,
2441 source: "bing".to_string(),
2442 backend_detail: None,
2443 results: normalize_entries(results),
2444 degraded,
2445 note: Some(format!("{error}; used Bing fallback")),
2446 })
2447 }
2448 Ok(_) => Err((ToolError::execution_failed(format!(
2449 "{error}; Bing fallback returned no results"
2450 )))
2451 .into()),
2452 Err(bing_error) if !bing_error.content() => Err(bing_error),
2453 Err(bing_error) => Err((ToolError::execution_failed(format!(
2454 "{error}; Bing fallback failed: {}",
2455 match &bing_error.error {
2456 ToolError::ExecutionFailed { message, .. } => message.clone(),
2457 other => other.to_string(),
2458 }
2459 )))
2460 .into()),
2461 };
2462 }
2463 Err(error) => return Err(error),
2464 };
2465
2466 let results = normalize_scraped_entries(
2467 parse_duckduckgo_results(&body, max_results),
2468 context,
2469 bing_budget(),
2470 )
2471 .await?;
2472 let blocked = is_duckduckgo_challenge(&body);
2473 if !results.is_empty() {
2474 return Ok(BackendSearch {
2475 backend: BackendId::DuckDuckGo,
2476 source: if allow_bing_fallback {
2477 "duckduckgo".to_string()
2478 } else {
2479 duckduckgo_host.clone()
2480 },
2481 backend_detail: (!allow_bing_fallback).then_some(duckduckgo_host),
2482 results: normalize_entries(results),
2483 degraded,
2484 note: None,
2485 });
2486 }
2487 if blocked {
2488 degraded.push(DegradedReason::ChallengeDetected {
2489 backend: BackendId::DuckDuckGo,
2490 });
2491 }
2492 if !allow_bing_fallback {
2493 if blocked {
2494 return Err((ToolError::execution_failed(format!(
2495 "DuckDuckGo-compatible search endpoint at {duckduckgo_host} returned a bot challenge; check the private search service, credentials, or network policy"
2496 ))).into());
2497 }
2498 return Ok(BackendSearch {
2499 backend: BackendId::DuckDuckGo,
2500 source: duckduckgo_host.clone(),
2501 backend_detail: Some(duckduckgo_host),
2502 results: Vec::new(),
2503 degraded,
2504 note: None,
2505 });
2506 }
2507
2508 check_policy(decider, BING_HOST)?;
2509 match run_bing_search(
2510 &client,
2511 &query.query,
2512 max_results,
2513 endpoints.bing,
2514 bing_budget(),
2515 context,
2516 )
2517 .await
2518 {
2519 Ok(results) if !results.is_empty() => {
2520 degraded.push(DegradedReason::ScrapeFallback {
2521 from: BackendId::DuckDuckGo,
2522 to: BackendId::Bing,
2523 });
2524 Ok(BackendSearch {
2525 backend: BackendId::Bing,
2526 source: "bing".to_string(),
2527 backend_detail: None,
2528 results: normalize_entries(results),
2529 degraded,
2530 note: Some(if blocked {
2531 "DuckDuckGo returned a bot challenge; used Bing fallback".to_string()
2532 } else {
2533 "DuckDuckGo returned no parseable results; used Bing fallback".to_string()
2534 }),
2535 })
2536 }
2537 Err(error) if !error.content() => Err(error),
2538 Ok(_) if blocked => Err((ToolError::execution_failed(
2539 "DuckDuckGo returned a bot challenge and Bing fallback returned no results",
2540 ))
2541 .into()),
2542 Err(error) if blocked => Err((ToolError::execution_failed(format!(
2543 "DuckDuckGo returned a bot challenge and Bing fallback failed: {error}"
2544 )))
2545 .into()),
2546 Ok(_) | Err(_) => Ok(BackendSearch {
2547 backend: BackendId::DuckDuckGo,
2548 source: "duckduckgo".to_string(),
2549 backend_detail: None,
2550 results: Vec::new(),
2551 degraded,
2552 note: None,
2553 }),
2554 }
2555 }
2556
2557 /// Fetch the DuckDuckGo HTML results page. A transport failure or a non-2xx
2558 /// status is an error so the caller can decide whether Bing may answer.
2559 async fn fetch_duckduckgo_html(
2560 client: &reqwest::Client,
2561 url: &str,
2562 timeout: Duration,
2563 context: &ToolContext,
2564 ) -> AdapterResult<String> {
2565 let resp = client
2566 .get(url)
2567 .timeout(timeout)
2568 .header(
2569 "Accept",
2570 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
2571 )
2572 .header("Accept-Language", "en-US,en;q=0.5")
2573 .send()
2574 .await
2575 .map_err(|error| {
2576 ToolError::execution_failed(format!("Web search request failed: {error}"))
2577 })?;
2578 let status = resp.status();
2579 let body = if adapter::search_selected(context) {
2580 adapter::read_response_with_limit(resp, context, super::web::fetch::HARD_MAX_BYTES).await?
2581 } else {
2582 resp.text().await.map_err(|error| {
2583 ToolError::execution_failed(format!("Failed to read response: {error}"))
2584 })?
2585 };
2586 if !status.is_success() {
2587 return Err(ToolError::execution_failed(format!(
2588 "Web search failed: HTTP {}",
2589 status.as_u16()
2590 ))
2591 .into());
2592 }
2593 Ok(body)
2594 }
2595
2596 async fn normalize_scraped_entries(
2597 entries: Vec<WebSearchEntry>,
2598 context: &ToolContext,
2599 budget: Duration,
2600 ) -> AdapterResult<Vec<WebSearchEntry>> {
2601 normalize_captured_entries(
2602 entries
2603 .into_iter()
2604 .map(|entry| super::web::contract::CapturedSearchEntry {
2605 title: entry.title,
2606 url: entry.url,
2607 snippet: entry.snippet,
2608 published: None,
2609 })
2610 .collect(),
2611 context,
2612 budget,
2613 )
2614 .await
2615 .map(|entries| {
2616 entries
2617 .into_iter()
2618 .map(|entry| WebSearchEntry {
2619 title: entry.title,
2620 url: entry.url,
2621 snippet: entry.snippet,
2622 })
2623 .collect()
2624 })
2625 }
2626
2627 fn normalize_entries(entries: Vec<WebSearchEntry>) -> Vec<SearchResult> {
2628 entries
2629 .into_iter()
2630 .enumerate()
2631 .map(|(index, entry)| {
2632 SearchResult::new(index + 1, entry.title, entry.url, entry.snippet, None)
2633 })
2634 .collect()
2635 }
2636
2637 fn rerank(results: &mut [SearchResult]) {
2638 for (index, result) in results.iter_mut().enumerate() {
2639 result.rank = u8::try_from(index + 1).unwrap_or(u8::MAX);
2640 }
2641 }
2642
2643 pub(crate) fn domain_matches(url: &str, domains: &[String]) -> bool {
2644 if domains.is_empty() {
2645 return true;
2646 }
2647 let Ok(parsed) = reqwest::Url::parse(url) else {
2648 return false;
2649 };
2650 let Some(host) = parsed.host_str() else {
2651 return false;
2652 };
2653 let host = host.trim_start_matches("www.").to_ascii_lowercase();
2654 domains.iter().any(|domain| {
2655 let domain = domain.trim_start_matches("www.").to_ascii_lowercase();
2656 host == domain || host.ends_with(&format!(".{domain}"))
2657 })
2658 }
2659
2660 fn truncate_error_body(body: &str) -> String {
2661 let stripped = sanitize_error_body(body);
2662 if stripped.len() <= ERROR_BODY_PREVIEW_BYTES {
2663 stripped
2664 } else {
2665 let mut end = ERROR_BODY_PREVIEW_BYTES;
2666 while !stripped.is_char_boundary(end) {
2667 end -= 1;
2668 }
2669 format!("{}...", &stripped[..end])
2670 }
2671 }
2672
2673 static TAG_RE: OnceLock<Regex> = OnceLock::new();
2674
2675 fn get_tag_re() -> &'static Regex {
2676 TAG_RE.get_or_init(|| Regex::new(r"<[^>]+>").expect("tag regex pattern is valid"))
2677 }
2678
2679 fn strip_html_tags(text: &str) -> String {
2680 get_tag_re().replace_all(text, "").to_string()
2681 }
2682
2683 fn sanitize_error_body(body: &str) -> String {
2684 let stripped = strip_html_tags(body);
2685 let visible: String = stripped
2686 .chars()
2687 .filter(|c| !c.is_control() || c.is_ascii_whitespace())
2688 .collect();
2689 get_bearer_token_re()
2690 .replace_all(&visible, "Bearer [REDACTED]")
2691 .to_string()
2692 }
2693
2694 fn parse_tavily_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2695 parsed
2696 .get("results")
2697 .and_then(Value::as_array)
2698 .into_iter()
2699 .flatten()
2700 .filter_map(|item| {
2701 let title = item.get("title")?.as_str()?.trim();
2702 let url = item.get("url")?.as_str()?.trim();
2703 if title.is_empty() || url.is_empty() {
2704 return None;
2705 }
2706 Some(WebSearchEntry {
2707 title: title.to_string(),
2708 url: url.to_string(),
2709 snippet: first_non_empty_string(item, &["content", "snippet"]),
2710 })
2711 })
2712 .take(max_results)
2713 .collect()
2714 }
2715
2716 fn parse_firecrawl_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2717 parsed
2718 .pointer("/data/web")
2719 .or_else(|| parsed.get("data"))
2720 .and_then(Value::as_array)
2721 .into_iter()
2722 .flatten()
2723 .filter_map(|item| {
2724 let title = item.get("title")?.as_str()?.trim();
2725 let url = item.get("url")?.as_str()?.trim();
2726 (!title.is_empty() && !url.is_empty()).then(|| WebSearchEntry {
2727 title: title.to_string(),
2728 url: url.to_string(),
2729 snippet: first_non_empty_string(item, &["description", "markdown", "content"])
2730 .map(|value| value.chars().take(1_000).collect()),
2731 })
2732 })
2733 .take(max_results)
2734 .collect()
2735 }
2736
2737 fn parse_metaso_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2738 parsed
2739 .get("webpages")
2740 .and_then(Value::as_array)
2741 .into_iter()
2742 .flatten()
2743 .filter_map(|item| {
2744 let title = item.get("title")?.as_str()?.trim();
2745 let url = item.get("link")?.as_str()?.trim();
2746 if title.is_empty() || url.is_empty() {
2747 return None;
2748 }
2749 Some(WebSearchEntry {
2750 title: title.to_string(),
2751 url: url.to_string(),
2752 snippet: first_non_empty_string(item, &["snippet", "summary"]),
2753 })
2754 })
2755 .take(max_results)
2756 .collect()
2757 }
2758
2759 fn parse_bocha_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2760 parsed
2761 .get("data")
2762 .and_then(|d| {
2763 d.get("webPages")
2764 .and_then(|w| w.get("value"))
2765 .or_else(|| d.get("pages"))
2766 })
2767 .or_else(|| parsed.get("pages"))
2768 .and_then(|v| v.as_array())
2769 .into_iter()
2770 .flat_map(|arr| arr.iter())
2771 .filter_map(|item| {
2772 let title = item
2773 .get("name")
2774 .or_else(|| item.get("title"))
2775 .and_then(|s| s.as_str())?
2776 .trim();
2777 let url = item
2778 .get("url")
2779 .or_else(|| item.get("link"))
2780 .and_then(|s| s.as_str())?
2781 .trim();
2782 if title.is_empty() || url.is_empty() {
2783 return None;
2784 }
2785 let snippet = item
2786 .get("summary")
2787 .or_else(|| item.get("snippet"))
2788 .or_else(|| item.get("description"))
2789 .and_then(|s| s.as_str())
2790 .map(str::trim)
2791 .filter(|s| !s.is_empty())
2792 .map(ToString::to_string);
2793 Some(WebSearchEntry {
2794 title: title.to_string(),
2795 url: url.to_string(),
2796 snippet,
2797 })
2798 })
2799 .take(max_results)
2800 .collect()
2801 }
2802
2803 fn bocha_error_message(parsed: &Value) -> Option<String> {
2804 let code = parsed.get("code").and_then(|v| v.as_i64())?;
2805 if code == 0 || code == 200 {
2806 return None;
2807 }
2808 let message = parsed
2809 .get("msg")
2810 .or_else(|| parsed.get("message"))
2811 .and_then(|v| v.as_str())
2812 .unwrap_or("unknown error");
2813 Some(format!("Bocha search API error (code {code}: {message})"))
2814 }
2815
2816 fn parse_baidu_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2817 parsed
2818 .get("references")
2819 .and_then(|v| v.as_array())
2820 .into_iter()
2821 .flat_map(|arr| arr.iter())
2822 .filter_map(|item| {
2823 let title = item
2824 .get("title")
2825 .or_else(|| item.get("name"))
2826 .and_then(|s| s.as_str())?
2827 .trim();
2828 let url = item
2829 .get("url")
2830 .or_else(|| item.get("link"))
2831 .and_then(|s| s.as_str())?
2832 .trim();
2833 if title.is_empty() || url.is_empty() {
2834 return None;
2835 }
2836 let snippet = item
2837 .get("content")
2838 .or_else(|| item.get("snippet"))
2839 .or_else(|| item.get("summary"))
2840 .and_then(|s| s.as_str())
2841 .map(str::trim)
2842 .filter(|s| !s.is_empty())
2843 .map(ToString::to_string);
2844 Some(WebSearchEntry {
2845 title: title.to_string(),
2846 url: url.to_string(),
2847 snippet,
2848 })
2849 })
2850 .take(max_results)
2851 .collect()
2852 }
2853
2854 /// Read a SearXNG result `score`.
2855 ///
2856 /// SearXNG emits a float, but instances and versions vary: a JSON integer, a
2857 /// numeric string, or no `score` at all are all tolerated. Unusable or
2858 /// non-finite values (`"not-a-number"`, `"NaN"`, `"inf"`, missing) read as
2859 /// `0.0`, so such rows keep their input order behind scored rows instead of
2860 /// being dropped or sorted by NaN.
2861 fn searxng_score(item: &Value) -> f64 {
2862 let raw = item.get("score");
2863 let n = raw
2864 .and_then(Value::as_f64)
2865 .or_else(|| raw.and_then(Value::as_i64).map(|i| i as f64))
2866 .or_else(|| {
2867 raw.and_then(Value::as_str)
2868 .and_then(|s| s.trim().parse().ok())
2869 })
2870 .unwrap_or(0.0);
2871 if n.is_finite() { n } else { 0.0 }
2872 }
2873
2874 /// Normalize a SearXNG JSON response into the engine-agnostic result shape.
2875 ///
2876 /// Rows without a non-empty `title` or `url` are skipped. Everything else is
2877 /// ordered by descending `score` with a stable sort (equal scores keep the
2878 /// instance's order) and only then capped, so a strong late row is not lost to
2879 /// an earlier `take` over the raw instance order.
2880 fn parse_searxng_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2881 let mut scored: Vec<(f64, WebSearchEntry)> = parsed
2882 .get("results")
2883 .and_then(|v| v.as_array())
2884 .into_iter()
2885 .flat_map(|arr| arr.iter())
2886 .filter_map(|item| {
2887 let title = item.get("title").and_then(Value::as_str)?.trim();
2888 let url = item.get("url").and_then(Value::as_str)?.trim();
2889 if title.is_empty() || url.is_empty() {
2890 return None;
2891 }
2892 let snippet = first_non_empty_string(item, &["content", "snippet"]);
2893 Some((
2894 searxng_score(item),
2895 WebSearchEntry {
2896 title: title.to_string(),
2897 url: url.to_string(),
2898 snippet,
2899 },
2900 ))
2901 })
2902 .collect();
2903
2904 scored.sort_by(|a, b| b.0.total_cmp(&a.0));
2905 scored.truncate(max_results);
2906
2907 scored.into_iter().map(|(_, entry)| entry).collect()
2908 }
2909
2910 fn baidu_error_message(parsed: &Value) -> Option<String> {
2911 let code = parsed
2912 .get("error_code")
2913 .or_else(|| parsed.get("code"))
2914 .and_then(|v| v.as_i64())?;
2915 if code == 0 {
2916 return None;
2917 }
2918 let message = parsed
2919 .get("error_msg")
2920 .or_else(|| parsed.get("message"))
2921 .and_then(|v| v.as_str())
2922 .unwrap_or("unknown error");
2923 Some(format!("Baidu search API error (code {code}: {message})"))
2924 }
2925
2926 async fn acquire_model_backed_search_inference_participant()
2927 -> crate::client::RemoteControlInferencePermit {
2928 crate::client::acquire_remote_control_inference_participant().await
2929 }
2930
2931 fn parse_sofya_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2932 parsed
2933 .get("results")
2934 .and_then(|v| v.as_array())
2935 .into_iter()
2936 .flat_map(|arr| arr.iter())
2937 .filter_map(|item| {
2938 let title = item.get("title")?.as_str()?.to_string();
2939 let url = item.get("url")?.as_str()?.to_string();
2940 let snippet = first_non_empty_string(item, &["content", "description"]);
2941 Some(WebSearchEntry {
2942 title,
2943 url,
2944 snippet,
2945 })
2946 })
2947 .take(max_results)
2948 .collect()
2949 }
2950
2951 /// Build the Serply `/v1/search` URL; `num` is the number of organic results.
2952 fn tavily_search_payload(
2953 api_key: &str,
2954 query: &str,
2955 filters: QueryFilters<'_>,
2956 max_results: usize,
2957 ) -> Value {
2958 let mut payload = json!({
2959 "api_key": api_key, // noqa: api-key-in-body
2960 "query": query,
2961 "search_depth": "basic",
2962 "max_results": max_results,
2963 });
2964 if let Some(window) = filters.window() {
2965 payload["time_range"] = json!(window);
2966 }
2967 payload
2968 }
2969
2970 /// Serply documents `gl` (country); `hl` is the Google interface-language
2971 /// parameter it forwards. Recency is not documented, so it is not sent.
2972 fn serply_search_url(
2973 query: &str,
2974 filters: QueryFilters<'_>,
2975 max_results: usize,
2976 ) -> Result<reqwest::Url, ToolError> {
2977 let mut url = reqwest::Url::parse(SERPLY_ENDPOINT)
2978 .map_err(|error| ToolError::invalid_input(format!("Invalid Serply endpoint: {error}")))?;
2979 {
2980 let mut pairs = url.query_pairs_mut();
2981 pairs
2982 .append_pair("q", query)
2983 .append_pair("num", &max_results.to_string());
2984 if let Some(language) = filters.language() {
2985 pairs.append_pair("hl", &language);
2986 }
2987 if let Some(region) = filters.region() {
2988 pairs.append_pair("gl", &region.to_ascii_lowercase());
2989 }
2990 }
2991 Ok(url)
2992 }
2993
2994 /// Parse Serply `/v1/search` output: `results[]` rows carry `title`, `link`, and
2995 /// a `description` snippet; ads, knowledge graph, and related questions are
2996 /// top-level siblings and are ignored.
2997 fn parse_serply_results(parsed: &Value, max_results: usize) -> Vec<WebSearchEntry> {
2998 parsed
2999 .get("results")
3000 .and_then(|v| v.as_array())
3001 .into_iter()
3002 .flat_map(|arr| arr.iter())
3003 .filter_map(|item| {
3004 let title = item.get("title")?.as_str()?.to_string();
3005 let url = item.get("link")?.as_str()?.to_string();
3006 let snippet = first_non_empty_string(item, &["description", "snippet"]);
3007 Some(WebSearchEntry {
3008 title,
3009 url,
3010 snippet,
3011 })
3012 })
3013 .take(max_results)
3014 .collect()
3015 }
3016
3017 fn first_non_empty_string(item: &Value, keys: &[&str]) -> Option<String> {
3018 keys.iter().find_map(|key| {
3019 item.get(*key)
3020 .and_then(Value::as_str)
3021 .map(str::trim)
3022 .filter(|value| !value.is_empty())
3023 .map(str::to_string)
3024 })
3025 }
3026
3027 fn baidu_search_payload(query: &str, max_results: usize) -> Value {
3028 json!({
3029 "messages": [
3030 {
3031 "role": "user",
3032 "content": query,
3033 }
3034 ],
3035 "search_source": "baidu_search_v2",
3036 "resource_type_filter": [
3037 {
3038 "type": "web",
3039 "top_k": max_results,
3040 }
3041 ],
3042 })
3043 }
3044
3045 fn volcengine_search_payload(query: &str, max_results: usize) -> Value {
3046 json!({
3047 "model": "doubao-seed-2-0-lite-260428",
3048 "stream": false,
3049 "tools": [{"type": "web_search"}],
3050 "input": [{
3051 "role": "user",
3052 "content": [{
3053 "type": "input_text",
3054 "text": format!(
3055 "Search the web for: {query}\n\n\
3056 CRITICAL: Respond ONLY with a valid JSON object. No markdown, no explanation.\n\
3057 Schema: {{\"results\":[{{\"title\":\"...\",\"url\":\"https://...\",\"snippet\":\"...\"}}]}}\n\
3058 - results: 1-{max_results} most relevant pages\n\
3059 - title: page title (required)\n\
3060 - url: full URL starting with https:// (required)\n\
3061 - snippet: 1-2 sentence factual summary (required)\n\
3062 - If zero results: {{\"results\":[]}}\n\
3063 - Your entire response must be valid, parseable JSON."
3064 )
3065 }]
3066 }]
3067 })
3068 }
3069
3070 /// Extracts the model's text response from a Volcengine Responses API output.
3071 fn volcengine_extract_text(parsed: &Value) -> Option<String> {
3072 parsed
3073 .get("output")
3074 .and_then(|v| v.as_array())
3075 .into_iter()
3076 .flat_map(|arr| arr.iter().rev())
3077 .find(|item| item.get("type").and_then(|t| t.as_str()) == Some("message"))
3078 .and_then(|msg| msg.get("content").and_then(|c| c.as_array()))
3079 .and_then(|content| {
3080 content
3081 .iter()
3082 .find(|c| c.get("text").and_then(|t| t.as_str()).is_some())
3083 })
3084 .and_then(|c| c.get("text").and_then(|t| t.as_str()))
3085 .map(|s| s.to_string())
3086 }
3087
3088 /// Checks for business-logic errors in a Volcengine Responses API response.
3089 fn volcengine_error_message(parsed: &Value) -> Option<String> {
3090 let error = parsed.get("error")?;
3091 let code = error
3092 .get("code")
3093 .and_then(|v| v.as_str())
3094 .unwrap_or("unknown");
3095 let message = error
3096 .get("message")
3097 .and_then(|v| v.as_str())
3098 .unwrap_or("no details");
3099 Some(format!("Volcengine API error (code {code}: {message})"))
3100 }
3101
3102 /// Parses Volcengine model-generated JSON results into `WebSearchEntry` items.
3103 fn parse_volcengine_results(response_text: &str, max_results: usize) -> Vec<WebSearchEntry> {
3104 let json_text = extract_json_block(response_text).unwrap_or(response_text);
3105
3106 let parsed: Value = match serde_json::from_str(json_text) {
3107 Ok(v) => v,
3108 Err(_) => return Vec::new(),
3109 };
3110
3111 parsed
3112 .get("results")
3113 .and_then(|v| v.as_array())
3114 .into_iter()
3115 .flat_map(|arr| arr.iter())
3116 .filter_map(|item| {
3117 let title = item.get("title").and_then(|s| s.as_str())?.trim();
3118 let url = item.get("url").and_then(|s| s.as_str())?.trim();
3119 if title.is_empty() || url.is_empty() {
3120 return None;
3121 }
3122 let snippet = item
3123 .get("snippet")
3124 .and_then(|s| s.as_str())
3125 .map(str::trim)
3126 .filter(|s| !s.is_empty())
3127 .map(ToString::to_string);
3128 Some(WebSearchEntry {
3129 title: title.to_string(),
3130 url: url.to_string(),
3131 snippet,
3132 })
3133 })
3134 .take(max_results)
3135 .collect()
3136 }
3137
3138 /// Attempts to extract a JSON block from text that may be wrapped in
3139 /// markdown fences (```json ... ```) or contain surrounding commentary.
3140 fn extract_json_block(text: &str) -> Option<&str> {
3141 if let Some(start) = text.find("```json") {
3142 let inner = &text[start + 7..];
3143 if let Some(end) = inner.find("```") {
3144 return Some(inner[..end].trim());
3145 }
3146 }
3147 if let Some(start) = text.find('{')
3148 && let Some(end) = text.rfind('}')
3149 {
3150 return Some(&text[start..=end]);
3151 }
3152 None
3153 }
3154
3155 fn extract_search_query(input: &Value) -> Result<String, ToolError> {
3156 for key in ["query", "q"] {
3157 if let Some(value) = input.get(key) {
3158 let Some(query) = value.as_str() else {
3159 return Err(ToolError::invalid_input(format!(
3160 "Field '{key}' must be a string"
3161 )));
3162 };
3163 let query = query.trim();
3164 if !query.is_empty() {
3165 return Ok(query.to_string());
3166 }
3167 }
3168 }
3169
3170 for item in search_query_items(input) {
3171 for key in ["q", "query"] {
3172 if let Some(value) = item.get(key) {
3173 let Some(query) = value.as_str() else {
3174 return Err(ToolError::invalid_input(format!(
3175 "Field 'search_query[].{key}' must be a string"
3176 )));
3177 };
3178 let query = query.trim();
3179 if !query.is_empty() {
3180 return Ok(query.to_string());
3181 }
3182 }
3183 }
3184 }
3185
3186 Err(ToolError::missing_field("query"))
3187 }
3188
3189 fn optional_search_max_results(input: &Value) -> u64 {
3190 if let Some(value) = input.get("max_results").and_then(Value::as_u64) {
3191 return value;
3192 }
3193 search_query_items(input)
3194 .filter_map(|item| item.get("max_results").and_then(Value::as_u64))
3195 .next()
3196 .unwrap_or(DEFAULT_SEARCH_RESULTS as u64)
3197 }
3198
3199 fn search_query_from_input(input: &Value) -> Result<SearchQuery, ToolError> {
3200 let query = extract_search_query(input)?;
3201 if query.is_empty() {
3202 return Err(ToolError::invalid_input("Query cannot be empty"));
3203 }
3204 let max_results = usize::try_from(optional_search_max_results(input))
3205 .unwrap_or(DEFAULT_SEARCH_RESULTS)
3206 .clamp(1, usize::from(MAX_SEARCH_RESULTS));
3207 let recency = search_option(input, "recency")
3208 .map(parse_recency)
3209 .transpose()?;
3210 let domains = match search_option(input, "domains") {
3211 Some(value) => value
3212 .as_array()
3213 .ok_or_else(|| ToolError::invalid_input("Field 'domains' must be an array"))?
3214 .iter()
3215 .map(|value| {
3216 value.as_str().map(str::to_string).ok_or_else(|| {
3217 ToolError::invalid_input("Every 'domains' entry must be a string")
3218 })
3219 })
3220 .collect::<Result<Vec<_>, _>>()?,
3221 None => Vec::new(),
3222 };
3223 let locale = search_option(input, "locale")
3224 .map(|value| {
3225 value
3226 .as_str()
3227 .map(str::to_string)
3228 .ok_or_else(|| ToolError::invalid_input("Field 'locale' must be a string"))
3229 })
3230 .transpose()?;
3231
3232 Ok(SearchQuery::new(
3233 query,
3234 max_results,
3235 recency,
3236 domains,
3237 locale,
3238 ))
3239 }
3240
3241 fn search_option<'a>(input: &'a Value, key: &str) -> Option<&'a Value> {
3242 input
3243 .get(key)
3244 .or_else(|| search_query_items(input).find_map(|item| item.get(key)))
3245 }
3246
3247 fn parse_recency(value: &Value) -> Result<Recency, ToolError> {
3248 if let Some(days) = value.as_u64() {
3249 let days = u16::try_from(days)
3250 .ok()
3251 .filter(|days| (1..=3650).contains(days))
3252 .ok_or_else(|| {
3253 ToolError::invalid_input("Field 'recency' must be between 1 and 3650 days")
3254 })?;
3255 return Ok(Recency::Days(days));
3256 }
3257 match value.as_str() {
3258 Some("day") => Ok(Recency::Day),
3259 Some("week") => Ok(Recency::Week),
3260 Some("month") => Ok(Recency::Month),
3261 Some("year") => Ok(Recency::Year),
3262 _ => Err(ToolError::invalid_input(
3263 "Field 'recency' must be day, week, month, year, or an integer day count",
3264 )),
3265 }
3266 }
3267
3268 fn search_query_items(input: &Value) -> impl Iterator<Item = &Value> {
3269 input
3270 .get("search_query")
3271 .and_then(Value::as_array)
3272 .into_iter()
3273 .flat_map(|items| items.iter())
3274 }
3275
3276 async fn run_bing_search(
3277 client: &reqwest::Client,
3278 query: &str,
3279 max_results: usize,
3280 endpoint: &str,
3281 timeout: Duration,
3282 context: &ToolContext,
3283 ) -> AdapterResult<Vec<WebSearchEntry>> {
3284 let mut url = reqwest::Url::parse(endpoint)
3285 .map_err(|error| ToolError::invalid_input(format!("Invalid Bing endpoint: {error}")))?;
3286 if let Some(plan) = host_request(
3287 "bing",
3288 query,
3289 QueryFilters::default(),
3290 max_results,
3291 context,
3292 u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX),
3293 )
3294 .await?
3295 {
3296 for (key, value) in plan.pairs {
3297 url.query_pairs_mut().append_pair(&key, &value);
3298 }
3299 } else {
3300 url.query_pairs_mut().append_pair("q", query);
3301 }
3302 let resp = client
3303 .get(url)
3304 .timeout(timeout)
3305 .header(
3306 "Accept",
3307 "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
3308 )
3309 .header("Accept-Language", "en-US,en;q=0.9")
3310 .send()
3311 .await
3312 .map_err(|e| ToolError::execution_failed(format!("Bing search request failed: {e}")))?;
3313
3314 let status = resp.status();
3315 let body = if adapter::search_selected(context) {
3316 adapter::read_response_with_limit(resp, context, super::web::fetch::HARD_MAX_BYTES).await?
3317 } else {
3318 resp.text().await.map_err(|e| {
3319 ToolError::execution_failed(format!("Failed to read Bing search response: {e}"))
3320 })?
3321 };
3322
3323 if !status.is_success() {
3324 return Err((ToolError::execution_failed(format!(
3325 "Bing search failed: HTTP {}",
3326 status.as_u16()
3327 )))
3328 .into());
3329 }
3330
3331 normalize_scraped_entries(parse_bing_results(&body, max_results), context, timeout).await
3332 }
3333
3334 fn parse_duckduckgo_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
3335 scrape_duckduckgo_results(html, max_results)
3336 .into_iter()
3337 .map(web_search_entry_from_scraped)
3338 .collect()
3339 }
3340
3341 fn parse_bing_results(html: &str, max_results: usize) -> Vec<WebSearchEntry> {
3342 scrape_bing_results(html, max_results)
3343 .into_iter()
3344 .map(web_search_entry_from_scraped)
3345 .collect()
3346 }
3347
3348 fn web_search_entry_from_scraped(entry: ScrapedSearchResult) -> WebSearchEntry {
3349 WebSearchEntry {
3350 title: entry.title,
3351 url: entry.url,
3352 snippet: entry.snippet,
3353 }
3354 }
3355
3356 fn duckduckgo_search_base(base_url: Option<&str>) -> Result<(reqwest::Url, String), ToolError> {
3357 let raw = configured_search_base_url(base_url).unwrap_or(DUCKDUCKGO_ENDPOINT);
3358 let url = reqwest::Url::parse(raw).map_err(|err| {
3359 ToolError::invalid_input(format!(
3360 "Invalid DuckDuckGo-compatible search base_url: {err}"
3361 ))
3362 })?;
3363 let host = url.host_str().ok_or_else(|| {
3364 ToolError::invalid_input("DuckDuckGo-compatible search base_url must include a host")
3365 })?;
3366 let host = host.to_owned();
3367 Ok((url, host))
3368 }
3369
3370 fn duckduckgo_search_url(
3371 base_url: Option<&str>,
3372 query: &str,
3373 ) -> Result<(String, String), ToolError> {
3374 let (mut url, host) = duckduckgo_search_base(base_url)?;
3375 url.query_pairs_mut().append_pair("q", query);
3376 Ok((url.to_string(), host))
3377 }
3378
3379 fn searxng_search_base(base_url: Option<&str>) -> Result<(reqwest::Url, String), ToolError> {
3380 let raw = configured_search_base_url(base_url).ok_or_else(|| {
3381 ToolError::invalid_input(
3382 "SearXNG search requires [search] base_url = \"https://your-searxng.example\"; no public instance is used by default.",
3383 )
3384 })?;
3385 let mut url = reqwest::Url::parse(raw).map_err(|err| {
3386 ToolError::invalid_input(format!("Invalid SearXNG search base_url: {err}"))
3387 })?;
3388 let host = url
3389 .host_str()
3390 .ok_or_else(|| ToolError::invalid_input("SearXNG search base_url must include a host"))?
3391 .to_string();
3392
3393 let path = url.path().trim_end_matches('/');
3394 if path.is_empty() {
3395 url.set_path("search");
3396 } else if path != "/search" && !path.ends_with("/search") {
3397 url.set_path(&format!("{path}/search"));
3398 }
3399 Ok((url, host))
3400 }
3401
3402 fn searxng_search_url(
3403 base_url: Option<&str>,
3404 query: &str,
3405 filters: QueryFilters<'_>,
3406 ) -> Result<(String, String), ToolError> {
3407 let (mut url, host) = searxng_search_base(base_url)?;
3408 {
3409 let mut pairs = url.query_pairs_mut();
3410 pairs.append_pair("q", query).append_pair("format", "json");
3411 if let Some(window) = filters.window() {
3412 pairs.append_pair("time_range", window);
3413 }
3414 if let Some(locale) = filters.locale {
3415 pairs.append_pair("language", locale);
3416 }
3417 }
3418
3419 Ok((url.to_string(), host))
3420 }
3421
3422 fn configured_search_base_url(base_url: Option<&str>) -> Option<&str> {
3423 base_url.map(str::trim).filter(|value| !value.is_empty())
3424 }
3425
3426 fn duckduckgo_allows_bing_fallback(base_url: Option<&str>) -> bool {
3427 configured_search_base_url(base_url).is_none()
3428 }
3429
3430 #[cfg(test)]
3431 mod tests {
3432 use super::{
3433 ERROR_BODY_PREVIEW_BYTES, KIMI_K3_FORMULA_MIN_TIMEOUT_MS, QueryFilters, ScrapeEndpoints,
3434 SearchProbeTargetError, WebSearchTool, acquire_model_backed_search_inference_participant,
3435 baidu_search_payload, bocha_error_message, domain_matches, duckduckgo_search_url,
3436 extract_search_query, finalize_search_response, native_answer_time_budget,
3437 optional_search_max_results, parse_baidu_results, parse_bocha_results,
3438 parse_metaso_results, parse_searxng_results, parse_serply_results, parse_sofya_results,
3439 parse_tavily_results, parse_volcengine_results, register_search_citations, rerank,
3440 run_scrape_search_with_endpoints, sanitize_error_body, search_probe_target,
3441 search_timeout_budgets, searxng_score, searxng_search_url, serply_search_url,
3442 truncate_error_body, volcengine_extract_text,
3443 };
3444 use crate::config::SearchProvider;
3445 use crate::tools::web::contract::{
3446 BackendId, BackendSearch, CapabilityState, DegradedReason, QueryCapabilities, QueryKnob,
3447 Recency, SearchQuery, SearchResult,
3448 };
3449 use crate::tools::web::scrape::{decode_html_entities, normalize_bing_url};
3450 use serde_json::json;
3451 use std::time::{Duration, Instant};
3452
3453 #[test]
3454 fn provider_native_receives_dedicated_budget_without_extending_fallback() {
3455 let requested = Duration::from_millis(15_000);
3456 let (total, first, fallback) =
3457 search_timeout_budgets(BackendId::ProviderNative, requested, None);
3458
3459 assert_eq!(total, Duration::from_millis(60_000));
3460 assert_eq!(first, Some(Duration::from_millis(45_000)));
3461 assert_eq!(fallback, Some(requested));
3462
3463 let (total, first, fallback) = search_timeout_budgets(
3464 BackendId::ProviderNative,
3465 requested,
3466 Some(Duration::from_millis(KIMI_K3_FORMULA_MIN_TIMEOUT_MS)),
3467 );
3468 assert_eq!(total, Duration::from_millis(195_000));
3469 assert_eq!(first, Some(Duration::from_millis(180_000)));
3470 assert_eq!(fallback, Some(requested));
3471 }
3472
3473 #[test]
3474 fn provider_native_budget_covers_the_requested_answer_length() {
3475 // #6508: native search asks for up to 8,192 output tokens in one
3476 // non-streaming request. The attempt must outlast generating them at
3477 // the assumed rate, or a long answer times out instead of arriving.
3478 let requested = Duration::from_millis(15_000);
3479 for tokens in [128_u32, 2_048, 4_096, 8_192] {
3480 let floor = native_answer_time_budget(tokens);
3481 let generation = Duration::from_millis(
3482 u64::from(tokens) * 1_000 / super::NATIVE_SEARCH_ASSUMED_TOKENS_PER_SEC,
3483 );
3484 assert!(floor >= generation, "{tokens} tokens: {floor:?}");
3485 let (total, first, fallback) =
3486 search_timeout_budgets(BackendId::ProviderNative, requested, Some(floor));
3487 let first = first.expect("provider-native gets a dedicated attempt");
3488 assert!(first >= floor, "{tokens} tokens: attempt {first:?}");
3489 // The minimum never drops below the pre-#6508 floor.
3490 assert!(first >= Duration::from_millis(45_000));
3491 assert_eq!(total, first + requested);
3492 assert_eq!(fallback, Some(requested));
3493 }
3494 // 8,192 tokens at 40/s plus the round-trip allowance: ~4 minutes.
3495 assert_eq!(
3496 native_answer_time_budget(8_192),
3497 Duration::from_millis(234_800)
3498 );
3499 }
3500
3501 #[test]
3502 fn provider_native_floor_follows_what_the_client_requests() {
3503 use crate::config::{Config, ProviderConfig, ProvidersConfig};
3504 let config = Config {
3505 provider: Some("anthropic".to_string()),
3506 providers: Some(ProvidersConfig {
3507 anthropic: ProviderConfig {
3508 api_key: Some("anthropic-test-key".to_string()),
3509 base_url: Some("https://api.anthropic.com".to_string()),
3510 model: Some("claude-opus-4-8".to_string()),
3511 ..ProviderConfig::default()
3512 },
3513 ..ProvidersConfig::default()
3514 }),
3515 ..Config::default()
3516 };
3517 let client = crate::client::ProviderNativeSearchClient::new(
3518 crate::client::CodewhaleClient::new(&config).expect("Anthropic client"),
3519 )
3520 .expect("Anthropic native adapter");
3521 let tokens = client
3522 .requested_answer_output_tokens()
3523 .expect("Anthropic requests an explicit answer length");
3524 assert!(tokens > 2_048, "catalogued model asks for more: {tokens}");
3525 assert_eq!(
3526 super::provider_native_timeout_floor(&client),
3527 Some(native_answer_time_budget(tokens))
3528 );
3529 }
3530
3531 #[test]
3532 fn doctor_search_probe_targets_cover_every_builtin_provider() {
3533 let cases = [
3534 (SearchProvider::Bing, "https://www.bing.com/search"),
3535 (
3536 SearchProvider::DuckDuckGo,
3537 "https://html.duckduckgo.com/html/",
3538 ),
3539 (
3540 SearchProvider::Firecrawl,
3541 "https://api.firecrawl.dev/v2/search",
3542 ),
3543 (SearchProvider::Tavily, "https://api.tavily.com/search"),
3544 (
3545 SearchProvider::Bocha,
3546 "https://api.bochaai.com/v1/web-search",
3547 ),
3548 (SearchProvider::Metaso, "https://metaso.cn/api/v1"),
3549 (
3550 SearchProvider::Baidu,
3551 "https://qianfan.baidubce.com/v2/ai_search/web_search",
3552 ),
3553 (
3554 SearchProvider::Volcengine,
3555 "https://ark.cn-beijing.volces.com/api/v3/responses",
3556 ),
3557 (SearchProvider::Sofya, "https://sofya.co/v1/search"),
3558 (SearchProvider::Serply, "https://api.serply.io/v1/search"),
3559 ];
3560
3561 for (provider, expected) in cases {
3562 let target = search_probe_target(provider, None).expect("built-in target");
3563 assert_eq!(target.url.as_str(), expected, "{provider:?}");
3564 assert_eq!(target.host, target.url.host_str().unwrap(), "{provider:?}");
3565 }
3566 }
3567
3568 #[test]
3569 fn doctor_search_probe_strips_every_secret_capable_custom_url_component() {
3570 let target = search_probe_target(
3571 SearchProvider::Searxng,
3572 Some(
3573 "https://URL-USER:URL-PASSWORD@search.example:8443/private/URL-PATH?URL-QUERY=secret#URL-FRAGMENT",
3574 ),
3575 )
3576 .expect("credential-free target");
3577
3578 assert_eq!(target.url.as_str(), "https://search.example:8443/");
3579 assert_eq!(target.host, "search.example");
3580 }
3581
3582 #[test]
3583 fn doctor_search_probe_rejects_configuration_that_runtime_cannot_use() {
3584 assert_eq!(
3585 search_probe_target(SearchProvider::Searxng, None),
3586 Err(SearchProbeTargetError::Missing)
3587 );
3588 assert_eq!(
3589 search_probe_target(SearchProvider::Tavily, Some("https://ignored.example")),
3590 Err(SearchProbeTargetError::Unsupported)
3591 );
3592 assert_eq!(
3593 search_probe_target(SearchProvider::DuckDuckGo, Some("file:///tmp/search")),
3594 Err(SearchProbeTargetError::Invalid)
3595 );
3596 }
3597
3598 #[test]
3599 fn bing_ckurl_with_html_entities_decodes_real_url() {
3600 let href = "https://www.bing.com/ck/a?!&amp;&amp;p=abc&amp;u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&amp;ntb=1";
3601 assert_eq!(normalize_bing_url(href), "https://rust-lang.org/");
3602 }
3603
3604 #[test]
3605 fn decode_html_entities_handles_named_entities() {
3606 assert_eq!(decode_html_entities("&amp;"), "&");
3607 assert_eq!(decode_html_entities("&lt;"), "<");
3608 assert_eq!(decode_html_entities("&gt;"), ">");
3609 assert_eq!(decode_html_entities("&quot;"), "\"");
3610 assert_eq!(decode_html_entities("&apos;"), "'");
3611 assert_eq!(decode_html_entities("&nbsp;"), " ");
3612 assert_eq!(decode_html_entities("&copy;"), "\u{00A9}");
3613 assert_eq!(decode_html_entities("&mdash;"), "\u{2014}");
3614 }
3615
3616 #[test]
3617 fn decode_html_entities_handles_decimal_numeric_references() {
3618 assert_eq!(decode_html_entities("&#65;"), "A");
3619 assert_eq!(decode_html_entities("&#60;"), "<");
3620 assert_eq!(decode_html_entities("&#8211;"), "\u{2013}");
3621 }
3622
3623 #[test]
3624 fn decode_html_entities_handles_hex_numeric_references() {
3625 assert_eq!(decode_html_entities("&#x41;"), "A");
3626 assert_eq!(decode_html_entities("&#x3C;"), "<");
3627 assert_eq!(decode_html_entities("&#x2014;"), "\u{2014}");
3628 }
3629
3630 #[test]
3631 fn decode_html_entities_passthrough_unknown() {
3632 assert_eq!(decode_html_entities("&unknown;"), "&unknown;");
3633 }
3634
3635 #[test]
3636 fn decode_html_entities_mixed_content() {
3637 let input = "Hello &amp; welcome to &quot;Rust&apos;s world&quot; &mdash; enjoy!";
3638 let expected = "Hello & welcome to \"Rust's world\" \u{2014} enjoy!";
3639 assert_eq!(decode_html_entities(input), expected);
3640 }
3641
3642 #[test]
3643 fn extract_search_query_accepts_legacy_query() {
3644 let query =
3645 extract_search_query(&json!({"query": " deepseek v4 "})).expect("query should parse");
3646 assert_eq!(query, "deepseek v4");
3647 }
3648
3649 #[test]
3650 fn extract_search_query_accepts_q_alias() {
3651 let query =
3652 extract_search_query(&json!({"q": "deepseek v4 pro"})).expect("q alias should parse");
3653 assert_eq!(query, "deepseek v4 pro");
3654 }
3655
3656 #[test]
3657 fn extract_search_query_accepts_array_form() {
3658 let input = json!({"search_query": [{"q": "deepseek api", "max_results": 3}]});
3659 let query = extract_search_query(&input).expect("array form should parse");
3660 assert_eq!(query, "deepseek api");
3661 assert_eq!(optional_search_max_results(&input), 3);
3662 }
3663
3664 #[test]
3665 fn extract_search_query_rejects_missing_query() {
3666 let err = extract_search_query(&json!({"max_results": 2}))
3667 .expect_err("missing query should fail");
3668 assert!(format!("{err}").contains("missing required field 'query'"));
3669 }
3670
3671 #[test]
3672 fn optional_max_results_prefers_top_level_value() {
3673 assert_eq!(
3674 optional_search_max_results(
3675 &json!({"query": "x", "max_results": 8, "search_query": [{"q": "y", "max_results": 2}]})
3676 ),
3677 8,
3678 );
3679 }
3680
3681 #[test]
3682 fn optional_max_results_falls_back_to_array_form() {
3683 assert_eq!(
3684 optional_search_max_results(&json!({"search_query": [{"q": "y", "max_results": 3}]})),
3685 3,
3686 );
3687 }
3688
3689 #[test]
3690 fn optional_max_results_uses_default_when_neither_set() {
3691 assert_eq!(optional_search_max_results(&json!({"query": "x"})), 5);
3692 assert_eq!(
3693 optional_search_max_results(&json!({"search_query": [{"q": "y"}]})),
3694 5,
3695 );
3696 }
3697
3698 #[test]
3699 fn optional_max_results_only_reads_first_array_entry() {
3700 assert_eq!(
3701 optional_search_max_results(
3702 &json!({"search_query": [{"q": "first", "max_results": 1}, {"q": "second", "max_results": 9}]})
3703 ),
3704 1,
3705 );
3706 }
3707
3708 #[test]
3709 fn extract_search_query_trims_whitespace_from_array_form_q_alias() {
3710 let q = extract_search_query(&json!({"search_query": [{"q": " deepseek tui "}]}))
3711 .expect("array form should parse with trim");
3712 assert_eq!(q, "deepseek tui");
3713 }
3714
3715 #[test]
3716 fn extract_search_query_rejects_empty_query() {
3717 for body in [json!({"query": ""}), json!({"q": " "}), json!({})] {
3718 let err = extract_search_query(&body).expect_err("empty query must reject");
3719 let msg = format!("{err}");
3720 assert!(
3721 msg.contains("missing required field 'query'") || msg.contains("Query"),
3722 "expected query-missing error, got `{msg}`"
3723 );
3724 }
3725 }
3726
3727 #[test]
3728 fn truncate_error_body_truncates_long_body() {
3729 let body = "a".repeat(ERROR_BODY_PREVIEW_BYTES + 100);
3730 let truncated = truncate_error_body(&body);
3731 assert!(truncated.len() <= ERROR_BODY_PREVIEW_BYTES + 3);
3732 assert!(truncated.ends_with("..."));
3733 }
3734
3735 #[test]
3736 fn truncate_error_body_keeps_short_body_intact() {
3737 let body = "short error";
3738 assert_eq!(truncate_error_body(body), body);
3739 }
3740
3741 #[test]
3742 fn sanitize_error_body_strips_html_and_control_chars() {
3743 let body = "<p>error</p>\x00\x01\x02";
3744 let sanitized = sanitize_error_body(body);
3745 assert_eq!(sanitized, "error");
3746 }
3747
3748 #[test]
3749 fn sanitize_error_body_redacts_bearer_tokens() {
3750 let body = r#"{"error":"bad token","authorization":"Bearer test-token/with+chars="}"#;
3751
3752 let sanitized = sanitize_error_body(body);
3753
3754 assert!(!sanitized.contains("test-token/with+chars="));
3755 assert!(sanitized.contains("Bearer [REDACTED]"));
3756 }
3757
3758 #[test]
3759 fn parse_bocha_web_pages_value_extracts_ranked_results() {
3760 let body = json!({
3761 "code": 200,
3762 "msg": null,
3763 "data": {
3764 "webPages": {
3765 "value": [
3766 {
3767 "name": "广州天气",
3768 "url": "https://bocha.cn/share/weather",
3769 "snippet": "广州今日雷阵雨转晴。"
3770 },
3771 {
3772 "name": "中央气象台",
3773 "url": "https://www.weather.com.cn/",
3774 "summary": "天气实况。"
3775 }
3776 ]
3777 }
3778 }
3779 });
3780
3781 let results = parse_bocha_results(&body, 10);
3782
3783 assert_eq!(results.len(), 2);
3784 assert_eq!(results[0].title, "广州天气");
3785 assert_eq!(results[0].url, "https://bocha.cn/share/weather");
3786 assert_eq!(results[0].snippet.as_deref(), Some("广州今日雷阵雨转晴。"));
3787 assert_eq!(results[1].title, "中央气象台");
3788 }
3789
3790 #[test]
3791 fn parse_bocha_keeps_legacy_pages_shape() {
3792 let body = json!({
3793 "code": 200,
3794 "data": {
3795 "pages": [
3796 {
3797 "title": "Legacy title",
3798 "link": "https://example.com/legacy",
3799 "description": "Legacy description"
3800 }
3801 ]
3802 }
3803 });
3804
3805 let results = parse_bocha_results(&body, 5);
3806
3807 assert_eq!(results.len(), 1);
3808 assert_eq!(results[0].title, "Legacy title");
3809 assert_eq!(results[0].url, "https://example.com/legacy");
3810 assert_eq!(results[0].snippet.as_deref(), Some("Legacy description"));
3811 }
3812
3813 #[test]
3814 fn bocha_error_message_flags_non_success_business_code() {
3815 let body = json!({"code": 401, "msg": "invalid api key"});
3816
3817 let error = bocha_error_message(&body).expect("non-success code should error");
3818
3819 assert!(error.contains("Bocha"));
3820 assert!(error.contains("401"));
3821 assert!(error.contains("invalid api key"));
3822 }
3823
3824 #[test]
3825 fn parse_baidu_references_extracts_ranked_results() {
3826 let body = json!({
3827 "references": [
3828 {
3829 "title": "Rust 官方文档",
3830 "url": "https://www.rust-lang.org/",
3831 "content": "Rust 是一门注重性能和可靠性的语言。"
3832 },
3833 {
3834 "title": "Cargo Book",
3835 "url": "https://doc.rust-lang.org/cargo/",
3836 "snippet": "Cargo is Rust's package manager."
3837 }
3838 ]
3839 });
3840
3841 let results = parse_baidu_results(&body, 10);
3842
3843 assert_eq!(results.len(), 2);
3844 assert_eq!(results[0].title, "Rust 官方文档");
3845 assert_eq!(results[0].url, "https://www.rust-lang.org/");
3846 assert_eq!(
3847 results[0].snippet.as_deref(),
3848 Some("Rust 是一门注重性能和可靠性的语言。")
3849 );
3850 assert_eq!(results[1].title, "Cargo Book");
3851 assert_eq!(results[1].url, "https://doc.rust-lang.org/cargo/");
3852 assert_eq!(
3853 results[1].snippet.as_deref(),
3854 Some("Cargo is Rust's package manager.")
3855 );
3856 }
3857
3858 #[test]
3859 fn parse_baidu_references_skips_incomplete_entries() {
3860 let body = json!({
3861 "references": [
3862 {"title": "No URL", "content": "missing url"},
3863 {"url": "https://example.com/no-title", "content": "missing title"},
3864 {"title": "Valid", "url": "https://example.com/valid"}
3865 ]
3866 });
3867
3868 let results = parse_baidu_results(&body, 10);
3869
3870 assert_eq!(results.len(), 1);
3871 assert_eq!(results[0].title, "Valid");
3872 assert_eq!(results[0].url, "https://example.com/valid");
3873 assert_eq!(results[0].snippet, None);
3874 }
3875
3876 #[test]
3877 fn baidu_search_payload_uses_official_search_source() {
3878 let payload = baidu_search_payload("Rust cargo workspace", 3);
3879
3880 assert_eq!(
3881 payload.get("search_source").and_then(|v| v.as_str()),
3882 Some("baidu_search_v2")
3883 );
3884 assert_eq!(
3885 payload
3886 .get("messages")
3887 .and_then(|v| v.as_array())
3888 .and_then(|messages| messages.first())
3889 .and_then(|message| message.get("content"))
3890 .and_then(|v| v.as_str()),
3891 Some("Rust cargo workspace")
3892 );
3893 assert_eq!(
3894 payload
3895 .get("resource_type_filter")
3896 .and_then(|v| v.as_array())
3897 .and_then(|filters| filters.first())
3898 .and_then(|filter| filter.get("top_k"))
3899 .and_then(|v| v.as_u64()),
3900 Some(3)
3901 );
3902 }
3903
3904 #[test]
3905 fn serply_search_url_encodes_query_and_result_count() {
3906 let url = serply_search_url("rust tui & ratatui", QueryFilters::default(), 7)
3907 .expect("serply url");
3908
3909 assert_eq!(url.host_str(), Some("api.serply.io"));
3910 assert_eq!(url.path(), "/v1/search");
3911 let pairs: Vec<(String, String)> = url
3912 .query_pairs()
3913 .map(|(k, v)| (k.into_owned(), v.into_owned()))
3914 .collect();
3915 assert_eq!(
3916 pairs,
3917 vec![
3918 ("q".to_string(), "rust tui & ratatui".to_string()),
3919 ("num".to_string(), "7".to_string()),
3920 ]
3921 );
3922 }
3923
3924 #[test]
3925 fn parse_serply_results_reads_link_and_description_and_skips_malformed_rows() {
3926 let body = json!({
3927 "results": [
3928 {
3929 "title": "Ratatui",
3930 "link": "https://ratatui.rs/",
3931 "description": "Cook up delicious terminal user interfaces in Rust.",
3932 "position": 1,
3933 "realPosition": 1
3934 },
3935 {
3936 "title": "No description",
3937 "link": "https://example.com/plain",
3938 "description": ""
3939 },
3940 {
3941 "title": "Missing link",
3942 "description": "dropped because there is no link"
3943 },
3944 "not an object",
3945 {
3946 "title": "Fourth",
3947 "link": "https://example.com/fourth",
3948 "description": "beyond max_results"
3949 }
3950 ],
3951 "knowledge_graph": {"title": "ignored sidebar"},
3952 "related_questions": [{"question": "ignored"}],
3953 "ads": [{"title": "ignored ad", "link": "https://ads.example.com"}]
3954 });
3955
3956 let results = parse_serply_results(&body, 2);
3957
3958 assert_eq!(results.len(), 2);
3959 assert_eq!(results[0].title, "Ratatui");
3960 assert_eq!(results[0].url, "https://ratatui.rs/");
3961 assert_eq!(
3962 results[0].snippet.as_deref(),
3963 Some("Cook up delicious terminal user interfaces in Rust.")
3964 );
3965 assert_eq!(results[1].url, "https://example.com/plain");
3966 assert_eq!(results[1].snippet, None);
3967
3968 assert!(parse_serply_results(&json!({"total": 0}), 5).is_empty());
3969 }
3970
3971 #[test]
3972 fn parse_sofya_results_falls_back_to_description_for_empty_content() {
3973 let body = json!({
3974 "results": [
3975 {
3976 "title": "Full content",
3977 "url": "https://example.com/full",
3978 "content": "full extracted page content",
3979 "description": "unused description"
3980 },
3981 {
3982 "title": "Null content",
3983 "url": "https://example.com/null",
3984 "content": null,
3985 "description": "description for null content"
3986 },
3987 {
3988 "title": "Empty content",
3989 "url": "https://example.com/empty",
3990 "content": "",
3991 "description": "description for empty content"
3992 },
3993 {
3994 "title": "Whitespace content",
3995 "url": "https://example.com/blank",
3996 "content": " ",
3997 "description": "description for blank content"
3998 },
3999 {
4000 "title": "No snippet",
4001 "url": "https://example.com/no-snippet"
4002 }
4003 ]
4004 });
4005
4006 let results = parse_sofya_results(&body, 10);
4007
4008 assert_eq!(results.len(), 5);
4009 assert_eq!(
4010 results[0].snippet.as_deref(),
4011 Some("full extracted page content")
4012 );
4013 assert_eq!(
4014 results[1].snippet.as_deref(),
4015 Some("description for null content")
4016 );
4017 assert_eq!(
4018 results[2].snippet.as_deref(),
4019 Some("description for empty content")
4020 );
4021 assert_eq!(
4022 results[3].snippet.as_deref(),
4023 Some("description for blank content")
4024 );
4025 assert_eq!(results[4].snippet, None);
4026 }
4027
4028 #[test]
4029 fn tavily_metaso_and_volcengine_payloads_use_normalized_entry_shape() {
4030 let tavily = parse_tavily_results(
4031 &json!({"results": [{
4032 "title": " Tavily result ",
4033 "url": "https://tavily.example/result",
4034 "content": " content "
4035 }]}),
4036 5,
4037 );
4038 let metaso = parse_metaso_results(
4039 &json!({"webpages": [{
4040 "title": " Metaso result ",
4041 "link": "https://metaso.example/result",
4042 "summary": " summary "
4043 }]}),
4044 5,
4045 );
4046 let volcengine = parse_volcengine_results(
4047 r#"{"results":[{"title":"Volcengine result","url":"https://volc.example/result","snippet":"summary"}]}"#,
4048 5,
4049 );
4050
4051 for (entries, title, snippet) in [
4052 (tavily, "Tavily result", "content"),
4053 (metaso, "Metaso result", "summary"),
4054 (volcengine, "Volcengine result", "summary"),
4055 ] {
4056 assert_eq!(entries.len(), 1);
4057 assert_eq!(entries[0].title, title);
4058 assert_eq!(entries[0].snippet.as_deref(), Some(snippet));
4059 }
4060 }
4061
4062 #[tokio::test]
4063 async fn firecrawl_keyless_request_is_headerless_and_keyed_request_is_explicit() {
4064 use wiremock::matchers::{method, path};
4065 use wiremock::{Mock, MockServer, ResponseTemplate};
4066
4067 let server = MockServer::start().await;
4068 Mock::given(method("POST"))
4069 .and(path("/v2/search"))
4070 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4071 "success": true,
4072 "data": {"web": [{
4073 "title": "Firecrawl result",
4074 "url": "https://example.com/firecrawl",
4075 "description": "x".repeat(1_200)
4076 }]}
4077 })))
4078 .mount(&server)
4079 .await;
4080 let endpoint = format!("{}/v2/search", server.uri());
4081 let (entries, mode) = WebSearchTool
4082 .run_firecrawl_search_at(
4083 &endpoint,
4084 "codewhale",
4085 QueryFilters::default(),
4086 5,
4087 5_000,
4088 None,
4089 )
4090 .await
4091 .expect("keyless Firecrawl search");
4092 WebSearchTool
4093 .run_firecrawl_search_at(
4094 &endpoint,
4095 "codewhale",
4096 QueryFilters::default(),
4097 5,
4098 5_000,
4099 Some("fc-secret"),
4100 )
4101 .await
4102 .expect("authenticated Firecrawl search");
4103 let requests = server.received_requests().await.expect("recorded requests");
4104 let payload: serde_json::Value =
4105 serde_json::from_slice(&requests[0].body).expect("request JSON");
4106
4107 assert_eq!(entries[0].title, "Firecrawl result");
4108 assert_eq!(entries[0].url, "https://example.com/firecrawl");
4109 assert_eq!(
4110 entries[0].snippet.as_deref().unwrap().chars().count(),
4111 1_000
4112 );
4113 assert_eq!(mode, "Firecrawl keyless");
4114 assert!(requests[0].headers.get("authorization").is_none());
4115 assert_eq!(requests[1].headers["authorization"], "Bearer fc-secret");
4116 assert!(payload.get("integration").is_none());
4117 assert_eq!(payload["sources"][0]["type"], "web");
4118 }
4119
4120 #[tokio::test]
4121 async fn firecrawl_keyless_rate_limit_is_actionable() {
4122 use wiremock::matchers::{method, path};
4123 use wiremock::{Mock, MockServer, ResponseTemplate};
4124
4125 let server = MockServer::start().await;
4126 Mock::given(method("POST"))
4127 .and(path("/v2/search"))
4128 .respond_with(ResponseTemplate::new(429))
4129 .mount(&server)
4130 .await;
4131 let error = WebSearchTool
4132 .run_firecrawl_search_at(
4133 &format!("{}/v2/search", server.uri()),
4134 "quota",
4135 QueryFilters::default(),
4136 5,
4137 5_000,
4138 None,
4139 )
4140 .await
4141 .expect_err("429 must be actionable");
4142 assert!(error.to_string().contains("keyless quota is exhausted"));
4143 assert!(error.to_string().contains("FIRECRAWL_API_KEY"));
4144 }
4145
4146 #[test]
4147 fn volcengine_extract_text_skips_non_text_content_blocks() {
4148 let body = json!({
4149 "output": [
4150 {
4151 "type": "message",
4152 "content": [
4153 {"type": "reasoning", "summary": "thinking first"},
4154 {"type": "output_text", "text": "{\"results\":[]}"}
4155 ]
4156 }
4157 ]
4158 });
4159
4160 assert_eq!(
4161 volcengine_extract_text(&body).as_deref(),
4162 Some("{\"results\":[]}")
4163 );
4164 }
4165
4166 #[tokio::test]
4167 async fn volcengine_model_search_waits_for_runtime_chat_ownership() {
4168 let ownership = crate::client::acquire_runtime_chat_inference_ownership().await;
4169 let mut participant =
4170 tokio::spawn(async { acquire_model_backed_search_inference_participant().await });
4171 assert!(
4172 tokio::time::timeout(std::time::Duration::from_millis(40), &mut participant)
4173 .await
4174 .is_err(),
4175 "model-backed web search must wait behind Runtime Chat ownership"
4176 );
4177 drop(ownership);
4178 let permit = tokio::time::timeout(std::time::Duration::from_secs(1), participant)
4179 .await
4180 .expect("model-backed search resumes after relay settlement")
4181 .expect("model-backed search participant task");
4182 drop(permit);
4183 }
4184
4185 #[tokio::test]
4186 async fn baidu_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
4187 use crate::config::SearchProvider;
4188 use crate::tools::spec::{ToolContext, ToolSpec};
4189
4190 let _env = crate::test_support::lock_test_env();
4191 let _baidu_key = crate::test_support::EnvVarGuard::remove("BAIDU_SEARCH_API_KEY");
4192
4193 let tmp = tempfile::tempdir().expect("tempdir");
4194 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4195 ctx.search_provider = SearchProvider::Baidu;
4196 ctx.search_api_key = None;
4197 let err = WebSearchTool
4198 .execute(json!({"query": "anything"}), &ctx)
4199 .await
4200 .expect_err("missing api_key must surface as ToolError");
4201
4202 let msg = err.to_string();
4203 assert!(
4204 msg.contains("Baidu") && msg.contains("API key"),
4205 "error must name the provider and missing key; got `{msg}`"
4206 );
4207 }
4208
4209 #[tokio::test]
4210 #[allow(clippy::await_holding_lock)]
4211 async fn serply_missing_key_is_fail_closed_inside_the_backend_chain() {
4212 use crate::tools::spec::ToolContext;
4213
4214 let _guard = crate::test_support::lock_test_env();
4215 let prev = std::env::var_os("SERPLY_API_KEY");
4216 unsafe { std::env::remove_var("SERPLY_API_KEY") };
4217
4218 let tmp = tempfile::tempdir().expect("tempdir");
4219 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4220 ctx.search_api_key = None;
4221 let err = WebSearchTool
4222 .run_serply_search("anything", QueryFilters::default(), 5, 1_000, &ctx)
4223 .await
4224 .expect_err("missing api_key must be an error");
4225
4226 match prev {
4227 Some(value) => unsafe { std::env::set_var("SERPLY_API_KEY", value) },
4228 None => unsafe { std::env::remove_var("SERPLY_API_KEY") },
4229 }
4230
4231 // A configured Serply route that reaches the adapter after a failed
4232 // provider-native attempt must stop the chain, not degrade to DuckDuckGo.
4233 assert!(
4234 matches!(
4235 err.error,
4236 crate::tools::spec::ToolError::InvalidInput { .. }
4237 ),
4238 "missing key must be classified fail-closed; got `{err:?}`"
4239 );
4240 }
4241
4242 #[tokio::test]
4243 #[allow(clippy::await_holding_lock)]
4244 async fn serply_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
4245 use crate::config::SearchProvider;
4246 use crate::tools::spec::{ToolContext, ToolSpec};
4247
4248 let _guard = crate::test_support::lock_test_env();
4249 let prev = std::env::var_os("SERPLY_API_KEY");
4250 unsafe { std::env::remove_var("SERPLY_API_KEY") };
4251
4252 let tmp = tempfile::tempdir().expect("tempdir");
4253 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4254 ctx.search_provider = SearchProvider::Serply;
4255 ctx.search_api_key = None;
4256 let err = WebSearchTool
4257 .execute(json!({"query": "anything"}), &ctx)
4258 .await
4259 .expect_err("missing api_key must surface as ToolError");
4260
4261 match prev {
4262 Some(value) => unsafe { std::env::set_var("SERPLY_API_KEY", value) },
4263 None => unsafe { std::env::remove_var("SERPLY_API_KEY") },
4264 }
4265
4266 let msg = err.to_string();
4267 assert!(
4268 msg.contains("Serply") && msg.contains("API key"),
4269 "error must name the provider and missing key; got `{msg}`"
4270 );
4271 }
4272
4273 #[tokio::test]
4274 #[allow(clippy::await_holding_lock)]
4275 async fn sofya_provider_without_api_key_surfaces_clear_error_not_silent_fallback() {
4276 use crate::config::SearchProvider;
4277 use crate::tools::spec::{ToolContext, ToolSpec};
4278
4279 let _guard = crate::test_support::lock_test_env();
4280 let prev = std::env::var_os("SOFYA_API_KEY");
4281 unsafe { std::env::remove_var("SOFYA_API_KEY") };
4282
4283 let tmp = tempfile::tempdir().expect("tempdir");
4284 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4285 ctx.search_provider = SearchProvider::Sofya;
4286 ctx.search_api_key = None;
4287 let err = WebSearchTool
4288 .execute(json!({"query": "anything"}), &ctx)
4289 .await
4290 .expect_err("missing api_key must surface as ToolError");
4291
4292 match prev {
4293 Some(value) => unsafe { std::env::set_var("SOFYA_API_KEY", value) },
4294 None => unsafe { std::env::remove_var("SOFYA_API_KEY") },
4295 }
4296
4297 let msg = err.to_string();
4298 assert!(
4299 msg.contains("Sofya") && msg.contains("API key"),
4300 "error must name the provider and missing key; got `{msg}`"
4301 );
4302 }
4303
4304 #[tokio::test]
4305 #[allow(clippy::await_holding_lock)]
4306 async fn volcengine_provider_without_api_key_lists_supported_env_fallbacks() {
4307 use crate::config::SearchProvider;
4308 use crate::tools::spec::{ToolContext, ToolSpec};
4309
4310 let _guard = crate::test_support::lock_test_env();
4311 let prev_volc = std::env::var_os("VOLCENGINE_API_KEY");
4312 let prev_volc_ark = std::env::var_os("VOLCENGINE_ARK_API_KEY");
4313 let prev_ark = std::env::var_os("ARK_API_KEY");
4314 unsafe {
4315 std::env::remove_var("VOLCENGINE_API_KEY");
4316 std::env::remove_var("VOLCENGINE_ARK_API_KEY");
4317 std::env::remove_var("ARK_API_KEY");
4318 }
4319
4320 let tmp = tempfile::tempdir().expect("tempdir");
4321 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4322 ctx.search_provider = SearchProvider::Volcengine;
4323 ctx.search_api_key = None;
4324 let err = WebSearchTool
4325 .execute(json!({"query": "anything"}), &ctx)
4326 .await
4327 .expect_err("missing api_key must surface as ToolError");
4328
4329 match prev_volc {
4330 Some(value) => unsafe { std::env::set_var("VOLCENGINE_API_KEY", value) },
4331 None => unsafe { std::env::remove_var("VOLCENGINE_API_KEY") },
4332 }
4333 match prev_volc_ark {
4334 Some(value) => unsafe { std::env::set_var("VOLCENGINE_ARK_API_KEY", value) },
4335 None => unsafe { std::env::remove_var("VOLCENGINE_ARK_API_KEY") },
4336 }
4337 match prev_ark {
4338 Some(value) => unsafe { std::env::set_var("ARK_API_KEY", value) },
4339 None => unsafe { std::env::remove_var("ARK_API_KEY") },
4340 }
4341
4342 let msg = err.to_string();
4343 assert!(msg.contains("Volcengine") && msg.contains("API key"));
4344 assert!(msg.contains("VOLCENGINE_API_KEY"));
4345 assert!(msg.contains("VOLCENGINE_ARK_API_KEY"));
4346 assert!(msg.contains("ARK_API_KEY"));
4347 assert!(!msg.contains("DEEPSEEK_SEARCH_API_KEY"));
4348 }
4349
4350 #[tokio::test]
4351 #[allow(clippy::await_holding_lock)]
4352 async fn metaso_provider_without_api_key_fails_closed_before_fallback() {
4353 use crate::config::SearchProvider;
4354 use crate::tools::spec::{ToolContext, ToolSpec};
4355
4356 let _guard = crate::test_support::lock_test_env();
4357 let previous = std::env::var_os("METASO_API_KEY");
4358 unsafe { std::env::remove_var("METASO_API_KEY") };
4359
4360 let tmp = tempfile::tempdir().expect("tempdir");
4361 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4362 ctx.search_provider = SearchProvider::Metaso;
4363 ctx.search_api_key = None;
4364 let error = WebSearchTool
4365 .execute(json!({"query": "anything"}), &ctx)
4366 .await
4367 .expect_err("missing Metaso key must fail before the fallback chain");
4368
4369 match previous {
4370 Some(value) => unsafe { std::env::set_var("METASO_API_KEY", value) },
4371 None => unsafe { std::env::remove_var("METASO_API_KEY") },
4372 }
4373
4374 let message = error.to_string();
4375 assert!(
4376 message.contains("Metaso")
4377 && message.contains("API key")
4378 && message.contains("METASO_API_KEY"),
4379 "got `{message}`"
4380 );
4381 assert!(
4382 !message.contains("duckduckgo"),
4383 "missing configuration must not cross providers: `{message}`"
4384 );
4385 }
4386
4387 #[test]
4388 fn duckduckgo_compatible_url_uses_custom_base_url_and_preserves_query() {
4389 let (url, host) = duckduckgo_search_url(
4390 Some("https://search.internal.example/html/?region=us"),
4391 "rust async",
4392 )
4393 .expect("custom duckduckgo-compatible url");
4394
4395 assert_eq!(host, "search.internal.example");
4396 assert_eq!(
4397 url,
4398 "https://search.internal.example/html/?region=us&q=rust+async"
4399 );
4400 }
4401
4402 #[test]
4403 fn custom_duckduckgo_endpoint_disables_public_bing_fallback() {
4404 assert!(super::duckduckgo_allows_bing_fallback(None));
4405 assert!(super::duckduckgo_allows_bing_fallback(Some(" ")));
4406 assert!(!super::duckduckgo_allows_bing_fallback(Some(
4407 "https://search.internal.example/html/"
4408 )));
4409 }
4410
4411 #[test]
4412 fn searxng_url_uses_search_path_and_json_format() {
4413 let (url, host) = searxng_search_url(
4414 Some("https://search.example/"),
4415 "rust async",
4416 QueryFilters::default(),
4417 )
4418 .expect("searxng url");
4419 let parsed = reqwest::Url::parse(&url).expect("valid url");
4420 assert_eq!(host, "search.example");
4421 assert_eq!(parsed.path(), "/search");
4422 assert_eq!(
4423 parsed.query_pairs().find(|(key, _)| key == "q").unwrap().1,
4424 "rust async"
4425 );
4426 assert_eq!(
4427 parsed
4428 .query_pairs()
4429 .find(|(key, _)| key == "format")
4430 .unwrap()
4431 .1,
4432 "json"
4433 );
4434
4435 let (subpath_url, _) = searxng_search_url(
4436 Some("https://search.example/searxng?language=en"),
4437 "codewhale",
4438 QueryFilters::default(),
4439 )
4440 .expect("searxng subpath url");
4441 let parsed = reqwest::Url::parse(&subpath_url).expect("valid subpath url");
4442 assert_eq!(parsed.path(), "/searxng/search");
4443 assert_eq!(
4444 parsed
4445 .query_pairs()
4446 .find(|(key, _)| key == "language")
4447 .unwrap()
4448 .1,
4449 "en"
4450 );
4451
4452 let (search_url, _) = searxng_search_url(
4453 Some("https://search.example/searxng/search"),
4454 "codewhale",
4455 QueryFilters::default(),
4456 )
4457 .expect("searxng search endpoint");
4458 assert_eq!(
4459 reqwest::Url::parse(&search_url)
4460 .expect("valid search url")
4461 .path(),
4462 "/searxng/search"
4463 );
4464 }
4465
4466 #[test]
4467 fn searxng_parser_normalizes_results() {
4468 let parsed = json!({
4469 "results": [
4470 {
4471 "title": " Rust async ",
4472 "url": " https://example.com/rust ",
4473 "content": " Result content "
4474 },
4475 {
4476 "title": "Empty snippet",
4477 "url": "https://example.com/empty",
4478 "content": " ",
4479 "snippet": " Fallback snippet "
4480 },
4481 {
4482 "title": "",
4483 "url": "https://example.com/missing-title",
4484 "content": "ignored"
4485 },
4486 {
4487 "title": "Missing URL",
4488 "content": "ignored"
4489 }
4490 ]
4491 });
4492
4493 let results = parse_searxng_results(&parsed, 10);
4494 assert_eq!(results.len(), 2);
4495 assert_eq!(results[0].title, "Rust async");
4496 assert_eq!(results[0].url, "https://example.com/rust");
4497 assert_eq!(results[0].snippet.as_deref(), Some("Result content"));
4498 assert_eq!(results[1].snippet.as_deref(), Some("Fallback snippet"));
4499 }
4500
4501 #[test]
4502 fn searxng_score_reads_floats_integers_strings_and_clamps_junk() {
4503 assert_eq!(searxng_score(&json!({"score": 0.75})), 0.75);
4504 assert_eq!(searxng_score(&json!({"score": 1})), 1.0);
4505 assert_eq!(searxng_score(&json!({"score": " 2.5 "})), 2.5);
4506 assert_eq!(searxng_score(&json!({"score": "-1.5"})), -1.5);
4507 assert_eq!(searxng_score(&json!({})), 0.0);
4508 assert_eq!(searxng_score(&json!({"score": null})), 0.0);
4509 assert_eq!(searxng_score(&json!({"score": true})), 0.0);
4510 assert_eq!(searxng_score(&json!({"score": ""})), 0.0);
4511 assert_eq!(searxng_score(&json!({"score": "not-a-number"})), 0.0);
4512 assert_eq!(searxng_score(&json!({"score": {"nested": 1.0}})), 0.0);
4513 assert_eq!(
4514 searxng_score(&json!({"score": "NaN"})),
4515 0.0,
4516 "a non-finite score must not reach the sort"
4517 );
4518 assert_eq!(
4519 searxng_score(&json!({"score": "inf"})),
4520 0.0,
4521 "an infinite score must not outrank every finite row"
4522 );
4523 }
4524
4525 #[test]
4526 fn searxng_parser_sorts_by_descending_score() {
4527 // The strongest row is last in the instance's own order; only the
4528 // score sort can promote it.
4529 let parsed = json!({
4530 "results": [
4531 {"title": "Low", "url": "https://example.com/low", "score": 0.25},
4532 {"title": "Middle", "url": "https://example.com/mid", "score": 1},
4533 {"title": "High", "url": "https://example.com/high", "score": "4.5"},
4534 {"title": "Zero", "url": "https://example.com/zero", "score": 0.0}
4535 ]
4536 });
4537
4538 let titles: Vec<String> = parse_searxng_results(&parsed, 10)
4539 .into_iter()
4540 .map(|entry| entry.title)
4541 .collect();
4542 assert_eq!(titles, ["High", "Middle", "Low", "Zero"]);
4543 }
4544
4545 #[test]
4546 fn searxng_parser_keeps_input_order_for_equal_scores() {
4547 let parsed = json!({
4548 "results": [
4549 {"title": "First", "url": "https://example.com/1", "score": 1.5},
4550 {"title": "Second", "url": "https://example.com/2", "score": 1.5},
4551 {"title": "Third", "url": "https://example.com/3", "score": 1.5},
4552 {"title": "Lower", "url": "https://example.com/4", "score": 1.4}
4553 ]
4554 });
4555
4556 let titles: Vec<String> = parse_searxng_results(&parsed, 10)
4557 .into_iter()
4558 .map(|entry| entry.title)
4559 .collect();
4560 assert_eq!(titles, ["First", "Second", "Third", "Lower"]);
4561 }
4562
4563 #[test]
4564 fn searxng_parser_sorts_missing_or_invalid_scores_last() {
4565 let parsed = json!({
4566 "results": [
4567 {"title": "No score", "url": "https://example.com/none"},
4568 {
4569 "title": "Garbage",
4570 "url": "https://example.com/garbage",
4571 "score": "not-a-number"
4572 },
4573 {"title": "NaN string", "url": "https://example.com/nan", "score": "NaN"},
4574 {"title": "Infinite string", "url": "https://example.com/inf", "score": "inf"},
4575 {"title": "Boolean", "url": "https://example.com/bool", "score": true},
4576 {"title": "Scored", "url": "https://example.com/scored", "score": 0.5}
4577 ]
4578 });
4579
4580 let results = parse_searxng_results(&parsed, 10);
4581 let titles: Vec<&str> = results.iter().map(|entry| entry.title.as_str()).collect();
4582 // Every row with a title and a URL survives. Unusable scores read as
4583 // 0.0 and keep their input order behind the one scored row.
4584 assert_eq!(
4585 titles,
4586 [
4587 "Scored",
4588 "No score",
4589 "Garbage",
4590 "NaN string",
4591 "Infinite string",
4592 "Boolean"
4593 ]
4594 );
4595 }
4596
4597 #[test]
4598 fn searxng_parser_caps_after_score_sort() {
4599 // A `take` before the sort would drop "Strong"; the cap must apply to
4600 // the ranked list instead.
4601 let parsed = json!({
4602 "results": [
4603 {"title": "Weak one", "url": "https://example.com/1", "score": 0.1},
4604 {"title": "Weak two", "url": "https://example.com/2", "score": 0.2},
4605 {"title": "Strong", "url": "https://example.com/3", "score": 9.0}
4606 ]
4607 });
4608
4609 let results = parse_searxng_results(&parsed, 2);
4610 assert_eq!(results.len(), 2, "max_results caps the ranked list");
4611 assert_eq!(results[0].title, "Strong");
4612 assert_eq!(results[1].title, "Weak two");
4613 }
4614
4615 #[tokio::test]
4616 async fn searxng_provider_requires_base_url() {
4617 use crate::config::SearchProvider;
4618 use crate::tools::spec::{ToolContext, ToolSpec};
4619
4620 let tmp = tempfile::tempdir().expect("tempdir");
4621 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4622 ctx.search_provider = SearchProvider::Searxng;
4623 ctx.search_base_url = None;
4624
4625 let err = WebSearchTool
4626 .execute(json!({"query": "rust async"}), &ctx)
4627 .await
4628 .expect_err("searxng requires explicit base_url");
4629 let msg = err.to_string();
4630 assert!(
4631 matches!(err, crate::tools::spec::ToolError::InvalidInput { .. }),
4632 "missing base_url is a configuration gap, not a transport failure: {err:?}"
4633 );
4634 assert!(
4635 msg.contains("SearXNG")
4636 && msg.contains("base_url")
4637 && msg.contains("no public instance"),
4638 "got `{msg}`"
4639 );
4640 }
4641
4642 #[tokio::test]
4643 #[allow(clippy::await_holding_lock)]
4644 async fn missing_provider_key_fails_closed_as_not_configured() {
4645 use crate::config::SearchProvider;
4646 use crate::tools::spec::{ToolContext, ToolError, ToolSpec};
4647
4648 let _guard = crate::test_support::lock_test_env();
4649 let prev_tavily = std::env::var_os("TAVILY_API_KEY");
4650 // "both keys empty" must mean *both*: an ambient key from the
4651 // operator's shell would otherwise satisfy the Tavily arm.
4652 unsafe { std::env::remove_var("TAVILY_API_KEY") };
4653
4654 for provider in [SearchProvider::Tavily, SearchProvider::Bocha] {
4655 let tmp = tempfile::tempdir().expect("tempdir");
4656 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4657 ctx.search_provider = provider;
4658 ctx.search_api_key = None;
4659
4660 let error = WebSearchTool
4661 .execute(json!({"query": "needs configuration"}), &ctx)
4662 .await
4663 .expect_err("a keyed provider without an API key must fail closed");
4664 assert!(
4665 matches!(error, ToolError::InvalidInput { .. }),
4666 "config gaps must stay distinguishable from transport failures: {error:?}"
4667 );
4668 let message = error.to_string();
4669 assert!(message.contains("is not configured"), "got `{message}`");
4670 assert!(message.contains("api_key"), "got `{message}`");
4671 }
4672
4673 // Sibling case: only `TAVILY_API_KEY` is set. Explicit Tavily is
4674 // configured, and the copy that names both sources is the one the
4675 // operator never sees here.
4676 unsafe { std::env::set_var("TAVILY_API_KEY", "tvly-test-env-only") };
4677 let tmp = tempfile::tempdir().expect("tempdir");
4678 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4679 ctx.search_provider = SearchProvider::Tavily;
4680 ctx.search_api_key = None;
4681 let preflight = super::preflight_search_provider(&ctx);
4682
4683 match prev_tavily {
4684 Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
4685 None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
4686 }
4687
4688 assert!(
4689 preflight.is_ok(),
4690 "TAVILY_API_KEY alone must configure explicit Tavily: {preflight:?}"
4691 );
4692 }
4693
4694 #[test]
4695 fn tavily_key_from_prefers_dedicated_env_and_prefix_gates_only_the_generic_key() {
4696 let _guard = crate::test_support::lock_test_env();
4697 let prev = std::env::var_os("TAVILY_API_KEY");
4698
4699 unsafe { std::env::set_var("TAVILY_API_KEY", "tvly-a") };
4700 assert_eq!(
4701 crate::config::tavily_key_from(Some("tvly-b")).as_deref(),
4702 Some("tvly-a"),
4703 "the dedicated env wins over the shared generic slot"
4704 );
4705 assert_eq!(crate::config::tavily_env_key().as_deref(), Some("tvly-a"));
4706
4707 // A dedicated env key is never prefix-checked.
4708 unsafe { std::env::set_var("TAVILY_API_KEY", "not-a-tvly-prefix") };
4709 assert_eq!(
4710 crate::config::tavily_key_from(None).as_deref(),
4711 Some("not-a-tvly-prefix")
4712 );
4713
4714 unsafe { std::env::set_var("TAVILY_API_KEY", " ") };
4715 assert_eq!(crate::config::tavily_env_key(), None);
4716
4717 unsafe { std::env::remove_var("TAVILY_API_KEY") };
4718 assert_eq!(
4719 crate::config::tavily_key_from(Some("tvly-b")).as_deref(),
4720 Some("tvly-b")
4721 );
4722 assert_eq!(
4723 crate::config::tavily_key_from(Some("doctor-offline-search-sentinel")),
4724 None,
4725 "a non-`tvly-` generic key must never autodetect Tavily"
4726 );
4727 assert_eq!(crate::config::tavily_key_from(Some(" ")), None);
4728 assert!(crate::config::looks_like_tavily_key(" tvly-x "));
4729 assert!(!crate::config::looks_like_tavily_key("fc-live-test"));
4730
4731 match prev {
4732 Some(value) => unsafe { std::env::set_var("TAVILY_API_KEY", value) },
4733 None => unsafe { std::env::remove_var("TAVILY_API_KEY") },
4734 }
4735 }
4736
4737 #[tokio::test]
4738 async fn searxng_search_returns_json_results() {
4739 use crate::config::SearchProvider;
4740 use crate::tools::spec::{ToolContext, ToolSpec};
4741 use wiremock::matchers::{method, path, query_param};
4742 use wiremock::{Mock, MockServer, ResponseTemplate};
4743
4744 let server = MockServer::start().await;
4745 Mock::given(method("GET"))
4746 .and(path("/search"))
4747 .and(query_param("q", "rust async"))
4748 .and(query_param("format", "json"))
4749 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4750 "results": [
4751 {
4752 "title": "Rust async",
4753 "url": "https://example.com/rust",
4754 "content": "Async Rust result"
4755 }
4756 ]
4757 })))
4758 .mount(&server)
4759 .await;
4760
4761 let tmp = tempfile::tempdir().expect("tempdir");
4762 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4763 ctx.search_provider = SearchProvider::Searxng;
4764 ctx.search_base_url = Some(server.uri());
4765
4766 let result = WebSearchTool
4767 .execute(json!({"query": "rust async"}), &ctx)
4768 .await
4769 .expect("searxng endpoint should return results");
4770 let value: serde_json::Value =
4771 serde_json::from_str(&result.content).expect("web search json response");
4772
4773 assert_eq!(value["source"].as_str(), Some("searxng"));
4774 assert_eq!(value["count"].as_u64(), Some(1));
4775 assert_eq!(value["results"][0]["rank"].as_u64(), Some(1));
4776 assert_eq!(value["results"][0]["domain"], "example.com");
4777 assert_eq!(value["receipt"]["backend"], "searxng");
4778 assert_eq!(
4779 value["receipt"]["backend_detail"].as_str(),
4780 Some("127.0.0.1")
4781 );
4782 assert!(
4783 value["message"]
4784 .as_str()
4785 .expect("message")
4786 .contains("Backend: searxng at")
4787 );
4788 }
4789
4790 #[tokio::test]
4791 async fn searxng_honors_recency_and_locale_and_post_filters_domains() {
4792 use crate::config::SearchProvider;
4793 use crate::tools::spec::{ToolContext, ToolSpec};
4794 use wiremock::matchers::{method, path, query_param};
4795 use wiremock::{Mock, MockServer, ResponseTemplate};
4796
4797 let server = MockServer::start().await;
4798 Mock::given(method("GET"))
4799 .and(path("/search"))
4800 .and(query_param("q", "fresh rust"))
4801 .and(query_param("format", "json"))
4802 .and(query_param("time_range", "week"))
4803 .and(query_param("language", "en-US"))
4804 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4805 "results": [
4806 {"title": "Keep", "url": "https://docs.example.com/rust", "content": "kept"},
4807 {"title": "Drop", "url": "https://other.test/rust", "content": "dropped"}
4808 ]
4809 })))
4810 .mount(&server)
4811 .await;
4812
4813 let tmp = tempfile::tempdir().expect("tempdir");
4814 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
4815 ctx.search_provider = SearchProvider::Searxng;
4816 ctx.search_base_url = Some(server.uri());
4817
4818 let result = WebSearchTool
4819 .execute(
4820 json!({
4821 "query": "fresh rust",
4822 "recency": "week",
4823 "domains": ["example.com"],
4824 "locale": "en-US"
4825 }),
4826 &ctx,
4827 )
4828 .await
4829 .expect("structured query should execute");
4830 let value: serde_json::Value =
4831 serde_json::from_str(&result.content).expect("web search json response");
4832
4833 assert_eq!(value["count"], 1);
4834 assert_eq!(value["results"][0]["domain"], "docs.example.com");
4835 assert_eq!(value["receipt"]["honored"]["domains"], true);
4836 assert_eq!(value["receipt"]["honored"]["recency"], true);
4837 assert_eq!(value["receipt"]["honored"]["locale"], true);
4838 let degraded = value["receipt"]["degraded"]
4839 .as_array()
4840 .expect("degraded receipt array");
4841 assert!(
4842 degraded
4843 .iter()
4844 .any(|item| { item["kind"] == "post_filtered" && item["knob"] == "domains" })
4845 );
4846 assert!(
4847 !degraded.iter().any(|item| item["kind"] == "knob_ignored"),
4848 "SearXNG forwards both knobs: {degraded:?}"
4849 );
4850 }
4851
4852 #[test]
4853 fn provider_native_domain_filter_is_reported_as_provider_honored() {
4854 let query = SearchQuery::new(
4855 "current release".to_string(),
4856 3,
4857 Some(Recency::Week),
4858 vec!["example.com".to_string()],
4859 None,
4860 );
4861 let raw = BackendSearch {
4862 backend: BackendId::ProviderNative,
4863 source: "provider-native/xai/grok-4.5".to_string(),
4864 backend_detail: Some("api.x.ai".to_string()),
4865 results: vec![SearchResult::new(
4866 1,
4867 "Exact source".to_string(),
4868 "https://docs.example.com/release".to_string(),
4869 None,
4870 None,
4871 )],
4872 degraded: Vec::new(),
4873 note: Some("Grounded answer.".to_string()),
4874 };
4875 let response = finalize_search_response(
4876 query,
4877 QueryCapabilities {
4878 max_results: CapabilityState::Supported,
4879 recency: CapabilityState::Unsupported,
4880 domains: CapabilityState::Supported,
4881 locale: CapabilityState::Unsupported,
4882 published_date: CapabilityState::Unknown,
4883 },
4884 raw,
4885 Instant::now(),
4886 );
4887
4888 assert!(response.receipt.honored.max_results);
4889 assert!(response.receipt.honored.domains);
4890 assert!(!response.receipt.honored.recency);
4891 assert!(response.receipt.degraded.iter().any(|reason| matches!(
4892 reason,
4893 DegradedReason::KnobIgnored {
4894 knob: QueryKnob::Recency
4895 }
4896 )));
4897 assert!(!response.receipt.degraded.iter().any(|reason| matches!(
4898 reason,
4899 DegradedReason::PostFiltered {
4900 knob: QueryKnob::Domains
4901 }
4902 )));
4903 assert!(response.message.contains("Grounded answer."));
4904 }
4905
4906 fn native_backend_search(note: String, degraded: Vec<DegradedReason>) -> BackendSearch {
4907 BackendSearch {
4908 backend: BackendId::ProviderNative,
4909 source: "provider-native/anthropic/claude-opus-4-8".to_string(),
4910 backend_detail: Some("api.anthropic.com".to_string()),
4911 results: (1..=5)
4912 .map(|rank| {
4913 SearchResult::new(
4914 rank,
4915 format!("Source {rank}"),
4916 format!("https://example.com/{rank}"),
4917 None,
4918 None,
4919 )
4920 })
4921 .collect(),
4922 degraded,
4923 note: Some(note),
4924 }
4925 }
4926
4927 fn native_query() -> SearchQuery {
4928 SearchQuery::new("current release".to_string(), 5, None, Vec::new(), None)
4929 }
4930
4931 fn native_capabilities() -> QueryCapabilities {
4932 QueryCapabilities {
4933 max_results: CapabilityState::Supported,
4934 recency: CapabilityState::Unsupported,
4935 domains: CapabilityState::Supported,
4936 locale: CapabilityState::Unsupported,
4937 published_date: CapabilityState::Unknown,
4938 }
4939 }
4940
4941 #[test]
4942 fn native_answer_reaches_the_model_whole_under_the_route_budget() {
4943 // #6508: a 6,000-character native answer with five citations used to
4944 // reach a 128K route as a ~900-character snippet (and earlier was cut
4945 // at 4,000 characters). Now the search result is whole within the
4946 // route's one inline budget.
4947 let answer = format!("{}END OF ANSWER", "Grounded answer sentence. ".repeat(240));
4948 assert!(answer.chars().count() > 6_000);
4949 let response = finalize_search_response(
4950 native_query(),
4951 native_capabilities(),
4952 native_backend_search(answer.clone(), Vec::new()),
4953 Instant::now(),
4954 );
4955 assert!(response.message.ends_with("END OF ANSWER"));
4956 assert!(!response.message.contains("output limit"));
4957
4958 let output = crate::tools::spec::ToolResult::json(&response).expect("json");
4959 let context = crate::core::engine::compact_tool_result_for_route(
4960 crate::config::ProviderKind::Deepseek,
4961 "deepseek-v3.2-128k",
4962 None,
4963 "web_search",
4964 &output,
4965 );
4966 assert_eq!(context, output.content.trim());
4967 assert!(context.contains("END OF ANSWER"));
4968 }
4969
4970 #[test]
4971 fn native_answer_cut_by_the_provider_says_so() {
4972 let response = finalize_search_response(
4973 native_query(),
4974 native_capabilities(),
4975 native_backend_search(
4976 "Partial answer".to_string(),
4977 vec![DegradedReason::AnswerCutByProvider],
4978 ),
4979 Instant::now(),
4980 );
4981 assert!(
4982 response
4983 .receipt
4984 .degraded
4985 .contains(&DegradedReason::AnswerCutByProvider)
4986 );
4987 assert!(
4988 response
4989 .message
4990 .contains("the provider stopped the search answer at its output limit"),
4991 "{}",
4992 response.message
4993 );
4994 }
4995
4996 #[test]
4997 fn provider_native_discards_answer_when_domain_filter_removes_a_source() {
4998 let query = SearchQuery::new(
4999 "current release".to_string(),
5000 3,
5001 None,
5002 vec!["example.com".to_string()],
5003 None,
5004 );
5005 let raw = BackendSearch {
5006 backend: BackendId::ProviderNative,
5007 source: "provider-native/xai/grok-4.5".to_string(),
5008 backend_detail: Some("api.x.ai".to_string()),
5009 results: vec![
5010 SearchResult::new(
5011 1,
5012 "Allowed source".to_string(),
5013 "https://docs.example.com/release".to_string(),
5014 None,
5015 None,
5016 ),
5017 SearchResult::new(
5018 2,
5019 "Leaked source".to_string(),
5020 "https://outside.test/release".to_string(),
5021 None,
5022 None,
5023 ),
5024 ],
5025 degraded: Vec::new(),
5026 note: Some("Answer synthesized from both sources.".to_string()),
5027 };
5028 let response = finalize_search_response(
5029 query,
5030 QueryCapabilities {
5031 max_results: CapabilityState::Supported,
5032 recency: CapabilityState::Unsupported,
5033 domains: CapabilityState::Supported,
5034 locale: CapabilityState::Unsupported,
5035 published_date: CapabilityState::Unknown,
5036 },
5037 raw,
5038 Instant::now(),
5039 );
5040
5041 assert_eq!(response.count, 1);
5042 assert_eq!(response.results[0].domain, "docs.example.com");
5043 assert_eq!(response.message, "Found 1 result(s)");
5044 assert!(response.receipt.degraded.iter().any(|reason| matches!(
5045 reason,
5046 DegradedReason::PostFiltered {
5047 knob: QueryKnob::Domains
5048 }
5049 )));
5050 }
5051
5052 #[test]
5053 fn search_results_receive_session_scoped_refs_and_sanitize_credential_urls() {
5054 let query = SearchQuery::new("sources".to_string(), 5, None, Vec::new(), None);
5055 let raw = BackendSearch {
5056 backend: BackendId::DuckDuckGo,
5057 source: "duckduckgo".to_string(),
5058 backend_detail: None,
5059 results: vec![
5060 SearchResult::new(
5061 1,
5062 "Valid".to_string(),
5063 "https://example.com/source#section".to_string(),
5064 None,
5065 None,
5066 ),
5067 SearchResult::new(
5068 2,
5069 "Protected".to_string(),
5070 "https://example.com/protected?access_token=sensitive&view=full".to_string(),
5071 None,
5072 None,
5073 ),
5074 ],
5075 degraded: Vec::new(),
5076 note: None,
5077 };
5078 let mut response =
5079 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
5080 let context = crate::tools::spec::ToolContext::new(std::path::PathBuf::from("."))
5081 .with_state_namespace("search-citation-session");
5082
5083 register_search_citations(&mut response, &context);
5084
5085 assert_eq!(response.count, 2);
5086 assert_eq!(response.results[0].url, "https://example.com/source");
5087 assert_eq!(
5088 response.results[1].url,
5089 "https://example.com/protected?view=full"
5090 );
5091 assert!(!response.results[1].url.contains("sensitive"));
5092 assert!(response.results[0].ref_id.starts_with("web_"));
5093 assert!(
5094 crate::tools::web::citations::resolve(
5095 "search-citation-session",
5096 &response.results[0].ref_id
5097 )
5098 .is_some()
5099 );
5100 assert!(
5101 crate::tools::web::citations::resolve(
5102 "foreign-search-citation-session",
5103 &response.results[0].ref_id
5104 )
5105 .is_none()
5106 );
5107 }
5108
5109 #[tokio::test]
5110 async fn searxng_empty_results_report_backend() {
5111 use crate::config::SearchProvider;
5112 use crate::tools::spec::ToolContext;
5113 use wiremock::matchers::{method, path, query_param};
5114 use wiremock::{Mock, MockServer, ResponseTemplate};
5115
5116 let server = MockServer::start().await;
5117 Mock::given(method("GET"))
5118 .and(path("/search"))
5119 .and(query_param("q", "empty"))
5120 .and(query_param("format", "json"))
5121 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"results": []})))
5122 .mount(&server)
5123 .await;
5124
5125 let tmp = tempfile::tempdir().expect("tempdir");
5126 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5127 ctx.search_provider = SearchProvider::Searxng;
5128 ctx.search_base_url = Some(server.uri());
5129
5130 let (results, host) = WebSearchTool
5131 .run_searxng_search("empty", QueryFilters::default(), 5, 5_000, &ctx)
5132 .await
5133 .expect("empty SearXNG adapter response should be successful");
5134 let expected_host = reqwest::Url::parse(&server.uri())
5135 .expect("mock URL")
5136 .host_str()
5137 .expect("mock host")
5138 .to_string();
5139
5140 assert!(results.is_empty());
5141 assert_eq!(host, expected_host);
5142 }
5143
5144 #[tokio::test]
5145 async fn searxng_http_errors_are_actionable() {
5146 use crate::config::SearchProvider;
5147 use crate::tools::spec::ToolContext;
5148 use wiremock::matchers::{method, path, query_param};
5149 use wiremock::{Mock, MockServer, ResponseTemplate};
5150
5151 let server = MockServer::start().await;
5152 Mock::given(method("GET"))
5153 .and(path("/search"))
5154 .and(query_param("q", "blocked"))
5155 .and(query_param("format", "json"))
5156 .respond_with(ResponseTemplate::new(403).set_body_string("json disabled"))
5157 .mount(&server)
5158 .await;
5159
5160 let tmp = tempfile::tempdir().expect("tempdir");
5161 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5162 ctx.search_provider = SearchProvider::Searxng;
5163 ctx.search_base_url = Some(server.uri());
5164
5165 let err = WebSearchTool
5166 .run_searxng_search("blocked", QueryFilters::default(), 5, 5_000, &ctx)
5167 .await
5168 .expect_err("403 should be actionable");
5169 let msg = err.to_string();
5170 assert!(
5171 msg.contains("HTTP 403")
5172 && msg.contains("JSON output")
5173 && msg.contains("permits API access"),
5174 "got `{msg}`"
5175 );
5176 }
5177
5178 #[tokio::test]
5179 async fn searxng_rate_limit_error_mentions_configured_instance() {
5180 use crate::config::SearchProvider;
5181 use crate::tools::spec::ToolContext;
5182 use wiremock::matchers::{method, path, query_param};
5183 use wiremock::{Mock, MockServer, ResponseTemplate};
5184
5185 let server = MockServer::start().await;
5186 Mock::given(method("GET"))
5187 .and(path("/search"))
5188 .and(query_param("q", "later"))
5189 .and(query_param("format", "json"))
5190 .respond_with(ResponseTemplate::new(429).set_body_string("too many requests"))
5191 .mount(&server)
5192 .await;
5193
5194 let tmp = tempfile::tempdir().expect("tempdir");
5195 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5196 ctx.search_provider = SearchProvider::Searxng;
5197 ctx.search_base_url = Some(server.uri());
5198
5199 let err = WebSearchTool
5200 .run_searxng_search("later", QueryFilters::default(), 5, 5_000, &ctx)
5201 .await
5202 .expect_err("429 should be actionable");
5203 let msg = err.to_string();
5204 assert!(
5205 msg.contains("HTTP 429")
5206 && msg.contains("rate-limiting")
5207 && msg.contains("trusted/self-hosted instance"),
5208 "got `{msg}`"
5209 );
5210 }
5211
5212 #[tokio::test]
5213 async fn searxng_invalid_json_is_actionable() {
5214 use crate::config::SearchProvider;
5215 use crate::tools::spec::ToolContext;
5216 use wiremock::matchers::{method, path, query_param};
5217 use wiremock::{Mock, MockServer, ResponseTemplate};
5218
5219 let server = MockServer::start().await;
5220 Mock::given(method("GET"))
5221 .and(path("/search"))
5222 .and(query_param("q", "html"))
5223 .and(query_param("format", "json"))
5224 .respond_with(ResponseTemplate::new(200).set_body_string("<html>not json</html>"))
5225 .mount(&server)
5226 .await;
5227
5228 let tmp = tempfile::tempdir().expect("tempdir");
5229 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5230 ctx.search_provider = SearchProvider::Searxng;
5231 ctx.search_base_url = Some(server.uri());
5232
5233 let err = WebSearchTool
5234 .run_searxng_search("html", QueryFilters::default(), 5, 5_000, &ctx)
5235 .await
5236 .expect_err("invalid JSON should be actionable");
5237 let msg = err.to_string();
5238 assert!(
5239 msg.contains("Failed to parse SearXNG JSON response")
5240 && msg.contains("format=json")
5241 && msg.contains("JSON output"),
5242 "got `{msg}`"
5243 );
5244 }
5245
5246 #[tokio::test]
5247 async fn custom_duckduckgo_results_report_custom_host_source() {
5248 use crate::config::SearchProvider;
5249 use crate::tools::spec::{ToolContext, ToolSpec};
5250 use wiremock::matchers::{method, path, query_param};
5251 use wiremock::{Mock, MockServer, ResponseTemplate};
5252
5253 let server = MockServer::start().await;
5254 Mock::given(method("GET"))
5255 .and(path("/html/"))
5256 .and(query_param("q", "rust async"))
5257 .respond_with(ResponseTemplate::new(200).set_body_string(
5258 r#"
5259 <html><body>
5260 <a class="result__a" href="https://example.com/rust">Rust async</a>
5261 <div class="result__snippet">Async Rust result</div>
5262 </body></html>
5263 "#,
5264 ))
5265 .mount(&server)
5266 .await;
5267
5268 let tmp = tempfile::tempdir().expect("tempdir");
5269 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5270 ctx.search_provider = SearchProvider::DuckDuckGo;
5271 let base_url = format!("{}/html/", server.uri());
5272 let expected_host = reqwest::Url::parse(&base_url)
5273 .expect("mock server url")
5274 .host_str()
5275 .expect("mock server host")
5276 .to_string();
5277 ctx.search_base_url = Some(base_url);
5278
5279 let result = WebSearchTool
5280 .execute(json!({"query": "rust async"}), &ctx)
5281 .await
5282 .expect("custom endpoint should return results");
5283 let value: serde_json::Value =
5284 serde_json::from_str(&result.content).expect("web search json response");
5285
5286 assert_eq!(value["source"].as_str(), Some(expected_host.as_str()));
5287 assert_eq!(value["count"].as_u64(), Some(1));
5288 }
5289
5290 #[tokio::test]
5291 async fn repeated_search_uses_session_cache_and_marks_receipt() {
5292 use crate::config::SearchProvider;
5293 use crate::tools::spec::{ToolContext, ToolSpec};
5294 use crate::tools::web::cache;
5295 use wiremock::matchers::{method, path, query_param};
5296 use wiremock::{Mock, MockServer, ResponseTemplate};
5297
5298 cache::reset_search();
5299 let server = MockServer::start().await;
5300 Mock::given(method("GET"))
5301 .and(path("/html/"))
5302 .and(query_param("q", "session cache receipt"))
5303 .respond_with(ResponseTemplate::new(200).set_body_string(
5304 r#"
5305 <html><body>
5306 <a class="result__a" href="https://example.com/cached">Cached result</a>
5307 <div class="result__snippet">Fetched once.</div>
5308 </body></html>
5309 "#,
5310 ))
5311 .mount(&server)
5312 .await;
5313
5314 let tmp = tempfile::tempdir().expect("tempdir");
5315 let mut context = ToolContext::new(tmp.path().to_path_buf())
5316 .with_state_namespace("web-search-query-cache");
5317 context.search_provider = SearchProvider::DuckDuckGo;
5318 context.search_base_url = Some(format!("{}/html/", server.uri()));
5319
5320 let first = WebSearchTool
5321 .execute(json!({"query": "session cache receipt"}), &context)
5322 .await
5323 .expect("first search should succeed");
5324 let second = WebSearchTool
5325 .execute(json!({"query": "session cache receipt"}), &context)
5326 .await
5327 .expect("second search should hit cache");
5328 let first: serde_json::Value =
5329 serde_json::from_str(&first.content).expect("first response json");
5330 let second: serde_json::Value =
5331 serde_json::from_str(&second.content).expect("second response json");
5332 let requests = server.received_requests().await.expect("recorded requests");
5333
5334 assert_eq!(requests.len(), 1);
5335 assert_eq!(first["receipt"]["cache_hit"], false);
5336 assert_eq!(second["receipt"]["cache_hit"], true);
5337 assert_eq!(second["receipt"]["latency_ms"], 0);
5338 assert_eq!(second["results"], first["results"]);
5339
5340 use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
5341 let denied_host = reqwest::Url::parse(&server.uri())
5342 .expect("mock server URL")
5343 .host_str()
5344 .expect("mock server host")
5345 .to_string();
5346 let policy = NetworkPolicy {
5347 default: Decision::Allow.into(),
5348 allow: Vec::new(),
5349 deny: vec![denied_host],
5350 proxy: Vec::new(),
5351 proxy_fake_ip_cidrs: Vec::new(),
5352 audit: false,
5353 };
5354 let blocked = context
5355 .clone()
5356 .with_network_policy(NetworkPolicyDecider::new(policy, None));
5357 let error = WebSearchTool
5358 .execute(json!({"query": "session cache receipt"}), &blocked)
5359 .await
5360 .expect_err("tightened policy must win over the query cache");
5361 assert!(error.to_string().contains("blocked by network policy"));
5362 assert_eq!(
5363 server
5364 .received_requests()
5365 .await
5366 .expect("recorded requests")
5367 .len(),
5368 1
5369 );
5370 }
5371
5372 #[tokio::test]
5373 async fn explicit_bing_does_not_fall_back_to_duckduckgo() {
5374 use crate::config::SearchProvider;
5375 use crate::tools::spec::ToolContext;
5376 use wiremock::matchers::{method, path, query_param};
5377 use wiremock::{Mock, MockServer, ResponseTemplate};
5378
5379 let server = MockServer::start().await;
5380 Mock::given(method("GET"))
5381 .and(path("/bing"))
5382 .and(query_param("q", "one way fallback"))
5383 .respond_with(ResponseTemplate::new(200).set_body_string("<html></html>"))
5384 .mount(&server)
5385 .await;
5386
5387 let tmp = tempfile::tempdir().expect("tempdir");
5388 let mut context = ToolContext::new(tmp.path().to_path_buf());
5389 context.search_provider = SearchProvider::Bing;
5390 context.search_base_url = Some(format!("{}/must-not-be-used", server.uri()));
5391 let query = SearchQuery::new("one way fallback".to_string(), 5, None, Vec::new(), None);
5392 let raw = run_scrape_search_with_endpoints(
5393 SearchProvider::Bing,
5394 &query,
5395 5_000,
5396 &context,
5397 ScrapeEndpoints {
5398 bing: &format!("{}/bing", server.uri()),
5399 allow_bing_fallback: Some(true),
5400 },
5401 )
5402 .await
5403 .expect("empty Bing response is a successful empty search");
5404 let requests = server.received_requests().await.expect("recorded requests");
5405
5406 assert_eq!(raw.backend, BackendId::Bing);
5407 assert!(raw.results.is_empty());
5408 assert!(raw.degraded.is_empty());
5409 assert_eq!(requests.len(), 1);
5410 assert_eq!(requests[0].url.path(), "/bing");
5411 }
5412
5413 #[tokio::test]
5414 async fn custom_duckduckgo_challenge_returns_actionable_error() {
5415 use crate::config::SearchProvider;
5416 use crate::tools::spec::{ToolContext, ToolSpec};
5417 use wiremock::matchers::{method, path, query_param};
5418 use wiremock::{Mock, MockServer, ResponseTemplate};
5419
5420 let server = MockServer::start().await;
5421 Mock::given(method("GET"))
5422 .and(path("/html/"))
5423 .and(query_param("q", "rust async"))
5424 .respond_with(ResponseTemplate::new(200).set_body_string(
5425 r#"<html><body><div class="anomaly-modal">Unfortunately, bots use DuckDuckGo too</div></body></html>"#,
5426 ))
5427 .mount(&server)
5428 .await;
5429
5430 let tmp = tempfile::tempdir().expect("tempdir");
5431 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5432 ctx.search_provider = SearchProvider::DuckDuckGo;
5433 ctx.search_base_url = Some(format!("{}/html/", server.uri()));
5434
5435 let err = WebSearchTool
5436 .execute(json!({"query": "rust async"}), &ctx)
5437 .await
5438 .expect_err("custom endpoint challenge should error");
5439 let msg = err.to_string();
5440 assert!(
5441 msg.contains("DuckDuckGo-compatible search endpoint")
5442 && msg.contains("bot challenge")
5443 && msg.contains("private search service"),
5444 "got `{msg}`"
5445 );
5446 }
5447
5448 #[tokio::test]
5449 async fn duckduckgo_challenge_to_bing_success_populates_fallback_receipt() {
5450 use crate::config::SearchProvider;
5451 use crate::tools::spec::ToolContext;
5452 use std::time::Instant;
5453 use wiremock::matchers::{method, path, query_param};
5454 use wiremock::{Mock, MockServer, ResponseTemplate};
5455
5456 let server = MockServer::start().await;
5457 Mock::given(method("GET"))
5458 .and(path("/html/"))
5459 .and(query_param("q", "fallback receipt"))
5460 .respond_with(ResponseTemplate::new(200).set_body_string(
5461 r#"<html><body><div class="anomaly-modal">Unfortunately, bots use DuckDuckGo too</div></body></html>"#,
5462 ))
5463 .mount(&server)
5464 .await;
5465 Mock::given(method("GET"))
5466 .and(path("/bing"))
5467 .and(query_param("q", "fallback receipt"))
5468 .respond_with(ResponseTemplate::new(200).set_body_string(
5469 r#"
5470 <ol><li class="b_algo">
5471 <h2><a href="https://example.com/fallback">Fallback result</a></h2>
5472 <div class="b_caption"><p>Bing result after challenge.</p></div>
5473 </li></ol>
5474 "#,
5475 ))
5476 .mount(&server)
5477 .await;
5478
5479 let tmp = tempfile::tempdir().expect("tempdir");
5480 let mut context = ToolContext::new(tmp.path().to_path_buf());
5481 context.search_provider = SearchProvider::DuckDuckGo;
5482 context.search_base_url = Some(format!("{}/html/", server.uri()));
5483 let query = SearchQuery::new("fallback receipt".to_string(), 5, None, Vec::new(), None);
5484 let started = Instant::now();
5485 let raw = run_scrape_search_with_endpoints(
5486 SearchProvider::DuckDuckGo,
5487 &query,
5488 5_000,
5489 &context,
5490 ScrapeEndpoints {
5491 bing: &format!("{}/bing", server.uri()),
5492 allow_bing_fallback: Some(true),
5493 },
5494 )
5495 .await
5496 .expect("Bing fallback should succeed");
5497 let response =
5498 finalize_search_response(query, QueryCapabilities::count_only(), raw, started);
5499 let value = serde_json::to_value(&response).expect("response serializes");
5500
5501 assert_eq!(value["source"], "bing");
5502 assert_eq!(value["count"], 1);
5503 assert_eq!(value["receipt"]["backend"], "bing");
5504 assert_eq!(
5505 value["receipt"]["degraded"][0],
5506 json!({"kind": "challenge_detected", "backend": "duckduckgo"})
5507 );
5508 assert_eq!(
5509 value["receipt"]["degraded"][1],
5510 json!({"kind": "scrape_fallback", "from": "duckduckgo", "to": "bing"})
5511 );
5512 assert!(
5513 response
5514 .receipt
5515 .warning()
5516 .expect("warning")
5517 .contains("used bing fallback")
5518 );
5519 }
5520
5521 /// #6746: DuckDuckGo being unreachable (connection refused) or answering
5522 /// non-2xx must still reach the Bing fallback instead of ending the chain.
5523 #[tokio::test]
5524 async fn duckduckgo_unreachable_or_non_2xx_falls_back_to_bing() {
5525 use crate::config::SearchProvider;
5526 use crate::tools::spec::ToolContext;
5527 use wiremock::matchers::{method, path, query_param};
5528 use wiremock::{Mock, MockServer, ResponseTemplate};
5529
5530 let server = MockServer::start().await;
5531 Mock::given(method("GET"))
5532 .and(path("/html/"))
5533 .respond_with(ResponseTemplate::new(503))
5534 .mount(&server)
5535 .await;
5536 Mock::given(method("GET"))
5537 .and(path("/bing"))
5538 .and(query_param("q", "ddg down"))
5539 .respond_with(ResponseTemplate::new(200).set_body_string(
5540 r#"
5541 <ol><li class="b_algo">
5542 <h2><a href="https://example.com/reachable">Reachable result</a></h2>
5543 <div class="b_caption"><p>Bing answered while DuckDuckGo was down.</p></div>
5544 </li></ol>
5545 "#,
5546 ))
5547 .mount(&server)
5548 .await;
5549 // Bind then drop a listener so the port refuses connections.
5550 let refused = {
5551 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
5552 listener.local_addr().expect("addr").port()
5553 };
5554
5555 let tmp = tempfile::tempdir().expect("tempdir");
5556 let bing = format!("{}/bing", server.uri());
5557 let query = SearchQuery::new("ddg down".to_string(), 5, None, Vec::new(), None);
5558 for ddg in [
5559 format!("http://127.0.0.1:{refused}/html/"),
5560 format!("{}/html/", server.uri()),
5561 ] {
5562 let mut context = ToolContext::new(tmp.path().to_path_buf());
5563 context.search_provider = SearchProvider::DuckDuckGo;
5564 context.search_base_url = Some(ddg.clone());
5565 let raw = run_scrape_search_with_endpoints(
5566 SearchProvider::DuckDuckGo,
5567 &query,
5568 5_000,
5569 &context,
5570 ScrapeEndpoints {
5571 bing: &bing,
5572 allow_bing_fallback: Some(true),
5573 },
5574 )
5575 .await
5576 .unwrap_or_else(|error| panic!("{ddg}: Bing fallback should answer: {error}"));
5577
5578 assert_eq!(raw.backend, BackendId::Bing, "{ddg}");
5579 assert_eq!(raw.results.len(), 1, "{ddg}");
5580 assert_eq!(raw.results[0].url, "https://example.com/reachable");
5581 assert_eq!(
5582 raw.degraded,
5583 vec![
5584 DegradedReason::BackendUnavailable {
5585 backend: BackendId::DuckDuckGo
5586 },
5587 DegradedReason::BackendFallback {
5588 from: BackendId::DuckDuckGo,
5589 to: BackendId::Bing
5590 },
5591 ],
5592 "{ddg}"
5593 );
5594 assert!(
5595 raw.note
5596 .as_deref()
5597 .is_some_and(|note| note.contains("used Bing fallback")),
5598 "{ddg}: {:?}",
5599 raw.note
5600 );
5601 }
5602
5603 // Without the Bing fallback the DuckDuckGo failure is still reported.
5604 let mut context = ToolContext::new(tmp.path().to_path_buf());
5605 context.search_provider = SearchProvider::DuckDuckGo;
5606 context.search_base_url = Some(format!("{}/html/", server.uri()));
5607 let Err(error) = run_scrape_search_with_endpoints(
5608 SearchProvider::DuckDuckGo,
5609 &query,
5610 5_000,
5611 &context,
5612 ScrapeEndpoints {
5613 bing: &bing,
5614 allow_bing_fallback: Some(false),
5615 },
5616 )
5617 .await
5618 else {
5619 panic!("no fallback means the HTTP failure surfaces");
5620 };
5621 assert!(error.to_string().contains("HTTP 503"), "{error}");
5622 }
5623
5624 /// #6746: a DuckDuckGo that accepts the connection and then hangs must not
5625 /// eat the whole budget; Bing answers inside the same total.
5626 #[tokio::test]
5627 async fn hanging_duckduckgo_leaves_bing_time_inside_the_total_budget() {
5628 use crate::config::SearchProvider;
5629 use crate::tools::spec::ToolContext;
5630 use wiremock::matchers::{method, path};
5631 use wiremock::{Mock, MockServer, ResponseTemplate};
5632
5633 let server = MockServer::start().await;
5634 Mock::given(method("GET"))
5635 .and(path("/html/"))
5636 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(30)))
5637 .mount(&server)
5638 .await;
5639 Mock::given(method("GET"))
5640 .and(path("/bing"))
5641 .respond_with(ResponseTemplate::new(200).set_body_string(
5642 r#"
5643 <ol><li class="b_algo">
5644 <h2><a href="https://example.com/after-hang">After the hang</a></h2>
5645 <div class="b_caption"><p>Bing answered after DuckDuckGo hung.</p></div>
5646 </li></ol>
5647 "#,
5648 ))
5649 .mount(&server)
5650 .await;
5651
5652 let tmp = tempfile::tempdir().expect("tempdir");
5653 let mut context = ToolContext::new(tmp.path().to_path_buf());
5654 context.search_provider = SearchProvider::DuckDuckGo;
5655 context.search_base_url = Some(format!("{}/html/", server.uri()));
5656 let query = SearchQuery::new("hang".to_string(), 5, None, Vec::new(), None);
5657 let budget = Duration::from_millis(3_000);
5658 let started = Instant::now();
5659 let outcome = tokio::time::timeout(
5660 budget,
5661 run_scrape_search_with_endpoints(
5662 SearchProvider::DuckDuckGo,
5663 &query,
5664 3_000,
5665 &context,
5666 ScrapeEndpoints {
5667 bing: &format!("{}/bing", server.uri()),
5668 allow_bing_fallback: Some(true),
5669 },
5670 ),
5671 )
5672 .await
5673 .expect("the whole search must finish inside its budget");
5674 let raw = outcome.expect("Bing fallback should answer after DuckDuckGo hangs");
5675
5676 assert_eq!(raw.backend, BackendId::Bing);
5677 assert_eq!(raw.results.len(), 1);
5678 assert!(started.elapsed() < budget, "{:?}", started.elapsed());
5679 assert!(
5680 raw.degraded.contains(&DegradedReason::BackendUnavailable {
5681 backend: BackendId::DuckDuckGo
5682 }),
5683 "{:?}",
5684 raw.degraded
5685 );
5686 }
5687
5688 /// #6746: a custom `search_base_url` keeps no public fallback and keeps the
5689 /// whole budget: a slow private endpoint is waited for, and Bing is never
5690 /// contacted, whether the endpoint answers late or hangs.
5691 #[tokio::test]
5692 async fn custom_base_url_keeps_full_budget_and_never_reaches_bing() {
5693 use crate::config::SearchProvider;
5694 use crate::tools::spec::ToolContext;
5695 use wiremock::matchers::{method, path};
5696 use wiremock::{Mock, MockServer, ResponseTemplate};
5697
5698 let tmp = tempfile::tempdir().expect("tempdir");
5699 let query = SearchQuery::new("private".to_string(), 5, None, Vec::new(), None);
5700 for (delay, expect_results) in [
5701 (Duration::from_millis(1_500), true),
5702 (Duration::from_secs(30), false),
5703 ] {
5704 let server = MockServer::start().await;
5705 Mock::given(method("GET"))
5706 .and(path("/html/"))
5707 .respond_with(ResponseTemplate::new(200).set_delay(delay).set_body_string(
5708 r#"
5709 <html><body>
5710 <a class="result__a" href="https://example.com/private">Private</a>
5711 <div class="result__snippet">Private result</div>
5712 </body></html>
5713 "#,
5714 ))
5715 .mount(&server)
5716 .await;
5717 Mock::given(method("GET"))
5718 .and(path("/bing"))
5719 .respond_with(ResponseTemplate::new(200))
5720 .mount(&server)
5721 .await;
5722
5723 let mut context = ToolContext::new(tmp.path().to_path_buf());
5724 context.search_provider = SearchProvider::DuckDuckGo;
5725 context.search_base_url = Some(format!("{}/html/", server.uri()));
5726 // 60% of 2s would be 1.2s: a 1.5s answer only arrives if the
5727 // private endpoint kept the whole budget.
5728 let outcome = run_scrape_search_with_endpoints(
5729 SearchProvider::DuckDuckGo,
5730 &query,
5731 2_000,
5732 &context,
5733 ScrapeEndpoints {
5734 bing: &format!("{}/bing", server.uri()),
5735 allow_bing_fallback: None,
5736 },
5737 )
5738 .await;
5739 if expect_results {
5740 let raw = outcome.expect("a slow private endpoint keeps its full budget");
5741 assert_eq!(raw.backend, BackendId::DuckDuckGo);
5742 assert_eq!(raw.results.len(), 1);
5743 } else {
5744 assert!(outcome.is_err(), "a hanging private endpoint is an error");
5745 }
5746 let bing_requests = server
5747 .received_requests()
5748 .await
5749 .expect("recorded requests")
5750 .iter()
5751 .filter(|request| request.url.path() == "/bing")
5752 .count();
5753 assert_eq!(bing_requests, 0, "custom base URL must not reach Bing");
5754 }
5755 }
5756
5757 #[tokio::test]
5758 async fn search_base_url_with_non_duckduckgo_provider_is_explicit_error() {
5759 use crate::config::SearchProvider;
5760 use crate::tools::spec::{ToolContext, ToolSpec};
5761
5762 let tmp = tempfile::tempdir().expect("tempdir");
5763 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
5764 ctx.search_provider = SearchProvider::Tavily;
5765 ctx.search_base_url = Some("https://search.internal.example/html/".to_string());
5766
5767 let err = WebSearchTool
5768 .execute(json!({"query": "rust async"}), &ctx)
5769 .await
5770 .expect_err("non-duckduckgo provider with base_url should error");
5771 let msg = err.to_string();
5772 assert!(
5773 msg.contains("[search].base_url")
5774 && msg.contains("provider = \"duckduckgo\" or \"searxng\"")
5775 && msg.contains("tavily"),
5776 "got `{msg}`"
5777 );
5778 }
5779
5780 #[test]
5781 fn rerank_assigns_sequential_ranks_starting_at_one() {
5782 // Simulates the post-dedup path: ranks may be non-contiguous after a
5783 // result is dropped; rerank must restore a clean 1..N sequence.
5784 let mut results = vec![
5785 SearchResult::new(
5786 5,
5787 "C".to_string(),
5788 "https://c.example.com/".to_string(),
5789 None,
5790 None,
5791 ),
5792 SearchResult::new(
5793 3,
5794 "A".to_string(),
5795 "https://a.example.com/".to_string(),
5796 None,
5797 None,
5798 ),
5799 SearchResult::new(
5800 1,
5801 "B".to_string(),
5802 "https://b.example.com/".to_string(),
5803 None,
5804 None,
5805 ),
5806 ];
5807 rerank(&mut results);
5808 assert_eq!(results[0].rank, 1);
5809 assert_eq!(results[1].rank, 2);
5810 assert_eq!(results[2].rank, 3);
5811 }
5812
5813 #[test]
5814 fn rerank_on_empty_slice_is_a_no_op() {
5815 let mut results: Vec<SearchResult> = Vec::new();
5816 rerank(&mut results); // must not panic
5817 }
5818
5819 #[test]
5820 fn register_search_citations_deduplicates_results_with_same_canonical_url() {
5821 let namespace = "dedup-test-session-fragments";
5822 let query = SearchQuery::new("deduplicate".to_string(), 5, None, Vec::new(), None);
5823 let raw = BackendSearch {
5824 backend: BackendId::DuckDuckGo,
5825 source: "duckduckgo".to_string(),
5826 backend_detail: None,
5827 results: vec![
5828 SearchResult::new(
5829 1,
5830 "First occurrence".to_string(),
5831 "https://dedup.example.com/page#section-a".to_string(),
5832 Some("first snippet".to_string()),
5833 None,
5834 ),
5835 SearchResult::new(
5836 2,
5837 "Unique result".to_string(),
5838 "https://other.dedup.example.com/different".to_string(),
5839 None,
5840 None,
5841 ),
5842 SearchResult::new(
5843 3,
5844 "Duplicate of first".to_string(),
5845 "https://dedup.example.com/page#section-b".to_string(),
5846 Some("duplicate snippet".to_string()),
5847 None,
5848 ),
5849 ],
5850 degraded: Vec::new(),
5851 note: None,
5852 };
5853 let mut response =
5854 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
5855 let context = crate::tools::spec::ToolContext::new(std::path::PathBuf::from("."))
5856 .with_state_namespace(namespace);
5857
5858 register_search_citations(&mut response, &context);
5859
5860 assert_eq!(
5861 response.count, 2,
5862 "duplicate canonical URL must reduce the result count"
5863 );
5864 assert_eq!(response.results.len(), 2);
5865 assert_eq!(response.results[0].rank, 1);
5866 assert_eq!(response.results[1].rank, 2);
5867 assert_eq!(response.results[0].url, "https://dedup.example.com/page");
5868 assert_eq!(
5869 response.results[1].url,
5870 "https://other.dedup.example.com/different"
5871 );
5872 assert_ne!(
5873 response.results[0].ref_id, response.results[1].ref_id,
5874 "surviving results must have distinct ref_ids"
5875 );
5876 assert!(response.message.contains('2'), "{}", response.message);
5877 }
5878
5879 #[test]
5880 fn register_search_citations_preserves_title_url_and_ref_id_metadata() {
5881 let namespace = "citation-metadata-test-session";
5882 let query = SearchQuery::new("docs".to_string(), 5, None, Vec::new(), None);
5883 let raw = BackendSearch {
5884 backend: BackendId::DuckDuckGo,
5885 source: "duckduckgo".to_string(),
5886 backend_detail: None,
5887 results: vec![SearchResult::new(
5888 1,
5889 "Official Docs".to_string(),
5890 "https://docs.citation-meta.example.com/reference".to_string(),
5891 Some("Comprehensive reference documentation.".to_string()),
5892 None,
5893 )],
5894 degraded: Vec::new(),
5895 note: None,
5896 };
5897 let mut response =
5898 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
5899 let context = crate::tools::spec::ToolContext::new(std::path::PathBuf::from("."))
5900 .with_state_namespace(namespace);
5901
5902 register_search_citations(&mut response, &context);
5903
5904 assert_eq!(response.count, 1);
5905 let result = &response.results[0];
5906 assert!(
5907 result.ref_id.starts_with("web_"),
5908 "ref_id must use web_ prefix; got `{}`",
5909 result.ref_id
5910 );
5911 assert_eq!(result.title, "Official Docs");
5912 assert_eq!(
5913 result.url,
5914 "https://docs.citation-meta.example.com/reference"
5915 );
5916 assert_eq!(result.rank, 1);
5917 let citation = crate::tools::web::citations::resolve(namespace, &result.ref_id)
5918 .expect("citation must be registered and resolvable in its session");
5919 assert_eq!(citation.ref_id, result.ref_id);
5920 assert_eq!(citation.url, result.url);
5921 assert_eq!(citation.title.as_deref(), Some("Official Docs"));
5922 assert!(
5923 !citation.retrieved_at.is_empty(),
5924 "retrieved_at must be set to the retrieval timestamp"
5925 );
5926 assert!(
5927 crate::tools::web::citations::resolve("other-session", &result.ref_id).is_none(),
5928 "citation must not leak to foreign sessions"
5929 );
5930 }
5931
5932 #[test]
5933 fn finalize_search_response_truncates_to_max_results_and_reranks() {
5934 let query = SearchQuery::new("truncate me".to_string(), 2, None, Vec::new(), None);
5935 let raw = BackendSearch {
5936 backend: BackendId::DuckDuckGo,
5937 source: "duckduckgo".to_string(),
5938 backend_detail: None,
5939 results: vec![
5940 SearchResult::new(
5941 1,
5942 "A".to_string(),
5943 "https://a.trunc.example.com/".to_string(),
5944 None,
5945 None,
5946 ),
5947 SearchResult::new(
5948 2,
5949 "B".to_string(),
5950 "https://b.trunc.example.com/".to_string(),
5951 None,
5952 None,
5953 ),
5954 SearchResult::new(
5955 3,
5956 "C".to_string(),
5957 "https://c.trunc.example.com/".to_string(),
5958 None,
5959 None,
5960 ),
5961 ],
5962 degraded: Vec::new(),
5963 note: None,
5964 };
5965
5966 let response =
5967 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
5968
5969 assert_eq!(response.count, 2, "must be truncated to max_results");
5970 assert_eq!(response.results.len(), 2);
5971 assert_eq!(response.results[0].rank, 1);
5972 assert_eq!(response.results[1].rank, 2);
5973 assert_eq!(response.results[0].title, "A");
5974 assert_eq!(response.results[1].title, "B");
5975 assert!(response.message.contains('2'), "{}", response.message);
5976 }
5977
5978 #[test]
5979 fn domain_matches_handles_subdomains_www_prefix_and_empty_list() {
5980 assert!(
5981 domain_matches("https://any.example.com/page", &[]),
5982 "empty domain list must accept all URLs"
5983 );
5984 assert!(domain_matches(
5985 "https://example.com/page",
5986 &["example.com".to_string()]
5987 ));
5988 assert!(domain_matches(
5989 "https://docs.example.com/page",
5990 &["example.com".to_string()]
5991 ));
5992 assert!(domain_matches(
5993 "https://www.example.com/page",
5994 &["example.com".to_string()]
5995 ));
5996 assert!(domain_matches(
5997 "https://example.com/page",
5998 &["www.example.com".to_string()]
5999 ));
6000 assert!(!domain_matches(
6001 "https://other.com/page",
6002 &["example.com".to_string()]
6003 ));
6004 assert!(!domain_matches(
6005 "https://notexample.com/page",
6006 &["example.com".to_string()]
6007 ));
6008 }
6009
6010 #[test]
6011 fn finalize_search_response_domain_post_filter_reranks_survivors() {
6012 let query = SearchQuery::new(
6013 "domain filter".to_string(),
6014 5,
6015 None,
6016 vec!["keep.example.com".to_string()],
6017 None,
6018 );
6019 let raw = BackendSearch {
6020 backend: BackendId::DuckDuckGo,
6021 source: "duckduckgo".to_string(),
6022 backend_detail: None,
6023 results: vec![
6024 SearchResult::new(
6025 1,
6026 "Drop this".to_string(),
6027 "https://other.example.com/page".to_string(),
6028 None,
6029 None,
6030 ),
6031 SearchResult::new(
6032 2,
6033 "Keep this".to_string(),
6034 "https://keep.example.com/page".to_string(),
6035 None,
6036 None,
6037 ),
6038 SearchResult::new(
6039 3,
6040 "Also drop".to_string(),
6041 "https://unrelated.example.com/page".to_string(),
6042 None,
6043 None,
6044 ),
6045 ],
6046 degraded: Vec::new(),
6047 note: None,
6048 };
6049
6050 let response =
6051 finalize_search_response(query, QueryCapabilities::count_only(), raw, Instant::now());
6052
6053 assert_eq!(response.count, 1, "only the matching domain must survive");
6054 assert_eq!(
6055 response.results[0].rank, 1,
6056 "survivor must be re-ranked to 1"
6057 );
6058 assert_eq!(response.results[0].title, "Keep this");
6059 assert!(
6060 response.receipt.degraded.iter().any(|reason| matches!(
6061 reason,
6062 DegradedReason::PostFiltered {
6063 knob: QueryKnob::Domains
6064 }
6065 )),
6066 "post-filtered degraded reason must be present"
6067 );
6068 }
6069
6070 #[test]
6071 fn fallback_receipt_carries_full_backend_chain_history() {
6072 // Verifies that the machine-readable degraded vec records every hop in
6073 // the fallback chain so callers can audit exactly what happened.
6074 let receipt = crate::tools::web::contract::SearchReceipt {
6075 backend: BackendId::Bing,
6076 backend_detail: None,
6077 requested: SearchQuery::new("fallback chain".to_string(), 5, None, Vec::new(), None),
6078 capabilities: QueryCapabilities::count_only(),
6079 honored: crate::tools::web::contract::HonoredQueryCapabilities {
6080 max_results: true,
6081 ..Default::default()
6082 },
6083 degraded: vec![
6084 DegradedReason::ChallengeDetected {
6085 backend: BackendId::DuckDuckGo,
6086 },
6087 DegradedReason::ScrapeFallback {
6088 from: BackendId::DuckDuckGo,
6089 to: BackendId::Bing,
6090 },
6091 ],
6092 latency_ms: 42,
6093 cache_hit: false,
6094 };
6095
6096 let value = serde_json::to_value(&receipt).expect("receipt must serialize");
6097 assert_eq!(value["backend"], "bing");
6098 assert_eq!(value["degraded"].as_array().unwrap().len(), 2);
6099 assert_eq!(value["degraded"][0]["kind"], "challenge_detected");
6100 assert_eq!(value["degraded"][0]["backend"], "duckduckgo");
6101 assert_eq!(value["degraded"][1]["kind"], "scrape_fallback");
6102 assert_eq!(value["degraded"][1]["from"], "duckduckgo");
6103 assert_eq!(value["degraded"][1]["to"], "bing");
6104
6105 let warning = receipt
6106 .warning()
6107 .expect("degraded receipt must produce a warning");
6108 assert!(warning.contains("bot challenge"), "{warning}");
6109 assert!(warning.contains("used bing fallback"), "{warning}");
6110 }
6111
6112 fn filtered_query(recency: Option<Recency>, locale: Option<&str>) -> SearchQuery {
6113 SearchQuery::new(
6114 "whale song".to_string(),
6115 5,
6116 recency,
6117 Vec::new(),
6118 locale.map(str::to_string),
6119 )
6120 }
6121
6122 #[test]
6123 fn query_filters_round_recency_up_and_split_locale() {
6124 let query = filtered_query(Some(Recency::Days(10)), Some("pt_BR"));
6125 let filters = QueryFilters::of(&query);
6126 assert_eq!(filters.window(), Some("month"));
6127 assert_eq!(filters.language().as_deref(), Some("pt"));
6128 assert_eq!(filters.region().as_deref(), Some("BR"));
6129 for (recency, window) in [
6130 (Recency::Day, "day"),
6131 (Recency::Week, "week"),
6132 (Recency::Month, "month"),
6133 (Recency::Year, "year"),
6134 (Recency::Days(400), "year"),
6135 ] {
6136 let query = filtered_query(Some(recency), None);
6137 assert_eq!(QueryFilters::of(&query).window(), Some(window));
6138 }
6139 let bare = filtered_query(None, Some("en"));
6140 assert_eq!(QueryFilters::of(&bare).region(), None);
6141 assert_eq!(QueryFilters::of(&bare).window(), None);
6142 }
6143
6144 #[tokio::test]
6145 async fn firecrawl_sends_recency_and_country() {
6146 use wiremock::matchers::{body_partial_json, method, path};
6147 use wiremock::{Mock, MockServer, ResponseTemplate};
6148
6149 let server = MockServer::start().await;
6150 Mock::given(method("POST"))
6151 .and(path("/v2/search"))
6152 .and(body_partial_json(json!({"tbs": "qdr:w", "country": "DE"})))
6153 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
6154 "success": true,
6155 "data": {"web": [{"title": "Fresh", "url": "https://example.de/a"}]}
6156 })))
6157 .expect(1)
6158 .mount(&server)
6159 .await;
6160 let query = filtered_query(Some(Recency::Week), Some("de-DE"));
6161 let (entries, _) = WebSearchTool
6162 .run_firecrawl_search_at(
6163 &format!("{}/v2/search", server.uri()),
6164 &query.query,
6165 QueryFilters::of(&query),
6166 5,
6167 5_000,
6168 None,
6169 )
6170 .await
6171 .expect("filtered Firecrawl search");
6172 assert_eq!(entries.len(), 1);
6173 }
6174
6175 #[tokio::test]
6176 async fn searxng_sends_time_range_and_language() {
6177 use crate::tools::spec::ToolContext;
6178 use wiremock::matchers::{method, path, query_param};
6179 use wiremock::{Mock, MockServer, ResponseTemplate};
6180
6181 let server = MockServer::start().await;
6182 Mock::given(method("GET"))
6183 .and(path("/search"))
6184 .and(query_param("time_range", "day"))
6185 .and(query_param("language", "fr-FR"))
6186 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
6187 "results": [{"title": "Aujourd'hui", "url": "https://example.fr/a", "content": "x"}]
6188 })))
6189 .expect(1)
6190 .mount(&server)
6191 .await;
6192 let tmp = tempfile::tempdir().expect("tempdir");
6193 let mut ctx = ToolContext::new(tmp.path().to_path_buf());
6194 ctx.search_provider = SearchProvider::Searxng;
6195 ctx.search_base_url = Some(server.uri());
6196 let query = filtered_query(Some(Recency::Day), Some("fr-FR"));
6197
6198 let (results, _) = WebSearchTool
6199 .run_searxng_search(&query.query, QueryFilters::of(&query), 5, 5_000, &ctx)
6200 .await
6201 .expect("filtered SearXNG search");
6202 assert_eq!(results.len(), 1);
6203 }
6204
6205 #[test]
6206 fn tavily_payload_carries_time_range_only_when_requested() {
6207 let query = filtered_query(Some(Recency::Year), Some("en-US"));
6208 let payload =
6209 super::tavily_search_payload("tvly-x", &query.query, QueryFilters::of(&query), 5);
6210 assert_eq!(payload["time_range"], "year");
6211 assert!(
6212 payload.get("country").is_none(),
6213 "Tavily takes country names, not tags"
6214 );
6215 let plain = super::tavily_search_payload("tvly-x", "q", QueryFilters::default(), 5);
6216 assert!(plain.get("time_range").is_none());
6217 }
6218
6219 #[test]
6220 fn serply_url_carries_language_and_country() {
6221 let query = filtered_query(Some(Recency::Day), Some("ja-JP"));
6222 let url = serply_search_url(&query.query, QueryFilters::of(&query), 3).expect("serply url");
6223 let pairs: std::collections::BTreeMap<String, String> = url
6224 .query_pairs()
6225 .map(|(k, v)| (k.into_owned(), v.into_owned()))
6226 .collect();
6227 assert_eq!(pairs.get("hl").map(String::as_str), Some("ja"));
6228 assert_eq!(pairs.get("gl").map(String::as_str), Some("jp"));
6229 assert!(
6230 !pairs.contains_key("tbs"),
6231 "Serply documents no recency parameter"
6232 );
6233 }
6234
6235 #[test]
6236 fn adapter_reported_ignored_locale_is_not_counted_as_honored() {
6237 let query = filtered_query(Some(Recency::Week), Some("en"));
6238 let raw = BackendSearch {
6239 backend: BackendId::Firecrawl,
6240 source: "firecrawl".to_string(),
6241 backend_detail: None,
6242 results: Vec::new(),
6243 degraded: vec![DegradedReason::KnobIgnored {
6244 knob: QueryKnob::Locale,
6245 }],
6246 note: None,
6247 };
6248 let capabilities = QueryCapabilities {
6249 recency: CapabilityState::Supported,
6250 locale: CapabilityState::Supported,
6251 ..QueryCapabilities::count_only()
6252 };
6253 let response = finalize_search_response(query, capabilities, raw, Instant::now());
6254 assert!(response.receipt.honored.recency);
6255 assert!(!response.receipt.honored.locale);
6256 assert_eq!(
6257 response
6258 .receipt
6259 .degraded
6260 .iter()
6261 .filter(|reason| matches!(
6262 reason,
6263 DegradedReason::KnobIgnored {
6264 knob: QueryKnob::Locale
6265 }
6266 ))
6267 .count(),
6268 1
6269 );
6270 }
6271 }
6272
6273 #[cfg(test)]
6274 #[path = "web/search_host_tests.rs"]
6275 mod host_tests;
6276
6276 lines RUST