diff --git a/crates/tui/src/config/search.rs b/crates/tui/src/config/search.rs index 0827a4d642..a8ca085706 100644 --- a/crates/tui/src/config/search.rs +++ b/crates/tui/src/config/search.rs @@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize}; /// Search provider enumeration — selects the first backend `web_search` uses. -/// API-backed providers may visibly degrade through the default DuckDuckGo → -/// Bing chain after runtime failure or an empty result. Configuration and +/// API-backed providers may visibly degrade through the keyless Bing tail +/// after runtime failure or an empty result. Configuration and /// network-policy errors fail closed without crossing providers. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/tui/src/tools/web/backend.rs b/crates/tui/src/tools/web/backend.rs index 3aa5b3ef5a..6ae36b0f40 100644 --- a/crates/tui/src/tools/web/backend.rs +++ b/crates/tui/src/tools/web/backend.rs @@ -113,9 +113,14 @@ impl<'a> SearchBackendChain<'a> { context, selected, ))); if !matches!(selected, SearchProvider::Bing | SearchProvider::DuckDuckGo) { + // The keyless tail must stay reachable from mainland-China + // networks, where DuckDuckGo is DNS-poisoned and SNI-reset while + // Bing serves both its global and China endpoints without a key. + // The tail is picked by reachability, not by geo detection: the + // engine never guesses the user's location. backends.push(Box::new(ConfiguredSearchBackend::from_provider( context, - SearchProvider::DuckDuckGo, + SearchProvider::Bing, ))); } Self { backends } @@ -255,7 +260,8 @@ async fn run_backend_chain( .collect::>() .join(", "); Err(ToolError::not_available(format!( - "web search backends unavailable: {backend_ids}" + "web search backends unavailable: {backend_ids}; \ + configure an API-backed [search] provider (tavily, bocha, metaso, baidu, volcengine) for dependable results" ))) } @@ -521,6 +527,47 @@ mod tests { } } + /// API-backed providers must fall back to the keyless Bing tail, not + /// DuckDuckGo: DDG is unreachable from mainland-China networks + /// (DNS poisoning + SNI reset), so a DDG tail turns every API outage + /// into a guaranteed total failure there, while Bing stays reachable + /// globally without a key. Bing and DuckDuckGo themselves stay + /// single-backend chains (their internal fallbacks own that job). + #[test] + fn forkguard_api_provider_chain_tail_is_bing() { + for selected in [ + SearchProvider::Tavily, + SearchProvider::Bocha, + SearchProvider::Metaso, + SearchProvider::Baidu, + SearchProvider::Searxng, + SearchProvider::Volcengine, + SearchProvider::Sofya, + ] { + let mut context = ToolContext::new(std::path::PathBuf::from(".")); + context.search_provider = selected; + let chain = SearchBackendChain::from_context(&context); + let ids: Vec = chain.backends.iter().map(|backend| backend.id()).collect(); + let expected_tail = BackendId::Bing; + assert_eq!( + ids.last(), + Some(&expected_tail), + "{selected:?} chain must keep a reachable keyless Bing tail" + ); + } + + for selected in [SearchProvider::Bing, SearchProvider::DuckDuckGo] { + let mut context = ToolContext::new(std::path::PathBuf::from(".")); + context.search_provider = selected; + let chain = SearchBackendChain::from_context(&context); + assert_eq!( + chain.backends.len(), + 1, + "{selected:?} must stay a single-backend chain" + ); + } + } + #[test] fn provider_native_is_fail_closed_without_both_fact_and_client() { assert!(!provider_native_is_available(false, false)); @@ -703,6 +750,7 @@ mod tests { assert!(matches!(error, ToolError::NotAvailable { .. })); assert!(message.contains("bocha, duckduckgo")); + assert!(message.contains("configure an API-backed [search] provider")); assert!(!message.contains(private_error)); assert!(!message.contains("different private response")); } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3bafa02780..fe247f894b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -2272,13 +2272,17 @@ first makes a separate, bounded request through that provider's native search contract. It reuses the active route's authentication and transport, but does not inject provider-owned tools into the main conversation. A runtime failure or a response without usable citations then falls back visibly to the -configured `[search]` backend and, unless that backend is already Bing or -DuckDuckGo, to DuckDuckGo. Provider-native search receives a separate bounded +configured `[search]` backend and then to the keyless Bing tail. Bing is +picked over DuckDuckGo by reachability, not geo detection: Bing serves both +its global and China endpoints without a key, while DuckDuckGo is unreachable +from mainland-China networks. Provider-native search receives a separate bounded budget of at least 45 seconds instead of an equal share that shrinks as fallbacks are added. Direct Moonshot `kimi-k3` Formula search uses a 180-second floor because its bounded protocol can require several Chat Completions and Fiber steps; the caller's configured search timeout remains available to the -configured/local fallback. The structured search receipt records every hop. +configured/local fallback. The structured search receipt records every hop. When every backend is +unavailable the tool fails closed with an error suggesting API-backed +`[search]` providers. Without an eligible provider-native route, `web_search` uses DuckDuckGo by default and does not require an API key. The DuckDuckGo path keeps a Bing