mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(tools): integrate Seltz as a direct-API search tool (#1717)
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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::<usize>() {
|
||||
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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Override the Seltz API base URL (default: `https://api.seltz.ai/v1`).
|
||||
#[serde(default)]
|
||||
pub api_url: Option<String>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SeltzDocument>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct SeltzDocument {
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub content: String,
|
||||
#[serde(default)]
|
||||
pub title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub published_date: Option<String>,
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
api_url: String,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
http_client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SeltzSearchTool {
|
||||
pub fn new(
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
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<ToolResult> {
|
||||
self.execute_with_options(args, ToolCallOptions::default())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute_with_options(
|
||||
&self,
|
||||
args: serde_json::Value,
|
||||
options: ToolCallOptions,
|
||||
) -> anyhow::Result<ToolResult> {
|
||||
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<Value>| 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ControllerSchema> {
|
||||
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<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture {
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_seltz_search(params: Map<String, Value>) -> 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<String, Value>) -> 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");
|
||||
|
||||
Reference in New Issue
Block a user