kader 0.1.0__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.
kader/tools/filesys.py ADDED
@@ -0,0 +1,650 @@
1
+ """
2
+ File System Tools for Agentic Operations.
3
+
4
+ All tools operate relative to the current working directory (CWD) for security.
5
+ Uses FilesystemBackend from filesystem.py for the underlying operations.
6
+ """
7
+
8
+ import asyncio
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .base import (
13
+ BaseTool,
14
+ ParameterSchema,
15
+ ToolCategory,
16
+ )
17
+ from .filesystem import FilesystemBackend
18
+ from .protocol import FileInfo
19
+
20
+
21
+ class ReadFileTool(BaseTool[str]):
22
+ """
23
+ Tool to read the contents of a file.
24
+
25
+ Uses FilesystemBackend for secure file access with path containment.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ base_path: Path | None = None,
31
+ virtual_mode: bool = False,
32
+ ) -> None:
33
+ """
34
+ Initialize the read file tool.
35
+
36
+ Args:
37
+ base_path: Base path for file operations (defaults to CWD)
38
+ virtual_mode: If True, use virtual path mode (sandboxed to base_path)
39
+ """
40
+ super().__init__(
41
+ name="read_file",
42
+ description=(
43
+ "Read the contents of a file. Returns the file content as text with line numbers. "
44
+ "Supports pagination with offset and limit for large files."
45
+ ),
46
+ parameters=[
47
+ ParameterSchema(
48
+ name="path",
49
+ type="string",
50
+ description="Path to the file to read",
51
+ ),
52
+ ParameterSchema(
53
+ name="offset",
54
+ type="integer",
55
+ description="Line offset to start reading from (0-indexed)",
56
+ required=False,
57
+ default=0,
58
+ minimum=0,
59
+ ),
60
+ ParameterSchema(
61
+ name="limit",
62
+ type="integer",
63
+ description="Maximum number of lines to read",
64
+ required=False,
65
+ default=2000,
66
+ minimum=1,
67
+ ),
68
+ ],
69
+ category=ToolCategory.FILE_SYSTEM,
70
+ )
71
+ self._backend = FilesystemBackend(
72
+ root_dir=base_path,
73
+ virtual_mode=virtual_mode,
74
+ )
75
+
76
+ def execute(
77
+ self,
78
+ path: str,
79
+ offset: int = 0,
80
+ limit: int = 2000,
81
+ ) -> str:
82
+ """
83
+ Read file contents.
84
+
85
+ Args:
86
+ path: Path to the file
87
+ offset: Line offset to start reading from (0-indexed)
88
+ limit: Maximum number of lines to read
89
+
90
+ Returns:
91
+ File contents with line numbers, or error message
92
+ """
93
+ return self._backend.read(path, offset=offset, limit=limit)
94
+
95
+ async def aexecute(
96
+ self,
97
+ path: str,
98
+ offset: int = 0,
99
+ limit: int = 2000,
100
+ ) -> str:
101
+ """Async version of execute."""
102
+ return await asyncio.to_thread(self.execute, path, offset, limit)
103
+
104
+ def get_interruption_message(self, path: str, **kwargs) -> str:
105
+ """Get interruption message for user confirmation."""
106
+ return f"execute read_file: {path}"
107
+
108
+
109
+ class ReadDirectoryTool(BaseTool[list[dict[str, Any]]]):
110
+ """
111
+ Tool to list the contents of a directory.
112
+
113
+ Uses FilesystemBackend for secure directory listing.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ base_path: Path | None = None,
119
+ virtual_mode: bool = False,
120
+ ) -> None:
121
+ """
122
+ Initialize the read directory tool.
123
+
124
+ Args:
125
+ base_path: Base path for file operations (defaults to CWD)
126
+ virtual_mode: If True, use virtual path mode (sandboxed to base_path)
127
+ """
128
+ super().__init__(
129
+ name="read_directory",
130
+ description=(
131
+ "List the contents of a directory. Returns a list of files and "
132
+ "subdirectories with their types and sizes."
133
+ ),
134
+ parameters=[
135
+ ParameterSchema(
136
+ name="path",
137
+ type="string",
138
+ description="Path to the directory (use '.' or '/' for root)",
139
+ default=".",
140
+ ),
141
+ ],
142
+ category=ToolCategory.FILE_SYSTEM,
143
+ )
144
+ self._backend = FilesystemBackend(
145
+ root_dir=base_path,
146
+ virtual_mode=virtual_mode,
147
+ )
148
+
149
+ def execute(
150
+ self,
151
+ path: str = ".",
152
+ ) -> list[dict[str, Any]]:
153
+ """
154
+ List directory contents.
155
+
156
+ Args:
157
+ path: Path to directory
158
+
159
+ Returns:
160
+ List of file/directory information dictionaries
161
+ """
162
+ result: list[FileInfo] = self._backend.ls_info(path)
163
+ # Convert FileInfo TypedDict to regular dict for JSON serialization
164
+ return [dict(item) for item in result]
165
+
166
+ async def aexecute(
167
+ self,
168
+ path: str = ".",
169
+ ) -> list[dict[str, Any]]:
170
+ """Async version of execute."""
171
+ return await asyncio.to_thread(self.execute, path)
172
+
173
+ def get_interruption_message(self, path: str = ".", **kwargs) -> str:
174
+ """Get interruption message for user confirmation."""
175
+ return f"execute read_dir: {path}"
176
+
177
+
178
+ class WriteFileTool(BaseTool[dict[str, Any]]):
179
+ """
180
+ Tool to write content to a new file.
181
+
182
+ Uses FilesystemBackend for secure file creation.
183
+ Only creates new files; fails if file already exists.
184
+ """
185
+
186
+ def __init__(
187
+ self,
188
+ base_path: Path | None = None,
189
+ virtual_mode: bool = False,
190
+ ) -> None:
191
+ """
192
+ Initialize the write file tool.
193
+
194
+ Args:
195
+ base_path: Base path for file operations (defaults to CWD)
196
+ virtual_mode: If True, use virtual path mode (sandboxed to base_path)
197
+ """
198
+ super().__init__(
199
+ name="write_file",
200
+ description=(
201
+ "Create a new file with content. Fails if the file already exists. "
202
+ "Use edit_file tool to modify existing files."
203
+ ),
204
+ parameters=[
205
+ ParameterSchema(
206
+ name="path",
207
+ type="string",
208
+ description="Path to the file to create",
209
+ ),
210
+ ParameterSchema(
211
+ name="content",
212
+ type="string",
213
+ description="Content to write to the file",
214
+ ),
215
+ ],
216
+ category=ToolCategory.FILE_SYSTEM,
217
+ )
218
+ self._backend = FilesystemBackend(
219
+ root_dir=base_path,
220
+ virtual_mode=virtual_mode,
221
+ )
222
+
223
+ def execute(
224
+ self,
225
+ path: str,
226
+ content: str,
227
+ ) -> dict[str, Any]:
228
+ """
229
+ Write content to a new file.
230
+
231
+ Args:
232
+ path: Path to the file to create
233
+ content: Content to write
234
+
235
+ Returns:
236
+ Dictionary with operation result
237
+ """
238
+ result = self._backend.write(path, content)
239
+
240
+ if result.error:
241
+ return {"error": result.error}
242
+
243
+ return {
244
+ "path": result.path,
245
+ "success": True,
246
+ "bytes_written": len(content.encode("utf-8")),
247
+ }
248
+
249
+ async def aexecute(
250
+ self,
251
+ path: str,
252
+ content: str,
253
+ ) -> dict[str, Any]:
254
+ """Async version of execute."""
255
+ return await asyncio.to_thread(self.execute, path, content)
256
+
257
+ def get_interruption_message(self, path: str, **kwargs) -> str:
258
+ """Get interruption message for user confirmation."""
259
+ return f"execute write_file: {path}"
260
+
261
+
262
+ class EditFileTool(BaseTool[dict[str, Any]]):
263
+ """
264
+ Tool to edit an existing file by string replacement.
265
+
266
+ Uses FilesystemBackend for secure file editing.
267
+ """
268
+
269
+ def __init__(
270
+ self,
271
+ base_path: Path | None = None,
272
+ virtual_mode: bool = False,
273
+ ) -> None:
274
+ """
275
+ Initialize the edit file tool.
276
+
277
+ Args:
278
+ base_path: Base path for file operations (defaults to CWD)
279
+ virtual_mode: If True, use virtual path mode (sandboxed to base_path)
280
+ """
281
+ super().__init__(
282
+ name="edit_file",
283
+ description=(
284
+ "Edit an existing file by replacing text. Replaces exact string matches. "
285
+ "Use replace_all=True to replace all occurrences."
286
+ ),
287
+ parameters=[
288
+ ParameterSchema(
289
+ name="path",
290
+ type="string",
291
+ description="Path to the file to edit",
292
+ ),
293
+ ParameterSchema(
294
+ name="old_string",
295
+ type="string",
296
+ description="Exact string to search for and replace",
297
+ ),
298
+ ParameterSchema(
299
+ name="new_string",
300
+ type="string",
301
+ description="String to replace old_string with",
302
+ ),
303
+ ParameterSchema(
304
+ name="replace_all",
305
+ type="boolean",
306
+ description="If True, replace all occurrences",
307
+ required=False,
308
+ default=False,
309
+ ),
310
+ ],
311
+ category=ToolCategory.FILE_SYSTEM,
312
+ )
313
+ self._backend = FilesystemBackend(
314
+ root_dir=base_path,
315
+ virtual_mode=virtual_mode,
316
+ )
317
+
318
+ def execute(
319
+ self,
320
+ path: str,
321
+ old_string: str,
322
+ new_string: str,
323
+ replace_all: bool = False,
324
+ ) -> dict[str, Any]:
325
+ """
326
+ Edit a file by replacing string occurrences.
327
+
328
+ Args:
329
+ path: Path to the file
330
+ old_string: String to replace
331
+ new_string: Replacement string
332
+ replace_all: Whether to replace all occurrences
333
+
334
+ Returns:
335
+ Dictionary with operation result
336
+ """
337
+ result = self._backend.edit(path, old_string, new_string, replace_all)
338
+
339
+ if result.error:
340
+ return {"error": result.error}
341
+
342
+ return {
343
+ "path": result.path,
344
+ "success": True,
345
+ "occurrences": result.occurrences,
346
+ }
347
+
348
+ async def aexecute(
349
+ self,
350
+ path: str,
351
+ old_string: str,
352
+ new_string: str,
353
+ replace_all: bool = False,
354
+ ) -> dict[str, Any]:
355
+ """Async version of execute."""
356
+ return await asyncio.to_thread(
357
+ self.execute, path, old_string, new_string, replace_all
358
+ )
359
+
360
+ def get_interruption_message(self, path: str, **kwargs) -> str:
361
+ """Get interruption message for user confirmation."""
362
+ return f"execute edit_file: {path}"
363
+
364
+
365
+ class GrepTool(BaseTool[list[dict[str, Any]]]):
366
+ """
367
+ Tool to search for patterns in files using regex.
368
+
369
+ Uses FilesystemBackend's grep_raw with ripgrep fallback to Python.
370
+ """
371
+
372
+ def __init__(
373
+ self,
374
+ base_path: Path | None = None,
375
+ virtual_mode: bool = False,
376
+ ) -> None:
377
+ """
378
+ Initialize the grep tool.
379
+
380
+ Args:
381
+ base_path: Base path for search operations (defaults to CWD)
382
+ virtual_mode: If True, use virtual path mode (sandboxed to base_path)
383
+ """
384
+ super().__init__(
385
+ name="grep",
386
+ description=(
387
+ "Search for a regex pattern in files. Returns matching lines with "
388
+ "file paths and line numbers. Optionally filter by glob pattern."
389
+ ),
390
+ parameters=[
391
+ ParameterSchema(
392
+ name="pattern",
393
+ type="string",
394
+ description="Regex pattern to search for",
395
+ ),
396
+ ParameterSchema(
397
+ name="path",
398
+ type="string",
399
+ description="Directory path to search in (defaults to current directory)",
400
+ required=False,
401
+ default=".",
402
+ ),
403
+ ParameterSchema(
404
+ name="glob",
405
+ type="string",
406
+ description="Glob pattern to filter files (e.g., '*.py')",
407
+ required=False,
408
+ ),
409
+ ],
410
+ category=ToolCategory.SEARCH,
411
+ )
412
+ self._backend = FilesystemBackend(
413
+ root_dir=base_path,
414
+ virtual_mode=virtual_mode,
415
+ )
416
+
417
+ def execute(
418
+ self,
419
+ pattern: str,
420
+ path: str | None = ".",
421
+ glob: str | None = None,
422
+ ) -> list[dict[str, Any]]:
423
+ """
424
+ Search for pattern in files.
425
+
426
+ Args:
427
+ pattern: Regex pattern to search for
428
+ path: Directory to search in
429
+ glob: Optional glob pattern to filter files
430
+
431
+ Returns:
432
+ List of match dictionaries with path, line, and text
433
+ """
434
+ result = self._backend.grep_raw(pattern, path, glob)
435
+
436
+ if isinstance(result, str):
437
+ # Error message
438
+ return [{"error": result}]
439
+
440
+ # Convert GrepMatch TypedDict to regular dict
441
+ return [dict(match) for match in result]
442
+
443
+ async def aexecute(
444
+ self,
445
+ pattern: str,
446
+ path: str | None = ".",
447
+ glob: str | None = None,
448
+ ) -> list[dict[str, Any]]:
449
+ """Async version of execute."""
450
+ return await asyncio.to_thread(self.execute, pattern, path, glob)
451
+
452
+ def get_interruption_message(self, **kwargs) -> str:
453
+ """Get interruption message for user confirmation."""
454
+ return "execute grep"
455
+
456
+
457
+ class GlobTool(BaseTool[list[dict[str, Any]]]):
458
+ """
459
+ Tool to find files matching a glob pattern.
460
+
461
+ Uses FilesystemBackend's glob_info for pattern matching.
462
+ """
463
+
464
+ def __init__(
465
+ self,
466
+ base_path: Path | None = None,
467
+ virtual_mode: bool = False,
468
+ ) -> None:
469
+ """
470
+ Initialize the glob tool.
471
+
472
+ Args:
473
+ base_path: Base path for search operations (defaults to CWD)
474
+ virtual_mode: If True, use virtual path mode (sandboxed to base_path)
475
+ """
476
+ super().__init__(
477
+ name="glob",
478
+ description=(
479
+ "Find files matching a glob pattern. Supports wildcards: "
480
+ "* (any chars), ** (recursive), ? (single char), [abc] (char set)."
481
+ ),
482
+ parameters=[
483
+ ParameterSchema(
484
+ name="pattern",
485
+ type="string",
486
+ description="Glob pattern to match (e.g., '*.py', '**/*.txt')",
487
+ ),
488
+ ParameterSchema(
489
+ name="path",
490
+ type="string",
491
+ description="Base directory to search from",
492
+ required=False,
493
+ default="/",
494
+ ),
495
+ ],
496
+ category=ToolCategory.SEARCH,
497
+ )
498
+ self._backend = FilesystemBackend(
499
+ root_dir=base_path,
500
+ virtual_mode=virtual_mode,
501
+ )
502
+
503
+ def execute(
504
+ self,
505
+ pattern: str,
506
+ path: str = "/",
507
+ ) -> list[dict[str, Any]]:
508
+ """
509
+ Find files matching glob pattern.
510
+
511
+ Args:
512
+ pattern: Glob pattern to match
513
+ path: Base directory to search from
514
+
515
+ Returns:
516
+ List of file info dictionaries
517
+ """
518
+ result: list[FileInfo] = self._backend.glob_info(pattern, path)
519
+ return [dict(item) for item in result]
520
+
521
+ async def aexecute(
522
+ self,
523
+ pattern: str,
524
+ path: str = "/",
525
+ ) -> list[dict[str, Any]]:
526
+ """Async version of execute."""
527
+ return await asyncio.to_thread(self.execute, pattern, path)
528
+
529
+ def get_interruption_message(self, **kwargs) -> str:
530
+ """Get interruption message for user confirmation."""
531
+ return "execute glob"
532
+
533
+
534
+ class SearchInDirectoryTool(BaseTool[list[dict[str, Any]]]):
535
+ """
536
+ Tool to search for content in files using RAG-based semantic search.
537
+
538
+ Uses Ollama embeddings and FAISS for intelligent code search.
539
+ """
540
+
541
+ def __init__(
542
+ self,
543
+ base_path: Path | None = None,
544
+ embedding_model: str = "all-minilm:22m",
545
+ ) -> None:
546
+ """
547
+ Initialize the search tool.
548
+
549
+ Args:
550
+ base_path: Base path for search operations (defaults to CWD)
551
+ embedding_model: Ollama embedding model to use
552
+ """
553
+ super().__init__(
554
+ name="search_in_directory",
555
+ description=(
556
+ "Search for code and content using semantic (meaning-based) search. "
557
+ "Finds relevant files and snippets based on your natural language query."
558
+ ),
559
+ parameters=[
560
+ ParameterSchema(
561
+ name="query",
562
+ type="string",
563
+ description="Natural language search query (e.g., 'function to read JSON files')",
564
+ ),
565
+ ParameterSchema(
566
+ name="top_k",
567
+ type="integer",
568
+ description="Number of results to return",
569
+ required=False,
570
+ default=5,
571
+ minimum=1,
572
+ maximum=20,
573
+ ),
574
+ ParameterSchema(
575
+ name="rebuild_index",
576
+ type="boolean",
577
+ description="Force rebuild the search index (use after file changes)",
578
+ required=False,
579
+ default=False,
580
+ ),
581
+ ],
582
+ category=ToolCategory.SEARCH,
583
+ )
584
+
585
+ # Lazy import to avoid loading dependencies at module import time
586
+ from .rag import RAGSearchTool
587
+
588
+ self._rag_tool = RAGSearchTool(
589
+ base_path=base_path,
590
+ embedding_model=embedding_model,
591
+ )
592
+
593
+ def execute(
594
+ self,
595
+ query: str,
596
+ top_k: int = 5,
597
+ rebuild_index: bool = False,
598
+ ) -> list[dict[str, Any]]:
599
+ """
600
+ Search for content in the directory.
601
+
602
+ Args:
603
+ query: Natural language search query
604
+ top_k: Number of results to return
605
+ rebuild_index: Force rebuild the index
606
+
607
+ Returns:
608
+ List of search result dictionaries
609
+ """
610
+ return self._rag_tool.execute(query, top_k, rebuild_index)
611
+
612
+ async def aexecute(
613
+ self,
614
+ query: str,
615
+ top_k: int = 5,
616
+ rebuild_index: bool = False,
617
+ ) -> list[dict[str, Any]]:
618
+ """Async version of execute."""
619
+ return await self._rag_tool.aexecute(query, top_k, rebuild_index)
620
+
621
+ def get_interruption_message(self, **kwargs) -> str:
622
+ """Get interruption message for user confirmation."""
623
+ return "execute search_in_directory"
624
+
625
+
626
+ # Convenience function to get all file system tools
627
+ def get_filesystem_tools(
628
+ base_path: Path | None = None,
629
+ virtual_mode: bool = False,
630
+ ) -> list[BaseTool]:
631
+ """
632
+ Get all file system tools configured with the given base path.
633
+
634
+ Args:
635
+ base_path: Base path for all tools (defaults to CWD)
636
+ virtual_mode: If True, use virtual path mode for security
637
+
638
+ Returns:
639
+ List of configured file system tools
640
+ """
641
+ bp = base_path or Path.cwd()
642
+ return [
643
+ ReadFileTool(bp, virtual_mode),
644
+ ReadDirectoryTool(bp, virtual_mode),
645
+ WriteFileTool(bp, virtual_mode),
646
+ EditFileTool(bp, virtual_mode),
647
+ GrepTool(bp, virtual_mode),
648
+ GlobTool(bp, virtual_mode),
649
+ SearchInDirectoryTool(bp),
650
+ ]