@agentforce/adk
Version:
AgentForce Agent Development Kit - A powerful framework for building AI agents and servers
313 lines (281 loc) • 13.7 kB
text/typescript
import { readdirSync, statSync, readFileSync } from "fs";
import { join, resolve, relative, extname } from "path";
import type { ToolImplementation } from "../types";
import { parseGitignore } from "../utils/gitignore_parser";
/**
* File content search tool - searches for patterns within file contents
* Returns filenames, line numbers, and matched content
*/
export const fs_search_content: ToolImplementation = {
definition: {
type: "function",
function: {
name: "fs_search_content",
description: "Search for patterns within file contents and return matches with line numbers and context",
parameters: {
type: "object",
properties: {
pattern: {
type: "string",
description: "The pattern to search for (can be regex if use_regex is true)",
},
path: {
type: "string",
description: "The directory path to search in (defaults to current working directory)",
},
file_extensions: {
type: "array",
description: "File extensions to include in search (e.g., ['.ts', '.js', '.md']). If empty, searches all text files.",
},
use_regex: {
type: "boolean",
description: "Whether to treat the pattern as a regular expression (default: false)",
},
case_sensitive: {
type: "boolean",
description: "Whether the search should be case-sensitive (default: true)",
},
include_context: {
type: "boolean",
description: "Whether to include surrounding lines as context (default: true)",
},
context_lines: {
type: "number",
description: "Number of lines before and after match to include as context (default: 2, max: 5)",
},
max_matches_per_file: {
type: "number",
description: "Maximum matches to return per file (default: 10, max: 50)",
},
max_total_matches: {
type: "number",
description: "Maximum total matches to return across all files (default: 100, max: 500)",
},
use_gitignore: {
type: "boolean",
description: "Whether to respect .gitignore patterns (default: true)",
},
max_depth: {
type: "number",
description: "Maximum directory depth to search (default: 10, max: 20)",
},
max_file_size: {
type: "number",
description: "Maximum file size in bytes to search (default: 1MB, max: 10MB)",
},
},
required: ["pattern"],
},
},
},
execute: async (args: Record<string, any>) => {
const pattern = args.pattern;
const searchPath = args.path || process.cwd();
const fileExtensions = args.file_extensions || [];
const useRegex = args.use_regex === true;
const caseSensitive = args.case_sensitive !== false; // Default to true
const includeContext = args.include_context !== false; // Default to true
const contextLines = Math.min(args.context_lines || 2, 5);
const maxMatchesPerFile = Math.min(args.max_matches_per_file || 10, 50);
const maxTotalMatches = Math.min(args.max_total_matches || 100, 500);
const useGitignore = args.use_gitignore !== false; // Default to true
const maxDepth = Math.min(args.max_depth || 10, 20);
const maxFileSize = Math.min(args.max_file_size || 1024 * 1024, 10 * 1024 * 1024); // Default 1MB, max 10MB
try {
// Resolve the absolute path
const absoluteSearchPath = resolve(process.cwd(), searchPath);
// Security check: ensure we're not searching outside the current working directory
const cwd = resolve(process.cwd());
if (!absoluteSearchPath.startsWith(cwd)) {
return {
success: false,
error: "Access denied: cannot search outside current working directory",
path: searchPath,
};
}
// Check if the search path exists
try {
statSync(absoluteSearchPath);
} catch {
return {
success: false,
error: `Search path does not exist: ${searchPath}`,
path: searchPath,
};
}
// Prepare search pattern
let searchRegex: RegExp;
try {
if (useRegex) {
searchRegex = new RegExp(pattern, caseSensitive ? "g" : "gi");
} else {
// Escape special regex characters for literal search
const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
searchRegex = new RegExp(escapedPattern, caseSensitive ? "g" : "gi");
}
} catch (error: any) {
return {
success: false,
error: `Invalid regex pattern: ${error.message}`,
pattern: pattern,
};
}
// Prepare exclusions
let excludes: string[] = [];
if (useGitignore) {
excludes = parseGitignore(cwd);
}
// Common binary file extensions to skip
const binaryExtensions = new Set([
".exe", ".dll", ".so", ".dylib", ".a", ".lib", ".bin", ".dat",
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".ico", ".svg",
".mp3", ".mp4", ".avi", ".mov", ".wav", ".pdf", ".zip",
".tar", ".gz", ".rar", ".7z", ".node", ".wasm",
]);
const results: Array<{
file: string;
line_number: number;
column: number;
match: string;
context_before: string[];
context_after: string[];
full_line: string;
}> = [];
const searchedFiles: string[] = [];
const skippedFiles: string[] = [];
const stack: Array<{ path: string; depth: number }> = [{ path: absoluteSearchPath, depth: 0 }];
let totalMatches = 0;
while (stack.length > 0 && totalMatches < maxTotalMatches) {
const { path: currentPath, depth } = stack.pop()!;
// Check depth limit
if (depth > maxDepth) {
continue;
}
let stats;
try {
stats = statSync(currentPath);
} catch {
continue; // Skip inaccessible paths
}
if (stats.isDirectory()) {
const baseName = require("path").basename(currentPath);
// Check if directory should be excluded
if (useGitignore && excludes.includes(baseName)) {
continue;
}
// Add subdirectories and files to search stack
try {
const entries = readdirSync(currentPath);
for (const entry of entries) {
if (totalMatches >= maxTotalMatches) break;
stack.push({
path: join(currentPath, entry),
depth: depth + 1,
});
}
} catch {
continue; // Skip directories we can't read
}
} else if (stats.isFile()) {
const relativePath = relative(cwd, currentPath);
const ext = extname(currentPath);
// Skip if file size exceeds limit
if (stats.size > maxFileSize) {
skippedFiles.push(`${relativePath} (file too large: ${Math.round(stats.size / 1024)}KB)`);
continue;
}
// Skip binary files
if (binaryExtensions.has(ext.toLowerCase())) {
continue;
}
// Filter by file extensions if specified
if (fileExtensions.length > 0 && !fileExtensions.includes(ext)) {
continue;
}
// Check if file should be excluded
const baseName = require("path").basename(currentPath);
if (useGitignore && excludes.includes(baseName)) {
continue;
}
try {
// Read file content
const content = readFileSync(currentPath, "utf-8");
const lines = content.split("\n");
searchedFiles.push(relativePath);
let fileMatches = 0;
// Search each line
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
if (fileMatches >= maxMatchesPerFile || totalMatches >= maxTotalMatches) {
break;
}
const line = lines[lineIndex];
if (!line) continue; // Skip undefined lines
let match;
searchRegex.lastIndex = 0; // Reset regex state
while ((match = searchRegex.exec(line)) !== null && fileMatches < maxMatchesPerFile && totalMatches < maxTotalMatches) {
const contextBefore = includeContext
? lines.slice(Math.max(0, lineIndex - contextLines), lineIndex)
: [];
const contextAfter = includeContext
? lines.slice(lineIndex + 1, Math.min(lines.length, lineIndex + 1 + contextLines))
: [];
results.push({
file: relativePath,
line_number: lineIndex + 1,
column: match.index + 1,
match: match[0],
context_before: contextBefore,
context_after: contextAfter,
full_line: line,
});
fileMatches++;
totalMatches++;
// Prevent infinite loops with zero-width matches
if (match[0].length === 0) {
searchRegex.lastIndex++;
}
}
}
} catch (error: any) {
// Skip files that can't be read as text
skippedFiles.push(`${relativePath} (${error.message})`);
continue;
}
}
}
const truncated = totalMatches >= maxTotalMatches;
return {
success: true,
pattern: pattern,
use_regex: useRegex,
case_sensitive: caseSensitive,
search_path: searchPath,
file_extensions: fileExtensions,
total_matches: totalMatches,
files_searched: searchedFiles.length,
files_with_matches: new Set(results.map(r => r.file)).size,
matches: results,
searched_files: searchedFiles,
skipped_files: skippedFiles,
truncated: truncated,
settings: {
max_matches_per_file: maxMatchesPerFile,
max_total_matches: maxTotalMatches,
context_lines: contextLines,
max_depth: maxDepth,
max_file_size: maxFileSize,
},
message: truncated
? `Found ${totalMatches} matches (truncated at ${maxTotalMatches} limit). Use max_total_matches parameter to increase limit.`
: `Found ${totalMatches} matches across ${new Set(results.map(r => r.file)).size} files`,
};
} catch (error: any) {
return {
success: false,
error: error.message,
pattern: pattern,
path: searchPath,
};
}
},
};