Improve local service checks and web search tooling

This commit is contained in:
William Valentin
2026-02-22 22:12:44 -08:00
parent b477705806
commit f27aabae3b
12 changed files with 228 additions and 11 deletions
+47
View File
@@ -69,6 +69,12 @@ const searxngConfig: WebSearchConfig = {
endpoint: 'http://searxng:8080',
};
const searxngWithFallbackConfig: WebSearchConfig = {
provider: 'searxng',
endpoint: 'http://searxng:8080',
fallbackEndpoint: 'https://searxng.homelab.local',
};
// ═════════════════════════════════════════════════════════════════════════════
// Tests
// ═════════════════════════════════════════════════════════════════════════════
@@ -216,6 +222,47 @@ describe('web.search', () => {
expect(result.error).toBe('SearXNG endpoint not configured');
expect(mockFetch).not.toHaveBeenCalled();
});
it('falls back to backup endpoint when primary endpoint fails', async () => {
mockFetch
.mockRejectedValueOnce(new Error('connect ECONNREFUSED'))
.mockResolvedValueOnce(mockJsonResponse(searxngResults));
const tool = createWebSearchTool(searxngWithFallbackConfig);
const result = await tool.execute({ query: 'test query' });
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch.mock.calls[0][0]).toContain('http://searxng:8080/search');
expect(mockFetch.mock.calls[1][0]).toContain('https://searxng.homelab.local/search');
});
it('returns combined error when primary and fallback both fail', async () => {
mockFetch
.mockRejectedValueOnce(new Error('primary timeout'))
.mockRejectedValueOnce(new Error('fallback timeout'));
const tool = createWebSearchTool(searxngWithFallbackConfig);
const result = await tool.execute({ query: 'test query' });
expect(result.success).toBe(false);
expect(result.error).toContain('SearXNG primary failed');
expect(result.error).toContain('fallback failed');
});
it('allows fallback-only endpoint configuration', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(searxngResults));
const tool = createWebSearchTool({
provider: 'searxng',
fallbackEndpoint: 'https://searxng.homelab.local',
});
const result = await tool.execute({ query: 'test query' });
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch.mock.calls[0][0]).toContain('https://searxng.homelab.local/search');
});
});
// ── Count parameter ─────────────────────────────────────────────────────
+63 -8
View File
@@ -7,6 +7,8 @@ export interface WebSearchConfig {
apiKey?: string;
/** Required for SearXNG (e.g. 'http://searxng:8080'). */
endpoint?: string;
/** Optional fallback SearXNG endpoint (for example a homelab instance). */
fallbackEndpoint?: string;
/** Maximum number of results to return (default: 5). */
maxResults?: number;
}
@@ -191,6 +193,66 @@ async function searchSearxng(
}));
}
function normalizeEndpoint(endpoint: string | undefined): string | null {
if (typeof endpoint !== 'string') {
return null;
}
const trimmed = endpoint.trim();
return trimmed.length > 0 ? trimmed : null;
}
function resolveSearxngEndpoints(config: WebSearchConfig): string[] {
const endpoints: string[] = [];
const primary = normalizeEndpoint(config.endpoint);
const fallback = normalizeEndpoint(config.fallbackEndpoint);
if (primary) {
endpoints.push(primary);
}
if (fallback && fallback !== primary) {
endpoints.push(fallback);
}
return endpoints;
}
function normalizeErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function searchSearxngWithFallback(
query: string,
count: number,
config: WebSearchConfig,
signal?: AbortSignal,
): Promise<SearchResult[]> {
const endpoints = resolveSearxngEndpoints(config);
if (endpoints.length === 0) {
throw new Error('SearXNG endpoint not configured');
}
if (endpoints.length === 1) {
return searchSearxng(query, count, endpoints[0] as string, signal);
}
const [primary, fallback] = endpoints;
try {
return await searchSearxng(query, count, primary as string, signal);
} catch (primaryError) {
if (signal?.aborted) {
throw primaryError;
}
try {
return await searchSearxng(query, count, fallback as string, signal);
} catch (fallbackError) {
throw new Error(
`SearXNG primary failed: ${normalizeErrorMessage(primaryError)}; fallback failed: ${normalizeErrorMessage(fallbackError)}`,
);
}
}
}
function normalizeToolError(error: unknown): ToolResult {
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
return { success: false, output: '', error: 'Operation aborted' };
@@ -274,14 +336,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
results = await searchBraveWeb(braveArgs, config.apiKey, context?.signal);
} else {
// SearXNG provider
if (!config.endpoint) {
return {
success: false,
output: '',
error: 'SearXNG endpoint not configured',
};
}
results = await searchSearxng(args.query, count, config.endpoint, context?.signal);
results = await searchSearxngWithFallback(args.query, count, config, context?.signal);
}
if (results.length === 0) {