agent-tools.js
1 /** 2 * Agent Tools Module 3 * 4 * Provides direct tool access for agents (Read, Write, Edit, Grep, Glob, Bash). 5 * Agents can use these tools to perform file operations, search code, and run commands 6 * without going through the LLM API. 7 * 8 * @module agents/utils/agent-tools 9 */ 10 11 import fs from 'fs/promises'; 12 import { execSync } from 'child_process'; 13 import path from 'path'; 14 import { fileURLToPath } from 'url'; 15 import { glob } from 'glob'; 16 import Logger from '../../utils/logger.js'; 17 18 const __filename = fileURLToPath(import.meta.url); 19 const __dirname = path.dirname(__filename); 20 const PROJECT_ROOT = path.resolve(__dirname, '../../..'); 21 22 const logger = new Logger('AgentTools'); 23 24 /** 25 * Read a file 26 * 27 * @param {string} filePath - Path to file (relative or absolute) 28 * @returns {Promise<string>} File content 29 * @throws {Error} If file cannot be read 30 * 31 * @example 32 * const content = await readFile('src/utils/logger.js'); 33 */ 34 export async function readFile(filePath) { 35 const absPath = path.isAbsolute(filePath) ? filePath : path.join(PROJECT_ROOT, filePath); 36 37 try { 38 const content = await fs.readFile(absPath, 'utf-8'); 39 logger.debug(`Read file: ${path.relative(PROJECT_ROOT, absPath)}`); 40 return content; 41 } catch (error) { 42 logger.error(`Failed to read file ${absPath}: ${error.message}`); 43 throw new Error(`Failed to read file: ${error.message}`); 44 } 45 } 46 47 /** 48 * Write a file 49 * 50 * @param {string} filePath - Path to file (relative or absolute) 51 * @param {string} content - Content to write 52 * @returns {Promise<void>} 53 * @throws {Error} If file cannot be written 54 * 55 * @example 56 * await writeFile('src/test.js', 'const x = 1;'); 57 */ 58 export async function writeFile(filePath, content) { 59 const absPath = path.isAbsolute(filePath) ? filePath : path.join(PROJECT_ROOT, filePath); 60 61 try { 62 await fs.mkdir(path.dirname(absPath), { recursive: true }); 63 await fs.writeFile(absPath, content, 'utf-8'); 64 logger.debug(`Wrote file: ${path.relative(PROJECT_ROOT, absPath)}`); 65 } catch (error) { 66 logger.error(`Failed to write file ${absPath}: ${error.message}`); 67 throw new Error(`Failed to write file: ${error.message}`); 68 } 69 } 70 71 /** 72 * Search for files matching a pattern (grep) 73 * 74 * @param {string} pattern - Search pattern (regex) 75 * @param {string} directory - Directory to search (default: project root) 76 * @param {Object} options - Search options 77 * @param {boolean} options.filesOnly - Return only file paths (default: false) 78 * @param {number} options.maxCount - Maximum number of results (default: 1000) 79 * @returns {Promise<string>} Search results 80 * @throws {Error} If search fails 81 * 82 * @example 83 * const results = await searchFiles('TODO', 'src/'); 84 * const files = await searchFiles('error', 'src/', { filesOnly: true }); 85 */ 86 export async function searchFiles(pattern, directory = '.', options = {}) { 87 const { filesOnly = false, maxCount = 1000 } = options; 88 const absDir = path.isAbsolute(directory) ? directory : path.join(PROJECT_ROOT, directory); 89 90 try { 91 const args = [ 92 'grep', 93 '-r', // Recursive 94 filesOnly ? '-l' : '', // Files only 95 `"${pattern}"`, 96 `"${absDir}"`, 97 '2>/dev/null', // Suppress permission errors 98 ] 99 .filter(Boolean) 100 .join(' '); 101 102 let result = execSync(args, { 103 encoding: 'utf-8', 104 maxBuffer: 10 * 1024 * 1024, // 10MB buffer 105 }).trim(); 106 107 // Limit results if maxCount specified 108 if (maxCount && result) { 109 const lines = result.split('\n'); 110 if (lines.length > maxCount) { 111 result = lines.slice(0, maxCount).join('\n'); 112 } 113 } 114 115 logger.debug( 116 `Searched for "${pattern}" in ${path.relative(PROJECT_ROOT, absDir)} (${filesOnly ? 'files only' : 'content'})` 117 ); 118 119 return result; 120 } catch (error) { 121 // grep returns exit code 1 when no matches found 122 if (error.status === 1) { 123 return ''; 124 } 125 126 logger.error(`Search failed: ${error.message}`); 127 throw new Error(`Search failed: ${error.message}`); 128 } 129 } 130 131 /** 132 * Search for content in files (grep with context) 133 * 134 * @param {string} pattern - Search pattern (regex) 135 * @param {string} directory - Directory to search (default: project root) 136 * @param {Object} options - Search options 137 * @param {number} options.contextBefore - Lines of context before match (default: 0) 138 * @param {number} options.contextAfter - Lines of context after match (default: 0) 139 * @param {string} options.glob - Glob pattern to filter files (e.g., "*.js") 140 * @returns {Promise<string>} Search results with context 141 * @throws {Error} If search fails 142 * 143 * @example 144 * const results = await searchContent('function.*export', 'src/', { contextBefore: 2, contextAfter: 2 }); 145 * const jsResults = await searchContent('TODO', 'src/', { glob: '*.js' }); 146 */ 147 export async function searchContent(pattern, directory = '.', options = {}) { 148 const { contextBefore = 0, contextAfter = 0, glob: globPattern } = options; 149 const absDir = path.isAbsolute(directory) ? directory : path.join(PROJECT_ROOT, directory); 150 151 try { 152 const args = [ 153 'grep', 154 '-r', // Recursive 155 contextBefore > 0 ? `-B ${contextBefore}` : '', 156 contextAfter > 0 ? `-A ${contextAfter}` : '', 157 globPattern ? `--include="${globPattern}"` : '', 158 `"${pattern}"`, 159 `"${absDir}"`, 160 '2>/dev/null', // Suppress permission errors 161 ] 162 .filter(Boolean) 163 .join(' '); 164 165 const result = execSync(args, { 166 encoding: 'utf-8', 167 maxBuffer: 10 * 1024 * 1024, // 10MB buffer 168 }).trim(); 169 170 logger.debug(`Searched content for "${pattern}" in ${path.relative(PROJECT_ROOT, absDir)}`); 171 172 return result; 173 } catch (error) { 174 // grep returns exit code 1 when no matches found 175 if (error.status === 1) { 176 return ''; 177 } 178 179 logger.error(`Content search failed: ${error.message}`); 180 throw new Error(`Content search failed: ${error.message}`); 181 } 182 } 183 184 /** 185 * Find files matching a glob pattern 186 * 187 * @param {string} pattern - Glob pattern (e.g., "**\/*.test.js") 188 * @param {string} directory - Directory to search (default: project root) 189 * @returns {Promise<string[]>} Array of matching file paths 190 * @throws {Error} If glob fails 191 * 192 * @example 193 * const testFiles = await globFiles('**\/*.test.js', 'tests/'); 194 * const jsFiles = await globFiles('src/**\/*.js'); 195 */ 196 export async function globFiles(pattern, directory = '.') { 197 const absDir = path.isAbsolute(directory) ? directory : path.join(PROJECT_ROOT, directory); 198 199 try { 200 const files = await glob(pattern, { 201 cwd: absDir, 202 absolute: true, 203 nodir: true, 204 }); 205 206 logger.debug( 207 `Glob "${pattern}" in ${path.relative(PROJECT_ROOT, absDir)} found ${files.length} files` 208 ); 209 210 return files; 211 } catch (error) { 212 logger.error(`Glob failed: ${error.message}`); 213 throw new Error(`Glob failed: ${error.message}`); 214 } 215 } 216 217 /** 218 * Run a shell command 219 * 220 * @param {string} cmd - Command to run 221 * @param {Object} options - Execution options 222 * @param {string} options.cwd - Working directory (default: project root) 223 * @param {number} options.timeout - Timeout in ms (default: 60000) 224 * @param {number} options.maxBuffer - Max stdout/stderr buffer (default: 10MB) 225 * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} Command output 226 * @throws {Error} If command fails 227 * 228 * @example 229 * const result = await runCommand('npm test'); 230 * const gitStatus = await runCommand('git status --short'); 231 */ 232 export async function runCommand(cmd, options = {}) { 233 const { cwd = PROJECT_ROOT, timeout = 60000, maxBuffer = 10 * 1024 * 1024 } = options; 234 235 try { 236 logger.debug(`Running command: ${cmd}`); 237 238 const result = execSync(cmd, { 239 encoding: 'utf-8', 240 cwd, 241 timeout, 242 maxBuffer, 243 }); 244 245 return { 246 stdout: result.trim(), 247 stderr: '', 248 exitCode: 0, 249 }; 250 } catch (error) { 251 logger.error(`Command failed: ${cmd} - ${error.message}`); 252 253 return { 254 stdout: error.stdout ? error.stdout.toString().trim() : '', 255 stderr: error.stderr ? error.stderr.toString().trim() : error.message, 256 exitCode: error.status || 1, 257 }; 258 } 259 } 260 261 /** 262 * Execute multiple operations in parallel 263 * 264 * @param {Array<Function>} operations - Array of async functions to execute 265 * @returns {Promise<Array>} Results from all operations 266 * @throws {Error} If any operation fails 267 * 268 * @example 269 * const [file1, file2, searchResults] = await executeInParallel([ 270 * () => readFile('src/example1.js'), 271 * () => readFile('src/example2.js'), 272 * () => searchContent('errorPattern'), 273 * ]); 274 */ 275 export async function executeInParallel(operations) { 276 try { 277 logger.debug(`Executing ${operations.length} operations in parallel`); 278 const results = await Promise.all(operations.map(op => op())); 279 logger.debug(`Parallel execution completed (${operations.length} operations)`); 280 return results; 281 } catch (error) { 282 logger.error(`Parallel execution failed: ${error.message}`); 283 throw new Error(`Parallel execution failed: ${error.message}`); 284 } 285 } 286 287 /** 288 * Check if a file exists 289 * 290 * @param {string} filePath - Path to file 291 * @returns {Promise<boolean>} True if file exists 292 * 293 * @example 294 * if (await fileExists('src/test.js')) { 295 * const content = await readFile('src/test.js'); 296 * } 297 */ 298 export async function fileExists(filePath) { 299 const absPath = path.isAbsolute(filePath) ? filePath : path.join(PROJECT_ROOT, filePath); 300 301 try { 302 await fs.access(absPath); 303 return true; 304 } catch { 305 return false; 306 } 307 } 308 309 /** 310 * List files in a directory 311 * 312 * @param {string} directory - Directory path 313 * @param {Object} options - List options 314 * @param {boolean} options.recursive - List recursively (default: false) 315 * @param {string} options.filter - Filter pattern (e.g., "*.js") 316 * @returns {Promise<string[]>} Array of file paths 317 * 318 * @example 319 * const files = await listFiles('src/', { filter: '*.js' }); 320 * const allFiles = await listFiles('tests/', { recursive: true }); 321 */ 322 export async function listFiles(directory, options = {}) { 323 const { recursive = false, filter } = options; 324 const absDir = path.isAbsolute(directory) ? directory : path.join(PROJECT_ROOT, directory); 325 326 try { 327 if (recursive) { 328 const pattern = filter || '**/*'; 329 return await globFiles(pattern, absDir); 330 } 331 332 const files = await fs.readdir(absDir, { withFileTypes: true }); 333 let results = files.filter(f => f.isFile()).map(f => path.join(absDir, f.name)); 334 335 if (filter) { 336 const regex = new RegExp(filter.replace('*', '.*')); 337 results = results.filter(f => regex.test(path.basename(f))); 338 } 339 340 logger.debug(`Listed ${results.length} files in ${path.relative(PROJECT_ROOT, absDir)}`); 341 342 return results; 343 } catch (error) { 344 logger.error(`Failed to list files in ${absDir}: ${error.message}`); 345 throw new Error(`Failed to list files: ${error.message}`); 346 } 347 } 348 349 export default { 350 readFile, 351 writeFile, 352 searchFiles, 353 searchContent, 354 globFiles, 355 runCommand, 356 executeInParallel, 357 fileExists, 358 listFiles, 359 };