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
+1
View File
@@ -776,6 +776,7 @@ const webSearchSchema = z.object({
provider: z.enum(['brave', 'searxng']).default('brave'),
api_key: z.string().optional(),
endpoint: z.string().optional(),
fallback_endpoint: z.string().optional(),
max_results: z.number().min(1).max(20).default(5),
}).default({});
+1
View File
@@ -31,6 +31,7 @@ export function initTools(deps: ToolsDeps): ToolsResult {
provider: config.web_search.provider,
apiKey: config.web_search.api_key,
endpoint: config.web_search.endpoint,
fallbackEndpoint: config.web_search.fallback_endpoint,
maxResults: config.web_search.max_results,
})) {
toolRegistry.register(tool);
@@ -7,6 +7,7 @@ function createConfig(params?: {
audioEnabled?: boolean;
webSearchProvider?: 'brave' | 'searxng';
webSearchApiKey?: string;
webSearchEndpoint?: string;
}): Config {
return {
audio: {
@@ -19,7 +20,7 @@ function createConfig(params?: {
web_search: {
provider: params?.webSearchProvider ?? 'brave',
api_key: params?.webSearchApiKey,
endpoint: undefined,
endpoint: params?.webSearchEndpoint,
max_results: 5,
},
} as unknown as Config;
@@ -201,6 +202,46 @@ describe('listDockerDependencyStatuses', () => {
expect(statuses[0]?.id).toBe('whisper');
expect(statuses[0]?.configured).toBe(false);
});
it('labels searxng dependency and marks configured when searxng endpoint is set', async () => {
const runner = async (args: string[]) => {
if (args.join(' ') === 'config --profiles') {
return { stdout: 'search\n', stderr: '' };
}
if (args.join(' ') === '--profile search config --services') {
return { stdout: 'flynn\nsearxng\n', stderr: '' };
}
if (args.join(' ') === '--profile search ps --all --format json') {
return { stdout: '[]', stderr: '' };
}
if (args.join(' ') === '--profile search ps searxng --format json') {
return {
stdout: JSON.stringify([{
Name: 'searxng',
Service: 'searxng',
State: 'running',
Status: 'Up 5 minutes',
}]),
stderr: '',
};
}
throw new Error(`Unexpected args: ${args.join(' ')}`);
};
const statuses = await listDockerDependencyStatuses(
createConfig({ webSearchProvider: 'searxng', webSearchEndpoint: 'http://searxng:8080' }),
runner,
);
expect(statuses).toHaveLength(1);
expect(statuses[0]).toMatchObject({
id: 'searxng',
name: 'SearXNG',
service: 'searxng',
configured: true,
state: 'running',
statusText: 'Up 5 minutes',
});
});
});
describe('controlDockerDependency', () => {
@@ -55,6 +55,7 @@ const FLYNN_SERVICE = 'flynn';
const WHISPER_SERVICE = 'whisper-server';
const WHISPER_DEPENDENCY_ID = 'whisper';
const BRAVE_SEARCH_SERVICE = 'brave-search';
const SEARXNG_SERVICE = 'searxng';
function runCompose(args: string[], timeout: number): Promise<DockerComposeResult> {
return execFile('docker', ['compose', '-f', COMPOSE_FILE, ...args], {
@@ -212,6 +213,12 @@ function isBraveSearchConfigured(config: Config): boolean {
return typeof apiKey === 'string' && apiKey.trim().length > 0;
}
function isSearxngConfigured(config: Config): boolean {
if (config.web_search.provider !== 'searxng') {return false;}
const endpoint = config.web_search.endpoint;
return typeof endpoint === 'string' && endpoint.trim().length > 0;
}
function toDependencyId(service: string): DockerDependencyId {
if (service === WHISPER_SERVICE) {
return WHISPER_DEPENDENCY_ID;
@@ -247,6 +254,14 @@ function describeDependency(service: string, config: Config): DockerDependencyDe
configured: isBraveSearchConfigured(config),
};
}
if (service === SEARXNG_SERVICE) {
return {
id: SEARXNG_SERVICE,
name: 'SearXNG',
service,
configured: isSearxngConfigured(config),
};
}
return {
id: toDependencyId(service),
name: toDependencyName(service),
+1
View File
@@ -97,6 +97,7 @@ export function discoverServices(
metadata: {
provider: config.web_search?.provider ?? 'brave',
endpoint: config.web_search?.endpoint,
fallback_endpoint: config.web_search?.fallback_endpoint,
max_results: config.web_search?.max_results,
},
});
+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) {