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 018 019import com.agentsflex.core.model.chat.tool.annotation.ToolDef; 020import com.agentsflex.core.model.chat.tool.annotation.ToolParam; 021import com.agentsflex.core.util.IOUtil; 022 023import java.io.*; 024import java.nio.file.Files; 025import java.nio.file.Path; 026import java.nio.file.Paths; 027import java.util.ArrayList; 028import java.util.List; 029 030/** 031 * @author Christian Tzolov 032 * @author Michael Yang 033 */ 034public class FileSystemTools { 035 036 @ToolDef(name = "Read", description = "Reads a file from the local filesystem. You can access any file directly by using this tool.\n" + 037 "Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n" + 038 "\n" + 039 "Usage:\n" + 040 "- The file_path parameter must be an absolute path, not a relative path\n" + 041 "- By default, it reads up to 2000 lines starting from the beginning of the file\n" + 042 "- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters\n" + 043 "- Any lines longer than 2000 characters will be truncated\n" + 044 "- Results are returned using cat -n format, with line numbers starting at 1\n" + 045 "- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM.\n" + 046 "- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis.\n" + 047 "- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n" + 048 "- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.\n" + 049 "- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.\n" + 050 "- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n" + 051 "- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.") 052 public String read( 053 @ToolParam(name = "filePath", description = "The absolute path to the file to read") String filePath, 054 @ToolParam(name = "offset", description = "The line number to start reading from. Only provide if the file is too large to read at once", required = false) Integer offset, 055 @ToolParam(name = "limit", description = "The number of lines to read. Only provide if the file is too large to read at once.", required = false) Integer limit) { 056 057 try { 058 File file = new File(filePath); 059 060 if (!file.exists()) { 061 return "Error: File does not exist: " + filePath; 062 } 063 064 if (file.isDirectory()) { 065 return "Error: Path is a directory, not a file: " + filePath; 066 } 067 068 // Default values 069 int startLine = offset != null ? offset : 1; 070 int maxLines = limit != null ? limit : 2000; 071 072 if (startLine < 1) { 073 startLine = 1; 074 } 075 076 List<String> lines = new ArrayList<>(); 077 int currentLine = 0; 078 int linesRead = 0; 079 080 try (BufferedReader reader = new BufferedReader(new FileReader(file))) { 081 String line; 082 while ((line = reader.readLine()) != null) { 083 currentLine++; 084 085 // Skip lines before the offset 086 if (currentLine < startLine) { 087 continue; 088 } 089 090 // Stop if we've read enough lines 091 if (linesRead >= maxLines) { 092 break; 093 } 094 095 // Truncate long lines to 2000 characters 096 if (line.length() > 2000) { 097 line = line.substring(0, 2000) + "... (line truncated)"; 098 } 099 100 lines.add(String.format("%6d%s", currentLine, line)); 101 linesRead++; 102 } 103 } 104 105 if (lines.isEmpty()) { 106 if (currentLine == 0) { 107 return "File is empty: " + filePath; 108 } else { 109 return String.format("No lines to read. File has %d lines, but offset was %d", currentLine, 110 startLine); 111 } 112 } 113 114 StringBuilder result = new StringBuilder(); 115 result.append(String.format("File: %s\n", filePath)); 116 result.append( 117 String.format("Showing lines %d-%d of %d\n\n", startLine, startLine + linesRead - 1, currentLine)); 118 119 for (String line : lines) { 120 result.append(line).append("\n"); 121 } 122 123 return result.toString(); 124 125 } catch (IOException e) { 126 return "Error reading file: " + e.getMessage(); 127 } 128 } 129 130 // @formatter:off 131 @ToolDef(name = "Write", description = "Writes a file to the local filesystem.\n" + 132 "\n" + 133 "Usage:\n" + 134 "- This tool will overwrite the existing file if there is one at the provided path.\n" + 135 "- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.\n" + 136 "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n" + 137 "- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n" + 138 "- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.") 139 public String write( 140 @ToolParam(name = "filePath", description = "The absolute path to the file to write (must be absolute, not relative)") String filePath, 141 @ToolParam(name = "content", description = "The content to write to the file") String content) { // @formatter:on 142 143 try { 144 content = content != null ? content : ""; 145 146 Path path = Paths.get(filePath); 147 File file = path.toFile(); 148 149 // Create parent directories if they don't exist 150 File parentDir = file.getParentFile(); 151 if (parentDir != null && !parentDir.exists()) { 152 if (!parentDir.mkdirs()) { 153 return "Error: Failed to create parent directories for: " + filePath; 154 } 155 } 156 157 // Check if file already exists 158 boolean fileExists = file.exists(); 159 160 // Write content to file 161 try (BufferedWriter writer = new BufferedWriter(new FileWriter(file, false))) { 162 writer.write(content); 163 } 164 165 if (fileExists) { 166 return String.format("Successfully overwrote file: %s (%d bytes)", filePath, content.length()); 167 } else { 168 return String.format("Successfully created file: %s (%d bytes)", filePath, content.length()); 169 } 170 171 } catch (IOException e) { 172 return "Error writing file: " + e.getMessage(); 173 } catch (Exception e) { 174 return "Error: " + e.getMessage(); 175 } 176 } 177 178 // @formatter:off 179 @ToolDef(name = "Edit", description = "Performs exact string replacements in files.\n" + 180 "\n" + 181 "Usage:\n" + 182 "- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.\n" + 183 "- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.\n" + 184 "- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n" + 185 "- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.\n" + 186 "- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n" + 187 "- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.") 188 public String edit( 189 @ToolParam(name = "filePath", description = "The absolute path to the file to modify") String filePath, 190 @ToolParam(name = "old_string", description = "The text to replace") String old_string, 191 @ToolParam(name = "new_string", description = "The text to replace it with (must be different from old_string)") String new_string, 192 @ToolParam(name = "replace_all", description = "Replace all occurences of old_string (default false)", required = false) Boolean replace_all) { // @formatter:on 193 194 try { 195 File file = new File(filePath); 196 197 if (!file.exists()) { 198 return "Error: File does not exist: " + filePath; 199 } 200 201 if (file.isDirectory()) { 202 return "Error: Path is a directory, not a file: " + filePath; 203 } 204 205 // Validate that old_string and new_string are different 206 if (old_string.equals(new_string)) { 207 return "Error: old_string and new_string must be different"; 208 } 209 210 // Read the entire file content preserving exact line endings 211 String originalContent; 212 try { 213 originalContent = IOUtil.readUtf8(Files.newInputStream(file.toPath())); 214 } catch (IOException e) { 215 return "Error reading file content: " + e.getMessage(); 216 } 217 218 // Count occurrences 219 int occurrences = countOccurrences(originalContent, old_string); 220 221 if (occurrences == 0) { 222 return "Error: old_string not found in file: " + filePath; 223 } 224 225 boolean replaceAll = Boolean.TRUE.equals(replace_all); 226 227 if (!replaceAll && occurrences > 1) { 228 return String.format( 229 "Error: old_string appears %d times in the file. Either provide a larger string with more surrounding context to make it unique or use replace_all=true to change all instances.", 230 occurrences); 231 } 232 233 // Perform replacement 234 String newContent; 235 if (replaceAll) { 236 // Replace all occurrences using literal string replacement 237 newContent = replaceAll(originalContent, old_string, new_string); 238 } else { 239 // Replace first occurrence only 240 newContent = replaceFirst(originalContent, old_string, new_string); 241 } 242 243 // Write the modified content back to the file 244 try (BufferedWriter writer = new BufferedWriter(new FileWriter(file, false))) { 245 writer.write(newContent); 246 } 247 248 // Generate a snippet showing the context around the edit 249 String snippet = generateEditSnippet(newContent, new_string); 250 251 // Return formatted response matching Claude Code's Edit tool format 252 return String.format( 253 "The file %s has been updated. Here's the result of running `cat -n` on a snippet of the edited file:\n%s", 254 filePath, snippet); 255 256 } catch (IOException e) { 257 return "Error editing file: " + e.getMessage(); 258 } 259 } 260 261 // Helper method to count occurrences of a substring 262 private int countOccurrences(String text, String substring) { 263 int count = 0; 264 int index = 0; 265 while ((index = text.indexOf(substring, index)) != -1) { 266 count++; 267 index += substring.length(); 268 } 269 return count; 270 } 271 272 // Helper method to replace first occurrence 273 private String replaceFirst(String text, String old_string, String new_string) { 274 int index = text.indexOf(old_string); 275 if (index == -1) { 276 return text; 277 } 278 return text.substring(0, index) + new_string + text.substring(index + old_string.length()); 279 } 280 281 // Helper method to replace all occurrences (literal, not regex) 282 private String replaceAll(String text, String old_string, String new_string) { 283 StringBuilder result = new StringBuilder(); 284 int index = 0; 285 int lastIndex = 0; 286 287 while ((index = text.indexOf(old_string, lastIndex)) != -1) { 288 result.append(text, lastIndex, index); 289 result.append(new_string); 290 lastIndex = index + old_string.length(); 291 } 292 result.append(text.substring(lastIndex)); 293 294 return result.toString(); 295 } 296 297 /** 298 * Generates a formatted snippet of the file showing context around the edited 299 * section. Matches Claude Code's Edit tool output format with line numbers and arrow 300 * separator. 301 * 302 * @param fileContent the complete file content after editing 303 * @param newString the new string that was inserted (used to find the edit location) 304 * @return formatted snippet with line numbers 305 */ 306 private String generateEditSnippet(String fileContent, String newString) { 307 String[] lines = fileContent.split("\n", -1); 308 309 // Find the line where the new content appears 310 int editStartLine = -1; 311 int editEndLine = -1; 312 313 // Split new_string into lines to find where it appears in the file 314 String[] newLines = newString.split("\n", -1); 315 316 // Search for the first line of the new content 317 for (int i = 0; i < lines.length; i++) { 318 if (newLines.length > 0 && lines[i].contains(newLines[0])) { 319 // Check if subsequent lines match (for multi-line edits) 320 boolean matches = true; 321 for (int j = 1; j < newLines.length && i + j < lines.length; j++) { 322 if (!lines[i + j].contains(newLines[j])) { 323 matches = false; 324 break; 325 } 326 } 327 if (matches) { 328 editStartLine = i; 329 editEndLine = i + newLines.length - 1; 330 break; 331 } 332 } 333 } 334 335 // If we didn't find the edit location, show the beginning of the file 336 if (editStartLine == -1) { 337 editStartLine = 0; 338 editEndLine = Math.min(10, lines.length - 1); 339 } 340 341 // Show context: ~5 lines before and ~5 lines after the edit 342 int contextBefore = 5; 343 int contextAfter = 5; 344 int startLine = Math.max(0, editStartLine - contextBefore); 345 int endLine = Math.min(lines.length - 1, editEndLine + contextAfter); 346 347 // Build the snippet with line numbers (1-indexed, right-aligned with arrow) 348 StringBuilder snippet = new StringBuilder(); 349 for (int i = startLine; i <= endLine; i++) { 350 // Line numbers are 1-indexed and right-aligned to 6 characters 351 snippet.append(String.format("%6d→%s", i + 1, lines[i])); 352 if (i < endLine) { 353 snippet.append("\n"); 354 } 355 } 356 357 return snippet.toString(); 358 } 359 360 public static Builder builder() { 361 return new Builder(); 362 } 363 364 public static class Builder { 365 366 public FileSystemTools build() { 367 return new FileSystemTools(); 368 } 369 370 } 371 372}