From 0c574bd9ea19f9328b2916e87d8902280b16856d Mon Sep 17 00:00:00 2001 From: Aqil Aziz Date: Wed, 20 May 2026 09:41:05 +0700 Subject: [PATCH] fix(search): let web_search_tool use direct search API (#2263) Co-authored-by: Steven Enamakel --- .../tools/impl/network/web_search.rs | 97 +++++++++++++++++-- src/openhuman/tools/ops.rs | 38 +++++++- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/src/openhuman/tools/impl/network/web_search.rs b/src/openhuman/tools/impl/network/web_search.rs index e6c31a99f..280c20cf4 100644 --- a/src/openhuman/tools/impl/network/web_search.rs +++ b/src/openhuman/tools/impl/network/web_search.rs @@ -1,14 +1,15 @@ use crate::openhuman::integrations::parallel::{SearchResponse, SearchResultItem}; -use crate::openhuman::integrations::IntegrationClient; +use crate::openhuman::integrations::{IntegrationClient, SeltzSearchTool}; use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult}; use async_trait::async_trait; -use serde_json::json; +use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::sync::Arc; /// Web search tool backed by the server-side Parallel integration proxy. pub struct WebSearchTool { client: Option>, + direct_search: Option, max_results: usize, timeout_secs: u64, } @@ -21,11 +22,17 @@ impl WebSearchTool { ) -> Self { Self { client, + direct_search: None, max_results: max_results.clamp(1, 10), timeout_secs: timeout_secs.max(1), } } + pub fn with_direct_search(mut self, direct_search: Option) -> Self { + self.direct_search = direct_search; + self + } + fn parse_parallel_results( &self, results: &[SearchResultItem], @@ -107,7 +114,7 @@ impl Tool for WebSearchTool { } fn description(&self) -> &str { - "Search the web for information via the backend search proxy. Returns relevant search results with titles, URLs, and descriptions." + "Search the web for information via a configured direct search API or the backend search proxy. Returns relevant search results with titles, URLs, and descriptions." } fn parameters_schema(&self) -> serde_json::Value { @@ -145,6 +152,23 @@ impl Tool for WebSearchTool { if query.trim().is_empty() { anyhow::bail!("Search query cannot be empty"); } + let query = query.trim().to_string(); + + if let Some(direct_search) = &self.direct_search { + tracing::debug!( + query_len = query.chars().count(), + max_results = self.max_results, + timeout_secs = self.timeout_secs, + "[web_search] direct Seltz search" + ); + let mut normalized_args = args; + if let Some(obj) = normalized_args.as_object_mut() { + obj.insert("query".to_string(), Value::String(query.clone())); + } + return direct_search + .execute_with_options(normalized_args, options) + .await; + } let client = self.client.as_ref().ok_or_else(|| { anyhow::anyhow!( @@ -168,8 +192,8 @@ impl Tool for WebSearchTool { // per-mode deadlines drive timing on the upstream side. let _ = self.timeout_secs; let body = json!({ - "objective": query, - "searchQueries": [query], + "objective": query.clone(), + "searchQueries": [query.clone()], "mode": "fast", "excerpts": { "maxResults": self.max_results, @@ -181,9 +205,9 @@ impl Tool for WebSearchTool { .post::("/agent-integrations/parallel/search", &body) .await?; - let mut result = ToolResult::success(self.parse_parallel_results(&resp.results, query)?); + let mut result = ToolResult::success(self.parse_parallel_results(&resp.results, &query)?); if options.prefer_markdown { - result.markdown_formatted = Some(self.render_results_markdown(&resp.results, query)); + result.markdown_formatted = Some(self.render_results_markdown(&resp.results, &query)); } Ok(result) } @@ -192,7 +216,7 @@ impl Tool for WebSearchTool { #[cfg(test)] mod tests { use super::*; - use axum::{extract::State, routing::post, Json, Router}; + use axum::{extract::State, http::HeaderMap, routing::post, Json, Router}; use serde_json::Value; use std::sync::atomic::{AtomicBool, Ordering}; @@ -397,4 +421,61 @@ mod tests { .output() .contains("Rendered excerpt from backend search.")); } + + #[tokio::test] + async fn test_execute_uses_direct_search_api_when_configured() { + #[derive(Clone)] + struct MockState { + called: Arc, + } + + let state = MockState { + called: Arc::new(AtomicBool::new(false)), + }; + let called = Arc::clone(&state.called); + let app = Router::new() + .route( + "/search", + post( + |State(state): State, + headers: HeaderMap, + Json(body): Json| async move { + state.called.store(true, Ordering::SeqCst); + assert_eq!( + headers.get("x-api-key").and_then(|v| v.to_str().ok()), + Some("test-key") + ); + assert_eq!(body["query"], "direct search"); + Json(json!({ + "documents": [ + { + "url": "https://example.com/direct", + "title": "Direct Search Result", + "content": "Rendered excerpt from direct search.", + "published_date": "2026-04-21" + } + ] + })) + }, + ), + ) + .with_state(state); + + let base_url = start_mock_backend(app).await; + let result = WebSearchTool::new(None, 5, 15) + .with_direct_search(Some(SeltzSearchTool::new( + Some("test-key".into()), + Some(base_url), + 5, + 15, + ))) + .execute(json!({"query": "direct search"})) + .await + .expect("execute() should return rendered direct search results"); + + assert!(called.load(Ordering::SeqCst)); + assert!(result.output().contains("via Seltz")); + assert!(result.output().contains("Direct Search Result")); + assert!(result.output().contains("https://example.com/direct")); + } } diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 8dc83c00e..37a65c3ec 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -291,11 +291,39 @@ pub fn all_tools_with_runtime( // knobs still come from `config.web_search`, but there is no // enable flag: every session needs research as a baseline // capability. - tools.push(Box::new(WebSearchTool::new( - crate::openhuman::integrations::build_client(root_config), - root_config.web_search.max_results, - root_config.web_search.timeout_secs, - ))); + let seltz_has_api_key = root_config + .seltz + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()); + let direct_seltz_for_web_search = if root_config.seltz.enabled && seltz_has_api_key { + tracing::debug!( + max_results = root_config.seltz.max_results, + timeout_secs = root_config.seltz.timeout_secs, + "[web_search] direct Seltz routing enabled" + ); + Some(crate::openhuman::integrations::SeltzSearchTool::new( + root_config.seltz.api_key.clone(), + root_config.seltz.api_url.clone(), + root_config.seltz.max_results, + root_config.seltz.timeout_secs, + )) + } else { + tracing::debug!( + seltz_enabled = root_config.seltz.enabled, + has_api_key = seltz_has_api_key, + "[web_search] direct Seltz routing disabled; backend proxy path remains" + ); + None + }; + tools.push(Box::new( + WebSearchTool::new( + crate::openhuman::integrations::build_client(root_config), + root_config.web_search.max_results, + root_config.web_search.timeout_secs, + ) + .with_direct_search(direct_seltz_for_web_search), + )); // Seltz — direct-API web search, gated on `seltz.enabled` (auto-set // when `SELTZ_API_KEY` env var is present). Unlike the backend-proxied