Repository navigation
fix(search): stop re-offering search a session cannot use #7004
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,9 @@ | |
| //! through `modules::search::execute_tool`, so a provider or login change is | ||
| //! honoured on the next call without rebuilding the session. | ||
|
|
||
| use std::sync::Arc; | ||
| use std::collections::hash_map::DefaultHasher; | ||
| use std::hash::{Hash, Hasher}; | ||
| use std::sync::{Arc, Mutex}; | ||
|
|
||
| use async_trait::async_trait; | ||
| use serde_json::Value; | ||
|
|
@@ -17,12 +19,35 @@ use crate::config::Config; | |
|
|
||
| /// One TinySearch tool (a role tool such as `web_search_tool`, or a provider | ||
| /// tool in `all_tools` presentation). | ||
| /// What a call reports once every provider has already answered | ||
| /// "unavailable" in this session. It tells the model to stop rather than to | ||
| /// wait, because nothing about the session will change the answer (#6991). | ||
| pub const SEARCH_EXHAUSTED_MESSAGE: &str = | ||
| "Web search is not available with this setup: no provider could answer. Do not call \ | ||
| it again \u{2014} answer from the material you already have."; | ||
|
|
||
| pub struct TinySearchTool { | ||
| spec: ToolSpec, | ||
| /// Spawn-time config. `None` for a deferred instance rebuilt from a | ||
| /// recorded transcript, which resolves the live config per call. | ||
| config: Option<Arc<Config>>, | ||
| exposure: ToolExposure, | ||
| /// The provider configuration a call last found nothing usable under. | ||
| /// | ||
| /// A credential alone makes the managed route look reachable | ||
| /// (`providers::backend_credential_available`), so a deployment that is | ||
| /// offline, firewalled, out of balance or holding a dead key offers this | ||
| /// tool on every turn and fails every call. The agent then spends turns on | ||
| /// a tool that cannot work, at the moment it is least sure what to do. | ||
| /// | ||
| /// Keyed by configuration rather than latched outright, because this | ||
| /// module's contract is that every call re-reads the live config so a | ||
| /// provider or login change is honoured without rebuilding the session. A | ||
| /// call whose signature differs from the recorded one tries again; only a | ||
| /// repeat under the same configuration is refused. The signature is this | ||
| /// tool's own view — its role order and the resolved providers — so one | ||
| /// tool's dead providers never answer for another's. | ||
| exhausted_for: Mutex<Option<u64>>, | ||
| } | ||
|
|
||
| impl TinySearchTool { | ||
|
|
@@ -31,6 +56,7 @@ impl TinySearchTool { | |
| spec, | ||
| config: Some(config), | ||
| exposure: ToolExposure::Direct, | ||
| exhausted_for: Mutex::new(None), | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -41,7 +67,46 @@ impl TinySearchTool { | |
| spec, | ||
| config: None, | ||
| exposure: ToolExposure::Direct, | ||
| exhausted_for: Mutex::new(None), | ||
| } | ||
| } | ||
|
|
||
| /// Whether this tool already found nothing usable under `signature`. | ||
| pub(crate) fn is_exhausted_for(&self, signature: u64) -> bool { | ||
| self.exhausted_for | ||
| .lock() | ||
| .map(|recorded| *recorded == Some(signature)) | ||
| .unwrap_or(false) | ||
| } | ||
|
|
||
| /// Record that no provider could answer under `signature`. Replaces any | ||
| /// earlier one, so the refusal always describes the current configuration. | ||
| pub(crate) fn mark_exhausted_for(&self, signature: u64) { | ||
| if let Ok(mut recorded) = self.exhausted_for.lock() { | ||
| *recorded = Some(signature); | ||
| } | ||
| } | ||
|
|
||
| /// What this tool's providers look like right now: the order its role | ||
| /// draws from, and every provider's resolved reachability. Two calls agree | ||
| /// only while nothing a user could change — a key, a route, a provider | ||
| /// selection, a login — has moved. | ||
| pub(crate) fn provider_signature(&self, config: &Config) -> u64 { | ||
| let mut hasher = DefaultHasher::new(); | ||
| if let Some(role) = tinysearch_bus::role_for_tool(&self.spec.name) { | ||
| for provider in super::providers::role_order(config, role) { | ||
| provider.hash(&mut hasher); | ||
| } | ||
| } | ||
| for provider in super::providers::resolve(config) { | ||
| provider.id.hash(&mut hasher); | ||
| provider.enabled.hash(&mut hasher); | ||
| provider.usable.hash(&mut hasher); | ||
| provider.key_configured.hash(&mut hasher); | ||
| provider.managed_available.hash(&mut hasher); | ||
| matches!(provider.route, crate::config::SearchRoute::Managed).hash(&mut hasher); | ||
| } | ||
| hasher.finish() | ||
| } | ||
|
|
||
| pub fn spec(&self) -> &ToolSpec { | ||
|
|
@@ -71,10 +136,7 @@ pub fn user_facing_error(error: &str) -> String { | |
| Some(code) if code == errors::RATE_LIMITED => { | ||
| "Web search is rate limited right now. Wait a moment and try again.".to_string() | ||
| } | ||
| Some(code) if code == errors::UNAVAILABLE => { | ||
| "Every configured search provider for this request is unavailable right now." | ||
| .to_string() | ||
| } | ||
| Some(code) if code == errors::UNAVAILABLE => SEARCH_EXHAUSTED_MESSAGE.to_string(), | ||
| Some(code) if code == errors::INVALID_ARGUMENTS => { | ||
| let marker = format!("{}{code}: ", errors::PREFIX); | ||
| let detail = error | ||
|
|
@@ -92,6 +154,16 @@ pub fn error_code(error: &str) -> Option<&'static str> { | |
| errors::code_of(error) | ||
| } | ||
|
|
||
| /// Whether this failure means no provider can answer for the rest of the | ||
| /// session, rather than something a later call could get past. | ||
| /// | ||
| /// Only the "every provider is unavailable" verdict qualifies. A rate limit | ||
| /// clears on its own, and a low balance or a rejected argument has its own | ||
| /// message telling the caller what to change, so neither latches. | ||
| pub(crate) fn exhausts_providers(error: &str) -> bool { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Latch only errors that prove every provider is unavailable
[RULE] overbroad-error-classification ·
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 4726dd8, though by narrowing the scope rather than by reclassifying the code. I can't classify exhaustion more precisely from here: What that buys for the transient case you describe: the verdict no longer survives any configuration change, and it never applies to a second tool. A transient outage under an unchanged configuration still costs the rest of that tool's calls in the session, which is the trade the issue asked for ("make the failure final for the session"). If you would rather it expire on time instead, say so and I will add that instead — it is a small change, but it is a different promise and I did not want to pick it unilaterally. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Latch only errors that prove every provider is unavailable This treats every error carrying [RULE] error-classification · |
||
| error_code(error) == Some(errors::UNAVAILABLE) | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl Tool for TinySearchTool { | ||
| fn name(&self) -> &str { | ||
|
|
@@ -133,6 +205,16 @@ impl Tool for TinySearchTool { | |
| options: ToolCallOptions, | ||
| ) -> anyhow::Result<ToolResult> { | ||
| let config = self.live_config().await?; | ||
| // Re-read per call, so a key added or a provider switched mid-session | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Read live configuration before applying the exhaustion latch For tools created by [RULE] stale-configuration-cache · |
||
| // clears an earlier refusal instead of outliving it. | ||
| let signature = self.provider_signature(&config); | ||
| if self.is_exhausted_for(signature) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drive the search-exhaustion refusal through the agent harness end to end This change's external surface is what the model sees when search is dead: the replacement of the old "unavailable right now" verdict with [RULE] e2e-uncovered · |
||
| tracing::debug!( | ||
| tool = %self.spec.name, | ||
| "[search][tool] refused: no provider answered under this configuration" | ||
| ); | ||
| return Ok(ToolResult::failed(SEARCH_EXHAUSTED_MESSAGE.to_string())); | ||
| } | ||
| let subject = super::render::subject(&args); | ||
| let max_results = args | ||
| .get("max_results") | ||
|
|
@@ -169,12 +251,20 @@ impl Tool for TinySearchTool { | |
| )) | ||
| } | ||
| Err(error) => { | ||
| if exhausts_providers(&error) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Allow provider configuration changes to recover search Deferred tools resolve live configuration on each call, and the module contract says provider or login changes are honored without rebuilding the session. After any [RULE] stale-session-state ·
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 4726dd8. This was the right call and the contract I broke is documented at the top of this very file: every call re-reads the live config "so a provider or login change is honoured on the next call without rebuilding the session". A permanent latch made that false. The refusal is now keyed to a provider signature rather than latched:
|
||
| self.mark_exhausted_for(signature); | ||
| } | ||
| tracing::warn!( | ||
| tool = %self.spec.name, | ||
| code = error_code(&error).unwrap_or("unclassified"), | ||
| exhausted = self.is_exhausted_for(signature), | ||
| "[search][tool] failed" | ||
| ); | ||
| Ok(ToolResult::error(user_facing_error(&error))) | ||
| Ok(if exhausts_providers(&error) { | ||
| ToolResult::failed(user_facing_error(&error)) | ||
| } else { | ||
| ToolResult::error(user_facing_error(&error)) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Detect replacement of an already configured provider key
The signature records only whether a key is configured, not which key is configured. If a dead or revoked key is replaced with a valid key, both configurations produce the same signature, so the prior
UNAVAILABLEresult remains latched and search continues to be refused. Include a non-secret fingerprint of the effective credential, or otherwise invalidate the latch when the credential value changes.[RULE] stale-cache ·