Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/tui/src/config/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
52 changes: 50 additions & 2 deletions crates/tui/src/tools/web/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -255,7 +260,8 @@ async fn run_backend_chain(
.collect::<Vec<_>>()
.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"
)))
}

Expand Down Expand Up @@ -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<BackendId> = 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));
Expand Down Expand Up @@ -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"));
}
Expand Down
10 changes: 7 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading