From 92ed27d0395b52685f27dd7bc5d2fd3713d3ca85 Mon Sep 17 00:00:00 2001 From: "Kai (hypescale Intelligence)" Date: Wed, 28 Jan 2026 06:43:31 +0100 Subject: [PATCH 1/2] feat(web-search): add SearXNG as third search provider Adds support for self-hosted SearXNG instances as a web_search provider. Changes: - Add 'searxng' to SEARCH_PROVIDERS - Add SearXNG config type and resolution functions - Implement runSearxngSearch() with JSON API support - Update Zod schema for tools.web.search.searxng.baseUrl - Add config help text for SearXNG options Configuration: ```yaml tools: web: search: provider: searxng searxng: baseUrl: https://search.example.com ``` Or via environment variable: SEARXNG_BASE_URL SearXNG aggregates results from multiple search engines and returns structured data including title, url, description, source engines, and relevance score - no API key required. --- src/agents/tools/web-search.ts | 161 +++++++++++++++++++++++-- src/config/schema.ts | 4 +- src/config/zod-schema.agent-runtime.ts | 8 +- 3 files changed, 164 insertions(+), 9 deletions(-) diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 1d87676e8..8b97c9a61 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -17,7 +17,7 @@ import { writeCache, } from "./web-shared.js"; -const SEARCH_PROVIDERS = ["brave", "perplexity"] as const; +const SEARCH_PROVIDERS = ["brave", "perplexity", "searxng"] as const; const DEFAULT_SEARCH_COUNT = 5; const MAX_SEARCH_COUNT = 10; @@ -90,6 +90,25 @@ type PerplexityConfig = { model?: string; }; +type SearxngConfig = { + baseUrl?: string; +}; + +type SearxngSearchResult = { + url?: string; + title?: string; + content?: string; + engines?: string[]; + score?: number; + publishedDate?: string | null; +}; + +type SearxngSearchResponse = { + query?: string; + number_of_results?: number; + results?: SearxngSearchResult[]; +}; + type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none"; type PerplexitySearchResponse = { @@ -131,6 +150,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) { docs: "https://docs.molt.bot/tools/web", }; } + if (provider === "searxng") { + return { + error: "missing_searxng_base_url", + message: + "web_search (searxng) needs a base URL. Configure tools.web.search.searxng.baseUrl with your SearXNG instance URL.", + docs: "https://docs.molt.bot/tools/web", + }; + } return { error: "missing_brave_api_key", message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("moltbot configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`, @@ -144,6 +171,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE ? search.provider.trim().toLowerCase() : ""; if (raw === "perplexity") return "perplexity"; + if (raw === "searxng") return "searxng"; if (raw === "brave") return "brave"; return "brave"; } @@ -155,6 +183,22 @@ function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig { return perplexity as PerplexityConfig; } +function resolveSearxngConfig(search?: WebSearchConfig): SearxngConfig { + if (!search || typeof search !== "object") return {}; + const searxng = "searxng" in search ? search.searxng : undefined; + if (!searxng || typeof searxng !== "object") return {}; + return searxng as SearxngConfig; +} + +function resolveSearxngBaseUrl(searxng?: SearxngConfig): string | undefined { + const fromConfig = + searxng && "baseUrl" in searxng && typeof searxng.baseUrl === "string" + ? searxng.baseUrl.trim() + : ""; + const fromEnv = (process.env.SEARXNG_BASE_URL ?? "").trim(); + return fromConfig || fromEnv || undefined; +} + function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { apiKey?: string; source: PerplexityApiKeySource; @@ -306,6 +350,64 @@ async function runPerplexitySearch(params: { return { content, citations }; } +async function runSearxngSearch(params: { + query: string; + baseUrl: string; + count: number; + timeoutSeconds: number; + language?: string; +}): Promise<{ + results: Array<{ + title: string; + url: string; + description: string; + engines: string[]; + score: number; + published?: string; + siteName?: string; + }>; +}> { + const url = new URL("/search", params.baseUrl.replace(/\/$/, "")); + url.searchParams.set("q", params.query); + url.searchParams.set("format", "json"); + if (params.language) { + url.searchParams.set("language", params.language); + } + + const res = await fetch(url.toString(), { + method: "GET", + headers: { + Accept: "application/json", + }, + signal: withTimeout(undefined, params.timeoutSeconds * 1000), + }); + + if (!res.ok) { + const detail = await readResponseText(res); + throw new Error(`SearXNG API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as SearxngSearchResponse; + const results = Array.isArray(data.results) ? data.results : []; + + // Sort by score descending and take top N results + const sorted = results + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + .slice(0, params.count); + + const mapped = sorted.map((entry) => ({ + title: entry.title ?? "", + url: entry.url ?? "", + description: entry.content ?? "", + engines: entry.engines ?? [], + score: entry.score ?? 0, + published: entry.publishedDate ?? undefined, + siteName: resolveSiteName(entry.url ?? ""), + })); + + return { results: mapped }; +} + async function runWebSearch(params: { query: string; count: number; @@ -319,11 +421,14 @@ async function runWebSearch(params: { freshness?: string; perplexityBaseUrl?: string; perplexityModel?: string; + searxngBaseUrl?: string; }): Promise> { const cacheKey = normalizeCacheKey( params.provider === "brave" ? `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}` - : `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`, + : params.provider === "searxng" + ? `${params.provider}:${params.searxngBaseUrl}:${params.query}:${params.count}:${params.search_lang || "default"}` + : `${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || "default"}:${params.ui_lang || "default"}`, ); const cached = readCache(SEARCH_CACHE, cacheKey); if (cached) return { ...cached.value, cached: true }; @@ -351,6 +456,31 @@ async function runWebSearch(params: { return payload; } + if (params.provider === "searxng") { + if (!params.searxngBaseUrl) { + throw new Error("SearXNG base URL is required."); + } + + const { results } = await runSearxngSearch({ + query: params.query, + baseUrl: params.searxngBaseUrl, + count: params.count, + timeoutSeconds: params.timeoutSeconds, + language: params.search_lang, + }); + + const payload = { + query: params.query, + provider: params.provider, + baseUrl: params.searxngBaseUrl, + count: results.length, + tookMs: Date.now() - start, + results, + }; + writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } + if (params.provider !== "brave") { throw new Error("Unsupported web search provider."); } @@ -415,11 +545,14 @@ export function createWebSearchTool(options?: { const provider = resolveSearchProvider(search); const perplexityConfig = resolvePerplexityConfig(search); + const searxngConfig = resolveSearxngConfig(search); const description = provider === "perplexity" ? "Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search." - : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; + : provider === "searxng" + ? "Search the web using a self-hosted SearXNG instance. Aggregates results from multiple search engines. Returns titles, URLs, descriptions, and source engines." + : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research."; return { label: "Web Search", @@ -429,12 +562,24 @@ export function createWebSearchTool(options?: { execute: async (_toolCallId, args) => { const perplexityAuth = provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined; - const apiKey = - provider === "perplexity" ? perplexityAuth?.apiKey : resolveSearchApiKey(search); + const searxngBaseUrl = provider === "searxng" ? resolveSearxngBaseUrl(searxngConfig) : undefined; - if (!apiKey) { + // For SearXNG, we don't need an API key, just a base URL + const apiKey = + provider === "perplexity" + ? perplexityAuth?.apiKey + : provider === "searxng" + ? "unused" // SearXNG doesn't need an API key + : resolveSearchApiKey(search); + + if (provider === "searxng" && !searxngBaseUrl) { return jsonResult(missingSearchKeyPayload(provider)); } + + if (!apiKey && provider !== "searxng") { + return jsonResult(missingSearchKeyPayload(provider)); + } + const params = args as Record; const query = readStringParam(params, "query", { required: true }); const count = @@ -462,7 +607,7 @@ export function createWebSearchTool(options?: { const result = await runWebSearch({ query, count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - apiKey, + apiKey: apiKey ?? "", timeoutSeconds: resolveTimeoutSeconds(search?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), cacheTtlMs: resolveCacheTtlMs(search?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), provider, @@ -476,6 +621,7 @@ export function createWebSearchTool(options?: { perplexityAuth?.apiKey, ), perplexityModel: resolvePerplexityModel(perplexityConfig), + searxngBaseUrl, }); return jsonResult(result); }, @@ -486,4 +632,5 @@ export const __testing = { inferPerplexityBaseUrlFromApiKey, resolvePerplexityBaseUrl, normalizeFreshness, + resolveSearxngBaseUrl, } as const; diff --git a/src/config/schema.ts b/src/config/schema.ts index b4ec8723b..bcead396d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -434,7 +434,7 @@ const FIELD_HELP: Record = { 'Text suffix for cross-context markers (supports "{channel}").', "tools.message.broadcast.enabled": "Enable broadcast action (default: true).", "tools.web.search.enabled": "Enable the web_search tool (requires a provider API key).", - "tools.web.search.provider": 'Search provider ("brave" or "perplexity").', + "tools.web.search.provider": 'Search provider ("brave", "perplexity", or "searxng").', "tools.web.search.apiKey": "Brave Search API key (fallback: BRAVE_API_KEY env var).", "tools.web.search.maxResults": "Default number of results to return (1-10).", "tools.web.search.timeoutSeconds": "Timeout in seconds for web_search requests.", @@ -445,6 +445,8 @@ const FIELD_HELP: Record = { "Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).", "tools.web.search.perplexity.model": 'Perplexity model override (default: "perplexity/sonar-pro").', + "tools.web.search.searxng.baseUrl": + "SearXNG instance base URL (e.g. https://search.example.com). Can also be set via SEARXNG_BASE_URL env var.", "tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).", "tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).", "tools.web.fetch.timeoutSeconds": "Timeout in seconds for web_fetch requests.", diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 7a63e307d..01a4892f9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -165,7 +165,7 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity")]).optional(), + provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("searxng")]).optional(), apiKey: z.string().optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), @@ -178,6 +178,12 @@ export const ToolsWebSearchSchema = z }) .strict() .optional(), + searxng: z + .object({ + baseUrl: z.string().optional(), + }) + .strict() + .optional(), }) .strict() .optional(); From 4040dc8f597434f88a0b1410817746de25450356 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Jan 2026 10:27:28 +0100 Subject: [PATCH 2/2] style: format with oxfmt --- src/agents/tools/web-search.ts | 7 +++---- src/config/zod-schema.agent-runtime.ts | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index 8b97c9a61..6917e828c 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -391,9 +391,7 @@ async function runSearxngSearch(params: { const results = Array.isArray(data.results) ? data.results : []; // Sort by score descending and take top N results - const sorted = results - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - .slice(0, params.count); + const sorted = results.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, params.count); const mapped = sorted.map((entry) => ({ title: entry.title ?? "", @@ -562,7 +560,8 @@ export function createWebSearchTool(options?: { execute: async (_toolCallId, args) => { const perplexityAuth = provider === "perplexity" ? resolvePerplexityApiKey(perplexityConfig) : undefined; - const searxngBaseUrl = provider === "searxng" ? resolveSearxngBaseUrl(searxngConfig) : undefined; + const searxngBaseUrl = + provider === "searxng" ? resolveSearxngBaseUrl(searxngConfig) : undefined; // For SearXNG, we don't need an API key, just a base URL const apiKey = diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 01a4892f9..6e9cb96be 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -165,7 +165,9 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => export const ToolsWebSearchSchema = z .object({ enabled: z.boolean().optional(), - provider: z.union([z.literal("brave"), z.literal("perplexity"), z.literal("searxng")]).optional(), + provider: z + .union([z.literal("brave"), z.literal("perplexity"), z.literal("searxng")]) + .optional(), apiKey: z.string().optional(), maxResults: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),