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
102 changes: 96 additions & 6 deletions crates/openhuman-core/src/search/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -31,6 +56,7 @@ impl TinySearchTool {
spec,
config: Some(config),
exposure: ToolExposure::Direct,
exhausted_for: Mutex::new(None),
}
}

Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique confident

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 UNAVAILABLE result 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 ·

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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high security confident

Latch only errors that prove every provider is unavailable

UNAVAILABLE is a coarse error code, but this function turns any error carrying it into a permanent session-wide latch. A single provider-specific, route-specific, transient, or otherwise recoverable unavailable response will therefore make all search tools return SEARCH_EXHAUSTED_MESSAGE without attempting the module again. Classify exhaustion from the module's explicit all-providers verdict (or include the affected provider/route in the state), rather than equating the generic error code with permanent exhaustion.

[RULE] overbroad-error-classification ·

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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: UNAVAILABLE is what the module hands back, and a finer "all providers, this route" verdict would have to come from tinysearch, which owns provider dispatch. So instead of treating the coarse code as a session-wide fact, it is now recorded against the configuration and the tool it happened on, and it stops answering as soon as either changes.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high critique likely

Latch only errors that prove every provider is unavailable

This treats every error carrying tinysearch.provider_unavailable as proof that no provider can answer, but the error code alone does not establish that all providers for this request were attempted and failed. A single-provider outage or an unavailable fallback can therefore permanently mark the whole tool exhausted and make subsequent calls return the stop message without trying a still-usable provider. Preserve provider-exhaustion information from the module result and latch only that aggregate verdict.

[RULE] error-classification ·

error_code(error) == Some(errors::UNAVAILABLE)
}

#[async_trait]
impl Tool for TinySearchTool {
fn name(&self) -> &str {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium security confident

Read live configuration before applying the exhaustion latch

For tools created by build_search_tools, live_config() returns the cloned spawn-time Arc<Config> rather than loading current configuration. After an unavailable result is cached, adding a key or switching providers therefore leaves the signature unchanged and causes every subsequent call to be refused, despite the comment and the documented recovery contract. Make normal tools resolve the current configuration per call, or otherwise invalidate/update the cached configuration when settings change.

[RULE] stale-configuration-cache ·

// clears an earlier refusal instead of outliving it.
let signature = self.provider_signature(&config);
if self.is_exhausted_for(signature) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium e2e uncertain

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 SEARCH_EXHAUSTED_MESSAGE ("Do not call it again"), the latch that answers a repeat call from the recorded verdict instead of reaching the search module, and the retagging of that verdict from ToolResult::error to ToolResult::failed so the harness does not treat it as retryable. All of it is covered by unit tests that call TinySearchTool::execute directly; nothing drives it through the running system. harness-search-tool-flow.spec.ts covers S3.2/S3.4 only on the happy path, where the mock backend serves results, and the lexical candidate scan shows no e2e file mentioning provider_unavailable, exhaustion or the new refusal text. A test would have to: point the search settings at a configuration whose providers all return tinysearch.provider_unavailable from the mock backend, send a prompt that makes the mock LLM emit web_search_tool (or web_answer_tool) twice in one conversation, and assert the second call is refused with the stop-message and produces no second request to the search module — the exact agent-behaviour #6991 says the hidden run depends on. Without it, a regression that makes the latch never fire or the message revert to a retry hint would pass CI silently.

[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")
Expand Down Expand Up @@ -169,12 +251,20 @@ impl Tool for TinySearchTool {
))
}
Err(error) => {
if exhausts_providers(&error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority medium critique likely

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 UNAVAILABLE response, however, this latch permanently refuses all subsequent calls, so a user who adds a key, restores connectivity, or changes the provider during the session still receives SEARCH_EXHAUSTED_MESSAGE and cannot recover. Do not make this state permanent across live configuration changes; either clear/version the latch when the effective search configuration changes or avoid latching the deferred tool path.

[RULE] stale-session-state ·

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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: provider_signature hashes the tool's role order plus every provider's resolved enabled / usable / key_configured / managed_available / route, and the recorded verdict only answers a later call whose signature matches. Add a key, switch a route, sign in, and the signature moves, so the next call goes through to the module.

the_refusal_answers_only_for_the_configuration_that_failed pins it: the tool records a failure against a key-less config, then the same tool with a Brave key set is not refused.

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))
})
}
}
}
Expand Down
137 changes: 136 additions & 1 deletion crates/openhuman-core/src/search/tools_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ fn classified_errors_become_actionable_messages() {
let balance = user_facing_error("tinysearch.insufficient_balance: 402 from backend");
assert!(balance.contains("balance is too low"));
assert!(user_facing_error("tinysearch.rate_limited: slow down").contains("rate limited"));
assert!(user_facing_error("tinysearch.provider_unavailable: all down").contains("unavailable"));
assert_eq!(
user_facing_error("tinysearch.provider_unavailable: all down"),
SEARCH_EXHAUSTED_MESSAGE
);
assert_eq!(
user_facing_error(
"search ExecuteTool failed: tinysearch.invalid_arguments: urls must not be empty"
Expand Down Expand Up @@ -100,3 +103,135 @@ fn standard_privacy_mode_allows_search_tool_dispatch() {

assert!(local_only_search_block("web_search_tool").is_none());
}

// ---------------------------------------------------------------------------
// #6991: a credential alone makes the managed route look reachable, so a
// deployment that is offline, firewalled, out of balance or holding a dead key
// offers these tools on every turn and fails every call. The old message ended
// "unavailable right now", which reads as "retry later": in the DeepSWE-10 run
// the agent called search three times on one task, then guessed, and the guess
// was what the hidden test rejected.
// ---------------------------------------------------------------------------

fn spec(name: &str) -> ToolSpec {
ToolSpec {
name: name.to_string(),
description: "search".into(),
parameters: serde_json::json!({"type": "object"}),
}
}

/// A config whose managed providers are selected but whose direct keys are
/// absent, which is the shape that offers the tools and cannot serve them.
fn nothing_usable_config() -> Config {
let mut config = Config::default();
config.search.providers = [("brave".to_string(), SearchProviderSettings::direct())]
.into_iter()
.collect();
config
}

#[test]
fn the_unavailable_verdict_tells_the_model_to_stop_rather_than_wait() {
let message = user_facing_error("tinysearch.provider_unavailable: all down");

assert!(
message.contains("Do not call it again"),
"the model must be told to stop, got: {message}"
);
assert!(
!message.contains("right now"),
"nothing the model can do will change the answer, so it must not read as a retry hint: {message}"
);
}

#[test]
fn only_the_exhaustion_verdict_is_treated_as_final() {
// A rate limit clears on its own; a low balance and a rejected argument
// each have a message naming what to change. None of them may latch.
assert!(exhausts_providers(
"tinysearch.provider_unavailable: all down"
));
assert!(!exhausts_providers("tinysearch.rate_limited: slow down"));
assert!(!exhausts_providers("tinysearch.insufficient_balance: 402"));
assert!(!exhausts_providers(
"tinysearch.invalid_arguments: urls must not be empty"
));
assert!(!exhausts_providers("boom"));
}

#[test]
fn a_tool_starts_with_nothing_recorded() {
let config = nothing_usable_config();
let tool = TinySearchTool::recorded(spec("web_search_tool"));

assert!(!tool.is_exhausted_for(tool.provider_signature(&config)));
}

#[test]
fn the_refusal_answers_only_for_the_configuration_that_failed() {
// The contract this module documents is that every call re-reads the live
// config, so a provider or login change is honoured without rebuilding the
// session. A refusal that outlived a config change would break it: the user
// adds a key and search stays dead until the thread is abandoned.
let dead = nothing_usable_config();
let tool = TinySearchTool::recorded(spec("web_search_tool"));
let dead_signature = tool.provider_signature(&dead);

tool.mark_exhausted_for(dead_signature);
assert!(tool.is_exhausted_for(dead_signature));

let mut fixed = dead.clone();
fixed.search.brave.api_key = Some("added-after-the-failure".into());

assert_ne!(
tool.provider_signature(&fixed),
dead_signature,
"adding a provider key must change what the tool sees"
);
assert!(
!tool.is_exhausted_for(tool.provider_signature(&fixed)),
"adding a key must clear the refusal"
);
}

#[test]
fn one_tools_dead_providers_do_not_answer_for_another() {
// `build_search_tools` can offer provider-specific tools beside the role
// tools, and the roles draw on different provider orders, so a shared
// verdict would let one provider's outage disable unrelated ones.
let config = nothing_usable_config();
let failed = TinySearchTool::recorded(spec("web_search_tool"));
let other = TinySearchTool::recorded(spec("web_answer_tool"));

failed.mark_exhausted_for(failed.provider_signature(&config));

assert!(!other.is_exhausted_for(other.provider_signature(&config)));
}

#[tokio::test]
async fn a_refused_call_does_not_reach_the_module() {
// The second call under the same configuration is answered from the
// recorded verdict, so a dead search costs one module round trip per
// configuration rather than one per call.
let config = nothing_usable_config();
let tool = TinySearchTool::new(std::sync::Arc::new(config.clone()), spec("web_search_tool"));
tool.mark_exhausted_for(tool.provider_signature(&config));

let result = tool
.execute(serde_json::json!({"query": "anything"}))
.await
.expect("a refusal is a reported failure, not a tool error");

assert!(result.is_error);
assert_eq!(
result.error_kind,
Some(tinytools::ToolErrorKind::Failed),
"the failure is permanent for this configuration, so it must not be tagged retryable"
);
assert!(
result.text().contains("Do not call it again"),
"{:?}",
result.text()
);
}
Loading