Skip to content

Commit 23790f3

Browse files
qhkmclaude
andauthored
feat(tools): add SearXNG web search provider (#214)
## Summary - Add SearXNG as a third web search provider alongside Brave and DuckDuckGo - Add explicit `provider` field to `WebSearchConfig` for user choice (`"brave"`, `"searxng"`, `"ddg"`) - Add `api_url` field for self-hosted SearXNG instance URL - Auto-detection fallback: `api_url` set → SearXNG, `api_key` set → Brave, else → DuckDuckGo - Env overrides: `ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER`, `ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL` Closes #196 ## Test plan - [x] 11 unit tests for SearxngSearchTool (JSON parsing, URL validation, tool properties) - [x] 2 config tests (defaults, deserialization) - [x] 1 env override test - [x] Full test suite passes (2772 lib tests) - [x] `cargo clippy -- -D warnings` clean - [ ] Manual test with self-hosted SearXNG instance 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added SearXNG as a new web search provider option * Implemented automatic provider detection based on available configuration * **Configuration** * New environment variable `ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER` to select search provider (brave, searxng, ddg) * New environment variable `ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL` to specify custom search API endpoint <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dff25b4 commit 23790f3

7 files changed

Lines changed: 401 additions & 23 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ src/
311311
│ ├── binary_plugin.rs # Binary plugin adapter (JSON-RPC 2.0 stdin/stdout)
312312
│ ├── shell.rs # Shell execution with runtime isolation
313313
│ ├── filesystem.rs # Read, write, list, edit files (4 tools: read, write, list, edit)
314-
│ ├── web.rs # Web search (Brave + DuckDuckGo fallback) and fetch with SSRF protection
314+
│ ├── web.rs # Web search (Brave + DuckDuckGo + SearXNG) and fetch with SSRF protection
315315
│ ├── git.rs # Git operations (status, diff, log, commit)
316316
│ ├── stripe.rs # Stripe API integration for payment operations
317317
│ ├── pdf_read.rs # PDF text extraction (PdfReadTool)
@@ -615,6 +615,8 @@ Environment variables override config:
615615
- `ZEPTOCLAW_PANEL_PORT` — panel frontend port (default: 9092)
616616
- `ZEPTOCLAW_PANEL_API_PORT` — panel API port (default: 9091)
617617
- `ZEPTOCLAW_PANEL_BIND` — bind address (default: 127.0.0.1)
618+
- `ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER` — search provider: "brave", "searxng", "ddg" (default: auto-detect)
619+
- `ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL` — SearXNG instance URL (required when provider is "searxng")
618620

619621
### Cargo Features
620622

src/cli/common.rs

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ use zeptoclaw::tools::GoogleTool;
4343
use zeptoclaw::tools::{
4444
DdgSearchTool, DocxReadTool, EchoTool, FindSkillsTool, GitTool, GoogleSheetsTool,
4545
HttpRequestTool, InstallSkillTool, MemoryGetTool, MemorySearchTool, MessageTool, PdfReadTool,
46-
ProjectTool, R8rTool, TranscribeTool, WebFetchTool, WebSearchTool, WhatsAppTool,
46+
ProjectTool, R8rTool, SearxngSearchTool, TranscribeTool, WebFetchTool, WebSearchTool,
47+
WhatsAppTool,
4748
};
4849

4950
/// Read a line from stdin, trimming whitespace.
@@ -747,25 +748,79 @@ Enable runtime.allow_fallback_to_native to opt in to native fallback.",
747748

748749
// Register web tools.
749750
if tool_enabled("web_search") {
750-
let brave_key = config
751-
.tools
752-
.web
753-
.search
754-
.api_key
751+
let max = config.tools.web.search.max_results as usize;
752+
let search_cfg = &config.tools.web.search;
753+
754+
// Resolve provider: explicit > auto-detect
755+
let provider = search_cfg
756+
.provider
755757
.as_deref()
756758
.map(str::trim)
757-
.filter(|k| !k.is_empty());
758-
let max = config.tools.web.search.max_results as usize;
759-
if let Some(key) = brave_key {
760-
agent
761-
.register_tool(Box::new(WebSearchTool::with_max_results(key, max)))
762-
.await;
763-
info!("Registered web_search tool (Brave)");
764-
} else {
765-
agent
766-
.register_tool(Box::new(DdgSearchTool::with_max_results(max)))
767-
.await;
768-
info!("Registered web_search tool (DuckDuckGo fallback)");
759+
.filter(|s| !s.is_empty())
760+
.map(|s| s.to_ascii_lowercase())
761+
.unwrap_or_else(|| {
762+
if search_cfg
763+
.api_url
764+
.as_deref()
765+
.map(str::trim)
766+
.filter(|s| !s.is_empty())
767+
.is_some()
768+
{
769+
"searxng".to_string()
770+
} else if search_cfg
771+
.api_key
772+
.as_deref()
773+
.map(str::trim)
774+
.filter(|s| !s.is_empty())
775+
.is_some()
776+
{
777+
"brave".to_string()
778+
} else {
779+
"ddg".to_string()
780+
}
781+
});
782+
783+
match provider.as_str() {
784+
"searxng" => {
785+
let url = search_cfg
786+
.api_url
787+
.as_deref()
788+
.map(str::trim)
789+
.filter(|s| !s.is_empty())
790+
.ok_or_else(|| {
791+
anyhow::anyhow!("SearXNG provider requires tools.web.search.api_url")
792+
})?;
793+
agent
794+
.register_tool(Box::new(SearxngSearchTool::with_max_results(url, max)?))
795+
.await;
796+
info!("Registered web_search tool (SearXNG)");
797+
}
798+
"brave" => {
799+
let key = search_cfg
800+
.api_key
801+
.as_deref()
802+
.map(str::trim)
803+
.filter(|s| !s.is_empty())
804+
.ok_or_else(|| {
805+
anyhow::anyhow!("Brave provider requires tools.web.search.api_key")
806+
})?;
807+
agent
808+
.register_tool(Box::new(WebSearchTool::with_max_results(key, max)))
809+
.await;
810+
info!("Registered web_search tool (Brave)");
811+
}
812+
"ddg" => {
813+
agent
814+
.register_tool(Box::new(DdgSearchTool::with_max_results(max)))
815+
.await;
816+
info!("Registered web_search tool (DuckDuckGo fallback)");
817+
}
818+
other => {
819+
return Err(anyhow::anyhow!(
820+
"Invalid tools.web.search.provider '{}'. Expected one of: brave, searxng, ddg",
821+
other
822+
));
823+
}
769824
}
770825
}
771826
if tool_enabled("web_fetch") {

src/config/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,13 @@ impl Config {
871871
}
872872
}
873873

874+
if let Ok(val) = std::env::var("ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER") {
875+
self.tools.web.search.provider = Some(val);
876+
}
877+
if let Ok(val) = std::env::var("ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL") {
878+
self.tools.web.search.api_url = Some(val);
879+
}
880+
874881
// WhatsApp tool configuration
875882
if let Ok(val) = std::env::var("ZEPTOCLAW_TOOLS_WHATSAPP_PHONE_NUMBER_ID") {
876883
self.tools.whatsapp.phone_number_id = Some(val);
@@ -2009,4 +2016,23 @@ mod tests {
20092016
);
20102017
std::env::remove_var("AWS_ACCESS_KEY_ID");
20112018
}
2019+
2020+
#[test]
2021+
fn test_web_search_env_provider_override() {
2022+
// Use unique env var names to avoid parallel test interference
2023+
std::env::set_var("ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER", "searxng");
2024+
std::env::set_var(
2025+
"ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL",
2026+
"https://s.example.com",
2027+
);
2028+
let mut cfg = Config::default();
2029+
cfg.apply_env_overrides();
2030+
assert_eq!(cfg.tools.web.search.provider.as_deref(), Some("searxng"));
2031+
assert_eq!(
2032+
cfg.tools.web.search.api_url.as_deref(),
2033+
Some("https://s.example.com")
2034+
);
2035+
std::env::remove_var("ZEPTOCLAW_TOOLS_WEB_SEARCH_PROVIDER");
2036+
std::env::remove_var("ZEPTOCLAW_TOOLS_WEB_SEARCH_API_URL");
2037+
}
20122038
}

src/config/types.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1503,17 +1503,25 @@ pub struct WebToolsConfig {
15031503
#[derive(Debug, Clone, Serialize, Deserialize)]
15041504
#[serde(default)]
15051505
pub struct WebSearchConfig {
1506-
/// API key for search service
1506+
/// Search provider: "brave", "searxng", "ddg" (default: auto-detect)
1507+
#[serde(default)]
1508+
pub provider: Option<String>,
1509+
/// API key for Brave Search
15071510
#[serde(default)]
15081511
pub api_key: Option<String>,
1512+
/// SearXNG instance URL (e.g. "https://search.example.com")
1513+
#[serde(default)]
1514+
pub api_url: Option<String>,
15091515
/// Maximum search results to return
15101516
pub max_results: u32,
15111517
}
15121518

15131519
impl Default for WebSearchConfig {
15141520
fn default() -> Self {
15151521
Self {
1522+
provider: None,
15161523
api_key: None,
1524+
api_url: None,
15171525
max_results: 5,
15181526
}
15191527
}
@@ -2726,6 +2734,23 @@ mod tests {
27262734
assert_eq!(azure.auth_header.as_deref(), Some("api-key"));
27272735
assert_eq!(azure.api_version.as_deref(), Some("2024-08-01-preview"));
27282736
}
2737+
2738+
#[test]
2739+
fn test_web_search_config_defaults() {
2740+
let cfg = WebSearchConfig::default();
2741+
assert_eq!(cfg.provider, None);
2742+
assert_eq!(cfg.api_key, None);
2743+
assert_eq!(cfg.api_url, None);
2744+
assert_eq!(cfg.max_results, 5);
2745+
}
2746+
2747+
#[test]
2748+
fn test_web_search_config_deserialize_provider() {
2749+
let json = r#"{"provider": "searxng", "api_url": "https://search.example.com"}"#;
2750+
let cfg: WebSearchConfig = serde_json::from_str(json).unwrap();
2751+
assert_eq!(cfg.provider.as_deref(), Some("searxng"));
2752+
assert_eq!(cfg.api_url.as_deref(), Some("https://search.example.com"));
2753+
}
27292754
}
27302755

27312756
// ---------------------------------------------------------------------------

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,6 @@ pub use tools::{
8989
composed::CreateToolTool, cron::CronTool, custom::CustomTool, delegate::DelegateTool,
9090
spawn::SpawnTool, BinaryPluginTool, DocxReadTool, EchoTool, GitTool, GoogleSheetsTool,
9191
HardwareTool, HttpRequestTool, MemoryGetTool, MemorySearchTool, MessageTool, PdfReadTool,
92-
ProjectTool, R8rTool, ReminderTool, StripeTool, Tool, ToolCategory, ToolContext, ToolRegistry,
93-
WebFetchTool, WebSearchTool, WhatsAppTool,
92+
ProjectTool, R8rTool, ReminderTool, SearxngSearchTool, StripeTool, Tool, ToolCategory,
93+
ToolContext, ToolRegistry, WebFetchTool, WebSearchTool, WhatsAppTool,
9494
};

src/tools/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
//! - `ShellTool`: Execute shell commands
2323
//! - `WebSearchTool`: Search the web via Brave Search API
2424
//! - `DdgSearchTool`: Free web search via DuckDuckGo HTML scraping (fallback)
25+
//! - `SearxngSearchTool`: Web search via self-hosted SearXNG instance
2526
//! - `WebFetchTool`: Fetch URL content and extract text
2627
//! - `MessageTool`: Send proactive outbound chat messages
2728
//! - `MemorySearchTool`: Search workspace markdown memory files
@@ -126,7 +127,8 @@ pub use task::TaskTool;
126127
pub use transcribe::TranscribeTool;
127128
pub use types::{Tool, ToolCategory, ToolContext, ToolOutput};
128129
pub use web::{
129-
is_blocked_host, resolve_and_check_host, DdgSearchTool, WebFetchTool, WebSearchTool,
130+
is_blocked_host, resolve_and_check_host, DdgSearchTool, SearxngSearchTool, WebFetchTool,
131+
WebSearchTool,
130132
};
131133
pub use whatsapp::WhatsAppTool;
132134

0 commit comments

Comments
 (0)