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:
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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') };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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**');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user