diff --git a/src/openhuman/config/mod.rs b/src/openhuman/config/mod.rs index 96f2993d5..bfb28d6ea 100644 --- a/src/openhuman/config/mod.rs +++ b/src/openhuman/config/mod.rs @@ -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, diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 20fc3dbde..70ec09356 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -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) 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::() { + 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::() { + 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. diff --git a/src/openhuman/config/schema/mod.rs b/src/openhuman/config/schema/mod.rs index be94e882d..97f7bac14 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -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; diff --git a/src/openhuman/config/schema/tools.rs b/src/openhuman/config/schema/tools.rs index 1050dc657..c7ae7aa09 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -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` 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, +} + +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 (1–20, 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, diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 94b871df8..2617e6bd7 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -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(), diff --git a/src/openhuman/integrations/brave.rs b/src/openhuman/integrations/brave.rs new file mode 100644 index 000000000..f1c1e3429 --- /dev/null +++ b/src/openhuman/integrations/brave.rs @@ -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: ` 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 { + 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, + 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 { + 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::(&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 { + 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 { + 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, +} +#[derive(Debug, Deserialize)] +struct WebContainer { + #[serde(default)] + results: Vec, +} +#[derive(Debug, Deserialize)] +struct WebResult { + #[serde(default)] + title: String, + #[serde(default)] + url: String, + #[serde(default)] + description: String, + #[serde(default)] + age: Option, +} + +pub struct BraveWebSearchTool { + cfg: BraveConfig, +} + +impl BraveWebSearchTool { + pub fn new(api_key: Option, 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 { + 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 { + 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, +} +#[derive(Debug, Deserialize)] +struct NewsResult { + #[serde(default)] + title: String, + #[serde(default)] + url: String, + #[serde(default)] + description: String, + #[serde(default)] + age: Option, + #[serde(default)] + source: Option, +} + +pub struct BraveNewsSearchTool { + cfg: BraveConfig, +} + +impl BraveNewsSearchTool { + pub fn new(api_key: Option, 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 { + 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, +} +#[derive(Debug, Deserialize)] +struct ImageResult { + #[serde(default)] + title: String, + #[serde(default)] + url: String, + #[serde(default)] + source: Option, + #[serde(default)] + thumbnail: Option, + #[serde(default)] + properties: Option, +} +#[derive(Debug, Deserialize)] +struct ImageThumb { + #[serde(default)] + src: Option, +} +#[derive(Debug, Deserialize)] +struct ImageProps { + #[serde(default)] + url: Option, +} + +pub struct BraveImageSearchTool { + cfg: BraveConfig, +} + +impl BraveImageSearchTool { + pub fn new(api_key: Option, 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 { + 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, +} +#[derive(Debug, Deserialize)] +struct VideoResult { + #[serde(default)] + title: String, + #[serde(default)] + url: String, + #[serde(default)] + description: String, + #[serde(default)] + age: Option, + #[serde(default)] + video: Option, +} +#[derive(Debug, Deserialize)] +struct VideoMeta { + #[serde(default)] + duration: Option, + #[serde(default)] + creator: Option, +} + +pub struct BraveVideoSearchTool { + cfg: BraveConfig, +} + +impl BraveVideoSearchTool { + pub fn new(api_key: Option, 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 { + 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); + } +} diff --git a/src/openhuman/integrations/mod.rs b/src/openhuman/integrations/mod.rs index a57e1bad9..654bdc44e 100644 --- a/src/openhuman/integrations/mod.rs +++ b/src/openhuman/integrations/mod.rs @@ -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::{ diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index ed61ea154..6336f8a25 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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( diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 615e24e5a..5fba879e5 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -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 {