diff --git a/src/tools/builtin/process/index.ts b/src/tools/builtin/process/index.ts new file mode 100644 index 0000000..cad7f8c --- /dev/null +++ b/src/tools/builtin/process/index.ts @@ -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), + ]; +} diff --git a/src/tools/builtin/process/kill.ts b/src/tools/builtin/process/kill.ts new file mode 100644 index 0000000..a99cff3 --- /dev/null +++ b/src/tools/builtin/process/kill.ts @@ -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 => { + 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', + }; + } + }, + }; +} diff --git a/src/tools/builtin/process/list.ts b/src/tools/builtin/process/list.ts new file mode 100644 index 0000000..d6d2258 --- /dev/null +++ b/src/tools/builtin/process/list.ts @@ -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 => { + 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') }; + }, + }; +} diff --git a/src/tools/builtin/process/manager.test.ts b/src/tools/builtin/process/manager.test.ts new file mode 100644 index 0000000..51c55ef --- /dev/null +++ b/src/tools/builtin/process/manager.test.ts @@ -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'); + }); +}); diff --git a/src/tools/builtin/process/manager.ts b/src/tools/builtin/process/manager.ts new file mode 100644 index 0000000..754383a --- /dev/null +++ b/src/tools/builtin/process/manager.ts @@ -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(); + private childProcesses = new Map(); + private nextId = 1; + private cleanupTimer: ReturnType | null = null; + private _config: Required; + + 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 { + 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 { + 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((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?.(); + }); + } +} diff --git a/src/tools/builtin/process/output.ts b/src/tools/builtin/process/output.ts new file mode 100644 index 0000000..33e6003 --- /dev/null +++ b/src/tools/builtin/process/output.ts @@ -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 => { + 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', + }; + } + }, + }; +} diff --git a/src/tools/builtin/process/start.ts b/src/tools/builtin/process/start.ts new file mode 100644 index 0000000..1bedc8e --- /dev/null +++ b/src/tools/builtin/process/start.ts @@ -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 => { + 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', + }; + } + }, + }; +} diff --git a/src/tools/builtin/process/status.ts b/src/tools/builtin/process/status.ts new file mode 100644 index 0000000..27c8dfc --- /dev/null +++ b/src/tools/builtin/process/status.ts @@ -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 => { + 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() }; + }, + }; +} diff --git a/src/tools/builtin/web-search.test.ts b/src/tools/builtin/web-search.test.ts new file mode 100644 index 0000000..e4ff5aa --- /dev/null +++ b/src/tools/builtin/web-search.test.ts @@ -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**'); + }); + }); +}); diff --git a/src/tools/builtin/web-search.ts b/src/tools/builtin/web-search.ts new file mode 100644 index 0000000..6d56c81 --- /dev/null +++ b/src/tools/builtin/web-search.ts @@ -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 { + 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 { + // 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 => { + 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), + }; + } + }, + }; +}