feat: add brave search container and toolset

This commit is contained in:
William Valentin
2026-02-22 20:12:54 -08:00
parent 487f26e36d
commit ba6abfb078
13 changed files with 502 additions and 53 deletions
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { createWebSearchTools } from './index.js';
describe('createWebSearchTools', () => {
it('registers web + news search tools for Brave provider', () => {
const tools = createWebSearchTools({
provider: 'brave',
apiKey: 'test-key',
});
expect(tools.map((tool) => tool.name)).toEqual(['web.search', 'web.search.news']);
});
it('registers only web.search for SearXNG provider', () => {
const tools = createWebSearchTools({
provider: 'searxng',
endpoint: 'http://searxng:8080',
});
expect(tools.map((tool) => tool.name)).toEqual(['web.search']);
});
});
+7 -2
View File
@@ -13,6 +13,7 @@ export { createMemoryReadTool } from './memory-read.js';
export { createMemoryWriteTool } from './memory-write.js';
export { createMemorySearchTool } from './memory-search.js';
export { createWebSearchTool } from './web-search.js';
export { createBraveNewsSearchTool } from './web-search.js';
export type { WebSearchConfig } from './web-search.js';
export { createProcessTools, ProcessManager } from './process/index.js';
export type { ProcessManagerConfig } from './process/index.js';
@@ -53,7 +54,7 @@ import { screenCaptureTool, cameraCaptureTool } from './capture.js';
import { createMemoryReadTool } from './memory-read.js';
import { createMemoryWriteTool } from './memory-write.js';
import { createMemorySearchTool } from './memory-search.js';
import { createWebSearchTool } from './web-search.js';
import { createBraveNewsSearchTool, createWebSearchTool } from './web-search.js';
/** Static builtin tools that don't require runtime dependencies. */
export const allBuiltinTools: Tool[] = [
@@ -80,5 +81,9 @@ export function createMemoryTools(store: MemoryStore, searchBackend?: MemorySear
/** Create the web search tool with provider config. */
export function createWebSearchTools(config: WebSearchConfig): Tool[] {
return [createWebSearchTool(config)];
const tools: Tool[] = [createWebSearchTool(config)];
if (config.provider === 'brave') {
tools.push(createBraveNewsSearchTool(config));
}
return tools;
}
+88 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createWebSearchTool } from './web-search.js';
import { createBraveNewsSearchTool, createWebSearchTool } from './web-search.js';
import type { WebSearchConfig } from './web-search.js';
// Mock global fetch
@@ -36,6 +36,13 @@ const braveResults = {
},
};
const braveNewsResults = {
results: [
{ title: 'News 1', url: 'https://news.example.com/1', description: 'News Description 1', age: '2h' },
{ title: 'News 2', url: 'https://news.example.com/2', description: 'News Description 2', age: '1d' },
],
};
// ── SearXNG mock data ────────────────────────────────────────────────────────
const searxngResults = {
@@ -143,6 +150,25 @@ describe('web.search', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to fetch');
});
it('passes optional Brave query controls to the API', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
await tool.execute({
query: 'privacy browsers',
country: 'us',
searchLang: 'en',
safeSearch: 'moderate',
freshness: 'pw',
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('country=us');
expect(calledUrl).toContain('search_lang=en');
expect(calledUrl).toContain('safesearch=moderate');
expect(calledUrl).toContain('freshness=pw');
});
});
// ── SearXNG provider ────────────────────────────────────────────────────
@@ -218,6 +244,16 @@ describe('web.search', () => {
expect(calledUrl).toContain('count=20');
});
it('clamps count minimum to 1', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
await tool.execute({ query: 'test', count: 0 });
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('count=1');
});
it('uses default count when not specified', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
@@ -271,3 +307,54 @@ describe('web.search', () => {
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('web.search.news', () => {
it('returns formatted news results from Brave news endpoint', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveNewsResults));
const tool = createBraveNewsSearchTool(braveConfig);
const result = await tool.execute({ query: 'flynn project updates', count: 2 });
expect(result.success).toBe(true);
expect(result.output).toContain('**News 1**');
expect(result.output).toContain('https://news.example.com/1');
expect(result.output).toContain('News Description 1');
expect(result.output).toContain('(2h)');
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('api.search.brave.com/res/v1/news/search'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Subscription-Token': 'test-brave-key',
}),
}),
);
});
it('returns error when no API key configured', async () => {
const tool = createBraveNewsSearchTool({ provider: 'brave' });
const result = await tool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toBe('Brave Search API key not configured');
expect(mockFetch).not.toHaveBeenCalled();
});
it('supports country, language, and freshness filters', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveNewsResults));
const tool = createBraveNewsSearchTool(braveConfig);
await tool.execute({
query: 'ai policy',
country: 'gb',
searchLang: 'en',
freshness: 'pd',
});
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('country=gb');
expect(calledUrl).toContain('search_lang=en');
expect(calledUrl).toContain('freshness=pd');
});
});
+195 -45
View File
@@ -14,12 +14,17 @@ export interface WebSearchConfig {
interface WebSearchArgs {
query: string;
count?: number;
country?: string;
searchLang?: string;
safeSearch?: 'off' | 'moderate' | 'strict';
freshness?: 'pd' | 'pw' | 'pm' | 'py';
}
interface SearchResult {
title: string;
url: string;
snippet: string;
age?: string;
}
/** Fetch timeout in milliseconds. */
@@ -37,38 +42,64 @@ const DEFAULT_RESULTS = 5;
function formatResults(results: SearchResult[]): string {
return results
.map(
(r, i) => `${i + 1}. **${r.title}** \u2014 ${r.url}\n ${r.snippet}`,
(r, i) => `${i + 1}. **${r.title}** \u2014 ${r.url}${r.age ? ` (${r.age})` : ''}\n ${r.snippet}`,
)
.join('\n\n');
}
/**
* Search using the Brave Search API.
*/
async function searchBrave(
query: string,
count: number,
apiKey: string,
signal?: AbortSignal,
): Promise<SearchResult[]> {
const params = new URLSearchParams({
q: query,
count: String(count),
});
function resolveCount(requestedCount: number | undefined, defaultCount: number): number {
const normalized = Number.isFinite(requestedCount)
? Math.trunc(requestedCount as number)
: defaultCount;
return Math.min(Math.max(normalized, 1), MAX_RESULTS);
}
function createBraveHeaders(apiKey: string): Record<string, string> {
return {
'Accept': 'application/json',
'X-Subscription-Token': apiKey,
};
}
function createFetchSignal(signal?: AbortSignal): AbortSignal {
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const fetchSignal = signal
return signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
}
function appendBraveCommonQueryParams(params: URLSearchParams, args: WebSearchArgs): void {
params.set('q', args.query);
params.set('count', String(args.count));
if (args.country) {
params.set('country', args.country);
}
if (args.searchLang) {
params.set('search_lang', args.searchLang);
}
if (args.freshness) {
params.set('freshness', args.freshness);
}
}
async function fetchBraveEndpoint<TResponse>(
endpoint: 'web' | 'news',
args: WebSearchArgs,
apiKey: string,
signal?: AbortSignal,
): Promise<TResponse> {
const params = new URLSearchParams();
appendBraveCommonQueryParams(params, args);
if (endpoint === 'web' && args.safeSearch) {
params.set('safesearch', args.safeSearch);
}
const response = await fetch(
`https://api.search.brave.com/res/v1/web/search?${params.toString()}`,
`https://api.search.brave.com/res/v1/${endpoint}/search?${params.toString()}`,
{
signal: fetchSignal,
headers: {
'Accept': 'application/json',
'X-Subscription-Token': apiKey,
},
signal: createFetchSignal(signal),
headers: createBraveHeaders(apiKey),
},
);
@@ -77,15 +108,45 @@ async function searchBrave(
throw new Error(`Brave API HTTP ${response.status}: ${body}`);
}
const data = (await response.json()) as {
return (await response.json()) as TResponse;
}
/**
* Search using the Brave Search API.
*/
async function searchBraveWeb(
args: WebSearchArgs,
apiKey: string,
signal?: AbortSignal,
): Promise<SearchResult[]> {
const data = await fetchBraveEndpoint<{
web?: { results?: Array<{ title: string; url: string; description: string }> };
};
}>('web', args, apiKey, signal);
const rawResults = data.web?.results ?? [];
return rawResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.description,
snippet: r.description ?? '',
}));
}
async function searchBraveNews(
args: WebSearchArgs,
apiKey: string,
signal?: AbortSignal,
): Promise<SearchResult[]> {
const data = await fetchBraveEndpoint<{
results?: Array<{ title: string; url: string; description?: string; age?: string }>;
news?: { results?: Array<{ title: string; url: string; description?: string; age?: string }> };
}>('news', args, apiKey, signal);
const rawResults = data.results ?? data.news?.results ?? [];
return rawResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.description ?? '',
age: r.age,
}));
}
@@ -106,13 +167,8 @@ async function searchSearxng(
categories: 'general',
});
const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
const fetchSignal = signal
? AbortSignal.any([signal, timeoutSignal])
: timeoutSignal;
const response = await fetch(`${baseUrl}/search?${params.toString()}`, {
signal: fetchSignal,
signal: createFetchSignal(signal),
headers: {
'Accept': 'application/json',
},
@@ -131,10 +187,28 @@ async function searchSearxng(
return rawResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.content,
snippet: r.content ?? '',
}));
}
function normalizeToolError(error: unknown): ToolResult {
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
return { success: false, output: '', error: 'Operation aborted' };
}
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
function noResults(query: string): ToolResult {
return {
success: true,
output: `No results found for: ${query}`,
};
}
/**
* Creates a web.search tool configured for the given search provider.
*
@@ -156,6 +230,24 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
type: 'number',
description: 'Number of results to return (default 5, max 20)',
},
country: {
type: 'string',
description: 'Optional country code for regionalized results (for example: us, gb, de)',
},
searchLang: {
type: 'string',
description: 'Optional language code for search results (for example: en, fr, de)',
},
safeSearch: {
type: 'string',
enum: ['off', 'moderate', 'strict'],
description: 'Brave-only safesearch level',
},
freshness: {
type: 'string',
enum: ['pd', 'pw', 'pm', 'py'],
description: 'Brave freshness filter: past day/week/month/year',
},
},
required: ['query'],
},
@@ -165,10 +257,11 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
return { success: false, output: '', error: 'Operation aborted' };
}
// Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default
const count = Math.min(args.count ?? defaultCount, MAX_RESULTS);
const count = resolveCount(args.count, defaultCount);
try {
let results: SearchResult[];
const braveArgs: WebSearchArgs = { ...args, count };
if (config.provider === 'brave') {
if (!config.apiKey) {
@@ -178,7 +271,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
error: 'Brave Search API key not configured',
};
}
results = await searchBrave(args.query, count, config.apiKey, context?.signal);
results = await searchBraveWeb(braveArgs, config.apiKey, context?.signal);
} else {
// SearXNG provider
if (!config.endpoint) {
@@ -192,22 +285,79 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
}
if (results.length === 0) {
return {
success: true,
output: `No results found for: ${args.query}`,
};
return noResults(args.query);
}
return { success: true, output: formatResults(results) };
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
return { success: false, output: '', error: 'Operation aborted' };
}
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
return normalizeToolError(error);
}
},
};
}
/**
* Brave Search API: dedicated news search endpoint.
*/
export function createBraveNewsSearchTool(config: WebSearchConfig): Tool {
const defaultCount = config.maxResults ?? DEFAULT_RESULTS;
return {
name: 'web.search.news',
description:
'Search recent news via Brave Search and return titles, URLs, snippets, and age metadata when available.',
requiredSecretScopes: ['web_search'],
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'News search query' },
count: {
type: 'number',
description: 'Number of results to return (default 5, max 20)',
},
country: {
type: 'string',
description: 'Optional country code for regionalized news results (for example: us, gb, de)',
},
searchLang: {
type: 'string',
description: 'Optional language code for news results (for example: en, fr, de)',
},
freshness: {
type: 'string',
enum: ['pd', 'pw', 'pm', 'py'],
description: 'Freshness filter: past day/week/month/year',
},
},
required: ['query'],
},
execute: async (rawArgs: unknown, context?: ToolExecutionContext): Promise<ToolResult> => {
const args = rawArgs as WebSearchArgs;
if (context?.signal?.aborted) {
return { success: false, output: '', error: 'Operation aborted' };
}
if (!config.apiKey) {
return {
success: false,
output: '',
error: 'Brave Search API key not configured',
};
}
try {
const results = await searchBraveNews(
{ ...args, count: resolveCount(args.count, defaultCount) },
config.apiKey,
context?.signal,
);
if (results.length === 0) {
return noResults(args.query);
}
return { success: true, output: formatResults(results) };
} catch (error) {
return normalizeToolError(error);
}
},
};
+19
View File
@@ -391,6 +391,25 @@ describe('ToolExecutor', () => {
expect(result.error).toContain('refusing to pass');
});
it('blocks passing secret-like args to web.search.* tools when untrusted content is present', async () => {
const registry = new ToolRegistry();
registry.register({
name: 'web.search.news',
description: 'search news',
inputSchema: { type: 'object', properties: {} },
execute: async () => ({ success: true, output: 'ok' }),
});
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
const executor = new ToolExecutor(registry, hooks);
const result = await executor.execute('web.search.news', { query: 'test', apiKey: 'secret-token' }, {
untrustedContent: true,
executionEnvironment: 'host',
});
expect(result.success).toBe(false);
expect(result.error).toContain('refusing to pass');
});
it('denies host high-risk tools for sandboxed skills unless elevation is active', async () => {
const registry = new ToolRegistry();
registry.register({
+1 -1
View File
@@ -626,7 +626,7 @@ export class ToolExecutor {
}
// When untrusted content is present, forbid passing secrets directly via tool args.
if ((toolName === 'web.fetch' || toolName === 'web.search') && containsSecretLikeKeys(args)) {
if ((toolName === 'web.fetch' || toolName.startsWith('web.search')) && containsSecretLikeKeys(args)) {
return 'refusing to pass secret-like fields to a network tool while untrusted content is present';
}
+3
View File
@@ -14,6 +14,7 @@ const ALL_TOOL_NAMES = [
'file.list',
'web.fetch',
'web.search',
'web.search.news',
'memory.read',
'memory.write',
'memory.search',
@@ -96,6 +97,7 @@ describe('PROFILE_TOOLS', () => {
}
expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('web.search')).toBe(true);
expect(PROFILE_TOOLS.messaging.has('web.search.news')).toBe(true);
});
it('coding is a superset of messaging', () => {
@@ -152,6 +154,7 @@ describe('ToolPolicy', () => {
expect(names).toContain('memory.read');
expect(names).toContain('memory.write');
expect(names).toContain('web.search');
expect(names).toContain('web.search.news');
expect(names).not.toContain('shell.exec');
expect(names).not.toContain('file.write');
});
+3 -1
View File
@@ -21,6 +21,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'memory.write',
'memory.search',
'web.search',
'web.search.news',
'gmail.list',
'gmail.search',
'gmail.read',
@@ -58,6 +59,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'memory.write',
'memory.search',
'web.search',
'web.search.news',
'gmail.list',
'gmail.search',
'gmail.read',
@@ -113,7 +115,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
export const TOOL_GROUPS: Record<string, string[]> = {
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'],
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'],
'group:web': ['web.fetch', 'web.search', 'web.search.news', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'],
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],