001/* 002 * Copyright 2025 - 2025 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package com.agentsflex.tool.commons; 017 018import com.agentsflex.core.model.chat.tool.annotation.ToolDef; 019import com.agentsflex.core.model.chat.tool.annotation.ToolParam; 020import com.agentsflex.core.util.StringUtil; 021 022import java.io.IOException; 023import java.nio.file.*; 024import java.nio.file.attribute.BasicFileAttributes; 025import java.util.ArrayList; 026import java.util.Comparator; 027import java.util.List; 028import java.util.stream.Stream; 029 030/** 031 * Pure Java glob implementation that doesn't require external tools. Uses Java NIO.2 for 032 * file pattern matching and traversal. 033 * <p> 034 * Generated with Claude Code AI assistant. 035 * 036 * @author Christian Tzolov 037 * @author Claude Code 038 * @author Michael Yang 039 */ 040public class GlobTool { 041 042 private final int maxDepth; 043 044 private final int maxResults; 045 046 private final Path workingDirectory; 047 048 /** 049 * Constructor with configurable parameters. 050 * 051 * @param maxDepth Maximum directory traversal depth to prevent infinite recursion 052 * (default: 100) 053 * @param maxResults Maximum number of results to return (default: 1000) 054 * @param workingDirectory The working directory to use when path is not specified. 055 * If null, defaults to current JVM working directory. 056 */ 057 protected GlobTool(int maxDepth, int maxResults, Path workingDirectory) { 058 this.maxDepth = maxDepth; 059 this.maxResults = maxResults; 060 this.workingDirectory = workingDirectory; 061 } 062 063 // @formatter:off 064 @ToolDef(name = "Glob", description = "- Fast file pattern matching tool that works with any codebase size\n" + 065 "- Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\"\n" + 066 "- Returns matching file paths sorted by modification time\n" + 067 "- Use this tool when you need to find files by name patterns\n" + 068 "- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead\n" + 069 "- You can call multiple tools in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.") 070 public String glob( 071 @ToolParam(name = "pattern", description = "The glob pattern to match files against") String pattern, 072 @ToolParam(name = "path", description = "The directory to search in. If not specified, the current working directory will be used. IMPORTANT: Omit this field to use the default directory. DO NOT enter \\\"undefined\\\" or \\\"null\\\" - simply omit it for the default behavior. Must be a valid directory path if provided.", required = false) String path) { // @formatter:on 073 074 if (StringUtil.noText(pattern)) { 075 return "Error: The glob pattern must not be empty"; 076 } else { 077 pattern = pattern.trim(); 078 } 079 080 try { 081 // Determine search path - use configured workingDirectory if path not specified 082 Path searchPath; 083 if (StringUtil.hasText(path)) { 084 searchPath = Paths.get(path); 085 } else if (this.workingDirectory != null) { 086 searchPath = this.workingDirectory; 087 } else { 088 searchPath = Paths.get("."); 089 } 090 091 if (!Files.exists(searchPath)) { 092 return "Error: Path does not exist: " + searchPath.toAbsolutePath(); 093 } 094 095 if (!Files.isDirectory(searchPath)) { 096 return "Error: Path is not a directory: " + searchPath.toAbsolutePath(); 097 } 098 099 // Build glob matcher 100 PathMatcher matcher = this.buildGlobMatcher(pattern); 101 102 // Find matching files 103 List<FileInfo> matchingFiles = new ArrayList<>(); 104 105 try (Stream<Path> paths = Files.walk(searchPath, this.maxDepth, FileVisitOption.FOLLOW_LINKS)) { 106 paths.filter(Files::isRegularFile) 107 .filter(p -> !this.isIgnoredPath(p)) 108 .filter(p -> this.matchesPattern(p, searchPath, matcher)) 109 .limit(this.maxResults) 110 .forEach(file -> { 111 try { 112 BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class); 113 matchingFiles.add(new FileInfo(file, attrs.lastModifiedTime().toMillis())); 114 } catch (IOException e) { 115 // Skip files that can't be read 116 matchingFiles.add(new FileInfo(file, 0)); 117 } 118 }); 119 } 120 121 if (matchingFiles.isEmpty()) { 122 return "No files found matching pattern: " + pattern; 123 } 124 125 // Sort by modification time (most recent first) 126 matchingFiles.sort(Comparator.comparingLong(FileInfo::modificationTime).reversed()); 127 128 // Build result 129 StringBuilder result = new StringBuilder(); 130 for (FileInfo fileInfo : matchingFiles) { 131 result.append(fileInfo.path().toString()).append("\n"); 132 } 133 134 return result.toString().trim(); 135 136 } catch (Exception e) { 137 return "Error executing glob: " + e.getMessage(); 138 } 139 } 140 141 /** 142 * Build a PathMatcher from the glob pattern 143 */ 144 private PathMatcher buildGlobMatcher(String pattern) { 145 // Handle both simple globs (*.java) and complex globs (**/*.java) 146 String globPattern = pattern.startsWith("**/") ? pattern : "**/" + pattern; 147 return FileSystems.getDefault().getPathMatcher("glob:" + globPattern); 148 } 149 150 /** 151 * Check if a path matches the glob pattern 152 */ 153 private boolean matchesPattern(Path file, Path searchPath, PathMatcher matcher) { 154 // Try matching against the full path 155 if (matcher.matches(file)) { 156 return true; 157 } 158 159 // Also try matching against the relative path from the search directory 160 try { 161 Path relativePath = searchPath.relativize(file); 162 return matcher.matches(relativePath); 163 } catch (IllegalArgumentException e) { 164 // If we can't relativize, just use the matcher on the file itself 165 return false; 166 } 167 } 168 169 /** 170 * Check if a file should be ignored (common ignore patterns) 171 */ 172 private boolean isIgnoredPath(Path path) { 173 String pathStr = path.toString(); 174 return pathStr.contains("/.git/") || pathStr.contains("/node_modules/") || pathStr.contains("/target/") 175 || pathStr.contains("/build/") || pathStr.contains("/.idea/") || pathStr.contains("/.vscode/") 176 || pathStr.contains("/dist/") || pathStr.contains("/__pycache__/"); 177 } 178 179 /** 180 * Record to hold file information 181 */ 182 private static class FileInfo { 183 private final Path path; 184 private final long modificationTime; 185 186 public FileInfo(Path path, long modificationTime) { 187 this.path = path; 188 this.modificationTime = modificationTime; 189 } 190 191 public Path path() { 192 return path; 193 } 194 195 public long modificationTime() { 196 return modificationTime; 197 } 198 } 199 200 public static Builder builder() { 201 return new Builder(); 202 } 203 204 public static class Builder { 205 206 private int maxDepth = 100; 207 208 private int maxResults = 1000; 209 210 private Path workingDirectory = null; 211 212 private Builder() { 213 } 214 215 public Builder maxDepth(int maxDepth) { 216 this.maxDepth = maxDepth; 217 return this; 218 } 219 220 public Builder maxResults(int maxResults) { 221 this.maxResults = maxResults; 222 return this; 223 } 224 225 /** 226 * Set the working directory to use when the agent doesn't specify a path. 227 * This allows tools to operate within a sandbox/workspace context. 228 * 229 * @param workingDirectory the working directory path 230 * @return this builder 231 */ 232 public Builder workingDirectory(Path workingDirectory) { 233 this.workingDirectory = workingDirectory; 234 return this; 235 } 236 237 /** 238 * Set the working directory using a string path. 239 * 240 * @param workingDirectory the working directory path as string 241 * @return this builder 242 */ 243 public Builder workingDirectory(String workingDirectory) { 244 this.workingDirectory = workingDirectory != null ? Paths.get(workingDirectory) : null; 245 return this; 246 } 247 248 public GlobTool build() { 249 return new GlobTool(maxDepth, maxResults, workingDirectory); 250 } 251 252 } 253 254}