feat: add brave search container and toolset
This commit is contained in:
@@ -437,6 +437,53 @@ Audio persistence and diagnostics:
|
|||||||
- Runbook: `docs/runbooks/VOICE_TRANSCRIPTION_DEBUG.md`.
|
- Runbook: `docs/runbooks/VOICE_TRANSCRIPTION_DEBUG.md`.
|
||||||
- SQLite quick reference: `docs/runbooks/SQLITE_QUICK_REFERENCE.md`.
|
- SQLite quick reference: `docs/runbooks/SQLITE_QUICK_REFERENCE.md`.
|
||||||
|
|
||||||
|
### Web Search
|
||||||
|
|
||||||
|
Flynn provides web search tools through the `web_search` config:
|
||||||
|
|
||||||
|
- `web.search` - general web results (Brave or SearXNG provider)
|
||||||
|
- `web.search.news` - news results (Brave provider only)
|
||||||
|
|
||||||
|
Brave Search configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
web_search:
|
||||||
|
provider: brave
|
||||||
|
api_key: "${BRAVE_API_KEY}"
|
||||||
|
max_results: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
SearXNG configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
web_search:
|
||||||
|
provider: searxng
|
||||||
|
endpoint: "http://searxng:8080"
|
||||||
|
max_results: 5
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `provider` | no | `brave` or `searxng` (default: `brave`) |
|
||||||
|
| `api_key` | yes (Brave) | Brave Search API key |
|
||||||
|
| `endpoint` | yes (SearXNG) | Base URL for self-hosted SearXNG |
|
||||||
|
| `max_results` | no | Default result count for `web.search*` tools (default: `5`, max: `20`) |
|
||||||
|
|
||||||
|
Brave-specific query fields (supported by `web.search`, and by `web.search.news` except `safeSearch`):
|
||||||
|
|
||||||
|
- `country` (for example `us`, `gb`)
|
||||||
|
- `searchLang` (for example `en`, `de`)
|
||||||
|
- `freshness` (`pd`, `pw`, `pm`, `py`)
|
||||||
|
- `safeSearch` (`off`, `moderate`, `strict`) - `web.search` only
|
||||||
|
|
||||||
|
Optional local Brave Search container (for MCP-based workflows):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile search up -d brave-search
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Flynn's built-in `web.search*` tools call Brave's HTTP API directly and use `web_search.api_key`; they do not route through the `brave-search` MCP container.
|
||||||
|
|
||||||
### Text-to-Speech (TTS) Reply Audio
|
### Text-to-Speech (TTS) Reply Audio
|
||||||
|
|
||||||
Flynn can attach synthesized voice replies (OpenAI-compatible `/v1/audio/speech`) alongside text responses.
|
Flynn can attach synthesized voice replies (OpenAI-compatible `/v1/audio/speech`) alongside text responses.
|
||||||
|
|||||||
@@ -245,6 +245,21 @@ models:
|
|||||||
# - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools.
|
# - `tools.profile: coding` or `tools.profile: full` must allow browser.* tools.
|
||||||
# - `tools.deny` patterns can still block browser.* even when browser.enabled is true.
|
# - `tools.deny` patterns can still block browser.* even when browser.enabled is true.
|
||||||
|
|
||||||
|
# ── Web Search ───────────────────────────────────────────────────────
|
||||||
|
# Configure web.search tools.
|
||||||
|
# - provider: brave -> enables web.search + web.search.news (requires api_key)
|
||||||
|
# - provider: searxng -> enables web.search (requires endpoint)
|
||||||
|
#
|
||||||
|
# web_search:
|
||||||
|
# provider: brave
|
||||||
|
# api_key: "${BRAVE_API_KEY}"
|
||||||
|
# max_results: 5
|
||||||
|
# # endpoint: "http://searxng:8080" # Used when provider=searxng
|
||||||
|
#
|
||||||
|
# Optional local Brave Search MCP container (for MCP workflows only):
|
||||||
|
# docker compose --profile search up -d brave-search
|
||||||
|
# Built-in web.search* tools call Brave/SearXNG HTTP endpoints directly.
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
confirm:
|
confirm:
|
||||||
- shell.*
|
- shell.*
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
# Optional local dependency: Brave Search MCP server (HTTP mode).
|
||||||
|
# Start with: docker compose --profile search up -d brave-search
|
||||||
|
brave-search:
|
||||||
|
image: mcp/brave-search:latest
|
||||||
|
container_name: brave-search
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["search"]
|
||||||
|
ports:
|
||||||
|
- "18802:8000"
|
||||||
|
environment:
|
||||||
|
- BRAVE_API_KEY=${BRAVE_API_KEY:?BRAVE_API_KEY is required}
|
||||||
|
- BRAVE_MCP_TRANSPORT=http
|
||||||
|
- BRAVE_MCP_HOST=0.0.0.0
|
||||||
|
- BRAVE_MCP_PORT=8000
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
flynn-data:
|
flynn-data:
|
||||||
whisper-models:
|
whisper-models:
|
||||||
|
|||||||
+55
-2
@@ -871,9 +871,62 @@ Search the web.
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Search query"
|
"description": "Search query"
|
||||||
},
|
},
|
||||||
"limit": {
|
"count": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Number of results to return (default: 10)"
|
"description": "Number of results to return (default: 5, max: 20)"
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional country code (Brave provider)"
|
||||||
|
},
|
||||||
|
"searchLang": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional language code (Brave provider)"
|
||||||
|
},
|
||||||
|
"safeSearch": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Brave-only safesearch mode: off | moderate | strict"
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Brave freshness filter: pd | pw | pm | py"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["query"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `web.search.news`
|
||||||
|
|
||||||
|
Search Brave News results (available when `web_search.provider: brave`).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "web.search.news",
|
||||||
|
"description": "Search Brave News and return results",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"searchLang": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional language code"
|
||||||
|
},
|
||||||
|
"freshness": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Freshness filter: pd | pw | pm | py"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["query"]
|
"required": ["query"]
|
||||||
|
|||||||
+32
-1
@@ -3,6 +3,37 @@
|
|||||||
"updated_at": "2026-02-23",
|
"updated_at": "2026-02-23",
|
||||||
"description": "Tracks the status of all Flynn plans and implementation phases",
|
"description": "Tracks the status of all Flynn plans and implementation phases",
|
||||||
"plans": {
|
"plans": {
|
||||||
|
"brave-search-tooling-docs": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-23",
|
||||||
|
"updated": "2026-02-23",
|
||||||
|
"summary": "Documented Brave web-search tooling in operator-facing docs: added README guidance for `web.search`/`web.search.news` provider config (Brave + SearXNG), Brave-specific query parameters, and clarified how the optional `brave-search` compose container relates to built-in tools. Added matching commented config block in `config/default.yaml`.",
|
||||||
|
"files_modified": [
|
||||||
|
"README.md",
|
||||||
|
"config/default.yaml",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "docs/config comments update only; no runtime code changes"
|
||||||
|
},
|
||||||
|
"brave-search-toolset-expansion": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-23",
|
||||||
|
"updated": "2026-02-23",
|
||||||
|
"summary": "Expanded Brave web tooling with richer query controls (`country`, `searchLang`, `safeSearch`, `freshness`) and added dedicated `web.search.news` mapped to Brave's news endpoint. Updated tool policy/profile allowlists and prompt-injection secret-arg safeguards so `web.search.*` inherits network secret protections.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/tools/builtin/web-search.ts",
|
||||||
|
"src/tools/builtin/web-search.test.ts",
|
||||||
|
"src/tools/builtin/index.test.ts",
|
||||||
|
"src/tools/builtin/index.ts",
|
||||||
|
"src/tools/policy.ts",
|
||||||
|
"src/tools/policy.test.ts",
|
||||||
|
"src/tools/executor.ts",
|
||||||
|
"src/tools/executor.test.ts",
|
||||||
|
"docs/api/TOOLS.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/tools/builtin/index.test.ts src/tools/builtin/web-search.test.ts src/tools/policy.test.ts src/tools/executor.test.ts + pnpm typecheck passing"
|
||||||
|
},
|
||||||
"dashboard-docker-dependency-controls": {
|
"dashboard-docker-dependency-controls": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-23",
|
"date": "2026-02-23",
|
||||||
@@ -6132,7 +6163,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
"total_test_count": 1942,
|
"total_test_count": 1951,
|
||||||
"all_tests_passing": true,
|
"all_tests_passing": true,
|
||||||
"p0_completion": "3/3 (100%)",
|
"p0_completion": "3/3 (100%)",
|
||||||
"p1_completion": "4/4 (100%)",
|
"p1_completion": "4/4 (100%)",
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ export { createMemoryReadTool } from './memory-read.js';
|
|||||||
export { createMemoryWriteTool } from './memory-write.js';
|
export { createMemoryWriteTool } from './memory-write.js';
|
||||||
export { createMemorySearchTool } from './memory-search.js';
|
export { createMemorySearchTool } from './memory-search.js';
|
||||||
export { createWebSearchTool } from './web-search.js';
|
export { createWebSearchTool } from './web-search.js';
|
||||||
|
export { createBraveNewsSearchTool } from './web-search.js';
|
||||||
export type { WebSearchConfig } from './web-search.js';
|
export type { WebSearchConfig } from './web-search.js';
|
||||||
export { createProcessTools, ProcessManager } from './process/index.js';
|
export { createProcessTools, ProcessManager } from './process/index.js';
|
||||||
export type { ProcessManagerConfig } 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 { createMemoryReadTool } from './memory-read.js';
|
||||||
import { createMemoryWriteTool } from './memory-write.js';
|
import { createMemoryWriteTool } from './memory-write.js';
|
||||||
import { createMemorySearchTool } from './memory-search.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. */
|
/** Static builtin tools that don't require runtime dependencies. */
|
||||||
export const allBuiltinTools: Tool[] = [
|
export const allBuiltinTools: Tool[] = [
|
||||||
@@ -80,5 +81,9 @@ export function createMemoryTools(store: MemoryStore, searchBackend?: MemorySear
|
|||||||
|
|
||||||
/** Create the web search tool with provider config. */
|
/** Create the web search tool with provider config. */
|
||||||
export function createWebSearchTools(config: WebSearchConfig): Tool[] {
|
export function createWebSearchTools(config: WebSearchConfig): Tool[] {
|
||||||
return [createWebSearchTool(config)];
|
const tools: Tool[] = [createWebSearchTool(config)];
|
||||||
|
if (config.provider === 'brave') {
|
||||||
|
tools.push(createBraveNewsSearchTool(config));
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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';
|
import type { WebSearchConfig } from './web-search.js';
|
||||||
|
|
||||||
// Mock global fetch
|
// 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 ────────────────────────────────────────────────────────
|
// ── SearXNG mock data ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const searxngResults = {
|
const searxngResults = {
|
||||||
@@ -143,6 +150,25 @@ describe('web.search', () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('Failed to fetch');
|
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 ────────────────────────────────────────────────────
|
// ── SearXNG provider ────────────────────────────────────────────────────
|
||||||
@@ -218,6 +244,16 @@ describe('web.search', () => {
|
|||||||
expect(calledUrl).toContain('count=20');
|
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 () => {
|
it('uses default count when not specified', async () => {
|
||||||
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
|
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
|
||||||
const tool = createWebSearchTool(braveConfig);
|
const tool = createWebSearchTool(braveConfig);
|
||||||
@@ -271,3 +307,54 @@ describe('web.search', () => {
|
|||||||
expect(mockFetch).not.toHaveBeenCalled();
|
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
@@ -14,12 +14,17 @@ export interface WebSearchConfig {
|
|||||||
interface WebSearchArgs {
|
interface WebSearchArgs {
|
||||||
query: string;
|
query: string;
|
||||||
count?: number;
|
count?: number;
|
||||||
|
country?: string;
|
||||||
|
searchLang?: string;
|
||||||
|
safeSearch?: 'off' | 'moderate' | 'strict';
|
||||||
|
freshness?: 'pd' | 'pw' | 'pm' | 'py';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SearchResult {
|
interface SearchResult {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
|
age?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch timeout in milliseconds. */
|
/** Fetch timeout in milliseconds. */
|
||||||
@@ -37,38 +42,64 @@ const DEFAULT_RESULTS = 5;
|
|||||||
function formatResults(results: SearchResult[]): string {
|
function formatResults(results: SearchResult[]): string {
|
||||||
return results
|
return results
|
||||||
.map(
|
.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');
|
.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function resolveCount(requestedCount: number | undefined, defaultCount: number): number {
|
||||||
* Search using the Brave Search API.
|
const normalized = Number.isFinite(requestedCount)
|
||||||
*/
|
? Math.trunc(requestedCount as number)
|
||||||
async function searchBrave(
|
: defaultCount;
|
||||||
query: string,
|
return Math.min(Math.max(normalized, 1), MAX_RESULTS);
|
||||||
count: number,
|
}
|
||||||
apiKey: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<SearchResult[]> {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
q: query,
|
|
||||||
count: String(count),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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 timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||||
const fetchSignal = signal
|
return signal
|
||||||
? AbortSignal.any([signal, timeoutSignal])
|
? AbortSignal.any([signal, timeoutSignal])
|
||||||
: 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(
|
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,
|
signal: createFetchSignal(signal),
|
||||||
headers: {
|
headers: createBraveHeaders(apiKey),
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Subscription-Token': apiKey,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -77,15 +108,45 @@ async function searchBrave(
|
|||||||
throw new Error(`Brave API HTTP ${response.status}: ${body}`);
|
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?: { results?: Array<{ title: string; url: string; description: string }> };
|
||||||
};
|
}>('web', args, apiKey, signal);
|
||||||
|
|
||||||
const rawResults = data.web?.results ?? [];
|
const rawResults = data.web?.results ?? [];
|
||||||
return rawResults.map((r) => ({
|
return rawResults.map((r) => ({
|
||||||
title: r.title,
|
title: r.title,
|
||||||
url: r.url,
|
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',
|
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()}`, {
|
const response = await fetch(`${baseUrl}/search?${params.toString()}`, {
|
||||||
signal: fetchSignal,
|
signal: createFetchSignal(signal),
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -131,10 +187,28 @@ async function searchSearxng(
|
|||||||
return rawResults.map((r) => ({
|
return rawResults.map((r) => ({
|
||||||
title: r.title,
|
title: r.title,
|
||||||
url: r.url,
|
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.
|
* Creates a web.search tool configured for the given search provider.
|
||||||
*
|
*
|
||||||
@@ -156,6 +230,24 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
description: 'Number of results to return (default 5, max 20)',
|
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'],
|
required: ['query'],
|
||||||
},
|
},
|
||||||
@@ -165,10 +257,11 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
|||||||
return { success: false, output: '', error: 'Operation aborted' };
|
return { success: false, output: '', error: 'Operation aborted' };
|
||||||
}
|
}
|
||||||
// Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default
|
// 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 {
|
try {
|
||||||
let results: SearchResult[];
|
let results: SearchResult[];
|
||||||
|
const braveArgs: WebSearchArgs = { ...args, count };
|
||||||
|
|
||||||
if (config.provider === 'brave') {
|
if (config.provider === 'brave') {
|
||||||
if (!config.apiKey) {
|
if (!config.apiKey) {
|
||||||
@@ -178,7 +271,7 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
|||||||
error: 'Brave Search API key not configured',
|
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 {
|
} else {
|
||||||
// SearXNG provider
|
// SearXNG provider
|
||||||
if (!config.endpoint) {
|
if (!config.endpoint) {
|
||||||
@@ -192,22 +285,79 @@ export function createWebSearchTool(config: WebSearchConfig): Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
return {
|
return noResults(args.query);
|
||||||
success: true,
|
|
||||||
output: `No results found for: ${args.query}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, output: formatResults(results) };
|
return { success: true, output: formatResults(results) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) {
|
return normalizeToolError(error);
|
||||||
return { success: false, output: '', error: 'Operation aborted' };
|
}
|
||||||
}
|
},
|
||||||
return {
|
};
|
||||||
success: false,
|
}
|
||||||
output: '',
|
|
||||||
error: error instanceof Error ? error.message : String(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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -391,6 +391,25 @@ describe('ToolExecutor', () => {
|
|||||||
expect(result.error).toContain('refusing to pass');
|
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 () => {
|
it('denies host high-risk tools for sandboxed skills unless elevation is active', async () => {
|
||||||
const registry = new ToolRegistry();
|
const registry = new ToolRegistry();
|
||||||
registry.register({
|
registry.register({
|
||||||
|
|||||||
@@ -626,7 +626,7 @@ export class ToolExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// When untrusted content is present, forbid passing secrets directly via tool args.
|
// 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';
|
return 'refusing to pass secret-like fields to a network tool while untrusted content is present';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const ALL_TOOL_NAMES = [
|
|||||||
'file.list',
|
'file.list',
|
||||||
'web.fetch',
|
'web.fetch',
|
||||||
'web.search',
|
'web.search',
|
||||||
|
'web.search.news',
|
||||||
'memory.read',
|
'memory.read',
|
||||||
'memory.write',
|
'memory.write',
|
||||||
'memory.search',
|
'memory.search',
|
||||||
@@ -96,6 +97,7 @@ describe('PROFILE_TOOLS', () => {
|
|||||||
}
|
}
|
||||||
expect(PROFILE_TOOLS.messaging.has('memory.read')).toBe(true);
|
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')).toBe(true);
|
||||||
|
expect(PROFILE_TOOLS.messaging.has('web.search.news')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coding is a superset of messaging', () => {
|
it('coding is a superset of messaging', () => {
|
||||||
@@ -152,6 +154,7 @@ describe('ToolPolicy', () => {
|
|||||||
expect(names).toContain('memory.read');
|
expect(names).toContain('memory.read');
|
||||||
expect(names).toContain('memory.write');
|
expect(names).toContain('memory.write');
|
||||||
expect(names).toContain('web.search');
|
expect(names).toContain('web.search');
|
||||||
|
expect(names).toContain('web.search.news');
|
||||||
expect(names).not.toContain('shell.exec');
|
expect(names).not.toContain('shell.exec');
|
||||||
expect(names).not.toContain('file.write');
|
expect(names).not.toContain('file.write');
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -21,6 +21,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
'memory.write',
|
'memory.write',
|
||||||
'memory.search',
|
'memory.search',
|
||||||
'web.search',
|
'web.search',
|
||||||
|
'web.search.news',
|
||||||
'gmail.list',
|
'gmail.list',
|
||||||
'gmail.search',
|
'gmail.search',
|
||||||
'gmail.read',
|
'gmail.read',
|
||||||
@@ -58,6 +59,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
'memory.write',
|
'memory.write',
|
||||||
'memory.search',
|
'memory.search',
|
||||||
'web.search',
|
'web.search',
|
||||||
|
'web.search.news',
|
||||||
'gmail.list',
|
'gmail.list',
|
||||||
'gmail.search',
|
'gmail.search',
|
||||||
'gmail.read',
|
'gmail.read',
|
||||||
@@ -113,7 +115,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
export const TOOL_GROUPS: Record<string, string[]> = {
|
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||||
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
|
'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: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:memory': ['memory.read', 'memory.write', 'memory.search'],
|
||||||
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
|
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
|
||||||
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],
|
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],
|
||||||
|
|||||||
Reference in New Issue
Block a user