/ src / agents / utils / agent-tools.js
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  };