mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(search): unified search-engine selector + Brave Search (#2516)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user