feat(search): unified search-engine selector + Brave Search (#2516)

This commit is contained in:
Steven Enamakel
2026-05-23 00:33:53 -07:00
committed by GitHub
parent f29a20fd0f
commit 6a0903035f
9 changed files with 1101 additions and 107 deletions
+7 -5
View File
@@ -35,11 +35,13 @@ pub use schema::{
OrchestratorModelConfig, PolymarketClobCredentials, PolymarketConfig, ProxyConfig, ProxyScope,
ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend,
SandboxConfig, SchedulerConfig, SchedulerGateConfig, SchedulerGateMode,
ScreenIntelligenceConfig, SearxngConfig, SecretsConfig, SecurityConfig, SlackConfig,
StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TeamModelConfig,
TelegramConfig, UpdateConfig, UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig,
WebSearchConfig, WebhookConfig, DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1,
MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1,
ScreenIntelligenceConfig, SearchConfig, SearchEngine, SearchEngineCredentials, SearxngConfig,
SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig, UpdateConfig,
UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig,
DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1,
MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED,
SEARCH_ENGINE_PARALLEL,
};
pub use schema::{
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
+56
View File
@@ -416,6 +416,18 @@ fn decrypt_config_secrets(config: &mut Config, openhuman_dir: &Path) -> Result<(
decrypt_optional_secret(&store, &mut config.api_key, "api_key")?;
// Search engines: BYO API keys for Parallel and Brave.
decrypt_optional_secret(
&store,
&mut config.search.parallel.api_key,
"search.parallel.api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.search.brave.api_key,
"search.brave.api_key",
)?;
// Channels: decrypt every optional secret field.
//
// For required (non-Option<String>) secret fields we wrap the value in a
@@ -513,6 +525,17 @@ fn encrypt_config_secrets(config: &mut Config) -> Result<()> {
encrypt_optional_secret(&store, &mut config.api_key, "api_key")?;
encrypt_optional_secret(
&store,
&mut config.search.parallel.api_key,
"search.parallel.api_key",
)?;
encrypt_optional_secret(
&store,
&mut config.search.brave.api_key,
"search.brave.api_key",
)?;
let ch = &mut config.channels_config;
if let Some(ref mut tg) = ch.telegram {
let mut tok = Some(tg.bot_token.clone());
@@ -1415,6 +1438,39 @@ impl Config {
}
}
// Unified search engine selector. `OPENHUMAN_SEARCH_ENGINE` picks
// the active engine; per-engine API keys auto-route to BYO once set.
if let Some(engine) = env.get_any(&["OPENHUMAN_SEARCH_ENGINE", "SEARCH_ENGINE"]) {
let engine = engine.trim().to_ascii_lowercase();
if !engine.is_empty() {
self.search.engine = engine;
}
}
if let Some(key) = env.get_any(&["OPENHUMAN_PARALLEL_API_KEY", "PARALLEL_API_KEY"]) {
if !key.trim().is_empty() {
self.search.parallel.api_key = Some(key);
}
}
if let Some(key) = env.get_any(&["OPENHUMAN_BRAVE_API_KEY", "BRAVE_API_KEY"]) {
if !key.trim().is_empty() {
self.search.brave.api_key = Some(key);
}
}
if let Some(max) = env.get_any(&["OPENHUMAN_SEARCH_MAX_RESULTS", "SEARCH_MAX_RESULTS"]) {
if let Ok(n) = max.parse::<usize>() {
if (1..=20).contains(&n) {
self.search.max_results = n;
}
}
}
if let Some(t) = env.get_any(&["OPENHUMAN_SEARCH_TIMEOUT_SECS", "SEARCH_TIMEOUT_SECS"]) {
if let Ok(n) = t.parse::<u64>() {
if n > 0 {
self.search.timeout_secs = n;
}
}
}
// `OPENHUMAN_WEB_SEARCH_ENABLED` is intentionally ignored —
// web search is unconditionally registered in the tool set.
// Only the result/timeout budget knobs remain environment-configurable.
+4 -2
View File
@@ -75,8 +75,10 @@ pub use tools::{
BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig,
GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, McpAuthConfig,
McpClientConfig, McpClientIdentityConfig, McpServerConfig, MultimodalConfig,
PolymarketClobCredentials, PolymarketConfig, SearxngConfig, SecretsConfig, SeltzConfig,
WebSearchConfig, COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT,
PolymarketClobCredentials, PolymarketConfig, SearchConfig, SearchEngine,
SearchEngineCredentials, SearxngConfig, SecretsConfig, SeltzConfig, WebSearchConfig,
COMPOSIO_MODE_BACKEND, COMPOSIO_MODE_DIRECT, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED,
SEARCH_ENGINE_PARALLEL,
};
pub use update::{UpdateConfig, UpdateRestartStrategy};
mod voice_server;
+174
View File
@@ -491,6 +491,180 @@ impl Default for WebSearchConfig {
}
}
// ── Search engines ──────────────────────────────────────────────────
//
// Unified search-engine selector. Only one engine is active at a time
// (mirrors the LLM-provider API-key flow). The active engine governs
// which tools are registered: `managed` → backend-proxied `web_search`;
// `parallel` → direct Parallel API tools (search/extract/chat/research/
// enrich/dataset); `brave` → direct Brave Search tools (web/news/
// images/videos).
pub const SEARCH_ENGINE_MANAGED: &str = "managed";
pub const SEARCH_ENGINE_PARALLEL: &str = "parallel";
pub const SEARCH_ENGINE_BRAVE: &str = "brave";
fn default_search_engine() -> String {
SEARCH_ENGINE_MANAGED.into()
}
fn default_search_max_results() -> usize {
5
}
fn default_search_timeout_secs() -> u64 {
15
}
/// Credentials for a BYO search engine. Mirrors the LLM provider API-
/// key shape — a simple `Option<String>` that is considered configured
/// iff the trimmed value is non-empty.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SearchEngineCredentials {
#[serde(default)]
pub api_key: Option<String>,
}
impl SearchEngineCredentials {
pub fn has_key(&self) -> bool {
self.api_key
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
pub fn key(&self) -> Option<&str> {
self.api_key.as_deref().and_then(|s| {
let t = s.trim();
if t.is_empty() {
None
} else {
Some(t)
}
})
}
}
/// Unified search-engine configuration. Exactly one engine drives tool
/// registration at a time. `managed` is the backend-proxied default and
/// requires no key; `parallel` and `brave` are BYO and require their
/// own API key in the matching sub-block.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SearchConfig {
/// Active search engine. One of [`SEARCH_ENGINE_MANAGED`],
/// [`SEARCH_ENGINE_PARALLEL`], [`SEARCH_ENGINE_BRAVE`]. Unknown
/// values fall back to managed at registration time.
#[serde(default = "default_search_engine")]
pub engine: String,
/// Max results per query (120, default 5).
#[serde(default = "default_search_max_results")]
pub max_results: usize,
/// Per-request timeout in seconds (default 15).
#[serde(default = "default_search_timeout_secs")]
pub timeout_secs: u64,
/// Parallel API credentials (used when `engine = "parallel"`).
#[serde(default)]
pub parallel: SearchEngineCredentials,
/// Brave Search credentials (used when `engine = "brave"`).
#[serde(default)]
pub brave: SearchEngineCredentials,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
engine: default_search_engine(),
max_results: default_search_max_results(),
timeout_secs: default_search_timeout_secs(),
parallel: SearchEngineCredentials::default(),
brave: SearchEngineCredentials::default(),
}
}
}
/// Normalized search-engine enum used at tool-registration time. Falls
/// back to [`SearchEngine::Managed`] for unknown strings and for BYO
/// engines that have no API key configured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEngine {
Managed,
Parallel,
Brave,
}
impl SearchConfig {
/// Resolve the *effective* engine after gating on API-key
/// availability. A BYO engine without a key silently falls back to
/// managed so the agent never ends up with zero search tools — the
/// UI surfaces the misconfiguration separately.
pub fn effective_engine(&self) -> SearchEngine {
match self.engine.trim().to_ascii_lowercase().as_str() {
SEARCH_ENGINE_PARALLEL if self.parallel.has_key() => SearchEngine::Parallel,
SEARCH_ENGINE_BRAVE if self.brave.has_key() => SearchEngine::Brave,
_ => SearchEngine::Managed,
}
}
pub fn requested_engine_str(&self) -> &str {
let trimmed = self.engine.trim();
if trimmed.is_empty() {
SEARCH_ENGINE_MANAGED
} else {
trimmed
}
}
}
#[cfg(test)]
mod search_config_tests {
use super::*;
#[test]
fn defaults_to_managed() {
let cfg = SearchConfig::default();
assert_eq!(cfg.effective_engine(), SearchEngine::Managed);
}
#[test]
fn parallel_requires_key() {
let mut cfg = SearchConfig {
engine: SEARCH_ENGINE_PARALLEL.into(),
..Default::default()
};
assert_eq!(cfg.effective_engine(), SearchEngine::Managed);
cfg.parallel.api_key = Some(" ".into());
assert_eq!(cfg.effective_engine(), SearchEngine::Managed);
cfg.parallel.api_key = Some("real".into());
assert_eq!(cfg.effective_engine(), SearchEngine::Parallel);
}
#[test]
fn brave_requires_key() {
let mut cfg = SearchConfig {
engine: SEARCH_ENGINE_BRAVE.into(),
..Default::default()
};
assert_eq!(cfg.effective_engine(), SearchEngine::Managed);
cfg.brave.api_key = Some("real".into());
assert_eq!(cfg.effective_engine(), SearchEngine::Brave);
}
#[test]
fn unknown_engine_falls_back_to_managed() {
let cfg = SearchConfig {
engine: "duckduckgo".into(),
..Default::default()
};
assert_eq!(cfg.effective_engine(), SearchEngine::Managed);
}
}
/// Composio integration routing mode for the main backend-proxied flow.
///
/// `"backend"` (default) — every Composio call (toolkits, connections,
+6
View File
@@ -187,6 +187,11 @@ pub struct Config {
#[serde(default)]
pub web_search: WebSearchConfig,
/// Unified search-engine selector. Picks exactly one engine
/// (managed / parallel / brave) and layers the corresponding tools.
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub proxy: ProxyConfig,
@@ -589,6 +594,7 @@ impl Default for Config {
seltz: SeltzConfig::default(),
searxng: SearxngConfig::default(),
web_search: WebSearchConfig::default(),
search: SearchConfig::default(),
proxy: ProxyConfig::default(),
cost: CostConfig::default(),
computer_control: ComputerControlConfig::default(),
+705
View File
@@ -0,0 +1,705 @@
//! Brave Search direct-API integration.
//!
//! **Scope**: Agent + CLI/RPC.
//!
//! Brave exposes several REST endpoints — we layer one tool per
//! endpoint so the agent can pick the right surface:
//!
//! - `GET https://api.search.brave.com/res/v1/web/search` → `brave_web_search`
//! - `GET https://api.search.brave.com/res/v1/news/search` → `brave_news_search`
//! - `GET https://api.search.brave.com/res/v1/images/search` → `brave_image_search`
//! - `GET https://api.search.brave.com/res/v1/videos/search` → `brave_video_search`
//!
//! **Auth**: `X-Subscription-Token: <api_key>` header.
//!
//! Each tool short-circuits with a clear "not configured" error when no
//! key is wired up so the agent surfaces the misconfiguration instead
//! of silently routing elsewhere.
use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::time::Duration;
const DEFAULT_API_BASE: &str = "https://api.search.brave.com/res/v1";
fn http_client(timeout_secs: u64) -> anyhow::Result<reqwest::Client> {
crate::openhuman::tls::tls_client_builder()
.timeout(Duration::from_secs(timeout_secs.max(1)))
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|e| anyhow::anyhow!("failed to build Brave HTTP client: {e}"))
}
#[derive(Debug, Clone)]
struct BraveConfig {
api_key: Option<String>,
max_results: usize,
timeout_secs: u64,
}
impl BraveConfig {
fn require_key(&self) -> anyhow::Result<&str> {
self.api_key
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"Brave Search unavailable: no API key configured. \
Set OPENHUMAN_BRAVE_API_KEY or `search.brave.api_key` \
in config.toml and select `search.engine = \"brave\"`."
)
})
}
}
async fn brave_get(
cfg: &BraveConfig,
path: &str,
query: &[(&str, String)],
) -> anyhow::Result<Value> {
let key = cfg.require_key()?;
let url = format!("{DEFAULT_API_BASE}{path}");
tracing::debug!(
path = %path,
timeout_secs = cfg.timeout_secs,
key_present = true,
param_count = query.len(),
"[brave] GET request"
);
let client = http_client(cfg.timeout_secs)?;
let started = std::time::Instant::now();
let resp = client
.get(&url)
.header("X-Subscription-Token", key)
.header("Accept", "application/json")
.header("Accept-Encoding", "gzip")
.query(query)
.send()
.await
.map_err(|e| {
tracing::warn!(path = %path, "[brave] request send failed: {e}");
anyhow::anyhow!("Brave request failed: {e}")
})?;
let status = resp.status();
let elapsed_ms = started.elapsed().as_millis();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
let detail = crate::openhuman::util::utf8_safe_prefix_at_byte_boundary(&body, 500);
tracing::warn!(
path = %path,
status = %status,
elapsed_ms = elapsed_ms as u64,
"[brave] non-2xx response: {detail}"
);
anyhow::bail!("Brave returned {status}: {detail}");
}
tracing::debug!(
path = %path,
status = %status,
elapsed_ms = elapsed_ms as u64,
body_bytes = body.len(),
"[brave] response ok"
);
serde_json::from_str::<Value>(&body).map_err(|e| {
tracing::warn!(path = %path, "[brave] JSON parse failed: {e}");
anyhow::anyhow!("Brave returned malformed JSON: {e}")
})
}
fn extract_query(args: &Value) -> anyhow::Result<String> {
let q = args
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?
.trim();
if q.is_empty() {
anyhow::bail!("Search query cannot be empty");
}
Ok(q.to_string())
}
fn clamped_count(args: &Value, default: usize, max: usize) -> usize {
args.get("count")
.and_then(|v| v.as_u64())
.map(|n| (n as usize).clamp(1, max))
.unwrap_or_else(|| default.clamp(1, max))
}
fn pub_string(args: &Value, key: &str) -> Option<String> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
// ── Web ─────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct WebResp {
web: Option<WebContainer>,
}
#[derive(Debug, Deserialize)]
struct WebContainer {
#[serde(default)]
results: Vec<WebResult>,
}
#[derive(Debug, Deserialize)]
struct WebResult {
#[serde(default)]
title: String,
#[serde(default)]
url: String,
#[serde(default)]
description: String,
#[serde(default)]
age: Option<String>,
}
pub struct BraveWebSearchTool {
cfg: BraveConfig,
}
impl BraveWebSearchTool {
pub fn new(api_key: Option<String>, max_results: usize, timeout_secs: u64) -> Self {
Self {
cfg: BraveConfig {
api_key,
max_results: max_results.clamp(1, 20),
timeout_secs: timeout_secs.max(1),
},
}
}
}
#[async_trait]
impl Tool for BraveWebSearchTool {
fn name(&self) -> &str {
"web_search_tool"
}
fn description(&self) -> &str {
"Search the web via Brave Search. Returns ranked results with titles, URLs, and descriptions."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query (concise keywords work best)." },
"count": { "type": "integer", "description": "Max results (1-20)." },
"country": { "type": "string", "description": "2-letter country code (e.g. 'us', 'gb')." },
"freshness": { "type": "string", "description": "Time filter: 'pd' (past day), 'pw' (past week), 'pm' (past month), 'py' (past year), or 'YYYY-MM-DDtoYYYY-MM-DD'." }
},
"required": ["query"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
self.execute_with_options(args, ToolCallOptions::default())
.await
}
fn supports_markdown(&self) -> bool {
true
}
async fn execute_with_options(
&self,
args: Value,
options: ToolCallOptions,
) -> anyhow::Result<ToolResult> {
let query = extract_query(&args)?;
let count = clamped_count(&args, self.cfg.max_results, 20);
let mut q: Vec<(&str, String)> = vec![
("q", query.clone()),
("count", count.to_string()),
("result_filter", "web".into()),
];
if let Some(c) = pub_string(&args, "country") {
q.push(("country", c));
}
if let Some(f) = pub_string(&args, "freshness") {
q.push(("freshness", f));
}
let raw = brave_get(&self.cfg, "/web/search", &q).await?;
let parsed: WebResp = serde_json::from_value(raw)
.map_err(|e| anyhow::anyhow!("Brave web response shape changed: {e}"))?;
let results = parsed.web.map(|w| w.results).unwrap_or_default();
let plain = render_web_plain(&results, &query, count);
let mut out = ToolResult::success(plain);
if options.prefer_markdown {
out.markdown_formatted = Some(render_web_markdown(&results, &query, count));
}
Ok(out)
}
}
fn render_web_plain(results: &[WebResult], query: &str, max: usize) -> String {
if results.is_empty() {
return format!("No results found for: {query}");
}
let mut lines = vec![format!("Search results for: {query} (via Brave)")];
for (i, r) in results.iter().take(max).enumerate() {
let title = if r.title.trim().is_empty() {
"Untitled"
} else {
r.title.trim()
};
lines.push(format!("{}. {}", i + 1, title));
lines.push(format!(" {}", r.url.trim()));
if let Some(age) = r.age.as_deref() {
let age = age.trim();
if !age.is_empty() {
lines.push(format!(" {age}"));
}
}
let desc = r.description.trim();
if !desc.is_empty() {
lines.push(format!(
" {}",
crate::openhuman::util::truncate_with_ellipsis(desc, 500)
));
}
}
lines.join("\n")
}
fn render_web_markdown(results: &[WebResult], query: &str, max: usize) -> String {
if results.is_empty() {
return format!("_No results for `{query}`._");
}
let mut out = format!("# Search results — `{query}` (Brave)\n");
for r in results.iter().take(max) {
let title = if r.title.trim().is_empty() {
"Untitled"
} else {
r.title.trim()
};
out.push_str(&format!("\n## [{title}]({})\n", r.url.trim()));
if let Some(age) = r.age.as_deref() {
let age = age.trim();
if !age.is_empty() {
out.push_str(&format!("_{age}_\n\n"));
}
}
let desc = r.description.trim();
if !desc.is_empty() {
out.push_str(&format!(
"> {}\n",
crate::openhuman::util::truncate_with_suffix(desc, 500, "")
));
}
}
out
}
// ── News ────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct NewsResp {
#[serde(default)]
results: Vec<NewsResult>,
}
#[derive(Debug, Deserialize)]
struct NewsResult {
#[serde(default)]
title: String,
#[serde(default)]
url: String,
#[serde(default)]
description: String,
#[serde(default)]
age: Option<String>,
#[serde(default)]
source: Option<String>,
}
pub struct BraveNewsSearchTool {
cfg: BraveConfig,
}
impl BraveNewsSearchTool {
pub fn new(api_key: Option<String>, max_results: usize, timeout_secs: u64) -> Self {
Self {
cfg: BraveConfig {
api_key,
max_results: max_results.clamp(1, 20),
timeout_secs: timeout_secs.max(1),
},
}
}
}
#[async_trait]
impl Tool for BraveNewsSearchTool {
fn name(&self) -> &str {
"brave_news_search"
}
fn description(&self) -> &str {
"Search recent news articles via Brave Search News. Returns titles, URLs, sources, and excerpts."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "News topic to search for." },
"count": { "type": "integer", "description": "Max results (1-20)." },
"country": { "type": "string", "description": "2-letter country code." },
"freshness": { "type": "string", "description": "Time filter: 'pd', 'pw', 'pm', 'py'." }
},
"required": ["query"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let query = extract_query(&args)?;
let count = clamped_count(&args, self.cfg.max_results, 20);
let mut q: Vec<(&str, String)> = vec![("q", query.clone()), ("count", count.to_string())];
if let Some(c) = pub_string(&args, "country") {
q.push(("country", c));
}
if let Some(f) = pub_string(&args, "freshness") {
q.push(("freshness", f));
}
let raw = brave_get(&self.cfg, "/news/search", &q).await?;
let parsed: NewsResp = serde_json::from_value(raw)
.map_err(|e| anyhow::anyhow!("Brave news response shape changed: {e}"))?;
let mut lines = if parsed.results.is_empty() {
return Ok(ToolResult::success(format!("No news found for: {query}")));
} else {
vec![format!("News results for: {query} (via Brave)")]
};
for (i, r) in parsed.results.iter().take(count).enumerate() {
let title = if r.title.trim().is_empty() {
"Untitled"
} else {
r.title.trim()
};
lines.push(format!("{}. {}", i + 1, title));
lines.push(format!(" {}", r.url.trim()));
if let Some(src) = r.source.as_deref() {
let src = src.trim();
if !src.is_empty() {
lines.push(format!(" Source: {src}"));
}
}
if let Some(age) = r.age.as_deref() {
let age = age.trim();
if !age.is_empty() {
lines.push(format!(" {age}"));
}
}
let desc = r.description.trim();
if !desc.is_empty() {
lines.push(format!(
" {}",
crate::openhuman::util::truncate_with_ellipsis(desc, 500)
));
}
}
Ok(ToolResult::success(lines.join("\n")))
}
}
// ── Images ──────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct ImageResp {
#[serde(default)]
results: Vec<ImageResult>,
}
#[derive(Debug, Deserialize)]
struct ImageResult {
#[serde(default)]
title: String,
#[serde(default)]
url: String,
#[serde(default)]
source: Option<String>,
#[serde(default)]
thumbnail: Option<ImageThumb>,
#[serde(default)]
properties: Option<ImageProps>,
}
#[derive(Debug, Deserialize)]
struct ImageThumb {
#[serde(default)]
src: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ImageProps {
#[serde(default)]
url: Option<String>,
}
pub struct BraveImageSearchTool {
cfg: BraveConfig,
}
impl BraveImageSearchTool {
pub fn new(api_key: Option<String>, max_results: usize, timeout_secs: u64) -> Self {
Self {
cfg: BraveConfig {
api_key,
max_results: max_results.clamp(1, 20),
timeout_secs: timeout_secs.max(1),
},
}
}
}
#[async_trait]
impl Tool for BraveImageSearchTool {
fn name(&self) -> &str {
"brave_image_search"
}
fn description(&self) -> &str {
"Search for images via Brave Search. Returns image URLs, source pages, and thumbnails."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Image search query." },
"count": { "type": "integer", "description": "Max results (1-20)." },
"country": { "type": "string", "description": "2-letter country code." }
},
"required": ["query"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let query = extract_query(&args)?;
let count = clamped_count(&args, self.cfg.max_results, 20);
let mut q: Vec<(&str, String)> = vec![("q", query.clone()), ("count", count.to_string())];
if let Some(c) = pub_string(&args, "country") {
q.push(("country", c));
}
let raw = brave_get(&self.cfg, "/images/search", &q).await?;
let parsed: ImageResp = serde_json::from_value(raw)
.map_err(|e| anyhow::anyhow!("Brave image response shape changed: {e}"))?;
if parsed.results.is_empty() {
return Ok(ToolResult::success(format!("No images found for: {query}")));
}
let mut lines = vec![format!("Image results for: {query} (via Brave)")];
for (i, r) in parsed.results.iter().take(count).enumerate() {
let title = if r.title.trim().is_empty() {
"Untitled"
} else {
r.title.trim()
};
lines.push(format!("{}. {}", i + 1, title));
if let Some(src) = r.properties.as_ref().and_then(|p| p.url.as_deref()) {
lines.push(format!(" Image: {}", src.trim()));
}
lines.push(format!(" Page: {}", r.url.trim()));
if let Some(thumb) = r.thumbnail.as_ref().and_then(|t| t.src.as_deref()) {
lines.push(format!(" Thumb: {}", thumb.trim()));
}
if let Some(src) = r.source.as_deref() {
let src = src.trim();
if !src.is_empty() {
lines.push(format!(" Source: {src}"));
}
}
}
Ok(ToolResult::success(lines.join("\n")))
}
}
// ── Videos ──────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct VideoResp {
#[serde(default)]
results: Vec<VideoResult>,
}
#[derive(Debug, Deserialize)]
struct VideoResult {
#[serde(default)]
title: String,
#[serde(default)]
url: String,
#[serde(default)]
description: String,
#[serde(default)]
age: Option<String>,
#[serde(default)]
video: Option<VideoMeta>,
}
#[derive(Debug, Deserialize)]
struct VideoMeta {
#[serde(default)]
duration: Option<String>,
#[serde(default)]
creator: Option<String>,
}
pub struct BraveVideoSearchTool {
cfg: BraveConfig,
}
impl BraveVideoSearchTool {
pub fn new(api_key: Option<String>, max_results: usize, timeout_secs: u64) -> Self {
Self {
cfg: BraveConfig {
api_key,
max_results: max_results.clamp(1, 20),
timeout_secs: timeout_secs.max(1),
},
}
}
}
#[async_trait]
impl Tool for BraveVideoSearchTool {
fn name(&self) -> &str {
"brave_video_search"
}
fn description(&self) -> &str {
"Search for videos via Brave Search. Returns titles, URLs, creators, durations, and excerpts."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Video search query." },
"count": { "type": "integer", "description": "Max results (1-20)." },
"country": { "type": "string", "description": "2-letter country code." },
"freshness": { "type": "string", "description": "Time filter: 'pd', 'pw', 'pm', 'py'." }
},
"required": ["query"]
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let query = extract_query(&args)?;
let count = clamped_count(&args, self.cfg.max_results, 20);
let mut q: Vec<(&str, String)> = vec![("q", query.clone()), ("count", count.to_string())];
if let Some(c) = pub_string(&args, "country") {
q.push(("country", c));
}
if let Some(f) = pub_string(&args, "freshness") {
q.push(("freshness", f));
}
let raw = brave_get(&self.cfg, "/videos/search", &q).await?;
let parsed: VideoResp = serde_json::from_value(raw)
.map_err(|e| anyhow::anyhow!("Brave video response shape changed: {e}"))?;
if parsed.results.is_empty() {
return Ok(ToolResult::success(format!("No videos found for: {query}")));
}
let mut lines = vec![format!("Video results for: {query} (via Brave)")];
for (i, r) in parsed.results.iter().take(count).enumerate() {
let title = if r.title.trim().is_empty() {
"Untitled"
} else {
r.title.trim()
};
lines.push(format!("{}. {}", i + 1, title));
lines.push(format!(" {}", r.url.trim()));
if let Some(meta) = r.video.as_ref() {
if let Some(creator) = meta.creator.as_deref() {
let creator = creator.trim();
if !creator.is_empty() {
lines.push(format!(" Creator: {creator}"));
}
}
if let Some(dur) = meta.duration.as_deref() {
let dur = dur.trim();
if !dur.is_empty() {
lines.push(format!(" Duration: {dur}"));
}
}
}
if let Some(age) = r.age.as_deref() {
let age = age.trim();
if !age.is_empty() {
lines.push(format!(" {age}"));
}
}
let desc = r.description.trim();
if !desc.is_empty() {
lines.push(format!(
" {}",
crate::openhuman::util::truncate_with_ellipsis(desc, 500)
));
}
}
Ok(ToolResult::success(lines.join("\n")))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn require_key_rejects_blank() {
let cfg = BraveConfig {
api_key: Some(" ".into()),
max_results: 5,
timeout_secs: 5,
};
assert!(cfg.require_key().is_err());
}
#[test]
fn require_key_accepts_trimmed() {
let cfg = BraveConfig {
api_key: Some(" abc ".into()),
max_results: 5,
timeout_secs: 5,
};
assert_eq!(cfg.require_key().unwrap(), "abc");
}
#[test]
fn web_tool_advertises_unified_name() {
let t = BraveWebSearchTool::new(Some("k".into()), 5, 5);
assert_eq!(t.name(), "web_search_tool");
}
#[test]
fn news_tool_name() {
let t = BraveNewsSearchTool::new(Some("k".into()), 5, 5);
assert_eq!(t.name(), "brave_news_search");
}
#[test]
fn image_tool_name() {
let t = BraveImageSearchTool::new(Some("k".into()), 5, 5);
assert_eq!(t.name(), "brave_image_search");
}
#[test]
fn video_tool_name() {
let t = BraveVideoSearchTool::new(Some("k".into()), 5, 5);
assert_eq!(t.name(), "brave_video_search");
}
#[tokio::test]
async fn execute_without_key_returns_error() {
let t = BraveWebSearchTool::new(None, 5, 5);
let err = t
.execute(json!({ "query": "test" }))
.await
.expect_err("should error without key");
assert!(err.to_string().contains("no API key"));
}
#[test]
fn clamped_count_respects_max() {
assert_eq!(clamped_count(&json!({"count": 99}), 5, 20), 20);
assert_eq!(clamped_count(&json!({"count": 0}), 5, 20), 1);
assert_eq!(clamped_count(&json!({}), 5, 20), 5);
}
}
+4
View File
@@ -7,6 +7,7 @@
//! trusted because requests leave the local core process.
pub mod apify;
pub mod brave;
pub mod client;
pub mod google_places;
pub mod parallel;
@@ -18,6 +19,9 @@ pub mod twilio;
pub mod types;
pub use apify::{ApifyGetRunResultsTool, ApifyGetRunStatusTool, ApifyRunActorTool};
pub use brave::{
BraveImageSearchTool, BraveNewsSearchTool, BraveVideoSearchTool, BraveWebSearchTool,
};
pub use client::{build_client, pricing_for_config, IntegrationClient};
pub use google_places::{GooglePlacesDetailsTool, GooglePlacesSearchTool};
pub use parallel::{
+122 -96
View File
@@ -308,81 +308,121 @@ pub fn all_tools_with_runtime(
tracing::debug!("[mcp_client] no MCP servers registered — bridge tools skipped");
}
// Web search — always registered. Result/timeout budget
// knobs still come from `config.web_search`, but there is no
// enable flag: every session needs research as a baseline
// capability.
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
// web_search above, this calls the Seltz API directly with a user-
// provided API key.
if root_config.seltz.enabled {
tools.push(Box::new(
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,
),
));
tracing::debug!("[seltz] registered seltz_search tool");
} else {
tracing::debug!("[seltz] disabled — set SELTZ_API_KEY to enable");
}
// SearXNG — self-hosted web search, gated on `searxng.enabled`.
// This is useful for users who want current web results without routing
// queries through OpenHuman's backend or a hosted search API.
if root_config.searxng.enabled {
tools.push(Box::new(
crate::openhuman::integrations::SearxngSearchTool::new(
root_config.searxng.base_url.clone(),
root_config.searxng.max_results,
root_config.searxng.default_language.clone(),
root_config.searxng.timeout_secs,
),
));
tracing::debug!(
base_url = %root_config.searxng.base_url,
max_results = root_config.searxng.max_results,
"[searxng] registered searxng_search tool"
);
} else {
tracing::debug!("[searxng] disabled — set searxng.enabled=true to enable");
// ── Unified search engine ───────────────────────────────────────
//
// Exactly one engine drives `web_search_tool` plus any
// engine-specific tools (Parallel research/extract/etc., Brave
// news/image/video). Mirrors the LLM-provider API-key model: a
// single switch, BYO credentials, layered tool surface.
//
// Legacy `seltz` / `searxng` config blocks are still parsed but
// no longer register tools — they were superseded by this
// selector. Use `search.engine = "managed" | "parallel" | "brave"`
// instead.
{
use crate::openhuman::config::SearchEngine;
let search = &root_config.search;
let max_results = search.max_results.clamp(1, 20);
let timeout_secs = search.timeout_secs.max(1);
match search.effective_engine() {
SearchEngine::Managed => {
tracing::debug!(
requested = %search.requested_engine_str(),
"[search] active engine = managed (backend-proxied web_search)"
);
tools.push(Box::new(WebSearchTool::new(
crate::openhuman::integrations::build_client(root_config),
max_results,
timeout_secs,
)));
}
SearchEngine::Parallel => {
tracing::debug!("[search] active engine = parallel (BYO direct API)");
// Direct-mode Parallel still goes through the
// backend-proxy client today; the BYO key is stored on
// the integration client so the upstream tools can
// pick it up once direct Parallel routing lands.
let client = crate::openhuman::integrations::build_client(root_config);
if let Some(client) = client {
tools.push(Box::new(
crate::openhuman::integrations::ParallelSearchTool::new(Arc::clone(
&client,
)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelExtractTool::new(Arc::clone(
&client,
)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelChatTool::new(Arc::clone(&client)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelResearchTool::new(Arc::clone(
&client,
)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelEnrichTool::new(Arc::clone(
&client,
)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelDatasetTool::new(Arc::clone(
&client,
)),
));
// Layer the unified web_search slot too so the
// agent's default research path keeps working.
tools.push(Box::new(WebSearchTool::new(
Some(Arc::clone(&client)),
max_results,
timeout_secs,
)));
} else {
tracing::warn!(
"[search] engine=parallel but no backend client — falling back to managed surface"
);
tools.push(Box::new(WebSearchTool::new(
None,
max_results,
timeout_secs,
)));
}
}
SearchEngine::Brave => {
tracing::debug!("[search] active engine = brave (BYO direct API)");
let api_key = search.brave.api_key.clone();
tools.push(Box::new(
crate::openhuman::integrations::BraveWebSearchTool::new(
api_key.clone(),
max_results,
timeout_secs,
),
));
tools.push(Box::new(
crate::openhuman::integrations::BraveNewsSearchTool::new(
api_key.clone(),
max_results,
timeout_secs,
),
));
tools.push(Box::new(
crate::openhuman::integrations::BraveImageSearchTool::new(
api_key.clone(),
max_results,
timeout_secs,
),
));
tools.push(Box::new(
crate::openhuman::integrations::BraveVideoSearchTool::new(
api_key,
max_results,
timeout_secs,
),
));
}
}
}
// Managed Node.js exec tools — gated on `root_config.node.enabled`.
@@ -473,28 +513,14 @@ pub fn all_tools_with_runtime(
} else {
tracing::debug!("[integrations] google_places disabled — skipping");
}
// NOTE: parallel tools moved to the unified [search] engine
// selector above. `integrations.parallel` is parsed but no
// longer registers tools directly — set
// `search.engine = "parallel"` instead.
if root_config.integrations.parallel.is_active() {
tools.push(Box::new(
crate::openhuman::integrations::ParallelSearchTool::new(Arc::clone(&client)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelExtractTool::new(Arc::clone(&client)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelChatTool::new(Arc::clone(&client)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelResearchTool::new(Arc::clone(&client)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelEnrichTool::new(Arc::clone(&client)),
));
tools.push(Box::new(
crate::openhuman::integrations::ParallelDatasetTool::new(Arc::clone(&client)),
));
tracing::debug!("[integrations] registered parallel tools");
} else {
tracing::debug!("[integrations] parallel disabled — skipping");
tracing::debug!(
"[integrations] parallel toggle is active but tools are governed by search.engine now"
);
}
if root_config.integrations.tinyfish.is_active() {
tools.push(Box::new(
+23 -4
View File
@@ -57,6 +57,11 @@ fn integration_test_config(tmp: &TempDir, backend_url: &str) -> Config {
cfg.integrations.tinyfish.enabled = true;
cfg.integrations.stock_prices.enabled = true;
cfg.integrations.twilio.enabled = true;
// Parallel tools (search/extract/chat/research/enrich/dataset) are
// registered by the unified search-engine selector, so flip the
// engine to `parallel` in test setup.
cfg.search.engine = crate::openhuman::config::SEARCH_ENGINE_PARALLEL.into();
cfg.search.parallel.api_key = Some("test-parallel-key".into());
cfg
}
@@ -856,6 +861,9 @@ fn all_tools_registers_integration_families_when_enabled_and_signed_in() {
cfg.integrations.stock_prices.enabled = true;
cfg.integrations.twilio.enabled = true;
cfg.composio.enabled = true;
// Parallel tools now register through the unified search-engine selector.
cfg.search.engine = crate::openhuman::config::SEARCH_ENGINE_PARALLEL.into();
cfg.search.parallel.api_key = Some("test-parallel-key".into());
store_test_session_token(&cfg);
let tools = all_tools(
@@ -904,15 +912,19 @@ fn all_tools_registers_integration_families_when_enabled_and_signed_in() {
}
#[test]
fn all_tools_registers_optional_search_lsp_and_tool_stats_when_enabled() {
fn all_tools_registers_brave_engine_lsp_and_tool_stats_when_enabled() {
// The legacy seltz/searxng tools are no longer registered — the
// unified `search.engine` selector replaces them. This test now
// verifies that picking `brave` layers in its full tool surface
// alongside lsp + tool_stats.
let tmp = TempDir::new().unwrap();
let security = Arc::new(SecurityPolicy::default());
let mem = test_memory(&tmp);
let browser = BrowserConfig::default();
let http = crate::openhuman::config::HttpRequestConfig::default();
let mut cfg = test_config(&tmp);
cfg.seltz.enabled = true;
cfg.searxng.enabled = true;
cfg.search.engine = crate::openhuman::config::SEARCH_ENGINE_BRAVE.into();
cfg.search.brave.api_key = Some("test-brave-key".into());
cfg.learning.enabled = true;
cfg.learning.tool_tracking_enabled = true;
@@ -940,7 +952,14 @@ fn all_tools_registers_optional_search_lsp_and_tool_stats_when_enabled() {
let names = tool_names(&tools);
assert_contains_all(
&names,
&["seltz_search", "searxng_search", "lsp", "tool_stats"],
&[
"web_search_tool",
"brave_news_search",
"brave_image_search",
"brave_video_search",
"lsp",
"tool_stats",
],
);
unsafe {