Skip to content

Commit eb2bba8

Browse files
committed
fix(web): enforce native search constraints before fallback
Apply domain constraints before accepting a provider-native attempt, keep generated answers behind the surviving citation boundary, and preserve an independent configured-search timeout.\n\nRefs Hmbown#5681. Signed-off-by: hexin <372726039@qq.com>
1 parent 66d1f0d commit eb2bba8

3 files changed

Lines changed: 311 additions & 66 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8686

8787
### Changed
8888

89+
- Provider-native web search now applies domain constraints before accepting an
90+
attempt, discards generated answers when returned citations violate those
91+
constraints, and preserves the caller's configured/local timeout as an
92+
independent fallback budget (#5681).
8993
- Idle session metrics omit zero facts (`0 turns`, `LLM 0s`) until the
9094
runtime has evidence. Working chrome says `in the current` instead of a
9195
generic `working`.

crates/tui/src/tools/web/backend.rs

Lines changed: 155 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,21 @@ impl<'a> SearchBackendChain<'a> {
138138
query: &SearchQuery,
139139
deadline: Instant,
140140
first_attempt_budget: Option<Duration>,
141+
fallback_budget_after_first: Option<Duration>,
141142
) -> Result<ChainedSearch, ToolError> {
142143
let backends = self
143144
.backends
144145
.iter()
145146
.map(|backend| backend.as_ref())
146147
.collect::<Vec<_>>();
147-
run_backend_chain(&backends, query, deadline, first_attempt_budget).await
148+
run_backend_chain(
149+
&backends,
150+
query,
151+
deadline,
152+
first_attempt_budget,
153+
fallback_budget_after_first,
154+
)
155+
.await
148156
}
149157
}
150158

@@ -165,14 +173,20 @@ const fn provider_native_is_available(capability_supported: bool, client_present
165173
async fn run_backend_chain(
166174
backends: &[&dyn SearchBackend],
167175
query: &SearchQuery,
168-
deadline: Instant,
176+
mut deadline: Instant,
169177
first_attempt_budget: Option<Duration>,
178+
fallback_budget_after_first: Option<Duration>,
170179
) -> Result<ChainedSearch, ToolError> {
171180
let mut degraded = Vec::new();
172181
let mut last_empty = None;
173182
let mut attempted = Vec::new();
174183

175184
for (index, backend) in backends.iter().enumerate() {
185+
if index == 1
186+
&& let Some(fallback_budget) = fallback_budget_after_first
187+
{
188+
deadline = Instant::now() + fallback_budget.max(Duration::from_millis(1));
189+
}
176190
let remaining = deadline.saturating_duration_since(Instant::now());
177191
if remaining.is_zero() {
178192
break;
@@ -208,20 +222,19 @@ async fn run_backend_chain(
208222
.and_then(std::convert::identity);
209223

210224
match result {
211-
Ok(mut raw) if !raw.results.is_empty() => {
212-
degraded.append(&mut raw.degraded);
213-
raw.degraded = degraded;
214-
return Ok(ChainedSearch {
215-
raw,
216-
capabilities: backend.capabilities(),
217-
});
218-
}
219225
Ok(mut raw) => {
226+
let capabilities = backend.capabilities();
227+
crate::tools::web_search::apply_domain_constraints(query, capabilities, &mut raw);
228+
if !raw.results.is_empty() {
229+
degraded.append(&mut raw.degraded);
230+
raw.degraded = degraded;
231+
return Ok(ChainedSearch { raw, capabilities });
232+
}
220233
degraded.push(DegradedReason::NoUsableResults {
221234
backend: backend_id,
222235
});
223236
degraded.append(&mut raw.degraded);
224-
last_empty = Some((raw, backend.capabilities()));
237+
last_empty = Some((raw, capabilities));
225238
}
226239
Err(error) if is_fail_closed(&error) => return Err(error),
227240
Err(error) if backends.len() == 1 => return Err(error),
@@ -403,6 +416,12 @@ mod tests {
403416
result: Result<Vec<super::super::contract::SearchResult>, ToolError>,
404417
}
405418

419+
struct DeadlineBackend {
420+
id: BackendId,
421+
observed_budget: Arc<Mutex<Option<Duration>>>,
422+
delay: Duration,
423+
}
424+
406425
#[async_trait]
407426
impl SearchBackend for FakeBackend {
408427
fn id(&self) -> BackendId {
@@ -429,6 +448,35 @@ mod tests {
429448
}
430449
}
431450

451+
#[async_trait]
452+
impl SearchBackend for DeadlineBackend {
453+
fn id(&self) -> BackendId {
454+
self.id
455+
}
456+
457+
fn capabilities(&self) -> QueryCapabilities {
458+
QueryCapabilities::count_only()
459+
}
460+
461+
async fn search(
462+
&self,
463+
_query: &SearchQuery,
464+
deadline: Instant,
465+
) -> Result<BackendSearch, ToolError> {
466+
*self.observed_budget.lock().expect("budget lock") =
467+
Some(deadline.saturating_duration_since(Instant::now()));
468+
tokio::time::sleep(self.delay).await;
469+
Ok(BackendSearch {
470+
backend: self.id,
471+
source: self.id.as_str().to_string(),
472+
backend_detail: None,
473+
results: vec![result()],
474+
degraded: Vec::new(),
475+
note: None,
476+
})
477+
}
478+
}
479+
432480
fn query() -> SearchQuery {
433481
SearchQuery::new("bounded chain".to_string(), 5, None, Vec::new(), None)
434482
}
@@ -495,6 +543,7 @@ mod tests {
495543
&query(),
496544
Instant::now() + Duration::from_secs(1),
497545
None,
546+
None,
498547
)
499548
.await
500549
.expect("fallback should succeed");
@@ -534,6 +583,7 @@ mod tests {
534583
&query(),
535584
Instant::now() + Duration::from_secs(1),
536585
None,
586+
None,
537587
)
538588
.await
539589
.expect("final scrape fallback should succeed");
@@ -562,41 +612,11 @@ mod tests {
562612

563613
#[tokio::test]
564614
async fn first_attempt_budget_overrides_the_default_fair_share() {
565-
struct DeadlineBackend {
566-
observed_budget: Arc<Mutex<Option<Duration>>>,
567-
}
568-
569-
#[async_trait]
570-
impl SearchBackend for DeadlineBackend {
571-
fn id(&self) -> BackendId {
572-
BackendId::Volcengine
573-
}
574-
575-
fn capabilities(&self) -> QueryCapabilities {
576-
QueryCapabilities::count_only()
577-
}
578-
579-
async fn search(
580-
&self,
581-
_query: &SearchQuery,
582-
deadline: Instant,
583-
) -> Result<BackendSearch, ToolError> {
584-
*self.observed_budget.lock().expect("budget lock") =
585-
Some(deadline.saturating_duration_since(Instant::now()));
586-
Ok(BackendSearch {
587-
backend: BackendId::Volcengine,
588-
source: "volcengine".to_string(),
589-
backend_detail: None,
590-
results: vec![result()],
591-
degraded: Vec::new(),
592-
note: None,
593-
})
594-
}
595-
}
596-
597615
let observed_budget = Arc::new(Mutex::new(None));
598616
let volcengine = DeadlineBackend {
617+
id: BackendId::Volcengine,
599618
observed_budget: Arc::clone(&observed_budget),
619+
delay: Duration::ZERO,
600620
};
601621
let fallback = FakeBackend {
602622
id: BackendId::DuckDuckGo,
@@ -608,6 +628,7 @@ mod tests {
608628
&query(),
609629
Instant::now() + Duration::from_secs(2),
610630
Some(first_attempt_budget),
631+
None,
611632
)
612633
.await
613634
.expect("the first backend should complete inside its dedicated budget");
@@ -624,6 +645,37 @@ mod tests {
624645
assert!(observed <= first_attempt_budget);
625646
}
626647

648+
#[tokio::test]
649+
async fn provider_native_unused_budget_does_not_extend_fallback_deadline() {
650+
let native = FakeBackend {
651+
id: BackendId::ProviderNative,
652+
result: Err(ToolError::execution_failed("native unavailable")),
653+
};
654+
let observed_budget = Arc::new(Mutex::new(None));
655+
let fallback = DeadlineBackend {
656+
id: BackendId::DuckDuckGo,
657+
observed_budget: Arc::clone(&observed_budget),
658+
delay: Duration::from_millis(200),
659+
};
660+
let fallback_budget = Duration::from_millis(30);
661+
let error = run_backend_chain(
662+
&[&native, &fallback],
663+
&query(),
664+
Instant::now() + Duration::from_millis(500),
665+
Some(Duration::from_millis(500)),
666+
Some(fallback_budget),
667+
)
668+
.await
669+
.expect_err("blocking fallback must stop at its own budget");
670+
671+
assert!(matches!(error, ToolError::NotAvailable { .. }));
672+
let observed = observed_budget
673+
.lock()
674+
.expect("budget lock")
675+
.expect("fallback must observe a deadline");
676+
assert!(observed <= fallback_budget);
677+
}
678+
627679
#[tokio::test]
628680
async fn all_unavailable_returns_typed_error_with_backend_ids_only() {
629681
let private_error = "secret provider response";
@@ -640,6 +692,7 @@ mod tests {
640692
&query(),
641693
Instant::now() + Duration::from_secs(1),
642694
None,
695+
None,
643696
)
644697
.await
645698
.expect_err("all-down chain must fail");
@@ -689,6 +742,7 @@ mod tests {
689742
&query(),
690743
Instant::now() + Duration::from_secs(1),
691744
None,
745+
None,
692746
)
693747
.await
694748
.expect_err("policy error must fail closed");
@@ -712,6 +766,7 @@ mod tests {
712766
&query(),
713767
Instant::now() + Duration::from_secs(1),
714768
None,
769+
None,
715770
)
716771
.await
717772
.expect("empty API response should fall back");
@@ -729,4 +784,61 @@ mod tests {
729784
]
730785
);
731786
}
787+
788+
#[tokio::test]
789+
async fn domain_filtered_results_fall_back_before_chain_success() {
790+
let native = FakeBackend {
791+
id: BackendId::ProviderNative,
792+
result: Ok(vec![SearchResult::new(
793+
1,
794+
"Outside source".to_string(),
795+
"https://outside.test/result".to_string(),
796+
None,
797+
None,
798+
)]),
799+
};
800+
let configured = FakeBackend {
801+
id: BackendId::Searxng,
802+
result: Ok(vec![SearchResult::new(
803+
1,
804+
"Matching source".to_string(),
805+
"https://docs.rs/example/latest/example/".to_string(),
806+
None,
807+
None,
808+
)]),
809+
};
810+
let constrained = SearchQuery::new(
811+
"example docs".to_string(),
812+
5,
813+
None,
814+
vec!["docs.rs".to_string()],
815+
None,
816+
);
817+
818+
let response = run_backend_chain(
819+
&[&native, &configured],
820+
&constrained,
821+
Instant::now() + Duration::from_secs(1),
822+
None,
823+
None,
824+
)
825+
.await
826+
.expect("configured backend should satisfy the domain constraint");
827+
828+
assert_eq!(response.raw.backend, BackendId::Searxng);
829+
assert_eq!(response.raw.results.len(), 1);
830+
assert!(response.raw.degraded.iter().any(|reason| matches!(
831+
reason,
832+
DegradedReason::NoUsableResults {
833+
backend: BackendId::ProviderNative
834+
}
835+
)));
836+
assert!(response.raw.degraded.iter().any(|reason| matches!(
837+
reason,
838+
DegradedReason::BackendFallback {
839+
from: BackendId::ProviderNative,
840+
to: BackendId::Searxng
841+
}
842+
)));
843+
}
732844
}

0 commit comments

Comments
 (0)