diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index dee121ce8..42522fc0c 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -1681,6 +1681,7 @@ export const SETTINGS_SCHEMA = { "kagi", "synthetic", "parallel", + "searxng", ] as const, default: "auto", ui: { @@ -1761,6 +1762,47 @@ export const SETTINGS_SCHEMA = { ui: { tab: "providers", label: "Exa Websets", description: "Webset management and enrichment tools" }, }, + // SearXNG + "searxng.endpoint": { + type: "string", + default: undefined, + ui: { + tab: "providers", + label: "SearXNG Endpoint", + description: "Base URL of the SearXNG instance (e.g. https://searx.example.org)", + }, + }, + + "searxng.token": { + type: "string", + default: undefined, + ui: { + tab: "providers", + label: "SearXNG Token", + description: "Optional bearer token for SearXNG authentication", + }, + }, + + "searxng.categories": { + type: "string", + default: undefined, + ui: { + tab: "providers", + label: "SearXNG Categories", + description: "Comma-separated categories filter (e.g. general,news,science)", + }, + }, + + "searxng.language": { + type: "string", + default: undefined, + ui: { + tab: "providers", + label: "SearXNG Language", + description: "Language code for search results (e.g. en, zh-CN)", + }, + }, + "commit.mapReduceEnabled": { type: "boolean", default: true }, "commit.mapReduceMinFiles": { type: "number", default: 4 }, diff --git a/packages/coding-agent/src/modes/components/settings-defs.ts b/packages/coding-agent/src/modes/components/settings-defs.ts index f8cafb7f0..5efacd776 100644 --- a/packages/coding-agent/src/modes/components/settings-defs.ts +++ b/packages/coding-agent/src/modes/components/settings-defs.ts @@ -348,6 +348,7 @@ const OPTION_PROVIDERS: Partial> = { { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" }, { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" }, { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" }, + { value: "searxng", label: "SearXNG", description: "Self-hosted metasearch; set searxng.endpoint" }, ], "providers.image": [ { diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index b31d34452..cedb8056c 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -1,7 +1,7 @@ /** * Unified Web Search Tool * - * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, and Synthetic + * Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Tavily, Kagi, Z.AI, SearXNG, and Synthetic * providers with provider-specific parameters exposed conditionally. * */ @@ -202,7 +202,7 @@ export async function runSearchQuery( /** * Web search tool implementation. * - * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, and Synthetic providers with automatic fallback. + * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, SearXNG, and Synthetic providers with automatic fallback. * Session is accepted for interface consistency but not used. */ export class SearchTool implements AgentTool { diff --git a/packages/coding-agent/src/web/search/provider.ts b/packages/coding-agent/src/web/search/provider.ts index 1161b1f17..39450120e 100644 --- a/packages/coding-agent/src/web/search/provider.ts +++ b/packages/coding-agent/src/web/search/provider.ts @@ -9,6 +9,7 @@ import { KagiProvider } from "./providers/kagi"; import { KimiProvider } from "./providers/kimi"; import { ParallelProvider } from "./providers/parallel"; import { PerplexityProvider } from "./providers/perplexity"; +import { SearXNGProvider } from "./providers/searxng"; import { SyntheticProvider } from "./providers/synthetic"; import { TavilyProvider } from "./providers/tavily"; import { ZaiProvider } from "./providers/zai"; @@ -31,6 +32,7 @@ const SEARCH_PROVIDERS: Record = { parallel: new ParallelProvider(), kagi: new KagiProvider(), synthetic: new SyntheticProvider(), + searxng: new SearXNGProvider(), } as const; export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [ @@ -47,6 +49,7 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [ "parallel", "kagi", "synthetic", + "searxng", ]; export function getSearchProvider(provider: SearchProviderId): SearchProvider { diff --git a/packages/coding-agent/src/web/search/providers/searxng.ts b/packages/coding-agent/src/web/search/providers/searxng.ts new file mode 100644 index 000000000..fbde0d411 --- /dev/null +++ b/packages/coding-agent/src/web/search/providers/searxng.ts @@ -0,0 +1,243 @@ +/** + * SearXNG Web Search Provider + * + * Calls a SearXNG instance's JSON search API and maps results into the unified + * SearchResponse shape used by the web search tool. + * + * SearXNG is a free, open-source metasearch engine that aggregates results from + * multiple sources without tracking users. It supports self-hosted instances + * and various authentication methods (bearer token, basic auth, or none). + * + * Configuration via settings: + * searxng.endpoint - Base URL of the SearXNG instance (e.g. https://searx.example.org) + * searxng.token - Optional bearer token for authentication + * searxng.categories - Optional comma-separated categories filter + * searxng.language - Optional language code (e.g. en, zh-CN) + * + * Environment variable fallbacks: + * SEARXNG_ENDPOINT - Base URL of the SearXNG instance + * SEARXNG_TOKEN - Optional bearer token + * + * Reference: https://docs.searxng.org/dev/search_api.html + */ +import type { SearchResponse, SearchSource } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; +import { settings } from "../../../config/settings"; +import { clampNumResults, dateToAgeSeconds } from "../utils"; +import type { SearchParams } from "./base"; +import { SearchProvider } from "./base"; + +const DEFAULT_NUM_RESULTS = 10; +const MAX_NUM_RESULTS = 20; + +/** Map our recency filter to SearXNG time_range parameter. + * SearXNG only supports day/month/year, so week maps to month. */ +const RECENCY_MAP: Record<"day" | "week" | "month" | "year", string> = { + day: "day", + week: "month", + month: "month", + year: "year", +}; + +/** SearXNG JSON API response types */ +interface SearXNGResult { + title?: string; + url?: string; + content?: string; + engine?: string; + publishedDate?: string; + /** SearXNG sometimes uses publishedDate, sometimes just date */ + published_date?: string; + score?: number; +} + +interface SearXNGResponse { + query?: string; + number_of_results?: number; + results?: SearXNGResult[]; + suggestions?: string[]; + corrections?: string[]; + unresponsive_engines?: Array<[string, string]>; +} + +/** Find SearXNG endpoint from settings or environment. */ +function findEndpoint(): string | null { + try { + const endpoint = settings.get("searxng.endpoint"); + if (endpoint) return endpoint; + } catch { + // Settings not initialized yet + } + return process.env.SEARXNG_ENDPOINT ?? null; +} + +/** Find SearXNG bearer token from settings or environment. */ +function findToken(): string | null { + try { + const token = settings.get("searxng.token"); + if (token) return token; + } catch { + // Settings not initialized yet + } + return process.env.SEARXNG_TOKEN ?? null; +} + +/** Build the search URL and headers for a SearXNG request */ +function buildRequest( + endpoint: string, + params: { + query: string; + num_results?: number; + recency?: "day" | "week" | "month" | "year"; + categories?: string; + language?: string; + signal?: AbortSignal; + }, + token: string | null, +): { url: URL; headers: Record } { + const base = endpoint.replace(/\/+$/, ""); + const url = new URL(`${base}/search`); + + url.searchParams.set("q", params.query); + url.searchParams.set("format", "json"); + + if (params.num_results) { + url.searchParams.set("pageno", "1"); + } + + if (params.recency) { + url.searchParams.set("time_range", RECENCY_MAP[params.recency]); + } + + if (params.categories) { + url.searchParams.set("categories", params.categories); + } + + if (params.language) { + url.searchParams.set("language", params.language); + } + + const headers: Record = { + Accept: "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + return { url, headers }; +} + +async function callSearXNGSearch( + endpoint: string, + params: { + query: string; + num_results?: number; + recency?: "day" | "week" | "month" | "year"; + categories?: string; + language?: string; + signal?: AbortSignal; + }, + token: string | null, +): Promise { + const { url, headers } = buildRequest(endpoint, params, token); + + const response = await fetch(url, { + headers, + signal: params.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new SearchProviderError( + "searxng", + `SearXNG API error (${response.status}): ${errorText}`, + response.status, + ); + } + + return (await response.json()) as SearXNGResponse; +} + +/** Execute SearXNG web search. */ +export async function searchSearXNG( + params: { + query: string; + num_results?: number; + recency?: "day" | "week" | "month" | "year"; + signal?: AbortSignal; + }, +): Promise { + const numResults = clampNumResults(params.num_results, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS); + + const endpoint = findEndpoint(); + if (!endpoint) { + throw new Error( + "SearXNG endpoint not configured. Set searxng.endpoint in settings or SEARXNG_ENDPOINT in environment.", + ); + } + + const token = findToken(); + + let categories: string | undefined; + let language: string | undefined; + try { + categories = settings.get("searxng.categories") ?? undefined; + language = settings.get("searxng.language") ?? undefined; + } catch { + // Settings not initialized yet + } + + const response = await callSearXNGSearch( + endpoint, + { + ...params, + categories, + language, + }, + token, + ); + + const sources: SearchSource[] = []; + + for (const result of response.results ?? []) { + if (!result.url) continue; + const publishedDate = result.publishedDate ?? result.published_date; + sources.push({ + title: result.title ?? result.url, + url: result.url, + snippet: result.content?.trim() || undefined, + publishedDate: publishedDate ?? undefined, + ageSeconds: dateToAgeSeconds(publishedDate), + }); + } + + return { + provider: "searxng", + sources: sources.slice(0, numResults), + relatedQuestions: response.suggestions?.length ? response.suggestions : undefined, + }; +} + +/** Search provider for SearXNG web search. */ +export class SearXNGProvider extends SearchProvider { + readonly id = "searxng"; + readonly label = "SearXNG"; + + isAvailable() { + try { + return !!findEndpoint(); + } catch { + return false; + } + } + + search(params: SearchParams): Promise { + return searchSearXNG({ + query: params.query, + num_results: params.numSearchResults ?? params.limit, + recency: params.recency, + signal: params.signal, + }); + } +} diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 0a5ce00be..8647c6006 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -18,7 +18,8 @@ export type SearchProviderId = | "tavily" | "parallel" | "kagi" - | "synthetic"; + | "synthetic" + | "searxng"; export function isSearchProviderId(value: string): value is SearchProviderId { return [ @@ -35,6 +36,7 @@ export function isSearchProviderId(value: string): value is SearchProviderId { "parallel", "kagi", "synthetic", + "searxng", ].includes(value); }