tiphys.tools.fs
File system tools for agents.
Provides safe file reading, writing, and directory listing.
1""" 2File system tools for agents. 3 4Provides safe file reading, writing, and directory listing. 5""" 6 7from pathlib import Path 8 9import aiofiles 10import aiofiles.os 11import structlog 12 13from tiphys.tools.base import ToolDefinition, tool 14 15logger = structlog.get_logger(__name__) 16 17# Maximum file size to read (10MB) 18MAX_READ_SIZE = 10 * 1024 * 1024 19 20# Maximum lines to return 21MAX_LINES = 1000 22 23 24def _validate_path(path: str, must_exist: bool = False) -> Path: 25 """ 26 Validate and normalize a file path. 27 28 Args: 29 path: The path to validate. 30 must_exist: If True, raise error if path doesn't exist. 31 32 Returns: 33 Resolved Path object. 34 35 Raises: 36 ValueError: If path is invalid. 37 FileNotFoundError: If must_exist=True and path doesn't exist. 38 """ 39 try: 40 p = Path(path).resolve() 41 except Exception as e: 42 raise ValueError(f"Invalid path: {path}") from e 43 44 # Security: prevent path traversal attacks 45 # Ensure the path doesn't escape to dangerous locations 46 dangerous_prefixes = ["/etc/passwd", "/etc/shadow", "/proc", "/sys"] 47 for prefix in dangerous_prefixes: 48 if str(p).startswith(prefix): 49 raise ValueError(f"Access denied: {path}") 50 51 if must_exist and not p.exists(): 52 raise FileNotFoundError(f"Path not found: {path}") 53 54 return p 55 56 57@tool( 58 name="read_file", 59 description="Read the contents of a file. Returns the file contents as text.", 60) 61async def read_file( 62 path: str, 63 start_line: int = 1, 64 end_line: int | None = None, 65) -> str: 66 """ 67 Read a file and return its contents. 68 69 Args: 70 path: Absolute or relative path to the file. 71 start_line: Starting line number (1-indexed). 72 end_line: Ending line number (inclusive). If None, reads to end. 73 74 Returns: 75 File contents as string. 76 """ 77 try: 78 file_path = _validate_path(path, must_exist=True) 79 except FileNotFoundError: 80 return f"Error: File not found: {path}" 81 except ValueError as e: 82 return f"Error: {e}" 83 84 if not file_path.is_file(): 85 return f"Error: {path} is not a file" 86 87 # Check file size 88 try: 89 stat = await aiofiles.os.stat(file_path) 90 if stat.st_size > MAX_READ_SIZE: 91 return ( 92 f"Error: File too large ({stat.st_size} bytes). Maximum is {MAX_READ_SIZE} bytes." 93 ) 94 except OSError as e: 95 return f"Error checking file: {e}" 96 97 try: 98 async with aiofiles.open(file_path, encoding="utf-8", errors="replace") as f: 99 if start_line == 1 and end_line is None: 100 # Read entire file 101 content = await f.read() 102 lines = content.splitlines() 103 if len(lines) > MAX_LINES: 104 return ( 105 f"File has {len(lines)} lines. Showing first {MAX_LINES}:\n\n" 106 + "\n".join(lines[:MAX_LINES]) 107 ) 108 return content 109 else: 110 # Read specific line range 111 lines = [] 112 line_num = 0 113 async for line in f: 114 line_num += 1 115 if line_num >= start_line: 116 lines.append(line.rstrip("\n")) 117 if end_line and line_num >= end_line: 118 break 119 if len(lines) >= MAX_LINES: 120 break 121 122 return "\n".join(lines) 123 124 except UnicodeDecodeError: 125 return f"Error: {path} is not a text file" 126 except PermissionError: 127 return f"Error: Permission denied reading {path}" 128 except Exception as e: 129 await logger.aerror("Failed to read file", path=path, error=str(e)) 130 return f"Error reading file: {e}" 131 132 133@tool( 134 name="write_file", 135 description="Write content to a file. Creates the file if it doesn't exist, overwrites if it does.", 136 requires_permission=True, 137) 138async def write_file( 139 path: str, 140 content: str, 141 append: bool = False, 142) -> str: 143 """ 144 Write content to a file. 145 146 Args: 147 path: Path to the file. 148 content: Content to write. 149 append: If True, append to file instead of overwriting. 150 151 Returns: 152 Success message or error. 153 """ 154 try: 155 file_path = _validate_path(path) 156 except ValueError as e: 157 return f"Error: {e}" 158 159 # Create parent directories if needed 160 try: 161 file_path.parent.mkdir(parents=True, exist_ok=True) 162 except PermissionError: 163 return f"Error: Permission denied creating directory {file_path.parent}" 164 165 mode = "a" if append else "w" 166 167 try: 168 async with aiofiles.open(file_path, mode, encoding="utf-8") as f: # type: ignore[call-overload] 169 await f.write(content) 170 171 action = "appended to" if append else "written to" 172 return f"Successfully {action} {path} ({len(content)} characters)" 173 174 except PermissionError: 175 return f"Error: Permission denied writing to {path}" 176 except Exception as e: 177 await logger.aerror("Failed to write file", path=path, error=str(e)) 178 return f"Error writing file: {e}" 179 180 181@tool( 182 name="list_directory", 183 description="List the contents of a directory. Returns files and subdirectories.", 184) 185async def list_directory( 186 path: str = ".", 187 show_hidden: bool = False, 188 recursive: bool = False, 189 max_depth: int = 2, 190) -> str: 191 """ 192 List directory contents. 193 194 Args: 195 path: Directory path. Defaults to current directory. 196 show_hidden: Include hidden files (starting with .). 197 recursive: List subdirectories recursively. 198 max_depth: Maximum recursion depth (only if recursive=True). 199 200 Returns: 201 Formatted directory listing. 202 """ 203 try: 204 dir_path = _validate_path(path, must_exist=True) 205 except (ValueError, FileNotFoundError) as e: 206 return f"Error: {e}" 207 208 if not dir_path.is_dir(): 209 return f"Error: {path} is not a directory" 210 211 try: 212 lines: list[str] = [f"Contents of {dir_path}:", ""] 213 214 if recursive: 215 lines.extend(_list_recursive(dir_path, show_hidden, max_depth, 0)) 216 else: 217 lines.extend(_list_flat(dir_path, show_hidden)) 218 219 return "\n".join(lines) 220 221 except PermissionError: 222 return f"Error: Permission denied accessing {path}" 223 except Exception as e: 224 await logger.aerror("Failed to list directory", path=path, error=str(e)) 225 return f"Error listing directory: {e}" 226 227 228def _list_flat(dir_path: Path, show_hidden: bool) -> list[str]: 229 """List directory contents without recursion.""" 230 items: list[str] = [] 231 232 try: 233 entries = sorted(dir_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())) 234 235 for entry in entries: 236 if not show_hidden and entry.name.startswith("."): 237 continue 238 239 if entry.is_dir(): 240 items.append(f"📁 {entry.name}/") 241 elif entry.is_symlink(): 242 items.append(f"🔗 {entry.name} -> {entry.resolve()}") 243 else: 244 size = _format_size(entry.stat().st_size) 245 items.append(f"📄 {entry.name} ({size})") 246 247 except PermissionError: 248 items.append(" (permission denied)") 249 250 return items 251 252 253def _list_recursive( 254 dir_path: Path, show_hidden: bool, max_depth: int, current_depth: int 255) -> list[str]: 256 """List directory contents recursively.""" 257 items: list[str] = [] 258 indent = " " * current_depth 259 260 try: 261 entries = sorted(dir_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())) 262 263 for entry in entries: 264 if not show_hidden and entry.name.startswith("."): 265 continue 266 267 if entry.is_dir(): 268 items.append(f"{indent}📁 {entry.name}/") 269 if current_depth < max_depth: 270 items.extend(_list_recursive(entry, show_hidden, max_depth, current_depth + 1)) 271 else: 272 size = _format_size(entry.stat().st_size) 273 items.append(f"{indent}📄 {entry.name} ({size})") 274 275 except PermissionError: 276 items.append(f"{indent} (permission denied)") 277 278 return items 279 280 281def _format_size(size_bytes: int) -> str: 282 """Format file size in human-readable format.""" 283 size: float = float(size_bytes) 284 for unit in ["B", "KB", "MB", "GB"]: 285 if size < 1024: 286 return f"{size:.1f}{unit}" if unit != "B" else f"{int(size)}{unit}" 287 size /= 1024 288 return f"{size:.1f}TB" 289 290 291# Export tool definitions 292def get_fs_tools() -> list[ToolDefinition]: 293 """Get all file system tool definitions.""" 294 tools: list[ToolDefinition] = [] 295 296 for func in [read_file, write_file, list_directory]: 297 if hasattr(func, "_tool_definition"): 298 tools.append(func._tool_definition) 299 300 return tools
58@tool( 59 name="read_file", 60 description="Read the contents of a file. Returns the file contents as text.", 61) 62async def read_file( 63 path: str, 64 start_line: int = 1, 65 end_line: int | None = None, 66) -> str: 67 """ 68 Read a file and return its contents. 69 70 Args: 71 path: Absolute or relative path to the file. 72 start_line: Starting line number (1-indexed). 73 end_line: Ending line number (inclusive). If None, reads to end. 74 75 Returns: 76 File contents as string. 77 """ 78 try: 79 file_path = _validate_path(path, must_exist=True) 80 except FileNotFoundError: 81 return f"Error: File not found: {path}" 82 except ValueError as e: 83 return f"Error: {e}" 84 85 if not file_path.is_file(): 86 return f"Error: {path} is not a file" 87 88 # Check file size 89 try: 90 stat = await aiofiles.os.stat(file_path) 91 if stat.st_size > MAX_READ_SIZE: 92 return ( 93 f"Error: File too large ({stat.st_size} bytes). Maximum is {MAX_READ_SIZE} bytes." 94 ) 95 except OSError as e: 96 return f"Error checking file: {e}" 97 98 try: 99 async with aiofiles.open(file_path, encoding="utf-8", errors="replace") as f: 100 if start_line == 1 and end_line is None: 101 # Read entire file 102 content = await f.read() 103 lines = content.splitlines() 104 if len(lines) > MAX_LINES: 105 return ( 106 f"File has {len(lines)} lines. Showing first {MAX_LINES}:\n\n" 107 + "\n".join(lines[:MAX_LINES]) 108 ) 109 return content 110 else: 111 # Read specific line range 112 lines = [] 113 line_num = 0 114 async for line in f: 115 line_num += 1 116 if line_num >= start_line: 117 lines.append(line.rstrip("\n")) 118 if end_line and line_num >= end_line: 119 break 120 if len(lines) >= MAX_LINES: 121 break 122 123 return "\n".join(lines) 124 125 except UnicodeDecodeError: 126 return f"Error: {path} is not a text file" 127 except PermissionError: 128 return f"Error: Permission denied reading {path}" 129 except Exception as e: 130 await logger.aerror("Failed to read file", path=path, error=str(e)) 131 return f"Error reading file: {e}"
Read a file and return its contents.
Args: path: Absolute or relative path to the file. start_line: Starting line number (1-indexed). end_line: Ending line number (inclusive). If None, reads to end.
Returns: File contents as string.
134@tool( 135 name="write_file", 136 description="Write content to a file. Creates the file if it doesn't exist, overwrites if it does.", 137 requires_permission=True, 138) 139async def write_file( 140 path: str, 141 content: str, 142 append: bool = False, 143) -> str: 144 """ 145 Write content to a file. 146 147 Args: 148 path: Path to the file. 149 content: Content to write. 150 append: If True, append to file instead of overwriting. 151 152 Returns: 153 Success message or error. 154 """ 155 try: 156 file_path = _validate_path(path) 157 except ValueError as e: 158 return f"Error: {e}" 159 160 # Create parent directories if needed 161 try: 162 file_path.parent.mkdir(parents=True, exist_ok=True) 163 except PermissionError: 164 return f"Error: Permission denied creating directory {file_path.parent}" 165 166 mode = "a" if append else "w" 167 168 try: 169 async with aiofiles.open(file_path, mode, encoding="utf-8") as f: # type: ignore[call-overload] 170 await f.write(content) 171 172 action = "appended to" if append else "written to" 173 return f"Successfully {action} {path} ({len(content)} characters)" 174 175 except PermissionError: 176 return f"Error: Permission denied writing to {path}" 177 except Exception as e: 178 await logger.aerror("Failed to write file", path=path, error=str(e)) 179 return f"Error writing file: {e}"
Write content to a file.
Args: path: Path to the file. content: Content to write. append: If True, append to file instead of overwriting.
Returns: Success message or error.
182@tool( 183 name="list_directory", 184 description="List the contents of a directory. Returns files and subdirectories.", 185) 186async def list_directory( 187 path: str = ".", 188 show_hidden: bool = False, 189 recursive: bool = False, 190 max_depth: int = 2, 191) -> str: 192 """ 193 List directory contents. 194 195 Args: 196 path: Directory path. Defaults to current directory. 197 show_hidden: Include hidden files (starting with .). 198 recursive: List subdirectories recursively. 199 max_depth: Maximum recursion depth (only if recursive=True). 200 201 Returns: 202 Formatted directory listing. 203 """ 204 try: 205 dir_path = _validate_path(path, must_exist=True) 206 except (ValueError, FileNotFoundError) as e: 207 return f"Error: {e}" 208 209 if not dir_path.is_dir(): 210 return f"Error: {path} is not a directory" 211 212 try: 213 lines: list[str] = [f"Contents of {dir_path}:", ""] 214 215 if recursive: 216 lines.extend(_list_recursive(dir_path, show_hidden, max_depth, 0)) 217 else: 218 lines.extend(_list_flat(dir_path, show_hidden)) 219 220 return "\n".join(lines) 221 222 except PermissionError: 223 return f"Error: Permission denied accessing {path}" 224 except Exception as e: 225 await logger.aerror("Failed to list directory", path=path, error=str(e)) 226 return f"Error listing directory: {e}"
List directory contents.
Args: path: Directory path. Defaults to current directory. show_hidden: Include hidden files (starting with .). recursive: List subdirectories recursively. max_depth: Maximum recursion depth (only if recursive=True).
Returns: Formatted directory listing.
293def get_fs_tools() -> list[ToolDefinition]: 294 """Get all file system tool definitions.""" 295 tools: list[ToolDefinition] = [] 296 297 for func in [read_file, write_file, list_directory]: 298 if hasattr(func, "_tool_definition"): 299 tools.append(func._tool_definition) 300 301 return tools
Get all file system tool definitions.