From a6def97ec2f35bc5f817e878c69e8ce99320b772 Mon Sep 17 00:00:00 2001 From: Antonio Mallia Date: Fri, 15 May 2026 08:28:38 +0200 Subject: [PATCH] feat(tools): integrate Seltz as a direct-API search tool (#1717) --- .env.example | 11 + src/openhuman/config/schema/load.rs | 21 ++ src/openhuman/config/schema/mod.rs | 2 +- src/openhuman/config/schema/tools.rs | 41 +++ src/openhuman/config/schema/types.rs | 4 + src/openhuman/integrations/mod.rs | 2 + src/openhuman/integrations/seltz.rs | 514 +++++++++++++++++++++++++++ src/openhuman/tools/ops.rs | 18 + src/openhuman/tools/schemas.rs | 161 ++++++++- 9 files changed, 769 insertions(+), 5 deletions(-) create mode 100644 src/openhuman/integrations/seltz.rs diff --git a/.env.example b/.env.example index 2d29d2c2f..bf5764451 100644 --- a/.env.example +++ b/.env.example @@ -99,6 +99,17 @@ OPENHUMAN_WEB_SEARCH_MAX_RESULTS=5 # [optional] Default: 10 OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS=10 +# --------------------------------------------------------------------------- +# Seltz search (direct API — https://seltz.ai) +# --------------------------------------------------------------------------- +# [optional] API key from https://console.seltz.ai. When set, enables the +# seltz_search agent tool. Fast (<200ms), independent index, domain/date filters. +# SELTZ_API_KEY= +# [optional] Override API base URL (default: https://api.seltz.ai/v1) +# SELTZ_API_URL= +# [optional] Default max results per query (1-20, default 10) +# OPENHUMAN_SELTZ_MAX_RESULTS=10 + # --------------------------------------------------------------------------- # Proxy # --------------------------------------------------------------------------- diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 277e2185d..060a6072e 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -872,6 +872,27 @@ impl Config { } } + // Seltz direct-API search. + if let Some(key) = env.get_any(&["OPENHUMAN_SELTZ_API_KEY", "SELTZ_API_KEY"]) { + if !key.is_empty() { + self.seltz.api_key = Some(key); + // Auto-enable when the key is set via env. + self.seltz.enabled = true; + } + } + if let Some(url) = env.get_any(&["OPENHUMAN_SELTZ_API_URL", "SELTZ_API_URL"]) { + if !url.is_empty() { + self.seltz.api_url = Some(url); + } + } + if let Some(max) = env.get_any(&["OPENHUMAN_SELTZ_MAX_RESULTS", "SELTZ_MAX_RESULTS"]) { + if let Ok(n) = max.parse::() { + if (1..=20).contains(&n) { + self.seltz.max_results = 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 f89074b80..c6c5bec6b 100644 --- a/src/openhuman/config/schema/mod.rs +++ b/src/openhuman/config/schema/mod.rs @@ -64,7 +64,7 @@ pub use storage_memory::{ pub use tools::{ BrowserComputerUseConfig, BrowserConfig, ComposioConfig, ComputerControlConfig, CurlConfig, GitbooksConfig, HttpRequestConfig, IntegrationToggle, IntegrationsConfig, MultimodalConfig, - SecretsConfig, WebSearchConfig, + SecretsConfig, SeltzConfig, WebSearchConfig, }; 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 c16276ade..864eae2ed 100644 --- a/src/openhuman/config/schema/tools.rs +++ b/src/openhuman/config/schema/tools.rs @@ -223,6 +223,47 @@ impl Default for GitbooksConfig { } } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct SeltzConfig { + /// When `true`, register `seltz_search` as an agent tool. + #[serde(default)] + pub enabled: bool, + /// Seltz API key. Can also be set via `SELTZ_API_KEY` or + /// `OPENHUMAN_SELTZ_API_KEY` env var. + #[serde(default)] + pub api_key: Option, + /// Override the Seltz API base URL (default: `https://api.seltz.ai/v1`). + #[serde(default)] + pub api_url: Option, + /// Max results per query (1–20, default 10). + #[serde(default = "default_seltz_max_results")] + pub max_results: usize, + /// Per-request timeout in seconds (default 15). + #[serde(default = "default_seltz_timeout_secs")] + pub timeout_secs: u64, +} + +fn default_seltz_max_results() -> usize { + 10 +} + +fn default_seltz_timeout_secs() -> u64 { + 15 +} + +impl Default for SeltzConfig { + fn default() -> Self { + Self { + enabled: false, + api_key: None, + api_url: None, + max_results: default_seltz_max_results(), + timeout_secs: default_seltz_timeout_secs(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] pub struct WebSearchConfig { diff --git a/src/openhuman/config/schema/types.rs b/src/openhuman/config/schema/types.rs index 33b6fd5bf..ab1ce8d45 100644 --- a/src/openhuman/config/schema/types.rs +++ b/src/openhuman/config/schema/types.rs @@ -142,6 +142,9 @@ pub struct Config { #[serde(default)] pub multimodal: MultimodalConfig, + #[serde(default)] + pub seltz: SeltzConfig, + #[serde(default)] pub web_search: WebSearchConfig, @@ -317,6 +320,7 @@ impl Default for Config { curl: CurlConfig::default(), gitbooks: GitbooksConfig::default(), multimodal: MultimodalConfig::default(), + seltz: SeltzConfig::default(), web_search: WebSearchConfig::default(), proxy: ProxyConfig::default(), cost: CostConfig::default(), diff --git a/src/openhuman/integrations/mod.rs b/src/openhuman/integrations/mod.rs index 50434f626..a3cfdcc75 100644 --- a/src/openhuman/integrations/mod.rs +++ b/src/openhuman/integrations/mod.rs @@ -8,6 +8,7 @@ pub mod apify; pub mod client; pub mod google_places; pub mod parallel; +pub mod seltz; pub mod stock_prices; pub mod twilio; pub mod types; @@ -19,6 +20,7 @@ pub use parallel::{ ParallelChatTool, ParallelDatasetTool, ParallelEnrichTool, ParallelExtractTool, ParallelResearchTool, ParallelSearchTool, }; +pub use seltz::SeltzSearchTool; pub use stock_prices::{ StockCommodityTool, StockCryptoSeriesTool, StockExchangeRateTool, StockOptionsTool, StockQuoteTool, diff --git a/src/openhuman/integrations/seltz.rs b/src/openhuman/integrations/seltz.rs new file mode 100644 index 000000000..e3112b2ad --- /dev/null +++ b/src/openhuman/integrations/seltz.rs @@ -0,0 +1,514 @@ +//! Seltz web search integration — direct API (not backend-proxied). +//! +//! **Scope**: Agent + CLI/RPC. +//! +//! **Endpoint**: `POST https://api.seltz.ai/v1/search` +//! +//! **Auth**: `x-api-key` header with user-provided API key. +//! +//! Seltz is an independent web search API optimized for AI agents, built on a +//! custom crawler/index with sub-200ms median latency. Unlike the Parallel +//! integration, this calls the Seltz API directly — no backend proxy needed. + +use crate::openhuman::tools::traits::{Tool, ToolCallOptions, ToolResult}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::Duration; + +/// Default Seltz API base URL. +const DEFAULT_API_URL: &str = "https://api.seltz.ai/v1"; + +// ── Response types ────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, Serialize)] +pub struct SeltzSearchResponse { + #[serde(default)] + pub documents: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct SeltzDocument { + #[serde(default)] + pub url: String, + #[serde(default)] + pub content: String, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub published_date: Option, +} + +// ── SeltzSearchTool ───────────────────────────────────────────────── + +/// Real-time web search via the Seltz API. +/// +/// Requires a `SELTZ_API_KEY` (or `OPENHUMAN_SELTZ_API_KEY`) environment +/// variable or `seltz.api_key` config field. When the key is absent the tool +/// is still registered but returns a clear "not configured" error at call time +/// so the agent can fall back to other search tools. +pub struct SeltzSearchTool { + api_key: Option, + api_url: String, + max_results: usize, + timeout_secs: u64, + http_client: reqwest::Client, +} + +impl SeltzSearchTool { + pub fn new( + api_key: Option, + api_url: Option, + max_results: usize, + timeout_secs: u64, + ) -> Self { + let timeout = timeout_secs.max(1); + let http_client = reqwest::Client::builder() + .use_rustls_tls() + .http1_only() + .timeout(Duration::from_secs(timeout)) + .connect_timeout(Duration::from_secs(10)) + .build() + .expect("failed to build Seltz HTTP client"); + + Self { + api_key, + api_url: api_url.unwrap_or_else(|| DEFAULT_API_URL.to_string()), + max_results: max_results.clamp(1, 20), + timeout_secs: timeout, + http_client, + } + } + + fn render_results_plain(&self, docs: &[SeltzDocument], query: &str) -> String { + if docs.is_empty() { + return format!("No results found for: {}", query); + } + + let mut lines = vec![format!("Search results for: {} (via Seltz)", query)]; + + for (i, doc) in docs.iter().take(self.max_results).enumerate() { + let title = doc + .title + .as_deref() + .filter(|t| !t.trim().is_empty()) + .unwrap_or("Untitled"); + let url = doc.url.trim(); + + lines.push(format!("{}. {}", i + 1, title)); + lines.push(format!(" {}", url)); + + if let Some(date) = doc.published_date.as_deref() { + let date = date.trim(); + if !date.is_empty() { + lines.push(format!(" Published: {}", date)); + } + } + + let content = doc.content.trim(); + if !content.is_empty() { + let truncated = crate::openhuman::util::truncate_with_ellipsis(content, 500); + lines.push(format!(" {}", truncated)); + } + } + + lines.join("\n") + } + + fn render_results_markdown(&self, docs: &[SeltzDocument], query: &str) -> String { + if docs.is_empty() { + return format!("_No results for `{query}`._"); + } + + let mut out = format!("# Search results — `{query}`\n"); + for doc in docs.iter().take(self.max_results) { + let title = doc + .title + .as_deref() + .filter(|t| !t.trim().is_empty()) + .unwrap_or("Untitled"); + out.push_str(&format!("\n## [{title}]({})\n", doc.url.trim())); + if let Some(date) = doc.published_date.as_deref() { + let date = date.trim(); + if !date.is_empty() { + out.push_str(&format!("_Published: {date}_\n\n")); + } + } + let content = doc.content.trim(); + if !content.is_empty() { + let truncated = crate::openhuman::util::truncate_with_suffix(content, 500, "…"); + out.push_str(&format!("> {truncated}\n")); + } + } + out + } +} + +#[async_trait] +impl Tool for SeltzSearchTool { + fn name(&self) -> &str { + "seltz_search" + } + + fn description(&self) -> &str { + "Search the web in real time using Seltz. Returns current information from trusted \ + sources with URLs and extracted content. Supports domain filtering, date ranges, \ + and news scope. Fast (<200ms) and optimized for AI agent workflows." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query. Use concise keywords for best results." + }, + "max_results": { + "type": "integer", + "description": "Maximum number of results to return (default from config, max 20)." + }, + "include_domains": { + "type": "array", + "items": { "type": "string" }, + "description": "Restrict results to these domains (e.g. [\"bbc.com\", \"reuters.com\"])." + }, + "exclude_domains": { + "type": "array", + "items": { "type": "string" }, + "description": "Exclude results from these domains." + }, + "from_date": { + "type": "string", + "description": "Only include results published on or after this date (YYYY-MM-DD)." + }, + "to_date": { + "type": "string", + "description": "Only include results published on or before this date (YYYY-MM-DD)." + }, + "scope": { + "type": "string", + "description": "Restrict to a specific scope. Currently supported: \"news\"." + } + }, + "required": ["query"] + }) + } + + fn supports_markdown(&self) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_with_options(args, ToolCallOptions::default()) + .await + } + + async fn execute_with_options( + &self, + args: serde_json::Value, + options: ToolCallOptions, + ) -> anyhow::Result { + let query = args + .get("query") + .and_then(|q| q.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required parameter: query"))?; + + if query.trim().is_empty() { + anyhow::bail!("Search query cannot be empty"); + } + + let api_key = self.api_key.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Seltz search unavailable: no API key configured. \ + Set SELTZ_API_KEY or OPENHUMAN_SELTZ_API_KEY, \ + or add seltz.api_key to config.toml." + ) + })?; + + let max_results = args + .get("max_results") + .and_then(|v| v.as_u64()) + .map(|n| n.clamp(1, 20) as usize) + .unwrap_or(self.max_results); + + // Build request body — only include optional fields when set. + let mut body = json!({ + "query": query, + "max_results": max_results, + }); + let body_map = body.as_object_mut().unwrap(); + + if let Some(include) = args.get("include_domains") { + if include.is_array() { + body_map.insert("include_domains".to_string(), include.clone()); + } + } + if let Some(exclude) = args.get("exclude_domains") { + if exclude.is_array() { + body_map.insert("exclude_domains".to_string(), exclude.clone()); + } + } + if let Some(from) = args.get("from_date").and_then(|v| v.as_str()) { + if !from.is_empty() { + body_map.insert("from_date".to_string(), json!(from)); + } + } + if let Some(to) = args.get("to_date").and_then(|v| v.as_str()) { + if !to.is_empty() { + body_map.insert("to_date".to_string(), json!(to)); + } + } + if let Some(scope) = args.get("scope").and_then(|v| v.as_str()) { + if !scope.is_empty() { + body_map.insert("scope".to_string(), json!(scope)); + } + } + + let url = format!("{}/search", self.api_url); + + tracing::debug!( + query_len = query.chars().count(), + max_results, + timeout_secs = self.timeout_secs, + "[seltz] POST {url}" + ); + + let resp = self + .http_client + .post(&url) + .header("x-api-key", api_key) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| { + tracing::warn!("[seltz] request failed: {e}"); + anyhow::anyhow!("Seltz search request failed: {e}") + })?; + + let status = resp.status(); + if !status.is_success() { + let body_text = resp.text().await.unwrap_or_default(); + let detail = if body_text.len() > 500 { + &body_text[..500] + } else { + &body_text + }; + tracing::warn!( + status = %status, + "[seltz] non-2xx response: {detail}" + ); + anyhow::bail!("Seltz returned {status}: {detail}"); + } + + let search_resp: SeltzSearchResponse = resp.json().await.map_err(|e| { + tracing::warn!("[seltz] failed to parse response: {e}"); + anyhow::anyhow!("Failed to parse Seltz response: {e}") + })?; + + tracing::debug!( + doc_count = search_resp.documents.len(), + "[seltz] search complete" + ); + + let mut result = + ToolResult::success(self.render_results_plain(&search_resp.documents, query)); + if options.prefer_markdown { + result.markdown_formatted = + Some(self.render_results_markdown(&search_resp.documents, query)); + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tool() -> SeltzSearchTool { + SeltzSearchTool::new(None, None, 5, 15) + } + + fn tool_with_key() -> SeltzSearchTool { + SeltzSearchTool::new(Some("test-key".into()), None, 5, 15) + } + + #[test] + fn test_tool_name() { + assert_eq!(tool().name(), "seltz_search"); + } + + #[test] + fn test_tool_description() { + assert!(tool().description().contains("Seltz")); + } + + #[test] + fn test_parameters_schema() { + let schema = tool().parameters_schema(); + assert_eq!(schema["type"], "object"); + assert!(schema["properties"]["query"].is_object()); + assert!(schema["properties"]["include_domains"].is_object()); + assert!(schema["properties"]["scope"].is_object()); + } + + #[test] + fn test_render_plain_empty() { + let result = tool().render_results_plain(&[], "test query"); + assert!(result.contains("No results found")); + } + + #[test] + fn test_render_plain_with_data() { + let docs = vec![ + SeltzDocument { + url: "https://example.com/a".into(), + content: "First result content.".into(), + title: Some("First Result".into()), + published_date: Some("2026-01-15".into()), + }, + SeltzDocument { + url: "https://example.com/b".into(), + content: "Second result content.".into(), + title: None, + published_date: None, + }, + ]; + + let result = tool().render_results_plain(&docs, "test"); + assert!(result.contains("via Seltz")); + assert!(result.contains("First Result")); + assert!(result.contains("https://example.com/a")); + assert!(result.contains("Published: 2026-01-15")); + assert!(result.contains("First result content.")); + assert!(result.contains("Untitled")); + } + + #[test] + fn test_render_plain_respects_max_results() { + let tool = SeltzSearchTool::new(None, None, 1, 15); + let docs = vec![ + SeltzDocument { + url: "https://a.com".into(), + content: "A".into(), + title: Some("A".into()), + published_date: None, + }, + SeltzDocument { + url: "https://b.com".into(), + content: "B".into(), + title: Some("B".into()), + published_date: None, + }, + ]; + let result = tool.render_results_plain(&docs, "q"); + assert!(result.contains("https://a.com")); + assert!(!result.contains("https://b.com")); + } + + #[test] + fn test_render_plain_truncates_long_content() { + let long_content = "x".repeat(600); + let docs = vec![SeltzDocument { + url: "https://t.com".into(), + content: long_content, + title: Some("T".into()), + published_date: None, + }]; + let result = tool().render_results_plain(&docs, "q"); + assert!(result.contains("...")); + let content_line = result.lines().find(|l| l.trim().starts_with('x')).unwrap(); + assert!(content_line.trim().len() <= 503); + } + + #[test] + fn test_render_markdown_empty() { + let result = tool().render_results_markdown(&[], "test"); + assert!(result.contains("No results")); + } + + #[test] + fn test_render_markdown_with_data() { + let docs = vec![SeltzDocument { + url: "https://example.com".into(), + content: "Some content.".into(), + title: Some("Example".into()), + published_date: Some("2026-01-01".into()), + }]; + let result = tool().render_results_markdown(&docs, "test"); + assert!(result.contains("[Example](https://example.com)")); + assert!(result.contains("Published: 2026-01-01")); + assert!(result.contains("> Some content.")); + } + + #[tokio::test] + async fn test_execute_missing_query() { + let result = tool_with_key().execute(json!({})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_execute_empty_query() { + let result = tool_with_key().execute(json!({"query": ""})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_execute_without_api_key() { + let result = tool().execute(json!({"query": "test"})).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("no API key configured")); + } + + #[tokio::test] + async fn test_execute_posts_to_seltz_and_renders_results() { + use axum::{extract::Json, routing::post, Router}; + use serde_json::Value; + + let app = Router::new().route( + "/search", + post(|Json(body): Json| async move { + assert_eq!(body["query"], "test query"); + Json(json!({ + "documents": [ + { + "url": "https://example.com/result", + "title": "Seltz Result", + "content": "Content from Seltz search.", + "published_date": "2026-05-01" + } + ] + })) + }), + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = format!("http://127.0.0.1:{}", addr.port()); + + let tool = SeltzSearchTool::new(Some("test-key".into()), Some(base_url), 5, 15); + let result = tool + .execute(json!({"query": "test query"})) + .await + .expect("execute() should succeed"); + + assert!(result.output().contains("Seltz Result")); + assert!(result.output().contains("https://example.com/result")); + assert!(result.output().contains("Content from Seltz search.")); + } + + #[test] + fn test_max_results_clamped() { + let tool = SeltzSearchTool::new(None, None, 100, 15); + assert_eq!(tool.max_results, 20); + let tool = SeltzSearchTool::new(None, None, 0, 15); + assert_eq!(tool.max_results, 1); + } +} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 4c0d4ad0d..f10fd6d06 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -241,6 +241,24 @@ pub fn all_tools_with_runtime( root_config.web_search.timeout_secs, ))); + // 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"); + } + // Managed Node.js exec tools — gated on `root_config.node.enabled`. // Both share the same `NodeBootstrap` as ShellTool so the download + // extract + install pipeline runs at most once per session. diff --git a/src/openhuman/tools/schemas.rs b/src/openhuman/tools/schemas.rs index c41207de1..5d0095824 100644 --- a/src/openhuman/tools/schemas.rs +++ b/src/openhuman/tools/schemas.rs @@ -11,12 +11,14 @@ use serde_json::{json, Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::rpc as config_rpc; +use crate::openhuman::tools::traits::Tool; use crate::rpc::RpcOutcome; pub fn all_controller_schemas() -> Vec { vec![ tools_schemas("tools_composio_execute"), tools_schemas("tools_web_search"), + tools_schemas("tools_seltz_search"), tools_schemas("tools_apify_linkedin_scrape"), ] } @@ -31,6 +33,10 @@ pub fn all_registered_controllers() -> Vec { schema: tools_schemas("tools_web_search"), handler: handle_web_search, }, + RegisteredController { + schema: tools_schemas("tools_seltz_search"), + handler: handle_seltz_search, + }, RegisteredController { schema: tools_schemas("tools_apify_linkedin_scrape"), handler: handle_apify_linkedin_scrape, @@ -120,6 +126,67 @@ pub fn tools_schemas(function: &str) -> ControllerSchema { required: true, }], }, + "tools_seltz_search" => ControllerSchema { + namespace: "tools", + function: "seltz_search", + description: "Web search via the Seltz API. Returns structured results with \ + URLs, content, and optional published dates. Supports domain \ + filtering, date ranges, and news scope.", + inputs: vec![ + FieldSchema { + name: "query", + ty: TypeSchema::String, + comment: "Search query string.", + required: true, + }, + FieldSchema { + name: "max_results", + ty: TypeSchema::Option(Box::new(TypeSchema::U64)), + comment: "Max results (1-20, default 10).", + required: false, + }, + FieldSchema { + name: "include_domains", + ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new( + TypeSchema::String, + )))), + comment: "Restrict results to these domains.", + required: false, + }, + FieldSchema { + name: "exclude_domains", + ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new( + TypeSchema::String, + )))), + comment: "Exclude results from these domains.", + required: false, + }, + FieldSchema { + name: "from_date", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Only results published on or after (YYYY-MM-DD).", + required: false, + }, + FieldSchema { + name: "to_date", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Only results published on or before (YYYY-MM-DD).", + required: false, + }, + FieldSchema { + name: "scope", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Restrict to a scope, e.g. \"news\".", + required: false, + }, + ], + outputs: vec![FieldSchema { + name: "documents", + ty: TypeSchema::Array(Box::new(TypeSchema::Json)), + comment: "Each item: {url, content, title?, published_date?}.", + required: true, + }], + }, "tools_apify_linkedin_scrape" => ControllerSchema { namespace: "tools", function: "apify_linkedin_scrape", @@ -259,6 +326,82 @@ fn handle_web_search(params: Map) -> ControllerFuture { }) } +fn handle_seltz_search(params: Map) -> ControllerFuture { + Box::pin(async move { + let query = params + .get("query") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| "missing or empty `query`".to_string())?; + let max_results = params + .get("max_results") + .and_then(Value::as_u64) + .map(|n| n.clamp(1, 20) as usize) + .unwrap_or(10); + + let config = config_rpc::load_config_with_timeout().await?; + + if !config.seltz.enabled { + tracing::debug!("[rpc][tools.seltz_search] seltz disabled — rejecting"); + return Err("Seltz search is not enabled. Set SELTZ_API_KEY to enable.".to_string()); + } + + let has_include_domains = params.get("include_domains").is_some(); + let has_exclude_domains = params.get("exclude_domains").is_some(); + let has_scope = params.get("scope").is_some(); + + tracing::debug!( + query_len = query.chars().count(), + max_results, + has_include_domains, + has_exclude_domains, + has_scope, + "[rpc][tools.seltz_search] start" + ); + + let tool = crate::openhuman::integrations::SeltzSearchTool::new( + config.seltz.api_key.clone(), + config.seltz.api_url.clone(), + max_results, + config.seltz.timeout_secs, + ); + + // Build args JSON with all optional fields. + let mut args = json!({ "query": query, "max_results": max_results }); + let args_map = args.as_object_mut().unwrap(); + if let Some(v) = params.get("include_domains") { + args_map.insert("include_domains".to_string(), v.clone()); + } + if let Some(v) = params.get("exclude_domains") { + args_map.insert("exclude_domains".to_string(), v.clone()); + } + if let Some(v) = params.get("from_date") { + args_map.insert("from_date".to_string(), v.clone()); + } + if let Some(v) = params.get("to_date") { + args_map.insert("to_date".to_string(), v.clone()); + } + if let Some(v) = params.get("scope") { + args_map.insert("scope".to_string(), v.clone()); + } + + let result = tool + .execute(args) + .await + .map_err(|e| format!("seltz search failed: {e:#}"))?; + + let payload = json!({ "documents": result.output() }); + let log = vec![format!( + "[rpc][tools.seltz_search] success query_len={} max_results={}", + query.chars().count(), + max_results + )]; + RpcOutcome::new(payload, log).into_cli_compatible_json() + }) +} + fn handle_apify_linkedin_scrape(params: Map) -> ControllerFuture { Box::pin(async move { let profile_url = params @@ -300,13 +443,13 @@ mod tests { use super::*; #[test] - fn all_schemas_returns_three() { - assert_eq!(all_controller_schemas().len(), 3); + fn all_schemas_returns_four() { + assert_eq!(all_controller_schemas().len(), 4); } #[test] - fn all_controllers_returns_three() { - assert_eq!(all_registered_controllers().len(), 3); + fn all_controllers_returns_four() { + assert_eq!(all_registered_controllers().len(), 4); } #[test] @@ -328,6 +471,16 @@ mod tests { assert!(s.inputs.iter().any(|f| f.name == "action" && f.required)); } + #[test] + fn seltz_search_schema_shape() { + let s = tools_schemas("tools_seltz_search"); + assert_eq!(s.namespace, "tools"); + assert_eq!(s.function, "seltz_search"); + assert!(s.inputs.iter().any(|f| f.name == "query" && f.required)); + assert!(s.inputs.iter().any(|f| f.name == "include_domains")); + assert!(s.inputs.iter().any(|f| f.name == "scope")); + } + #[test] fn web_search_schema_shape() { let s = tools_schemas("tools_web_search");