Improve local service checks and web search tooling
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user