mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(search): let web_search_tool use direct search API (#2263)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
094d482210
commit
0c574bd9ea
@@ -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<Arc<IntegrationClient>>,
|
||||
direct_search: Option<SeltzSearchTool>,
|
||||
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<SeltzSearchTool>) -> 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::<SearchResponse>("/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<AtomicBool>,
|
||||
}
|
||||
|
||||
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<MockState>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<Value>| 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user