feat: add web search and background process tools (Phases 4-5)

Phase 4 - Web search tool:
- Brave Search API + SearXNG fallback
- Configurable provider, max results
- 14 tests

Phase 5 - Background process management:
- ProcessManager with start/status/output/kill/list tools
- Configurable max concurrent, max runtime, buffer size
- 28 tests
This commit is contained in:
William Valentin
2026-02-06 14:24:23 -08:00
parent eeaec53893
commit 6af26f407c
10 changed files with 1265 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
export { ProcessManager, RingBuffer } from './manager.js';
export type { ManagedProcess, ProcessManagerConfig } from './manager.js';
export { createProcessStartTool } from './start.js';
export { createProcessStatusTool } from './status.js';
export { createProcessOutputTool } from './output.js';
export { createProcessKillTool } from './kill.js';
export { createProcessListTool } from './list.js';
import type { Tool } from '../../types.js';
import type { ProcessManager } from './manager.js';
import { createProcessStartTool } from './start.js';
import { createProcessStatusTool } from './status.js';
import { createProcessOutputTool } from './output.js';
import { createProcessKillTool } from './kill.js';
import { createProcessListTool } from './list.js';
/** Create all process management tools bound to a ProcessManager instance. */
export function createProcessTools(manager: ProcessManager): Tool[] {
return [
createProcessStartTool(manager),
createProcessStatusTool(manager),
createProcessOutputTool(manager),
createProcessKillTool(manager),
createProcessListTool(manager),
];
}
+44
View File
@@ -0,0 +1,44 @@
import type { Tool, ToolResult } from '../../types.js';
import type { ProcessManager } from './manager.js';
interface ProcessKillArgs {
id: string;
signal?: string;
}
export function createProcessKillTool(manager: ProcessManager): Tool {
return {
name: 'process.kill',
description: 'Kill a background process by its ID.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Process ID (e.g. "proc_1")' },
signal: { type: 'string', description: 'Signal to send (default: SIGTERM). Use SIGKILL for force kill.' },
},
required: ['id'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as ProcessKillArgs;
try {
const signal = (args.signal ?? 'SIGTERM') as NodeJS.Signals;
const killed = manager.kill(args.id, signal);
if (killed) {
return { success: true, output: `Sent ${signal} to process ${args.id}` };
}
const proc = manager.get(args.id);
return {
success: false,
output: '',
error: `Process ${args.id} is not running (status: ${proc?.status ?? 'unknown'})`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : 'Failed to kill process',
};
}
},
};
}
+31
View File
@@ -0,0 +1,31 @@
import type { Tool, ToolResult } from '../../types.js';
import type { ProcessManager } from './manager.js';
export function createProcessListTool(manager: ProcessManager): Tool {
return {
name: 'process.list',
description: 'List all managed background processes and their current status.',
inputSchema: {
type: 'object',
properties: {},
required: [],
},
execute: async (): Promise<ToolResult> => {
const procs = manager.list();
if (procs.length === 0) {
return { success: true, output: 'No managed processes.' };
}
const lines = procs.map(p => {
const uptime = p.status === 'running'
? `${Math.round((Date.now() - p.startedAt) / 1000)}s`
: 'N/A';
let line = `${p.id} PID=${p.pid} status=${p.status} uptime=${uptime} cmd="${p.command}"`;
if (p.exitCode !== undefined) line += ` exit=${p.exitCode}`;
return line;
});
return { success: true, output: lines.join('\n') };
},
};
}
+306
View File
@@ -0,0 +1,306 @@
import { describe, it, expect, afterEach } from 'vitest';
import { RingBuffer, ProcessManager } from './manager.js';
describe('RingBuffer', () => {
it('reads empty buffer as empty string', () => {
const buf = new RingBuffer(16);
expect(buf.read()).toBe('');
expect(buf.size).toBe(0);
});
it('writes and reads data smaller than capacity', () => {
const buf = new RingBuffer(64);
buf.write('hello');
expect(buf.read()).toBe('hello');
expect(buf.size).toBe(5);
});
it('accumulates multiple writes', () => {
const buf = new RingBuffer(64);
buf.write('hello ');
buf.write('world');
expect(buf.read()).toBe('hello world');
expect(buf.size).toBe(11);
});
it('wraps around when full, keeping newest data', () => {
const buf = new RingBuffer(8);
buf.write('ABCDEFGH'); // fills exactly
expect(buf.read()).toBe('ABCDEFGH');
buf.write('IJ'); // overwrites A and B
expect(buf.read()).toBe('CDEFGHIJ');
});
it('handles data larger than capacity', () => {
const buf = new RingBuffer(4);
buf.write('ABCDEFGH'); // only last 4 bytes kept
expect(buf.read()).toBe('EFGH');
expect(buf.size).toBe(4);
});
it('handles exact capacity writes', () => {
const buf = new RingBuffer(5);
buf.write('ABCDE');
expect(buf.read()).toBe('ABCDE');
});
it('clears the buffer', () => {
const buf = new RingBuffer(16);
buf.write('data');
buf.clear();
expect(buf.read()).toBe('');
expect(buf.size).toBe(0);
});
it('handles Buffer input', () => {
const buf = new RingBuffer(64);
buf.write(Buffer.from('bytes'));
expect(buf.read()).toBe('bytes');
});
it('handles multiple wraps correctly', () => {
const buf = new RingBuffer(8);
// Write 6 bytes
buf.write('ABCDEF');
expect(buf.read()).toBe('ABCDEF');
// Write 6 more (wraps past end)
buf.write('GHIJKL');
// Should have the last 8 bytes: EFGHIJKL
expect(buf.read()).toBe('EFGHIJKL');
});
});
describe('ProcessManager', () => {
const managers: ProcessManager[] = [];
function createManager(config?: { maxConcurrent?: number; maxRuntimeMinutes?: number; bufferSize?: number }) {
const m = new ProcessManager(config);
managers.push(m);
return m;
}
afterEach(async () => {
for (const m of managers) {
await m.shutdown();
}
managers.length = 0;
});
it('starts a process and captures output', async () => {
const manager = createManager();
const proc = manager.start('echo "hello background"');
expect(proc.id).toBe('proc_1');
expect(proc.status).toBe('running');
expect(proc.pid).toBeGreaterThan(0);
// Wait for process to finish
await new Promise(resolve => setTimeout(resolve, 500));
expect(proc.status).toBe('exited');
expect(proc.exitCode).toBe(0);
const output = manager.getOutput(proc.id);
expect(output).toContain('hello background');
});
it('tracks multiple processes', async () => {
const manager = createManager();
const p1 = manager.start('echo one');
const p2 = manager.start('echo two');
expect(p1.id).toBe('proc_1');
expect(p2.id).toBe('proc_2');
expect(manager.list()).toHaveLength(2);
await new Promise(resolve => setTimeout(resolve, 500));
expect(manager.getOutput(p1.id)).toContain('one');
expect(manager.getOutput(p2.id)).toContain('two');
});
it('kills a running process', async () => {
const manager = createManager();
const proc = manager.start('sleep 60');
expect(proc.status).toBe('running');
expect(manager.runningCount()).toBe(1);
const killed = manager.kill(proc.id);
expect(killed).toBe(true);
// Wait for exit event
await new Promise(resolve => setTimeout(resolve, 300));
expect(proc.status).toBe('killed');
expect(manager.runningCount()).toBe(0);
});
it('enforces max concurrent limit', () => {
const manager = createManager({ maxConcurrent: 2 });
manager.start('sleep 60');
manager.start('sleep 60');
expect(() => manager.start('sleep 60')).toThrow('Maximum concurrent processes (2) reached');
});
it('returns undefined for unknown process ID', () => {
const manager = createManager();
expect(manager.get('proc_999')).toBeUndefined();
});
it('throws when getting output of unknown process', () => {
const manager = createManager();
expect(() => manager.getOutput('proc_999')).toThrow('Process proc_999 not found');
});
it('throws when killing unknown process', () => {
const manager = createManager();
expect(() => manager.kill('proc_999')).toThrow('Process proc_999 not found');
});
it('returns false when killing a non-running process', async () => {
const manager = createManager();
const proc = manager.start('echo done');
await new Promise(resolve => setTimeout(resolve, 500));
expect(proc.status).toBe('exited');
expect(manager.kill(proc.id)).toBe(false);
});
it('captures stderr in output buffer', async () => {
const manager = createManager();
const proc = manager.start('echo "stderr msg" >&2');
await new Promise(resolve => setTimeout(resolve, 500));
const output = manager.getOutput(proc.id);
expect(output).toContain('stderr msg');
});
it('shutdown kills all running processes', async () => {
const manager = createManager();
const p1 = manager.start('sleep 60');
const p2 = manager.start('sleep 60');
expect(manager.runningCount()).toBe(2);
await manager.shutdown();
expect(p1.status).not.toBe('running');
expect(p2.status).not.toBe('running');
});
it('exposes config defaults', () => {
const manager = createManager();
expect(manager.config.maxConcurrent).toBe(10);
expect(manager.config.maxRuntimeMinutes).toBe(60);
expect(manager.config.bufferSize).toBe(65536);
});
it('exposes custom config', () => {
const manager = createManager({ maxConcurrent: 5, maxRuntimeMinutes: 30, bufferSize: 1024 });
expect(manager.config.maxConcurrent).toBe(5);
expect(manager.config.maxRuntimeMinutes).toBe(30);
expect(manager.config.bufferSize).toBe(1024);
});
});
describe('Process tools', () => {
let manager: ProcessManager;
afterEach(async () => {
if (manager) await manager.shutdown();
});
it('process.start tool creates and returns process info', async () => {
const { createProcessStartTool } = await import('./start.js');
manager = new ProcessManager();
const tool = createProcessStartTool(manager);
expect(tool.name).toBe('process.start');
const result = await tool.execute({ command: 'echo test' });
expect(result.success).toBe(true);
expect(result.output).toContain('proc_1');
expect(result.output).toContain('PID');
await new Promise(resolve => setTimeout(resolve, 300));
});
it('process.status tool returns status info', async () => {
const { createProcessStatusTool } = await import('./status.js');
manager = new ProcessManager();
const tool = createProcessStatusTool(manager);
manager.start('sleep 60');
const result = await tool.execute({ id: 'proc_1' });
expect(result.success).toBe(true);
expect(result.output).toContain('Status: running');
expect(result.output).toContain('proc_1');
});
it('process.status tool returns error for unknown ID', async () => {
const { createProcessStatusTool } = await import('./status.js');
manager = new ProcessManager();
const tool = createProcessStatusTool(manager);
const result = await tool.execute({ id: 'proc_999' });
expect(result.success).toBe(false);
expect(result.error).toContain('not found');
});
it('process.output tool returns process output', async () => {
const { createProcessOutputTool } = await import('./output.js');
manager = new ProcessManager();
const tool = createProcessOutputTool(manager);
manager.start('echo "tool output"');
await new Promise(resolve => setTimeout(resolve, 500));
const result = await tool.execute({ id: 'proc_1' });
expect(result.success).toBe(true);
expect(result.output).toContain('tool output');
});
it('process.kill tool kills a running process', async () => {
const { createProcessKillTool } = await import('./kill.js');
manager = new ProcessManager();
const tool = createProcessKillTool(manager);
manager.start('sleep 60');
const result = await tool.execute({ id: 'proc_1' });
expect(result.success).toBe(true);
expect(result.output).toContain('SIGTERM');
await new Promise(resolve => setTimeout(resolve, 300));
});
it('process.list tool lists all processes', async () => {
const { createProcessListTool } = await import('./list.js');
manager = new ProcessManager();
const tool = createProcessListTool(manager);
// Empty list
let result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No managed processes.');
// With processes
manager.start('sleep 60');
manager.start('echo hi');
await new Promise(resolve => setTimeout(resolve, 300));
result = await tool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('proc_1');
expect(result.output).toContain('proc_2');
});
it('createProcessTools returns all 5 tools', async () => {
const { createProcessTools } = await import('./index.js');
manager = new ProcessManager();
const tools = createProcessTools(manager);
expect(tools).toHaveLength(5);
const names = tools.map(t => t.name);
expect(names).toContain('process.start');
expect(names).toContain('process.status');
expect(names).toContain('process.output');
expect(names).toContain('process.kill');
expect(names).toContain('process.list');
});
});
+289
View File
@@ -0,0 +1,289 @@
import { spawn } from 'child_process';
import type { ChildProcess } from 'child_process';
/**
* RingBuffer — circular buffer for efficiently capturing process output.
* Keeps only the most recent `capacity` bytes, discarding older data.
*/
export class RingBuffer {
private buffer: Buffer;
private writePos = 0;
private length = 0;
constructor(private capacity: number = 65536) {
this.buffer = Buffer.alloc(capacity);
}
/** Write data into the ring buffer. Older data is overwritten when full. */
write(data: string | Buffer): void {
const buf = typeof data === 'string' ? Buffer.from(data) : data;
if (buf.length >= this.capacity) {
// Data larger than buffer — keep only the last `capacity` bytes
buf.copy(this.buffer, 0, buf.length - this.capacity);
this.writePos = 0;
this.length = this.capacity;
return;
}
// Handle wrap-around: write in up to two chunks
const firstChunk = Math.min(buf.length, this.capacity - this.writePos);
buf.copy(this.buffer, this.writePos, 0, firstChunk);
if (firstChunk < buf.length) {
buf.copy(this.buffer, 0, firstChunk);
}
this.writePos = (this.writePos + buf.length) % this.capacity;
this.length = Math.min(this.length + buf.length, this.capacity);
}
/** Read all buffered data as a UTF-8 string. */
read(): string {
if (this.length === 0) return '';
if (this.length < this.capacity) {
// Buffer not full — data is contiguous, starting at (writePos - length)
const start = this.writePos - this.length;
if (start >= 0) {
// Data sits in a single contiguous region
return this.buffer.subarray(start, this.writePos).toString('utf-8');
}
// Wrapped: data spans from (start + capacity) to end, then 0 to writePos
const tail = this.buffer.subarray(start + this.capacity);
const head = this.buffer.subarray(0, this.writePos);
return Buffer.concat([tail, head]).toString('utf-8');
}
// Buffer full — read from writePos (oldest) to end, then 0 to writePos (newest)
if (this.writePos === 0) {
// Special case: writePos wrapped to 0, entire buffer is valid in order
return this.buffer.toString('utf-8');
}
const end = this.buffer.subarray(this.writePos);
const start = this.buffer.subarray(0, this.writePos);
return Buffer.concat([end, start]).toString('utf-8');
}
/** Clear all buffered data. */
clear(): void {
this.writePos = 0;
this.length = 0;
}
/** Current amount of data stored (in bytes). */
get size(): number {
return this.length;
}
}
/** A background process managed by the ProcessManager. */
export interface ManagedProcess {
id: string;
command: string;
cwd?: string;
pid: number;
status: 'running' | 'exited' | 'killed' | 'error';
exitCode?: number;
errorMessage?: string;
outputBuffer: RingBuffer;
startedAt: number;
}
export interface ProcessManagerConfig {
/** Maximum number of concurrent running processes (default 10). */
maxConcurrent?: number;
/** Maximum runtime in minutes before a process is killed (default 60). */
maxRuntimeMinutes?: number;
/** Ring buffer size in bytes for each process's output (default 65536 / 64KB). */
bufferSize?: number;
}
/**
* ProcessManager — manages background processes spawned by an AI agent.
*
* Features:
* - Enforces a concurrent process limit
* - Captures stdout/stderr into a fixed-size ring buffer per process
* - Automatically kills processes that exceed a configurable max runtime
* - Provides graceful shutdown with SIGTERM → SIGKILL escalation
*/
export class ProcessManager {
private processes = new Map<string, ManagedProcess>();
private childProcesses = new Map<string, ChildProcess>();
private nextId = 1;
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
private _config: Required<ProcessManagerConfig>;
constructor(config: ProcessManagerConfig = {}) {
this._config = {
maxConcurrent: config.maxConcurrent ?? 10,
maxRuntimeMinutes: config.maxRuntimeMinutes ?? 60,
bufferSize: config.bufferSize ?? 65536,
};
// Periodic cleanup of expired processes (every 60 seconds)
this.cleanupTimer = setInterval(() => this._cleanupExpired(), 60_000);
// Ensure the timer doesn't keep the process alive
if (this.cleanupTimer.unref) {
this.cleanupTimer.unref();
}
}
get config(): Required<ProcessManagerConfig> {
return this._config;
}
/** Start a background process. Returns the managed process info. */
start(command: string, cwd?: string): ManagedProcess {
// Check concurrent limit
const running = this.runningCount();
if (running >= this._config.maxConcurrent) {
throw new Error(
`Maximum concurrent processes (${this._config.maxConcurrent}) reached. Kill a running process first.`,
);
}
const id = `proc_${this.nextId++}`;
const outputBuffer = new RingBuffer(this._config.bufferSize);
const child = spawn('bash', ['-c', command], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
const proc: ManagedProcess = {
id,
command,
cwd,
pid: child.pid!,
status: 'running',
outputBuffer,
startedAt: Date.now(),
};
this.processes.set(id, proc);
this.childProcesses.set(id, child);
// Capture stdout and stderr into the ring buffer
child.stdout?.on('data', (data: Buffer) => {
outputBuffer.write(data);
});
child.stderr?.on('data', (data: Buffer) => {
outputBuffer.write(data);
});
// Handle process exit
child.on('exit', (code, signal) => {
if (signal === 'SIGKILL' || signal === 'SIGTERM') {
proc.status = 'killed';
} else {
proc.status = 'exited';
}
proc.exitCode = code ?? undefined;
this.childProcesses.delete(id);
});
// Handle spawn errors (e.g. command not found)
child.on('error', (err) => {
proc.status = 'error';
proc.errorMessage = err.message;
this.childProcesses.delete(id);
});
return proc;
}
/** Get a process by ID. */
get(id: string): ManagedProcess | undefined {
return this.processes.get(id);
}
/** Get recent output from a process's ring buffer. */
getOutput(id: string): string {
const proc = this.processes.get(id);
if (!proc) throw new Error(`Process ${id} not found`);
return proc.outputBuffer.read();
}
/** Kill a process by sending a signal. Returns true if signal was sent. */
kill(id: string, signal: NodeJS.Signals = 'SIGTERM'): boolean {
const proc = this.processes.get(id);
if (!proc) throw new Error(`Process ${id} not found`);
if (proc.status !== 'running') return false;
const child = this.childProcesses.get(id);
if (child) {
child.kill(signal);
return true;
}
return false;
}
/** List all managed processes. */
list(): ManagedProcess[] {
return Array.from(this.processes.values());
}
/** Count currently running processes. */
runningCount(): number {
let count = 0;
for (const proc of this.processes.values()) {
if (proc.status === 'running') count++;
}
return count;
}
/** Clean up expired processes that have exceeded the max runtime. */
private _cleanupExpired(): void {
const maxAge = this._config.maxRuntimeMinutes * 60_000;
const now = Date.now();
for (const [id, proc] of this.processes) {
if (proc.status === 'running' && (now - proc.startedAt) > maxAge) {
console.log(`[ProcessManager] Killing expired process ${id} (${proc.command})`);
this.kill(id, 'SIGKILL');
}
}
}
/** Shut down all running processes. Called on daemon shutdown. */
async shutdown(): Promise<void> {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
const running = this.list().filter(p => p.status === 'running');
if (running.length === 0) return;
console.log(`[ProcessManager] Killing ${running.length} running process(es)...`);
// Send SIGTERM first for graceful shutdown
for (const proc of running) {
this.kill(proc.id, 'SIGTERM');
}
// Wait up to 5 seconds for graceful shutdown, then escalate to SIGKILL
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
for (const proc of running) {
if (proc.status === 'running') {
this.kill(proc.id, 'SIGKILL');
}
}
resolve();
}, 5000);
timeout.unref?.();
// Check every 200ms if all processes have exited
const check = setInterval(() => {
if (running.every(p => p.status !== 'running')) {
clearTimeout(timeout);
clearInterval(check);
resolve();
}
}, 200);
check.unref?.();
});
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { Tool, ToolResult } from '../../types.js';
import type { ProcessManager } from './manager.js';
interface ProcessOutputArgs {
id: string;
}
export function createProcessOutputTool(manager: ProcessManager): Tool {
return {
name: 'process.output',
description: 'Read recent stdout/stderr output from a background process. Returns the last ~64KB of output.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Process ID (e.g. "proc_1")' },
},
required: ['id'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as ProcessOutputArgs;
try {
const output = manager.getOutput(args.id);
if (!output) {
return { success: true, output: '(no output yet)' };
}
return { success: true, output };
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : 'Failed to get process output',
};
}
},
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { Tool, ToolResult } from '../../types.js';
import type { ProcessManager } from './manager.js';
interface ProcessStartArgs {
command: string;
cwd?: string;
}
export function createProcessStartTool(manager: ProcessManager): Tool {
return {
name: 'process.start',
description: 'Start a command in the background. Returns a process ID that can be used with process.status, process.output, and process.kill.',
inputSchema: {
type: 'object',
properties: {
command: { type: 'string', description: 'The shell command to run in the background' },
cwd: { type: 'string', description: 'Working directory (optional)' },
},
required: ['command'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as ProcessStartArgs;
try {
const proc = manager.start(args.command, args.cwd);
return {
success: true,
output: `Started background process ${proc.id} (PID ${proc.pid})\nCommand: ${proc.command}`,
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : 'Failed to start process',
};
}
},
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { Tool, ToolResult } from '../../types.js';
import type { ProcessManager } from './manager.js';
interface ProcessStatusArgs {
id: string;
}
export function createProcessStatusTool(manager: ProcessManager): Tool {
return {
name: 'process.status',
description: 'Check the status of a background process by its ID.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Process ID (e.g. "proc_1")' },
},
required: ['id'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as ProcessStatusArgs;
const proc = manager.get(args.id);
if (!proc) {
return { success: false, output: '', error: `Process ${args.id} not found` };
}
const uptime = proc.status === 'running'
? `${Math.round((Date.now() - proc.startedAt) / 1000)}s`
: 'N/A';
let info = `Process: ${proc.id}\n`;
info += `Command: ${proc.command}\n`;
info += `PID: ${proc.pid}\n`;
info += `Status: ${proc.status}\n`;
info += `Uptime: ${uptime}\n`;
if (proc.exitCode !== undefined) info += `Exit code: ${proc.exitCode}\n`;
if (proc.errorMessage) info += `Error: ${proc.errorMessage}\n`;
if (proc.cwd) info += `CWD: ${proc.cwd}\n`;
return { success: true, output: info.trimEnd() };
},
};
}
+258
View File
@@ -0,0 +1,258 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createWebSearchTool } from './web-search.js';
import type { WebSearchConfig } from './web-search.js';
// Mock global fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
beforeEach(() => {
mockFetch.mockReset();
});
// ── Helper: build mock Response objects ──────────────────────────────────────
function mockJsonResponse(body: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
text: async () => JSON.stringify(body),
json: async () => body,
headers: new Headers({ 'content-type': 'application/json' }),
};
}
// ── Brave mock data ──────────────────────────────────────────────────────────
const braveResults = {
web: {
results: [
{ title: 'Result 1', url: 'https://example.com/1', description: 'Description 1' },
{ title: 'Result 2', url: 'https://example.com/2', description: 'Description 2' },
{ title: 'Result 3', url: 'https://example.com/3', description: 'Description 3' },
{ title: 'Result 4', url: 'https://example.com/4', description: 'Description 4' },
{ title: 'Result 5', url: 'https://example.com/5', description: 'Description 5' },
],
},
};
// ── SearXNG mock data ────────────────────────────────────────────────────────
const searxngResults = {
results: [
{ title: 'Result 1', url: 'https://example.com/1', content: 'Content 1' },
{ title: 'Result 2', url: 'https://example.com/2', content: 'Content 2' },
{ title: 'Result 3', url: 'https://example.com/3', content: 'Content 3' },
{ title: 'Result 4', url: 'https://example.com/4', content: 'Content 4' },
{ title: 'Result 5', url: 'https://example.com/5', content: 'Content 5' },
],
};
// ── Brave config ─────────────────────────────────────────────────────────────
const braveConfig: WebSearchConfig = {
provider: 'brave',
apiKey: 'test-brave-key',
};
// ── SearXNG config ───────────────────────────────────────────────────────────
const searxngConfig: WebSearchConfig = {
provider: 'searxng',
endpoint: 'http://searxng:8080',
};
// ═════════════════════════════════════════════════════════════════════════════
// Tests
// ═════════════════════════════════════════════════════════════════════════════
describe('web.search', () => {
// ── Metadata ─────────────────────────────────────────────────────────────
it('has correct metadata', () => {
const tool = createWebSearchTool(braveConfig);
expect(tool.name).toBe('web.search');
expect(tool.description).toBeTruthy();
expect(tool.inputSchema.required).toContain('query');
});
// ── Brave provider ──────────────────────────────────────────────────────
describe('brave provider', () => {
it('returns formatted results on successful search', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
const result = await tool.execute({ query: 'test query' });
expect(result.success).toBe(true);
expect(result.output).toContain('**Result 1**');
expect(result.output).toContain('https://example.com/1');
expect(result.output).toContain('Description 1');
expect(result.output).toContain('**Result 2**');
expect(result.output).toContain('https://example.com/2');
expect(result.output).toContain('Description 2');
// Verify fetch was called with correct URL and headers
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('api.search.brave.com'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Subscription-Token': 'test-brave-key',
}),
}),
);
});
it('handles empty results', async () => {
mockFetch.mockResolvedValue(mockJsonResponse({ web: { results: [] } }));
const tool = createWebSearchTool(braveConfig);
const result = await tool.execute({ query: 'obscure query' });
expect(result.success).toBe(true);
expect(result.output).toBe('No results found for: obscure query');
});
it('returns error when no API key configured', async () => {
const tool = createWebSearchTool({ 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('handles HTTP errors from Brave API', async () => {
mockFetch.mockResolvedValue(mockJsonResponse({ error: 'rate limited' }, 429));
const tool = createWebSearchTool(braveConfig);
const result = await tool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('429');
});
it('handles network errors', async () => {
mockFetch.mockRejectedValue(new Error('Failed to fetch'));
const tool = createWebSearchTool(braveConfig);
const result = await tool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to fetch');
});
});
// ── SearXNG provider ────────────────────────────────────────────────────
describe('searxng provider', () => {
it('returns formatted results on successful search', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(searxngResults));
const tool = createWebSearchTool(searxngConfig);
const result = await tool.execute({ query: 'test query' });
expect(result.success).toBe(true);
expect(result.output).toContain('**Result 1**');
expect(result.output).toContain('https://example.com/1');
expect(result.output).toContain('Content 1');
expect(result.output).toContain('**Result 2**');
// Verify fetch was called with correct URL
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('http://searxng:8080/search'),
expect.any(Object),
);
// Verify query params include format=json
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('format=json');
expect(calledUrl).toContain('categories=general');
});
it('handles empty results', async () => {
mockFetch.mockResolvedValue(mockJsonResponse({ results: [] }));
const tool = createWebSearchTool(searxngConfig);
const result = await tool.execute({ query: 'nothing here' });
expect(result.success).toBe(true);
expect(result.output).toBe('No results found for: nothing here');
});
it('returns error when no endpoint configured', async () => {
const tool = createWebSearchTool({ provider: 'searxng' });
const result = await tool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toBe('SearXNG endpoint not configured');
expect(mockFetch).not.toHaveBeenCalled();
});
});
// ── Count parameter ─────────────────────────────────────────────────────
describe('count parameter', () => {
it('limits results to the requested count', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
const result = await tool.execute({ query: 'test', count: 2 });
expect(result.success).toBe(true);
// Verify count=2 was passed to the Brave API
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('count=2');
});
it('caps count at 20', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
await tool.execute({ query: 'test', count: 50 });
// Verify count was capped at 20
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('count=20');
});
it('uses default count when not specified', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
await tool.execute({ query: 'test' });
// Default count is 5
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('count=5');
});
it('respects maxResults from config', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool({ ...braveConfig, maxResults: 3 });
await tool.execute({ query: 'test' });
const calledUrl = mockFetch.mock.calls[0][0] as string;
expect(calledUrl).toContain('count=3');
});
});
// ── Output format ───────────────────────────────────────────────────────
describe('output format', () => {
it('formats results as numbered markdown', async () => {
mockFetch.mockResolvedValue(mockJsonResponse(braveResults));
const tool = createWebSearchTool(braveConfig);
const result = await tool.execute({ query: 'test', count: 2 });
expect(result.success).toBe(true);
// Check numbered format with em-dash separator
expect(result.output).toMatch(/^1\. \*\*Result 1\*\* \u2014 https:\/\/example\.com\/1/);
expect(result.output).toContain('2. **Result 2**');
});
});
});
+195
View File
@@ -0,0 +1,195 @@
import type { Tool, ToolResult } from '../types.js';
/** Configuration for the web search tool. */
export interface WebSearchConfig {
provider: 'brave' | 'searxng';
/** Required for Brave Search API. */
apiKey?: string;
/** Required for SearXNG (e.g. 'http://searxng:8080'). */
endpoint?: string;
/** Maximum number of results to return (default: 5). */
maxResults?: number;
}
interface WebSearchArgs {
query: string;
count?: number;
}
interface SearchResult {
title: string;
url: string;
snippet: string;
}
/** Fetch timeout in milliseconds. */
const FETCH_TIMEOUT_MS = 10_000;
/** Maximum allowed result count. */
const MAX_RESULTS = 20;
/** Default result count. */
const DEFAULT_RESULTS = 5;
/**
* Format search results as numbered markdown.
*/
function formatResults(results: SearchResult[]): string {
return results
.map(
(r, i) => `${i + 1}. **${r.title}** \u2014 ${r.url}\n ${r.snippet}`,
)
.join('\n\n');
}
/**
* Search using the Brave Search API.
*/
async function searchBrave(
query: string,
count: number,
apiKey: string,
): Promise<SearchResult[]> {
const params = new URLSearchParams({
q: query,
count: String(count),
});
const response = await fetch(
`https://api.search.brave.com/res/v1/web/search?${params.toString()}`,
{
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
'Accept': 'application/json',
'X-Subscription-Token': apiKey,
},
},
);
if (!response.ok) {
const body = await response.text();
throw new Error(`Brave API HTTP ${response.status}: ${body}`);
}
const data = (await response.json()) as {
web?: { results?: Array<{ title: string; url: string; description: string }> };
};
const rawResults = data.web?.results ?? [];
return rawResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.description,
}));
}
/**
* Search using a self-hosted SearXNG instance.
*/
async function searchSearxng(
query: string,
count: number,
endpoint: string,
): Promise<SearchResult[]> {
// Strip trailing slash from endpoint
const baseUrl = endpoint.replace(/\/+$/, '');
const params = new URLSearchParams({
q: query,
format: 'json',
categories: 'general',
});
const response = await fetch(`${baseUrl}/search?${params.toString()}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(`SearXNG HTTP ${response.status}: ${body}`);
}
const data = (await response.json()) as {
results?: Array<{ title: string; url: string; content: string }>;
};
const rawResults = (data.results ?? []).slice(0, count);
return rawResults.map((r) => ({
title: r.title,
url: r.url,
snippet: r.content,
}));
}
/**
* Creates a web.search tool configured for the given search provider.
*
* Supports Brave Search API and self-hosted SearXNG.
*/
export function createWebSearchTool(config: WebSearchConfig): Tool {
const defaultCount = config.maxResults ?? DEFAULT_RESULTS;
return {
name: 'web.search',
description:
'Search the web for current information. Returns titles, URLs, and snippets from web search results. Use this to find up-to-date information about any topic.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query' },
count: {
type: 'number',
description: 'Number of results to return (default 5, max 20)',
},
},
required: ['query'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as WebSearchArgs;
// Clamp count: use provided value (capped at MAX_RESULTS), or fall back to default
const count = Math.min(args.count ?? defaultCount, MAX_RESULTS);
try {
let results: SearchResult[];
if (config.provider === 'brave') {
if (!config.apiKey) {
return {
success: false,
output: '',
error: 'Brave Search API key not configured',
};
}
results = await searchBrave(args.query, count, config.apiKey);
} else {
// SearXNG provider
if (!config.endpoint) {
return {
success: false,
output: '',
error: 'SearXNG endpoint not configured',
};
}
results = await searchSearxng(args.query, count, config.endpoint);
}
if (results.length === 0) {
return {
success: true,
output: `No results found for: ${args.query}`,
};
}
return { success: true, output: formatResults(results) };
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
}