mojentic 0.7.1__py3-none-any.whl → 0.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,41 +1,306 @@
1
1
  import os
2
+ import re
3
+ import glob
4
+ import difflib
2
5
 
3
6
  from mojentic.llm.tools.llm_tool import LLMTool
4
7
 
5
8
 
6
9
  class FilesystemGateway:
7
- def ls(self, path):
8
- return os.listdir(path)
10
+ def __init__(self, base_path: str):
11
+ self.base_path = base_path
9
12
 
10
- def read(self, path, file_name):
11
- with open(os.path.join(path, file_name), 'r') as f:
13
+ def _resolve_path(self, path: str) -> str:
14
+ """Resolve a path relative to the base path."""
15
+ # Ensure the path is within the sandbox
16
+ resolved_path = os.path.normpath(os.path.join(self.base_path, path))
17
+ # Verify the path is within the sandbox
18
+ if not resolved_path.startswith(os.path.normpath(self.base_path)):
19
+ raise ValueError(f"Path {path} attempts to escape the sandbox")
20
+ return resolved_path
21
+
22
+ def ls(self, path: str) -> list[str]:
23
+ resolved_path = self._resolve_path(path)
24
+ files = os.listdir(resolved_path)
25
+
26
+ # Convert the filenames to paths relative to the base_path using list comprehension
27
+ relative_files = [os.path.relpath(os.path.join(resolved_path, file), self.base_path)
28
+ for file in files]
29
+
30
+ return relative_files
31
+
32
+ def list_all_files(self, path: str) -> list[str]:
33
+ """List all files recursively in the given path."""
34
+ resolved_path = self._resolve_path(path)
35
+ all_files = []
36
+
37
+ for root, _, files in os.walk(resolved_path):
38
+ for file in files:
39
+ full_path = os.path.join(root, file)
40
+ relative_path = os.path.relpath(full_path, self.base_path)
41
+ all_files.append(relative_path)
42
+
43
+ return all_files
44
+
45
+ def find_files_by_glob(self, path: str, pattern: str) -> list[str]:
46
+ """Find files matching a glob pattern."""
47
+ resolved_path = self._resolve_path(path)
48
+ # Use glob to find files matching the pattern
49
+ matching_files = glob.glob(os.path.join(resolved_path, pattern), recursive=True)
50
+
51
+ # Convert to paths relative to base_path
52
+ relative_files = [os.path.relpath(file, self.base_path) for file in matching_files]
53
+ return relative_files
54
+
55
+ def find_files_containing(self, path: str, pattern: str) -> list[str]:
56
+ """Find files containing text matching a regex pattern."""
57
+ resolved_path = self._resolve_path(path)
58
+ matching_files = []
59
+ regex = re.compile(pattern)
60
+
61
+ for root, _, files in os.walk(resolved_path):
62
+ for file in files:
63
+ full_path = os.path.join(root, file)
64
+ try:
65
+ with open(full_path, 'r', errors='ignore') as f:
66
+ content = f.read()
67
+ if regex.search(content):
68
+ relative_path = os.path.relpath(full_path, self.base_path)
69
+ matching_files.append(relative_path)
70
+ except (IOError, UnicodeDecodeError):
71
+ # Skip files that can't be read as text
72
+ pass
73
+
74
+ return matching_files
75
+
76
+ def find_lines_matching(self, path: str, file_name: str, pattern: str) -> list[dict]:
77
+ """Find all lines in a file matching a regex pattern."""
78
+ resolved_path = self._resolve_path(path)
79
+ file_path = os.path.join(resolved_path, file_name)
80
+ matching_lines = []
81
+ regex = re.compile(pattern)
82
+
83
+ try:
84
+ with open(file_path, 'r', errors='ignore') as f:
85
+ for i, line in enumerate(f, 1):
86
+ if regex.search(line):
87
+ matching_lines.append({
88
+ 'line_number': i,
89
+ 'content': line.rstrip('\n')
90
+ })
91
+ except (IOError, UnicodeDecodeError) as e:
92
+ raise ValueError(f"Error reading file {file_name}: {str(e)}")
93
+
94
+ return matching_lines
95
+
96
+
97
+ def edit_file_with_diff(self, path: str, file_name: str, diff: str) -> str:
98
+ """Edit a file by applying a diff to it."""
99
+ resolved_path = self._resolve_path(path)
100
+ file_path = os.path.join(resolved_path, file_name)
101
+
102
+ try:
103
+ # Read the original content
104
+ with open(file_path, 'r') as f:
105
+ original_content = f.read()
106
+
107
+ # Check if the original file ends with a newline
108
+ ends_with_newline = original_content.endswith('\n')
109
+
110
+ # Apply the diff using difflib's unified_diff parser
111
+ original_lines = original_content.splitlines()
112
+ patched_content = self._apply_unified_diff(original_lines, diff)
113
+
114
+ # Preserve the trailing newline if it existed
115
+ if ends_with_newline and not patched_content.endswith('\n'):
116
+ patched_content += '\n'
117
+
118
+ # Write the modified content
119
+ with open(file_path, 'w') as f:
120
+ f.write(patched_content)
121
+
122
+ return f"Successfully applied diff to {file_name}"
123
+ except Exception as e:
124
+ raise ValueError(f"Error applying diff to {file_name}: {str(e)}")
125
+
126
+ def _apply_unified_diff(self, original_lines, diff_text):
127
+ """Apply a unified diff to the original lines."""
128
+ # Split the diff into lines
129
+ diff_lines = diff_text.splitlines()
130
+
131
+ # Create a copy of the original lines to modify
132
+ result_lines = original_lines.copy()
133
+
134
+ # Parse the diff and apply changes
135
+ i = 0
136
+ while i < len(diff_lines):
137
+ line = diff_lines[i]
138
+
139
+ # Skip file headers
140
+ if line.startswith('---') or line.startswith('+++'):
141
+ i += 1
142
+ continue
143
+
144
+ # Parse hunk header
145
+ if line.startswith('@@'):
146
+ # Format: @@ -start,count +start,count @@
147
+ match = re.match(r'@@ -(\d+),(\d+) \+(\d+),(\d+) @@', line)
148
+ if not match:
149
+ # Try alternative format without counts
150
+ match = re.match(r'@@ -(\d+) \+(\d+) @@', line)
151
+ if match:
152
+ start_line = int(match.group(1))
153
+ start_line_new = int(match.group(2))
154
+ else:
155
+ # Skip invalid hunk header
156
+ i += 1
157
+ continue
158
+ else:
159
+ start_line = int(match.group(1))
160
+ start_line_new = int(match.group(3))
161
+
162
+ # Line numbers in diff are 1-based, but our array is 0-based
163
+ start_line -= 1
164
+
165
+ # Extract the hunk content
166
+ hunk_lines = []
167
+ i += 1
168
+ while i < len(diff_lines) and not diff_lines[i].startswith('@@'):
169
+ hunk_lines.append(diff_lines[i])
170
+ i += 1
171
+
172
+ # Apply this hunk
173
+ self._apply_hunk(result_lines, hunk_lines, start_line)
174
+ continue
175
+
176
+ i += 1
177
+
178
+ # Join the result lines into a single string
179
+ return '\n'.join(result_lines)
180
+
181
+ def _apply_hunk(self, result_lines, hunk_lines, start_line):
182
+ """Apply a single hunk to the result_lines list."""
183
+ # Find the actual position in the file by matching context lines
184
+ actual_pos = self._find_hunk_position(result_lines, hunk_lines, start_line)
185
+ if actual_pos == -1:
186
+ # Could not find the position, use the start_line as a fallback
187
+ actual_pos = start_line
188
+
189
+ # Apply the changes
190
+ removed = 0
191
+ added = 0
192
+ pos = actual_pos
193
+
194
+ for line in hunk_lines:
195
+ if line.startswith('-'):
196
+ # Remove line
197
+ if pos < len(result_lines):
198
+ result_lines.pop(pos)
199
+ removed += 1
200
+ # Don't increment pos as we're removing this line
201
+ elif line.startswith('+'):
202
+ # Add line
203
+ result_lines.insert(pos, line[1:])
204
+ pos += 1
205
+ added += 1
206
+ else:
207
+ # Context line (unchanged)
208
+ if line.startswith(' '):
209
+ line = line[1:]
210
+ pos += 1
211
+
212
+ def _find_hunk_position(self, result_lines, hunk_lines, start_line):
213
+ """Find the actual position of a hunk in the file by matching context lines."""
214
+ # Extract context lines from the beginning of the hunk
215
+ context_lines = []
216
+ for line in hunk_lines:
217
+ if line.startswith(' '):
218
+ context_lines.append(line[1:])
219
+ else:
220
+ break
221
+
222
+ if not context_lines:
223
+ return start_line
224
+
225
+ # Try to find the context lines in the file
226
+ for i in range(max(0, start_line - 10), min(len(result_lines), start_line + 10)):
227
+ if i + len(context_lines) <= len(result_lines):
228
+ match = True
229
+ for j, context_line in enumerate(context_lines):
230
+ if result_lines[i + j] != context_line:
231
+ match = False
232
+ break
233
+ if match:
234
+ return i
235
+
236
+ return -1
237
+
238
+ def read(self, path: str, file_name: str) -> str:
239
+ resolved_path = self._resolve_path(path)
240
+ with open(os.path.join(resolved_path, file_name), 'r') as f:
12
241
  return f.read()
13
242
 
14
- def write(self, path, file_name, content):
15
- with open(os.path.join(path, file_name), 'w') as f:
243
+ def write(self, path: str, file_name: str, content: str) -> None:
244
+ resolved_path = self._resolve_path(path)
245
+ with open(os.path.join(resolved_path, file_name), 'w') as f:
16
246
  f.write(content)
17
247
 
18
248
 
19
249
  class FileManager:
20
- def __init__(self, path: str, fs=None):
21
- self.path: str = path
22
- self.fs = fs or FilesystemGateway()
250
+ def __init__(self, fs: FilesystemGateway):
251
+ self.fs = fs
23
252
 
24
- def ls(self, extension):
25
- entries = self.fs.ls(self.path)
253
+ def ls(self, path: str, extension: str = None) -> list[str]:
254
+ entries = self.fs.ls(path)
255
+ if extension is None:
256
+ return entries
26
257
  return [f for f in entries if f.endswith(extension)]
27
258
 
28
- def read(self, file_name):
29
- return self.fs.read(self.path, file_name)
259
+ def list_all_files(self, path: str) -> list[str]:
260
+ """List all files recursively in the sandbox."""
261
+ return self.fs.list_all_files(path)
30
262
 
31
- def write(self, file_name, content):
32
- self.fs.write(self.path, file_name, content)
263
+ def find_files_by_glob(self, path: str, pattern: str) -> list[str]:
264
+ """Find files matching a glob pattern."""
265
+ return self.fs.find_files_by_glob(path, pattern)
33
266
 
267
+ def find_files_containing(self, path: str, pattern: str) -> list[str]:
268
+ """Find files containing text matching a regex pattern."""
269
+ return self.fs.find_files_containing(path, pattern)
34
270
 
35
- class ListFilesTool(FileManager):
36
- def run(self, extension):
37
- entries = self.fs.ls(self.path)
38
- return [f for f in entries if f.endswith(extension)]
271
+ def find_lines_matching(self, path: str, file_name: str, pattern: str) -> list[dict]:
272
+ """Find all lines in a file matching a regex pattern."""
273
+ return self.fs.find_lines_matching(path, file_name, pattern)
274
+
275
+ def edit_file_with_diff(self, path: str, file_name: str, diff: str) -> str:
276
+ """Edit a file by applying a diff to it."""
277
+ return self.fs.edit_file_with_diff(path, file_name, diff)
278
+
279
+ def read(self, path: str, file_name: str) -> str:
280
+ return self.fs.read(path, file_name)
281
+
282
+ def write(self, path: str, file_name: str, content: str) -> None:
283
+ self.fs.write(path, file_name, content)
284
+
285
+
286
+ class ListFilesTool(LLMTool):
287
+ def __init__(self, fs: FilesystemGateway):
288
+ self.fs = fs
289
+
290
+ def run(self, path: str, extension: str = None) -> list[str]:
291
+ try:
292
+ entries = self.fs.ls(path)
293
+ if extension is None:
294
+ return entries
295
+ return [f for f in entries if f.endswith(extension)]
296
+ except ValueError as e:
297
+ return f"Error: {str(e)}"
298
+ except FileNotFoundError:
299
+ return f"Error: Directory '{path}' not found"
300
+ except PermissionError:
301
+ return f"Error: Permission denied when accessing directory '{path}'"
302
+ except Exception as e:
303
+ return f"Error listing files in '{path}': {str(e)}"
39
304
 
40
305
  @property
41
306
  def descriptor(self):
@@ -43,29 +308,47 @@ class ListFilesTool(FileManager):
43
308
  "type": "function",
44
309
  "function": {
45
310
  "name": "list_files",
46
- "description": "List files in a directory with a specific extension.",
311
+ "description": "List files in the specified directory (non-recursive), optionally filtered by extension. Use this when you need to see what files are available in a specific directory without including subdirectories.",
47
312
  "parameters": {
48
313
  "type": "object",
49
314
  "properties": {
315
+ "path": {
316
+ "type": "string",
317
+ "description": "The path relative to the sandbox root to list files from. For example, '.' for the root directory, 'src' for the src directory, or 'docs/images' for a nested directory."
318
+ },
50
319
  "extension": {
51
320
  "type": "string",
52
- "description": "The file extension to filter by."
321
+ "description": "The file extension to filter by (e.g., '.py', '.txt', '.md'). If not provided, all files will be listed. For example, using '.py' will only list Python files in the directory."
53
322
  }
54
323
  },
55
324
  "additionalProperties": False,
56
- "required": ["extension"]
325
+ "required": ["path"]
57
326
  },
58
327
  },
59
328
  }
60
329
 
61
330
 
62
331
  class ReadFileTool(LLMTool):
63
- def __init__(self, path: str, fs=None):
64
- self.path: str = path
65
- self.fs = fs or FilesystemGateway()
332
+ def __init__(self, fs: FilesystemGateway):
333
+ self.fs = fs
66
334
 
67
- def run(self, file_name):
68
- return self.fs.read(self.path, file_name)
335
+ def run(self, path: str) -> str:
336
+ try:
337
+ # Split the path into directory and filename
338
+ directory, file_name = os.path.split(path)
339
+ return self.fs.read(directory, file_name)
340
+ except ValueError as e:
341
+ return f"Error: {str(e)}"
342
+ except FileNotFoundError:
343
+ return f"Error: File '{path}' not found"
344
+ except PermissionError:
345
+ return f"Error: Permission denied when accessing file '{path}'"
346
+ except IOError as e:
347
+ return f"Error reading file '{path}': {str(e)}"
348
+ except UnicodeDecodeError:
349
+ return f"Error: File '{path}' contains non-text content that cannot be read as text"
350
+ except Exception as e:
351
+ return f"Error reading file '{path}': {str(e)}"
69
352
 
70
353
  @property
71
354
  def descriptor(self):
@@ -73,30 +356,42 @@ class ReadFileTool(LLMTool):
73
356
  "type": "function",
74
357
  "function": {
75
358
  "name": "read_file",
76
- "description": "Read the content of a file.",
359
+ "description": "Read the entire content of a file as a string. Use this when you need to access or analyze the complete contents of a file.",
77
360
  "parameters": {
78
361
  "type": "object",
79
362
  "properties": {
80
- "file_name": {
363
+ "path": {
81
364
  "type": "string",
82
- "description": "The name of the file to read."
365
+ "description": "The full relative path including the filename of the file to read. For example, 'README.md' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
83
366
  }
84
367
  },
85
368
  "additionalProperties": False,
86
- "required": ["file_name"]
369
+ "required": ["path"]
87
370
  },
88
371
  },
89
372
  }
90
373
 
91
374
 
92
375
  class WriteFileTool(LLMTool):
93
- def __init__(self, path: str, fs=None):
94
- self.path: str = path
95
- self.fs = fs or FilesystemGateway()
376
+ def __init__(self, fs: FilesystemGateway):
377
+ self.fs = fs
96
378
 
97
- def run(self, file_name, content):
98
- self.fs.write(self.path, file_name, content)
99
- return f"Successfully wrote to {file_name}"
379
+ def run(self, path: str, content: str) -> str:
380
+ try:
381
+ # Split the path into directory and filename
382
+ directory, file_name = os.path.split(path)
383
+ self.fs.write(directory, file_name, content)
384
+ return f"Successfully wrote to {path}"
385
+ except ValueError as e:
386
+ return f"Error: {str(e)}"
387
+ except FileNotFoundError:
388
+ return f"Error: Directory not found. Cannot write file '{path}'"
389
+ except PermissionError:
390
+ return f"Error: Permission denied when writing to file '{path}'"
391
+ except IOError as e:
392
+ return f"Error writing to file '{path}': {str(e)}"
393
+ except Exception as e:
394
+ return f"Error writing to file '{path}': {str(e)}"
100
395
 
101
396
  @property
102
397
  def descriptor(self):
@@ -104,21 +399,287 @@ class WriteFileTool(LLMTool):
104
399
  "type": "function",
105
400
  "function": {
106
401
  "name": "write_file",
107
- "description": "Write content to a file.",
402
+ "description": "Write content to a file, completely overwriting any existing content. Use this when you want to replace the entire contents of a file with new content.",
108
403
  "parameters": {
109
404
  "type": "object",
110
405
  "properties": {
111
- "file_name": {
406
+ "path": {
112
407
  "type": "string",
113
- "description": "The name of the file to write to."
408
+ "description": "The full relative path including the filename where the file should be written. For example, 'output.txt' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
114
409
  },
115
410
  "content": {
116
411
  "type": "string",
117
- "description": "The content to write to the file."
412
+ "description": "The content to write to the file. This will completely replace any existing content in the file. For example, 'Hello, world!' for a simple text file, or a JSON string for a configuration file."
413
+ }
414
+ },
415
+ "additionalProperties": False,
416
+ "required": ["path", "content"]
417
+ },
418
+ },
419
+ }
420
+
421
+
422
+ class ListAllFilesTool(LLMTool):
423
+ def __init__(self, fs: FilesystemGateway):
424
+ self.fs = fs
425
+
426
+ def run(self, path: str) -> list[str]:
427
+ try:
428
+ return self.fs.list_all_files(path)
429
+ except ValueError as e:
430
+ return f"Error: {str(e)}"
431
+ except FileNotFoundError:
432
+ return f"Error: Directory '{path}' not found"
433
+ except PermissionError:
434
+ return f"Error: Permission denied when accessing directory '{path}'"
435
+ except Exception as e:
436
+ return f"Error listing files recursively in '{path}': {str(e)}"
437
+
438
+ @property
439
+ def descriptor(self):
440
+ return {
441
+ "type": "function",
442
+ "function": {
443
+ "name": "list_all_files",
444
+ "description": "List all files recursively in the specified directory, including files in subdirectories. Use this when you need a complete inventory of all files in a directory and its subdirectories.",
445
+ "parameters": {
446
+ "type": "object",
447
+ "properties": {
448
+ "path": {
449
+ "type": "string",
450
+ "description": "The path relative to the sandbox root to list files from recursively. For example, '.' for the root directory and all subdirectories, 'src' for the src directory and all its subdirectories, or 'docs/images' for a nested directory and its subdirectories."
451
+ }
452
+ },
453
+ "additionalProperties": False,
454
+ "required": ["path"]
455
+ },
456
+ },
457
+ }
458
+
459
+
460
+ class FindFilesByGlobTool(LLMTool):
461
+ def __init__(self, fs: FilesystemGateway):
462
+ self.fs = fs
463
+
464
+ def run(self, path: str, pattern: str) -> list[str]:
465
+ try:
466
+ return self.fs.find_files_by_glob(path, pattern)
467
+ except ValueError as e:
468
+ return f"Error: {str(e)}"
469
+ except FileNotFoundError:
470
+ return f"Error: Directory '{path}' not found"
471
+ except PermissionError:
472
+ return f"Error: Permission denied when accessing directory '{path}'"
473
+ except Exception as e:
474
+ return f"Error finding files with pattern '{pattern}' in '{path}': {str(e)}"
475
+
476
+ @property
477
+ def descriptor(self):
478
+ return {
479
+ "type": "function",
480
+ "function": {
481
+ "name": "find_files_by_glob",
482
+ "description": "Find files matching a glob pattern in the specified directory. Use this when you need to locate files with specific patterns in their names or paths (e.g., all Python files with '*.py' or all text files in any subdirectory with '**/*.txt').",
483
+ "parameters": {
484
+ "type": "object",
485
+ "properties": {
486
+ "path": {
487
+ "type": "string",
488
+ "description": "The path relative to the sandbox root to search in. For example, '.' for the root directory, 'src' for the src directory, or 'docs/images' for a nested directory."
489
+ },
490
+ "pattern": {
491
+ "type": "string",
492
+ "description": "The glob pattern to match files against. Examples: '*.py' for all Python files in the specified directory, '**/*.txt' for all text files in the specified directory and any subdirectory, or '**/*test*.py' for all Python files with 'test' in their name in the specified directory and any subdirectory."
493
+ }
494
+ },
495
+ "additionalProperties": False,
496
+ "required": ["path", "pattern"]
497
+ },
498
+ },
499
+ }
500
+
501
+
502
+ class FindFilesContainingTool(LLMTool):
503
+ def __init__(self, fs: FilesystemGateway):
504
+ self.fs = fs
505
+
506
+ def run(self, path: str, pattern: str) -> list[str]:
507
+ try:
508
+ return self.fs.find_files_containing(path, pattern)
509
+ except ValueError as e:
510
+ return f"Error: {str(e)}"
511
+ except FileNotFoundError:
512
+ return f"Error: Directory '{path}' not found"
513
+ except PermissionError:
514
+ return f"Error: Permission denied when accessing directory '{path}'"
515
+ except re.error as e:
516
+ return f"Error: Invalid regex pattern '{pattern}': {str(e)}"
517
+ except Exception as e:
518
+ return f"Error finding files containing pattern '{pattern}' in '{path}': {str(e)}"
519
+
520
+ @property
521
+ def descriptor(self):
522
+ return {
523
+ "type": "function",
524
+ "function": {
525
+ "name": "find_files_containing",
526
+ "description": "Find files containing text matching a regex pattern in the specified directory. Use this when you need to search for specific content across multiple files, such as finding all files that contain a particular function name or text string.",
527
+ "parameters": {
528
+ "type": "object",
529
+ "properties": {
530
+ "path": {
531
+ "type": "string",
532
+ "description": "The path relative to the sandbox root to search in. For example, '.' for the root directory, 'src' for the src directory, or 'docs/images' for a nested directory."
533
+ },
534
+ "pattern": {
535
+ "type": "string",
536
+ "description": "The regex pattern to search for in files. Examples: 'function\\s+main' to find files containing a main function, 'import\\s+os' to find files importing the os module, or 'TODO|FIXME' to find files containing TODO or FIXME comments. The pattern uses Python's re module syntax."
537
+ }
538
+ },
539
+ "additionalProperties": False,
540
+ "required": ["path", "pattern"]
541
+ },
542
+ },
543
+ }
544
+
545
+
546
+ class FindLinesMatchingTool(LLMTool):
547
+ def __init__(self, fs: FilesystemGateway):
548
+ self.fs = fs
549
+
550
+ def run(self, path: str, pattern: str) -> list[dict]:
551
+ try:
552
+ # Split the path into directory and filename
553
+ directory, file_name = os.path.split(path)
554
+ return self.fs.find_lines_matching(directory, file_name, pattern)
555
+ except ValueError as e:
556
+ return f"Error: {str(e)}"
557
+ except FileNotFoundError:
558
+ return f"Error: File '{path}' not found"
559
+ except PermissionError:
560
+ return f"Error: Permission denied when accessing file '{path}'"
561
+ except re.error as e:
562
+ return f"Error: Invalid regex pattern '{pattern}': {str(e)}"
563
+ except IOError as e:
564
+ return f"Error reading file '{path}': {str(e)}"
565
+ except UnicodeDecodeError:
566
+ return f"Error: File '{path}' contains non-text content that cannot be read as text"
567
+ except Exception as e:
568
+ return f"Error finding lines matching pattern '{pattern}' in file '{path}': {str(e)}"
569
+
570
+ @property
571
+ def descriptor(self):
572
+ return {
573
+ "type": "function",
574
+ "function": {
575
+ "name": "find_lines_matching",
576
+ "description": "Find all lines in a file matching a regex pattern, returning both line numbers and content. Use this when you need to locate specific patterns within a single file and need to know exactly where they appear.",
577
+ "parameters": {
578
+ "type": "object",
579
+ "properties": {
580
+ "path": {
581
+ "type": "string",
582
+ "description": "The full relative path including the filename of the file to search in. For example, 'README.md' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
583
+ },
584
+ "pattern": {
585
+ "type": "string",
586
+ "description": "The regex pattern to match lines against. Examples: 'def\\s+\\w+' to find all function definitions, 'class\\s+\\w+' to find all class definitions, or 'TODO|FIXME' to find all TODO or FIXME comments. The pattern uses Python's re module syntax."
587
+ }
588
+ },
589
+ "additionalProperties": False,
590
+ "required": ["path", "pattern"]
591
+ },
592
+ },
593
+ }
594
+
595
+
596
+ class EditFileWithDiffTool(LLMTool):
597
+ def __init__(self, fs: FilesystemGateway):
598
+ self.fs = fs
599
+
600
+ def run(self, path: str, diff: str) -> str:
601
+ try:
602
+ # Split the path into directory and filename
603
+ directory, file_name = os.path.split(path)
604
+ return self.fs.edit_file_with_diff(directory, file_name, diff)
605
+ except ValueError as e:
606
+ return f"Error: {str(e)}"
607
+ except FileNotFoundError:
608
+ return f"Error: File '{path}' not found"
609
+ except PermissionError:
610
+ return f"Error: Permission denied when accessing file '{path}'"
611
+ except IOError as e:
612
+ return f"Error accessing file '{path}': {str(e)}"
613
+ except UnicodeDecodeError:
614
+ return f"Error: File '{path}' contains non-text content that cannot be edited as text"
615
+ except Exception as e:
616
+ return f"Error applying diff to file '{path}': {str(e)}"
617
+
618
+ @property
619
+ def descriptor(self):
620
+ return {
621
+ "type": "function",
622
+ "function": {
623
+ "name": "edit_file_with_diff",
624
+ "description": "Edit a file by applying a diff to it. Use this for making selective changes to parts of a file while preserving the rest of the content, unlike write_file which completely replaces the file. The diff should be in a unified diff format with lines prefixed by '+' (add), '-' (remove), or ' ' (context).",
625
+ "parameters": {
626
+ "type": "object",
627
+ "properties": {
628
+ "path": {
629
+ "type": "string",
630
+ "description": "The full relative path including the filename of the file to edit. For example, 'README.md' for a file in the root directory, 'src/main.py' for a file in the src directory, or 'docs/images/diagram.png' for a file in a nested directory."
631
+ },
632
+ "diff": {
633
+ "type": "string",
634
+ "description": "The diff to apply to the file in unified diff format. Lines to add should be prefixed with '+', lines to remove with '-', and context lines with ' ' (space). Example:\n\n```\n This is a context line (unchanged)\n-This line will be removed\n+This line will be added\n This is another context line\n```\n\nThe diff should include enough context lines to uniquely identify the section of the file to modify."
635
+ }
636
+ },
637
+ "additionalProperties": False,
638
+ "required": ["path", "diff"]
639
+ },
640
+ },
641
+ }
642
+
643
+
644
+ class CreateDirectoryTool(LLMTool):
645
+ def __init__(self, fs: FilesystemGateway):
646
+ self.fs = fs
647
+
648
+ def run(self, path: str) -> str:
649
+ try:
650
+ # Resolve the path relative to the base path
651
+ resolved_path = self.fs._resolve_path(path)
652
+
653
+ # Create the directory
654
+ os.makedirs(resolved_path, exist_ok=True)
655
+
656
+ return f"Successfully created directory '{path}'"
657
+ except ValueError as e:
658
+ return f"Error: {str(e)}"
659
+ except PermissionError:
660
+ return f"Error: Permission denied when creating directory '{path}'"
661
+ except OSError as e:
662
+ return f"Error creating directory '{path}': {str(e)}"
663
+ except Exception as e:
664
+ return f"Error creating directory '{path}': {str(e)}"
665
+
666
+ @property
667
+ def descriptor(self):
668
+ return {
669
+ "type": "function",
670
+ "function": {
671
+ "name": "create_directory",
672
+ "description": "Create a new directory at the specified path. If the directory already exists, this operation will succeed without error. Use this when you need to create a directory structure before writing files to it.",
673
+ "parameters": {
674
+ "type": "object",
675
+ "properties": {
676
+ "path": {
677
+ "type": "string",
678
+ "description": "The relative path where the directory should be created. For example, 'new_folder' for a directory in the root, 'src/new_folder' for a directory in the src directory, or 'docs/images/new_folder' for a nested directory. Parent directories will be created automatically if they don't exist."
118
679
  }
119
680
  },
120
681
  "additionalProperties": False,
121
- "required": ["file_name", "content"]
682
+ "required": ["path"]
122
683
  },
123
684
  },
124
685
  }