alita-sdk 0.3.457__py3-none-any.whl → 0.3.486__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.

Potentially problematic release.


This version of alita-sdk might be problematic. Click here for more details.

Files changed (102) hide show
  1. alita_sdk/cli/__init__.py +10 -0
  2. alita_sdk/cli/__main__.py +17 -0
  3. alita_sdk/cli/agent/__init__.py +5 -0
  4. alita_sdk/cli/agent/default.py +258 -0
  5. alita_sdk/cli/agent_executor.py +155 -0
  6. alita_sdk/cli/agent_loader.py +194 -0
  7. alita_sdk/cli/agent_ui.py +228 -0
  8. alita_sdk/cli/agents.py +3592 -0
  9. alita_sdk/cli/callbacks.py +647 -0
  10. alita_sdk/cli/cli.py +168 -0
  11. alita_sdk/cli/config.py +306 -0
  12. alita_sdk/cli/context/__init__.py +30 -0
  13. alita_sdk/cli/context/cleanup.py +198 -0
  14. alita_sdk/cli/context/manager.py +731 -0
  15. alita_sdk/cli/context/message.py +285 -0
  16. alita_sdk/cli/context/strategies.py +289 -0
  17. alita_sdk/cli/context/token_estimation.py +127 -0
  18. alita_sdk/cli/formatting.py +182 -0
  19. alita_sdk/cli/input_handler.py +419 -0
  20. alita_sdk/cli/inventory.py +1256 -0
  21. alita_sdk/cli/mcp_loader.py +315 -0
  22. alita_sdk/cli/toolkit.py +327 -0
  23. alita_sdk/cli/toolkit_loader.py +85 -0
  24. alita_sdk/cli/tools/__init__.py +43 -0
  25. alita_sdk/cli/tools/approval.py +224 -0
  26. alita_sdk/cli/tools/filesystem.py +1665 -0
  27. alita_sdk/cli/tools/planning.py +389 -0
  28. alita_sdk/cli/tools/terminal.py +414 -0
  29. alita_sdk/community/__init__.py +64 -8
  30. alita_sdk/community/inventory/__init__.py +224 -0
  31. alita_sdk/community/inventory/config.py +257 -0
  32. alita_sdk/community/inventory/enrichment.py +2137 -0
  33. alita_sdk/community/inventory/extractors.py +1469 -0
  34. alita_sdk/community/inventory/ingestion.py +3172 -0
  35. alita_sdk/community/inventory/knowledge_graph.py +1457 -0
  36. alita_sdk/community/inventory/parsers/__init__.py +218 -0
  37. alita_sdk/community/inventory/parsers/base.py +295 -0
  38. alita_sdk/community/inventory/parsers/csharp_parser.py +907 -0
  39. alita_sdk/community/inventory/parsers/go_parser.py +851 -0
  40. alita_sdk/community/inventory/parsers/html_parser.py +389 -0
  41. alita_sdk/community/inventory/parsers/java_parser.py +593 -0
  42. alita_sdk/community/inventory/parsers/javascript_parser.py +629 -0
  43. alita_sdk/community/inventory/parsers/kotlin_parser.py +768 -0
  44. alita_sdk/community/inventory/parsers/markdown_parser.py +362 -0
  45. alita_sdk/community/inventory/parsers/python_parser.py +604 -0
  46. alita_sdk/community/inventory/parsers/rust_parser.py +858 -0
  47. alita_sdk/community/inventory/parsers/swift_parser.py +832 -0
  48. alita_sdk/community/inventory/parsers/text_parser.py +322 -0
  49. alita_sdk/community/inventory/parsers/yaml_parser.py +370 -0
  50. alita_sdk/community/inventory/patterns/__init__.py +61 -0
  51. alita_sdk/community/inventory/patterns/ast_adapter.py +380 -0
  52. alita_sdk/community/inventory/patterns/loader.py +348 -0
  53. alita_sdk/community/inventory/patterns/registry.py +198 -0
  54. alita_sdk/community/inventory/presets.py +535 -0
  55. alita_sdk/community/inventory/retrieval.py +1403 -0
  56. alita_sdk/community/inventory/toolkit.py +169 -0
  57. alita_sdk/community/inventory/visualize.py +1370 -0
  58. alita_sdk/configurations/bitbucket.py +0 -3
  59. alita_sdk/runtime/clients/client.py +99 -26
  60. alita_sdk/runtime/langchain/assistant.py +4 -2
  61. alita_sdk/runtime/langchain/constants.py +2 -1
  62. alita_sdk/runtime/langchain/langraph_agent.py +134 -31
  63. alita_sdk/runtime/langchain/utils.py +1 -1
  64. alita_sdk/runtime/llms/preloaded.py +2 -6
  65. alita_sdk/runtime/toolkits/__init__.py +2 -0
  66. alita_sdk/runtime/toolkits/application.py +1 -1
  67. alita_sdk/runtime/toolkits/mcp.py +46 -36
  68. alita_sdk/runtime/toolkits/planning.py +171 -0
  69. alita_sdk/runtime/toolkits/tools.py +39 -6
  70. alita_sdk/runtime/tools/function.py +17 -5
  71. alita_sdk/runtime/tools/llm.py +249 -14
  72. alita_sdk/runtime/tools/planning/__init__.py +36 -0
  73. alita_sdk/runtime/tools/planning/models.py +246 -0
  74. alita_sdk/runtime/tools/planning/wrapper.py +607 -0
  75. alita_sdk/runtime/tools/vectorstore_base.py +41 -6
  76. alita_sdk/runtime/utils/mcp_oauth.py +80 -0
  77. alita_sdk/runtime/utils/streamlit.py +6 -10
  78. alita_sdk/runtime/utils/toolkit_utils.py +19 -4
  79. alita_sdk/tools/__init__.py +54 -27
  80. alita_sdk/tools/ado/repos/repos_wrapper.py +1 -2
  81. alita_sdk/tools/base_indexer_toolkit.py +150 -19
  82. alita_sdk/tools/bitbucket/__init__.py +2 -2
  83. alita_sdk/tools/chunkers/__init__.py +3 -1
  84. alita_sdk/tools/chunkers/sematic/markdown_chunker.py +95 -6
  85. alita_sdk/tools/chunkers/universal_chunker.py +269 -0
  86. alita_sdk/tools/code_indexer_toolkit.py +55 -22
  87. alita_sdk/tools/elitea_base.py +86 -21
  88. alita_sdk/tools/jira/__init__.py +1 -1
  89. alita_sdk/tools/jira/api_wrapper.py +91 -40
  90. alita_sdk/tools/non_code_indexer_toolkit.py +1 -0
  91. alita_sdk/tools/qtest/__init__.py +1 -1
  92. alita_sdk/tools/qtest/api_wrapper.py +871 -32
  93. alita_sdk/tools/sharepoint/api_wrapper.py +22 -2
  94. alita_sdk/tools/sharepoint/authorization_helper.py +17 -1
  95. alita_sdk/tools/vector_adapters/VectorStoreAdapter.py +8 -2
  96. alita_sdk/tools/zephyr_essential/api_wrapper.py +12 -13
  97. {alita_sdk-0.3.457.dist-info → alita_sdk-0.3.486.dist-info}/METADATA +146 -2
  98. {alita_sdk-0.3.457.dist-info → alita_sdk-0.3.486.dist-info}/RECORD +102 -40
  99. alita_sdk-0.3.486.dist-info/entry_points.txt +2 -0
  100. {alita_sdk-0.3.457.dist-info → alita_sdk-0.3.486.dist-info}/WHEEL +0 -0
  101. {alita_sdk-0.3.457.dist-info → alita_sdk-0.3.486.dist-info}/licenses/LICENSE +0 -0
  102. {alita_sdk-0.3.457.dist-info → alita_sdk-0.3.486.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1665 @@
1
+ """
2
+ Filesystem tools for CLI agents.
3
+
4
+ Provides comprehensive file system operations restricted to specific directories.
5
+ Inspired by MCP filesystem server implementation.
6
+
7
+ Also provides a FilesystemApiWrapper for integration with the inventory ingestion
8
+ pipeline, enabling local document loading and chunking.
9
+ """
10
+
11
+ import base64
12
+ import fnmatch
13
+ import hashlib
14
+ import logging
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Optional, List, Dict, Any, Generator, ClassVar
18
+ from datetime import datetime
19
+ from langchain_core.tools import BaseTool, ToolException
20
+ from langchain_core.documents import Document
21
+ from pydantic import BaseModel, Field, model_validator
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ # Maximum recommended content size for single write operations (in characters)
27
+ MAX_RECOMMENDED_CONTENT_SIZE = 5000 # ~5KB, roughly 1,200-1,500 tokens
28
+
29
+ # Helpful error message for truncated content
30
+ CONTENT_TRUNCATED_ERROR = """
31
+ ⚠️ CONTENT FIELD MISSING - OUTPUT TRUNCATED
32
+
33
+ Your tool call was cut off because the content was too large for the context window.
34
+ The JSON was truncated, leaving the 'content' field incomplete or missing.
35
+
36
+ 🔧 HOW TO FIX THIS:
37
+
38
+ 1. **Use incremental writes** - Don't write large files in one call:
39
+ - First: filesystem_write_file(path, "# Header\\nimport x\\n\\n")
40
+ - Then: filesystem_append_file(path, "def func1():\\n ...\\n\\n")
41
+ - Then: filesystem_append_file(path, "def func2():\\n ...\\n\\n")
42
+
43
+ 2. **Keep each chunk small** - Under 2000 characters per call
44
+
45
+ 3. **Structure first, details later**:
46
+ - Write skeleton/structure first
47
+ - Add implementations section by section
48
+
49
+ 4. **For documentation/reports**:
50
+ - Write one section at a time
51
+ - Use append_file for each new section
52
+
53
+ ❌ DON'T: Try to write the entire file content again
54
+ ✅ DO: Break it into 3-5 smaller append_file calls
55
+ """
56
+
57
+
58
+ class ReadFileInput(BaseModel):
59
+ """Input for reading a file."""
60
+ path: str = Field(description="Relative path to the file to read")
61
+ head: Optional[int] = Field(None, description="If provided, read only the first N lines")
62
+ tail: Optional[int] = Field(None, description="If provided, read only the last N lines")
63
+
64
+
65
+ class ReadFileChunkInput(BaseModel):
66
+ """Input for reading a file in chunks."""
67
+ path: str = Field(description="Relative path to the file to read")
68
+ start_line: int = Field(default=1, description="Starting line number (1-indexed)")
69
+ end_line: Optional[int] = Field(None, description="Ending line number (inclusive). If None, read to end of file")
70
+
71
+
72
+ class ApplyPatchInput(BaseModel):
73
+ """Input for applying multiple edits to a file."""
74
+ path: str = Field(description="Relative path to the file to edit")
75
+ edits: List[Dict[str, str]] = Field(
76
+ description="List of edits, each with 'old_text' and 'new_text' keys. Applied sequentially."
77
+ )
78
+ dry_run: bool = Field(default=False, description="If True, preview changes without applying them")
79
+
80
+
81
+ class ReadMultipleFilesInput(BaseModel):
82
+ """Input for reading multiple files."""
83
+ paths: List[str] = Field(
84
+ min_length=1,
85
+ description="Array of file paths to read. Each path must point to a valid file within allowed directories."
86
+ )
87
+
88
+
89
+ class WriteFileInput(BaseModel):
90
+ """Input for writing to a file."""
91
+ path: str = Field(description="Relative path to the file to write")
92
+ content: Optional[str] = Field(
93
+ default=None,
94
+ description="Content to write to the file. REQUIRED - this field cannot be empty or omitted."
95
+ )
96
+
97
+ @model_validator(mode='after')
98
+ def validate_content_required(self):
99
+ """Provide helpful error message when content is missing or truncated."""
100
+ if self.content is None:
101
+ raise ToolException(CONTENT_TRUNCATED_ERROR)
102
+ if len(self.content) > MAX_RECOMMENDED_CONTENT_SIZE:
103
+ logger.warning(
104
+ f"Content is very large ({len(self.content)} chars). Consider using append_file "
105
+ "for incremental writes to avoid truncation issues."
106
+ )
107
+ return self
108
+
109
+
110
+ class AppendFileInput(BaseModel):
111
+ """Input for appending to a file."""
112
+ path: str = Field(description="Relative path to the file to append to")
113
+ content: Optional[str] = Field(
114
+ default=None,
115
+ description="Content to append to the end of the file. REQUIRED - this field cannot be empty or omitted."
116
+ )
117
+
118
+ @model_validator(mode='after')
119
+ def validate_content_required(self):
120
+ """Provide helpful error message when content is missing or truncated."""
121
+ if self.content is None:
122
+ raise ToolException(CONTENT_TRUNCATED_ERROR)
123
+ return self
124
+
125
+
126
+ class EditFileInput(BaseModel):
127
+ """Input for editing a file with precise replacements."""
128
+ path: str = Field(description="Relative path to the file to edit")
129
+ old_text: str = Field(description="Exact text to search for and replace")
130
+ new_text: str = Field(description="Text to replace with")
131
+
132
+
133
+ class ListDirectoryInput(BaseModel):
134
+ """Input for listing directory contents."""
135
+ path: str = Field(default=".", description="Relative path to the directory to list")
136
+ include_sizes: bool = Field(default=False, description="Include file sizes in the output")
137
+ sort_by: str = Field(default="name", description="Sort by 'name' or 'size'")
138
+
139
+
140
+ class DirectoryTreeInput(BaseModel):
141
+ """Input for getting a directory tree."""
142
+ path: str = Field(default=".", description="Relative path to the directory")
143
+ max_depth: Optional[int] = Field(default=3, description="Maximum depth to traverse. Default is 3 to prevent excessive output. Use None for unlimited (caution: may exceed context limits).")
144
+ max_items: Optional[int] = Field(default=200, description="Maximum number of files/directories to include. Default is 200 to prevent context window overflow. Use None for unlimited (caution: large directories may exceed context limits).")
145
+
146
+
147
+ class SearchFilesInput(BaseModel):
148
+ """Input for searching files."""
149
+ path: str = Field(default=".", description="Relative path to search from")
150
+ pattern: str = Field(description="Glob pattern to match (e.g., '*.py', '**/*.txt')")
151
+ max_results: Optional[int] = Field(default=100, description="Maximum number of results to return. Default is 100 to prevent context overflow. Use None for unlimited.")
152
+
153
+
154
+ class DeleteFileInput(BaseModel):
155
+ """Input for deleting a file."""
156
+ path: str = Field(description="Relative path to the file to delete")
157
+
158
+
159
+ class MoveFileInput(BaseModel):
160
+ """Input for moving/renaming a file."""
161
+ source: str = Field(description="Relative path to the source file")
162
+ destination: str = Field(description="Relative path to the destination")
163
+
164
+
165
+ class CreateDirectoryInput(BaseModel):
166
+ """Input for creating a directory."""
167
+ path: str = Field(description="Relative path to the directory to create")
168
+
169
+
170
+ class GetFileInfoInput(BaseModel):
171
+ """Input for getting file information."""
172
+ path: str = Field(description="Relative path to the file or directory")
173
+
174
+
175
+ class EmptyInput(BaseModel):
176
+ """Empty input schema for tools that take no arguments."""
177
+ pass
178
+
179
+
180
+ class FileSystemTool(BaseTool):
181
+ """Base class for filesystem tools with directory restriction."""
182
+ base_directory: str # Primary directory (for backward compatibility)
183
+ allowed_directories: List[str] = [] # Additional allowed directories
184
+
185
+ def _get_all_allowed_directories(self) -> List[Path]:
186
+ """Get all allowed directories as resolved Paths."""
187
+ dirs = [Path(self.base_directory).resolve()]
188
+ for d in self.allowed_directories:
189
+ resolved = Path(d).resolve()
190
+ if resolved not in dirs:
191
+ dirs.append(resolved)
192
+ return dirs
193
+
194
+ def _resolve_path(self, relative_path: str) -> Path:
195
+ """
196
+ Resolve and validate a path within any of the allowed directories.
197
+
198
+ Security: Ensures resolved path is within one of the allowed directories.
199
+ """
200
+ allowed_dirs = self._get_all_allowed_directories()
201
+
202
+ # Handle absolute paths - check if within any allowed directory
203
+ if Path(relative_path).is_absolute():
204
+ target = Path(relative_path).resolve()
205
+ for base in allowed_dirs:
206
+ try:
207
+ target.relative_to(base)
208
+ return target
209
+ except ValueError:
210
+ continue
211
+ raise ValueError(f"Access denied: path '{relative_path}' is outside allowed directories")
212
+
213
+ # For relative paths, try to resolve against each allowed directory
214
+ # First check primary base_directory
215
+ primary_base = allowed_dirs[0]
216
+ target = (primary_base / relative_path).resolve()
217
+
218
+ # Check if target is within any allowed directory
219
+ for base in allowed_dirs:
220
+ try:
221
+ target.relative_to(base)
222
+ return target
223
+ except ValueError:
224
+ continue
225
+
226
+ # If relative path doesn't work from primary, try finding the file in other directories
227
+ for base in allowed_dirs[1:]:
228
+ candidate = (base / relative_path).resolve()
229
+ if candidate.exists():
230
+ return candidate
231
+
232
+ # Default to primary base directory resolution
233
+ raise ValueError(f"Access denied: path '{relative_path}' is outside allowed directories")
234
+
235
+ def _format_size(self, size: int) -> str:
236
+ """Format file size in human-readable format."""
237
+ for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
238
+ if size < 1024.0:
239
+ return f"{size:.1f} {unit}"
240
+ size /= 1024.0
241
+ return f"{size:.1f} PB"
242
+
243
+
244
+ class ReadFileTool(FileSystemTool):
245
+ """Read file contents with optional head/tail support."""
246
+ name: str = "filesystem_read_file"
247
+ description: str = (
248
+ "Read the complete contents of a file from the file system. "
249
+ "Handles various text encodings and provides detailed error messages if the file cannot be read. "
250
+ "Use 'head' parameter to read only the first N lines, or 'tail' parameter to read only the last N lines. "
251
+ "Only works within allowed directories."
252
+ )
253
+ args_schema: type[BaseModel] = ReadFileInput
254
+ truncation_suggestions: ClassVar[List[str]] = [
255
+ "Use head=100 to read only the first 100 lines",
256
+ "Use tail=100 to read only the last 100 lines",
257
+ "Use filesystem_read_file_chunk with start_line and end_line for specific sections",
258
+ ]
259
+
260
+ def _run(self, path: str, head: Optional[int] = None, tail: Optional[int] = None) -> str:
261
+ """Read a file with optional head/tail."""
262
+ try:
263
+ target = self._resolve_path(path)
264
+
265
+ if not target.exists():
266
+ return f"Error: File '{path}' does not exist"
267
+
268
+ if not target.is_file():
269
+ return f"Error: '{path}' is not a file"
270
+
271
+ if head and tail:
272
+ return "Error: Cannot specify both head and tail parameters simultaneously"
273
+
274
+ with open(target, 'r', encoding='utf-8') as f:
275
+ if tail:
276
+ lines = f.readlines()
277
+ content = ''.join(lines[-tail:])
278
+ elif head:
279
+ lines = []
280
+ for i, line in enumerate(f):
281
+ if i >= head:
282
+ break
283
+ lines.append(line)
284
+ content = ''.join(lines)
285
+ else:
286
+ content = f.read()
287
+
288
+ char_count = len(content)
289
+ line_count = content.count('\n') + (1 if content and not content.endswith('\n') else 0)
290
+
291
+ return f"Successfully read '{path}' ({char_count} characters, {line_count} lines):\n\n{content}"
292
+ except UnicodeDecodeError:
293
+ return f"Error: File '{path}' appears to be binary or uses an unsupported encoding"
294
+ except Exception as e:
295
+ return f"Error reading file '{path}': {str(e)}"
296
+
297
+
298
+ class ReadFileChunkTool(FileSystemTool):
299
+ """Read a file in chunks by line range."""
300
+ name: str = "filesystem_read_file_chunk"
301
+ description: str = (
302
+ "Read a specific range of lines from a file. This is efficient for large files where you only need a portion. "
303
+ "Specify start_line (1-indexed) and optionally end_line. If end_line is omitted, reads to end of file. "
304
+ "Use this to avoid loading entire large files into memory. "
305
+ "Only works within allowed directories."
306
+ )
307
+ args_schema: type[BaseModel] = ReadFileChunkInput
308
+ truncation_suggestions: ClassVar[List[str]] = [
309
+ "Reduce the line range (end_line - start_line) to read fewer lines at once",
310
+ "Read smaller chunks sequentially if you need to process the entire file",
311
+ ]
312
+
313
+ def _run(self, path: str, start_line: int = 1, end_line: Optional[int] = None) -> str:
314
+ """Read a chunk of a file by line range."""
315
+ try:
316
+ target = self._resolve_path(path)
317
+
318
+ if not target.exists():
319
+ return f"Error: File '{path}' does not exist"
320
+
321
+ if not target.is_file():
322
+ return f"Error: '{path}' is not a file"
323
+
324
+ if start_line < 1:
325
+ return "Error: start_line must be >= 1"
326
+
327
+ if end_line is not None and end_line < start_line:
328
+ return "Error: end_line must be >= start_line"
329
+
330
+ lines = []
331
+ with open(target, 'r', encoding='utf-8') as f:
332
+ for i, line in enumerate(f, 1):
333
+ if i < start_line:
334
+ continue
335
+ if end_line is not None and i > end_line:
336
+ break
337
+ lines.append(line)
338
+
339
+ content = ''.join(lines)
340
+ actual_end = end_line if end_line else start_line + len(lines) - 1
341
+
342
+ if not lines:
343
+ return f"No lines found in range {start_line}-{actual_end} in '{path}'"
344
+
345
+ return f"Successfully read '{path}' lines {start_line}-{actual_end} ({len(content)} characters, {len(lines)} lines):\n\n{content}"
346
+ except UnicodeDecodeError:
347
+ return f"Error: File '{path}' appears to be binary or uses an unsupported encoding"
348
+ except Exception as e:
349
+ return f"Error reading file '{path}': {str(e)}"
350
+
351
+
352
+ class ReadMultipleFilesTool(FileSystemTool):
353
+ """Read multiple files simultaneously."""
354
+ name: str = "filesystem_read_multiple_files"
355
+ description: str = (
356
+ "Read the contents of multiple files simultaneously. This is more efficient than reading files one by one. "
357
+ "Each file's content is returned with its path as a reference. "
358
+ "Failed reads for individual files won't stop the entire operation. "
359
+ "Only works within allowed directories."
360
+ )
361
+ args_schema: type[BaseModel] = ReadMultipleFilesInput
362
+ truncation_suggestions: ClassVar[List[str]] = [
363
+ "Read fewer files at once - split into multiple smaller batches",
364
+ "Use filesystem_read_file with head parameter on individual large files instead",
365
+ ]
366
+
367
+ def _run(self, paths: List[str]) -> str:
368
+ """Read multiple files."""
369
+ results = []
370
+
371
+ for file_path in paths:
372
+ try:
373
+ target = self._resolve_path(file_path)
374
+ with open(target, 'r', encoding='utf-8') as f:
375
+ content = f.read()
376
+ results.append(f"{file_path}:\n{content}")
377
+ except Exception as e:
378
+ results.append(f"{file_path}: Error - {str(e)}")
379
+
380
+ return "\n\n---\n\n".join(results)
381
+
382
+
383
+ class WriteFileTool(FileSystemTool):
384
+ """Write content to a file."""
385
+ name: str = "filesystem_write_file"
386
+ description: str = (
387
+ "Create a new file or completely overwrite an existing file with new content. "
388
+ "Use with caution as it will overwrite existing files without warning. "
389
+ "Handles text content with proper encoding. Creates parent directories if needed. "
390
+ "Only works within allowed directories."
391
+ )
392
+ args_schema: type[BaseModel] = WriteFileInput
393
+
394
+ def _run(self, path: str, content: str) -> str:
395
+ """Write to a file."""
396
+ try:
397
+ target = self._resolve_path(path)
398
+
399
+ # Create parent directories if they don't exist
400
+ target.parent.mkdir(parents=True, exist_ok=True)
401
+
402
+ with open(target, 'w', encoding='utf-8') as f:
403
+ f.write(content)
404
+
405
+ size = len(content.encode('utf-8'))
406
+ return f"Successfully wrote to '{path}' ({self._format_size(size)})"
407
+ except Exception as e:
408
+ return f"Error writing to file '{path}': {str(e)}"
409
+
410
+
411
+ class AppendFileTool(FileSystemTool):
412
+ """Append content to the end of a file."""
413
+ name: str = "filesystem_append_file"
414
+ description: str = (
415
+ "Append content to the end of an existing file. Creates the file if it doesn't exist. "
416
+ "Use this for incremental file creation - write initial structure with write_file, "
417
+ "then add sections progressively with append_file. This is safer than rewriting "
418
+ "entire files and prevents context overflow. Only works within allowed directories."
419
+ )
420
+ args_schema: type[BaseModel] = AppendFileInput
421
+
422
+ def _run(self, path: str, content: str) -> str:
423
+ """Append to a file."""
424
+ try:
425
+ target = self._resolve_path(path)
426
+
427
+ # Create parent directories if they don't exist
428
+ target.parent.mkdir(parents=True, exist_ok=True)
429
+
430
+ # Check current file size if it exists
431
+ existed = target.exists()
432
+ original_size = target.stat().st_size if existed else 0
433
+
434
+ with open(target, 'a', encoding='utf-8') as f:
435
+ f.write(content)
436
+
437
+ appended_size = len(content.encode('utf-8'))
438
+ new_size = original_size + appended_size
439
+
440
+ if existed:
441
+ return f"Successfully appended {self._format_size(appended_size)} to '{path}' (total: {self._format_size(new_size)})"
442
+ else:
443
+ return f"Created '{path}' and wrote {self._format_size(appended_size)}"
444
+ except Exception as e:
445
+ return f"Error appending to file '{path}': {str(e)}"
446
+
447
+
448
+ class EditFileTool(FileSystemTool):
449
+ """Edit file with precise text replacement."""
450
+ name: str = "filesystem_edit_file"
451
+ description: str = (
452
+ "Make precise edits to a text file by replacing exact text matches. "
453
+ "The old_text must match exactly (including whitespace and line breaks). "
454
+ "This is safer than rewriting entire files when making small changes. "
455
+ "Only works within allowed directories."
456
+ )
457
+ args_schema: type[BaseModel] = EditFileInput
458
+
459
+ def _run(self, path: str, old_text: str, new_text: str) -> str:
460
+ """Edit a file by replacing exact text."""
461
+ try:
462
+ target = self._resolve_path(path)
463
+
464
+ if not target.exists():
465
+ return f"Error: File '{path}' does not exist"
466
+
467
+ if not target.is_file():
468
+ return f"Error: '{path}' is not a file"
469
+
470
+ # Read current content
471
+ with open(target, 'r', encoding='utf-8') as f:
472
+ content = f.read()
473
+
474
+ # Check if old_text exists
475
+ if old_text not in content:
476
+ return f"Error: Could not find the specified text in '{path}'"
477
+
478
+ # Count occurrences
479
+ occurrences = content.count(old_text)
480
+ if occurrences > 1:
481
+ return f"Error: Found {occurrences} occurrences of the text. Please be more specific to ensure correct replacement."
482
+
483
+ # Replace text
484
+ new_content = content.replace(old_text, new_text)
485
+
486
+ # Write back
487
+ with open(target, 'w', encoding='utf-8') as f:
488
+ f.write(new_content)
489
+
490
+ chars_before = len(old_text)
491
+ chars_after = len(new_text)
492
+ diff = chars_after - chars_before
493
+
494
+ return f"Successfully edited '{path}': replaced {chars_before} characters with {chars_after} characters ({diff:+d} character difference)"
495
+ except Exception as e:
496
+ return f"Error editing file '{path}': {str(e)}"
497
+
498
+
499
+ class ApplyPatchTool(FileSystemTool):
500
+ """Apply multiple edits to a file like a patch."""
501
+ name: str = "filesystem_apply_patch"
502
+ description: str = (
503
+ "Apply multiple precise edits to a file in a single operation, similar to applying a patch. "
504
+ "Each edit specifies 'old_text' (exact text to find) and 'new_text' (replacement text). "
505
+ "Edits are applied sequentially. Use dry_run=true to preview changes without applying them. "
506
+ "This is efficient for making multiple changes to large files. "
507
+ "Only works within allowed directories."
508
+ )
509
+ args_schema: type[BaseModel] = ApplyPatchInput
510
+
511
+ def _run(self, path: str, edits: List[Dict[str, str]], dry_run: bool = False) -> str:
512
+ """Apply multiple edits to a file."""
513
+ try:
514
+ target = self._resolve_path(path)
515
+
516
+ if not target.exists():
517
+ return f"Error: File '{path}' does not exist"
518
+
519
+ if not target.is_file():
520
+ return f"Error: '{path}' is not a file"
521
+
522
+ # Read current content
523
+ with open(target, 'r', encoding='utf-8') as f:
524
+ original_content = f.read()
525
+
526
+ content = original_content
527
+ changes = []
528
+
529
+ # Apply edits sequentially
530
+ for i, edit in enumerate(edits, 1):
531
+ old_text = edit.get('old_text', '')
532
+ new_text = edit.get('new_text', '')
533
+
534
+ if not old_text:
535
+ return f"Error: Edit #{i} is missing 'old_text'"
536
+
537
+ if old_text not in content:
538
+ return f"Error: Edit #{i} - could not find the specified text in current content"
539
+
540
+ # Count occurrences
541
+ occurrences = content.count(old_text)
542
+ if occurrences > 1:
543
+ return f"Error: Edit #{i} - found {occurrences} occurrences. Please be more specific."
544
+
545
+ # Apply the edit
546
+ content = content.replace(old_text, new_text)
547
+ changes.append({
548
+ 'edit_num': i,
549
+ 'old_len': len(old_text),
550
+ 'new_len': len(new_text),
551
+ 'diff': len(new_text) - len(old_text)
552
+ })
553
+
554
+ if dry_run:
555
+ # Show preview in diff-like format
556
+ lines = [f"Preview of changes to '{path}' ({len(edits)} edits):\n"]
557
+ for change in changes:
558
+ lines.append(
559
+ f"Edit #{change['edit_num']}: "
560
+ f"{change['old_len']} → {change['new_len']} chars "
561
+ f"({change['diff']:+d})"
562
+ )
563
+
564
+ total_diff = sum(c['diff'] for c in changes)
565
+ lines.append(f"\nTotal change: {len(original_content)} → {len(content)} chars ({total_diff:+d})")
566
+ lines.append("\n[DRY RUN - No changes written to file]")
567
+ return "\n".join(lines)
568
+
569
+ # Write the modified content
570
+ with open(target, 'w', encoding='utf-8') as f:
571
+ f.write(content)
572
+
573
+ # Build success message
574
+ lines = [f"Successfully applied {len(edits)} edits to '{path}':\n"]
575
+ for change in changes:
576
+ lines.append(
577
+ f"Edit #{change['edit_num']}: "
578
+ f"{change['old_len']} → {change['new_len']} chars "
579
+ f"({change['diff']:+d})"
580
+ )
581
+
582
+ total_diff = sum(c['diff'] for c in changes)
583
+ lines.append(f"\nTotal change: {len(original_content)} → {len(content)} chars ({total_diff:+d})")
584
+
585
+ return "\n".join(lines)
586
+ except Exception as e:
587
+ return f"Error applying patch to '{path}': {str(e)}"
588
+
589
+
590
+ class ListDirectoryTool(FileSystemTool):
591
+ """List directory contents."""
592
+ name: str = "filesystem_list_directory"
593
+ description: str = (
594
+ "Get a detailed listing of all files and directories in a specified path. "
595
+ "Results clearly distinguish between files and directories with [FILE] and [DIR] prefixes. "
596
+ "Can optionally include file sizes and sort by name or size. "
597
+ "Only works within allowed directories."
598
+ )
599
+ args_schema: type[BaseModel] = ListDirectoryInput
600
+ truncation_suggestions: ClassVar[List[str]] = [
601
+ "List a specific subdirectory instead of the root directory",
602
+ "Consider using filesystem_directory_tree with max_depth=1 for hierarchical overview",
603
+ ]
604
+
605
+ def _run(self, path: str = ".", include_sizes: bool = False, sort_by: str = "name") -> str:
606
+ """List directory contents."""
607
+ try:
608
+ target = self._resolve_path(path)
609
+
610
+ if not target.exists():
611
+ return f"Error: Directory '{path}' does not exist"
612
+
613
+ if not target.is_dir():
614
+ return f"Error: '{path}' is not a directory"
615
+
616
+ entries = []
617
+ for entry in target.iterdir():
618
+ entry_info = {
619
+ 'name': entry.name,
620
+ 'is_dir': entry.is_dir(),
621
+ 'size': entry.stat().st_size if entry.is_file() else 0
622
+ }
623
+ entries.append(entry_info)
624
+
625
+ # Sort entries
626
+ if sort_by == "size":
627
+ entries.sort(key=lambda x: x['size'], reverse=True)
628
+ else:
629
+ entries.sort(key=lambda x: x['name'].lower())
630
+
631
+ # Format output
632
+ lines = []
633
+ total_files = 0
634
+ total_dirs = 0
635
+ total_size = 0
636
+
637
+ for entry in entries:
638
+ prefix = "[DIR] " if entry['is_dir'] else "[FILE]"
639
+ name = entry['name']
640
+
641
+ if include_sizes and not entry['is_dir']:
642
+ size_str = self._format_size(entry['size'])
643
+ lines.append(f"{prefix} {name:<40} {size_str:>10}")
644
+ total_size += entry['size']
645
+ else:
646
+ lines.append(f"{prefix} {name}")
647
+
648
+ if entry['is_dir']:
649
+ total_dirs += 1
650
+ else:
651
+ total_files += 1
652
+
653
+ result = "\n".join(lines)
654
+
655
+ # Add header showing the listing context
656
+ if path in (".", "", "./"):
657
+ header = "Contents of working directory (./):\n\n"
658
+ else:
659
+ header = f"Contents of {path}/:\n\n"
660
+ result = header + result
661
+
662
+ if include_sizes:
663
+ summary = f"\n\nTotal: {total_files} files, {total_dirs} directories"
664
+ if total_files > 0:
665
+ summary += f"\nCombined size: {self._format_size(total_size)}"
666
+ result += summary
667
+
668
+ # Add note about how to access files
669
+ result += "\n\nNote: Access files using paths shown above (e.g., 'agents/file.md' for items in agents/ directory)"
670
+
671
+ return result if lines else "Directory is empty"
672
+ except Exception as e:
673
+ return f"Error listing directory '{path}': {str(e)}"
674
+
675
+
676
+ class DirectoryTreeTool(FileSystemTool):
677
+ """Get recursive directory tree."""
678
+ name: str = "filesystem_directory_tree"
679
+ description: str = (
680
+ "Get a recursive tree view of files and directories. "
681
+ "Shows the complete structure in an easy-to-read tree format. "
682
+ "IMPORTANT: For large directories, use max_depth (default: 3) and max_items (default: 200) "
683
+ "to prevent context window overflow. Increase these only if needed for smaller directories. "
684
+ "Only works within allowed directories."
685
+ )
686
+ args_schema: type[BaseModel] = DirectoryTreeInput
687
+ truncation_suggestions: ClassVar[List[str]] = [
688
+ "Use max_depth=2 to limit directory traversal depth",
689
+ "Use max_items=50 to limit total items returned",
690
+ "Target a specific subdirectory instead of the root",
691
+ ]
692
+
693
+ # Track item count during tree building
694
+ _item_count: int = 0
695
+ _max_items: Optional[int] = None
696
+ _truncated: bool = False
697
+
698
+ def _build_tree(self, directory: Path, prefix: str = "", depth: int = 0, max_depth: Optional[int] = None) -> List[str]:
699
+ """Recursively build directory tree with item limit."""
700
+ # Check depth limit
701
+ if max_depth is not None and depth >= max_depth:
702
+ return []
703
+
704
+ # Check item limit
705
+ if self._max_items is not None and self._item_count >= self._max_items:
706
+ if not self._truncated:
707
+ self._truncated = True
708
+ return []
709
+
710
+ lines = []
711
+ try:
712
+ entries = sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
713
+
714
+ for i, entry in enumerate(entries):
715
+ # Check item limit before adding each entry
716
+ if self._max_items is not None and self._item_count >= self._max_items:
717
+ if not self._truncated:
718
+ self._truncated = True
719
+ break
720
+
721
+ is_last = i == len(entries) - 1
722
+ current_prefix = "└── " if is_last else "├── "
723
+ next_prefix = " " if is_last else "│ "
724
+
725
+ self._item_count += 1
726
+
727
+ if entry.is_dir():
728
+ lines.append(f"{prefix}{current_prefix}📁 {entry.name}/")
729
+ lines.extend(self._build_tree(entry, prefix + next_prefix, depth + 1, max_depth))
730
+ else:
731
+ size = self._format_size(entry.stat().st_size)
732
+ lines.append(f"{prefix}{current_prefix}📄 {entry.name} ({size})")
733
+ except PermissionError:
734
+ lines.append(f"{prefix}[Permission Denied]")
735
+
736
+ return lines
737
+
738
+ def _run(self, path: str = ".", max_depth: Optional[int] = 3, max_items: Optional[int] = 200) -> str:
739
+ """Get directory tree with size limits to prevent context overflow."""
740
+ try:
741
+ target = self._resolve_path(path)
742
+
743
+ if not target.exists():
744
+ return f"Error: Directory '{path}' does not exist"
745
+
746
+ if not target.is_dir():
747
+ return f"Error: '{path}' is not a directory"
748
+
749
+ # Reset counters for this run
750
+ self._item_count = 0
751
+ self._max_items = max_items
752
+ self._truncated = False
753
+
754
+ # Show relative path from base directory, use '.' for root
755
+ # This prevents confusion - files should be accessed relative to working directory
756
+ if path in (".", "", "./"):
757
+ display_root = "." # Root of working directory
758
+ else:
759
+ display_root = path.rstrip('/')
760
+
761
+ lines = [f"📁 {display_root}/"]
762
+ lines.extend(self._build_tree(target, "", 0, max_depth))
763
+
764
+ # Add truncation warning if limit was reached
765
+ if self._truncated:
766
+ lines.append("")
767
+ lines.append(f"⚠️ OUTPUT TRUNCATED: Showing {self._item_count} of more items (max_items={max_items}, max_depth={max_depth})")
768
+ lines.append(f" To see more: increase max_items or max_depth, or use filesystem_list_directory on specific subdirectories")
769
+
770
+ # Add note about file paths
771
+ lines.append("")
772
+ lines.append("Note: Use paths relative to working directory (e.g., 'agents/file.md', not including the root directory name)")
773
+
774
+ return "\n".join(lines)
775
+ except Exception as e:
776
+ return f"Error building directory tree for '{path}': {str(e)}"
777
+
778
+
779
+ class SearchFilesTool(FileSystemTool):
780
+ """Search for files matching a pattern."""
781
+ name: str = "filesystem_search_files"
782
+ description: str = (
783
+ "Recursively search for files and directories matching a glob pattern. "
784
+ "Use patterns like '*.py' for Python files in current dir, or '**/*.py' for all Python files recursively. "
785
+ "Returns paths to matching items (default limit: 100 results to prevent context overflow). "
786
+ "Only searches within allowed directories."
787
+ )
788
+ args_schema: type[BaseModel] = SearchFilesInput
789
+ truncation_suggestions: ClassVar[List[str]] = [
790
+ "Use max_results=50 to limit number of results",
791
+ "Use a more specific glob pattern (e.g., 'src/**/*.py' instead of '**/*.py')",
792
+ "Search in a specific subdirectory instead of the root",
793
+ ]
794
+
795
+ def _run(self, path: str = ".", pattern: str = "*", max_results: Optional[int] = 100) -> str:
796
+ """Search for files with result limit."""
797
+ try:
798
+ target = self._resolve_path(path)
799
+
800
+ if not target.exists():
801
+ return f"Error: Directory '{path}' does not exist"
802
+
803
+ if not target.is_dir():
804
+ return f"Error: '{path}' is not a directory"
805
+
806
+ # Use glob to find matching files
807
+ all_matches = list(target.glob(pattern))
808
+ total_count = len(all_matches)
809
+
810
+ if not all_matches:
811
+ return f"No files matching '{pattern}' found in '{path}'"
812
+
813
+ # Apply limit
814
+ truncated = False
815
+ if max_results is not None and total_count > max_results:
816
+ matches = sorted(all_matches)[:max_results]
817
+ truncated = True
818
+ else:
819
+ matches = sorted(all_matches)
820
+
821
+ # Format results
822
+ base = Path(self.base_directory).resolve()
823
+ results = []
824
+
825
+ for match in matches:
826
+ rel_path = match.relative_to(base)
827
+ if match.is_dir():
828
+ results.append(f"📁 {rel_path}/")
829
+ else:
830
+ size = self._format_size(match.stat().st_size)
831
+ results.append(f"📄 {rel_path} ({size})")
832
+
833
+ header = f"Found {total_count} matches for '{pattern}':\n\n"
834
+ output = header + "\n".join(results)
835
+
836
+ if truncated:
837
+ output += f"\n\n⚠️ OUTPUT TRUNCATED: Showing {max_results} of {total_count} results (max_results={max_results})"
838
+ output += "\n To see more: increase max_results or use a more specific pattern"
839
+
840
+ return output
841
+ except Exception as e:
842
+ return f"Error searching files in '{path}': {str(e)}"
843
+
844
+
845
+ class DeleteFileTool(FileSystemTool):
846
+ """Delete a file."""
847
+ name: str = "filesystem_delete_file"
848
+ description: str = (
849
+ "Delete a file. Use with caution as this operation cannot be undone. "
850
+ "Only deletes files, not directories. "
851
+ "Only works within allowed directories."
852
+ )
853
+ args_schema: type[BaseModel] = DeleteFileInput
854
+
855
+ def _run(self, path: str) -> str:
856
+ """Delete a file."""
857
+ try:
858
+ target = self._resolve_path(path)
859
+
860
+ if not target.exists():
861
+ return f"Error: File '{path}' does not exist"
862
+
863
+ if not target.is_file():
864
+ return f"Error: '{path}' is not a file (directories cannot be deleted with this tool)"
865
+
866
+ size = target.stat().st_size
867
+ target.unlink()
868
+
869
+ return f"Successfully deleted '{path}' ({self._format_size(size)})"
870
+ except Exception as e:
871
+ return f"Error deleting file '{path}': {str(e)}"
872
+
873
+
874
+ class MoveFileTool(FileSystemTool):
875
+ """Move or rename files and directories."""
876
+ name: str = "filesystem_move_file"
877
+ description: str = (
878
+ "Move or rename files and directories. Can move files between directories and rename them in a single operation. "
879
+ "If the destination exists, the operation will fail. "
880
+ "Works across different directories and can be used for simple renaming within the same directory. "
881
+ "Both source and destination must be within allowed directories."
882
+ )
883
+ args_schema: type[BaseModel] = MoveFileInput
884
+
885
+ def _run(self, source: str, destination: str) -> str:
886
+ """Move or rename a file."""
887
+ try:
888
+ source_path = self._resolve_path(source)
889
+ dest_path = self._resolve_path(destination)
890
+
891
+ if not source_path.exists():
892
+ return f"Error: Source '{source}' does not exist"
893
+
894
+ if dest_path.exists():
895
+ return f"Error: Destination '{destination}' already exists"
896
+
897
+ # Create parent directories if needed
898
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
899
+
900
+ source_path.rename(dest_path)
901
+
902
+ return f"Successfully moved '{source}' to '{destination}'"
903
+ except Exception as e:
904
+ return f"Error moving '{source}' to '{destination}': {str(e)}"
905
+
906
+
907
+ class CreateDirectoryTool(FileSystemTool):
908
+ """Create a directory."""
909
+ name: str = "filesystem_create_directory"
910
+ description: str = (
911
+ "Create a new directory or ensure a directory exists. "
912
+ "Can create multiple nested directories in one operation. "
913
+ "If the directory already exists, this operation will succeed silently. "
914
+ "Only works within allowed directories."
915
+ )
916
+ args_schema: type[BaseModel] = CreateDirectoryInput
917
+
918
+ def _run(self, path: str) -> str:
919
+ """Create a directory."""
920
+ try:
921
+ target = self._resolve_path(path)
922
+
923
+ if target.exists():
924
+ if target.is_dir():
925
+ return f"Directory '{path}' already exists"
926
+ else:
927
+ return f"Error: '{path}' exists but is not a directory"
928
+
929
+ target.mkdir(parents=True, exist_ok=True)
930
+
931
+ return f"Successfully created directory '{path}'"
932
+ except Exception as e:
933
+ return f"Error creating directory '{path}': {str(e)}"
934
+
935
+
936
+ class GetFileInfoTool(FileSystemTool):
937
+ """Get detailed file/directory information."""
938
+ name: str = "filesystem_get_file_info"
939
+ description: str = (
940
+ "Retrieve detailed metadata about a file or directory. "
941
+ "Returns comprehensive information including size, creation time, last modified time, permissions, and type. "
942
+ "This tool is perfect for understanding file characteristics without reading the actual content. "
943
+ "Only works within allowed directories."
944
+ )
945
+ args_schema: type[BaseModel] = GetFileInfoInput
946
+
947
+ def _run(self, path: str) -> str:
948
+ """Get file information."""
949
+ try:
950
+ target = self._resolve_path(path)
951
+
952
+ if not target.exists():
953
+ return f"Error: Path '{path}' does not exist"
954
+
955
+ stat = target.stat()
956
+
957
+ info = {
958
+ "Path": str(path),
959
+ "Type": "Directory" if target.is_dir() else "File",
960
+ "Size": self._format_size(stat.st_size) if target.is_file() else "N/A",
961
+ "Created": datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M:%S"),
962
+ "Modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
963
+ "Accessed": datetime.fromtimestamp(stat.st_atime).strftime("%Y-%m-%d %H:%M:%S"),
964
+ "Permissions": oct(stat.st_mode)[-3:],
965
+ }
966
+
967
+ if target.is_file():
968
+ info["Readable"] = os.access(target, os.R_OK)
969
+ info["Writable"] = os.access(target, os.W_OK)
970
+ info["Executable"] = os.access(target, os.X_OK)
971
+
972
+ return "\n".join(f"{key}: {value}" for key, value in info.items())
973
+ except Exception as e:
974
+ return f"Error getting info for '{path}': {str(e)}"
975
+
976
+
977
+ class ListAllowedDirectoriesTool(FileSystemTool):
978
+ """List allowed directories."""
979
+ name: str = "filesystem_list_allowed_directories"
980
+ description: str = (
981
+ "Returns the list of directories that are accessible. "
982
+ "Subdirectories within allowed directories are also accessible. "
983
+ "Use this to understand which directories and their nested paths are available before trying to access files."
984
+ )
985
+ args_schema: type[BaseModel] = EmptyInput
986
+
987
+ def _run(self) -> str:
988
+ """List allowed directories."""
989
+ dirs = self._get_all_allowed_directories()
990
+ if len(dirs) == 1:
991
+ return f"Allowed directory:\n{dirs[0]}\n\nAll subdirectories within this path are accessible."
992
+ else:
993
+ dir_list = "\n".join(f" - {d}" for d in dirs)
994
+ return f"Allowed directories:\n{dir_list}\n\nAll subdirectories within these paths are accessible."
995
+
996
+
997
+ # ========== Filesystem API Wrapper for Inventory Ingestion ==========
998
+
999
+ class FilesystemApiWrapper:
1000
+ """
1001
+ API Wrapper for filesystem operations compatible with inventory ingestion pipeline.
1002
+
1003
+ Supports both text and non-text files:
1004
+ - Text files: .py, .md, .txt, .json, .yaml, etc.
1005
+ - Documents: .pdf, .docx, .pptx, .xlsx, .xls (converted to markdown)
1006
+ - Images: .png, .jpg, .gif, .webp (base64 encoded or described via LLM)
1007
+
1008
+ Usage:
1009
+ # Create wrapper for a directory
1010
+ wrapper = FilesystemApiWrapper(base_directory="/path/to/docs")
1011
+
1012
+ # Load documents (uses inherited loader())
1013
+ for doc in wrapper.loader(whitelist=["*.md", "*.pdf"]):
1014
+ print(doc.page_content[:100])
1015
+
1016
+ # For image description, provide an LLM
1017
+ wrapper = FilesystemApiWrapper(base_directory="/path/to/docs", llm=my_llm)
1018
+ for doc in wrapper.loader(whitelist=["*.png"]):
1019
+ print(doc.page_content) # LLM-generated description
1020
+
1021
+ # Use with inventory ingestion
1022
+ pipeline = IngestionPipeline(llm=llm, graph_path="./graph.json")
1023
+ pipeline.register_toolkit("local_docs", wrapper)
1024
+ result = pipeline.run(source="local_docs", whitelist=["*.md", "*.pdf"])
1025
+ """
1026
+
1027
+ # Filesystem-specific settings
1028
+ base_directory: str = ""
1029
+ recursive: bool = True
1030
+ follow_symlinks: bool = False
1031
+ llm: Any = None # Optional LLM for image processing
1032
+
1033
+ # File type categories
1034
+ BINARY_EXTENSIONS = {'.pdf', '.docx', '.doc', '.pptx', '.ppt', '.xlsx', '.xls'}
1035
+ IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'}
1036
+
1037
+ def __init__(
1038
+ self,
1039
+ base_directory: str,
1040
+ recursive: bool = True,
1041
+ follow_symlinks: bool = False,
1042
+ llm: Any = None,
1043
+ **kwargs
1044
+ ):
1045
+ """
1046
+ Initialize filesystem wrapper.
1047
+
1048
+ Args:
1049
+ base_directory: Root directory for file operations
1050
+ recursive: If True, search subdirectories recursively
1051
+ follow_symlinks: If True, follow symbolic links
1052
+ llm: Optional LLM for image description (if not provided, images are base64 encoded)
1053
+ **kwargs: Additional arguments (ignored, for compatibility)
1054
+ """
1055
+ self.base_directory = str(Path(base_directory).resolve())
1056
+ self.recursive = recursive
1057
+ self.follow_symlinks = follow_symlinks
1058
+ self.llm = llm
1059
+
1060
+ # For compatibility with BaseCodeToolApiWrapper.loader()
1061
+ self.active_branch = None
1062
+
1063
+ # Validate directory
1064
+ if not Path(self.base_directory).exists():
1065
+ raise ValueError(f"Directory does not exist: {self.base_directory}")
1066
+ if not Path(self.base_directory).is_dir():
1067
+ raise ValueError(f"Path is not a directory: {self.base_directory}")
1068
+
1069
+ # Optional RunnableConfig for CLI/standalone usage
1070
+ self._runnable_config = None
1071
+
1072
+ def set_runnable_config(self, config: Optional[Dict[str, Any]]) -> None:
1073
+ """
1074
+ Set the RunnableConfig for dispatching custom events.
1075
+
1076
+ This is required when running outside of a LangChain agent context
1077
+ (e.g., from CLI). Without a config containing a run_id,
1078
+ dispatch_custom_event will fail with "Unable to dispatch an adhoc event
1079
+ without a parent run id".
1080
+
1081
+ Args:
1082
+ config: A RunnableConfig dict with at least {'run_id': uuid}
1083
+ """
1084
+ self._runnable_config = config
1085
+
1086
+ def _log_tool_event(self, message: str, tool_name: str = None, config: Optional[Dict[str, Any]] = None):
1087
+ """Log progress events (mirrors BaseToolApiWrapper).
1088
+
1089
+ Args:
1090
+ message: The message to log
1091
+ tool_name: Name of the tool (defaults to 'filesystem')
1092
+ config: Optional RunnableConfig. If not provided, uses self._runnable_config.
1093
+ Required when running outside a LangChain agent context.
1094
+ """
1095
+ logger.info(f"[{tool_name or 'filesystem'}] {message}")
1096
+ try:
1097
+ from langchain_core.callbacks import dispatch_custom_event
1098
+
1099
+ # Use provided config, fall back to instance config
1100
+ effective_config = config or getattr(self, '_runnable_config', None)
1101
+
1102
+ dispatch_custom_event(
1103
+ name="thinking_step",
1104
+ data={
1105
+ "message": message,
1106
+ "tool_name": tool_name or "filesystem",
1107
+ "toolkit": "FilesystemApiWrapper",
1108
+ },
1109
+ config=effective_config,
1110
+ )
1111
+ except Exception:
1112
+ pass
1113
+
1114
+ def _get_files(self, path: str = "", branch: str = None) -> List[str]:
1115
+ """
1116
+ Get list of files in the directory.
1117
+
1118
+ Implements BaseCodeToolApiWrapper._get_files() for filesystem.
1119
+
1120
+ Args:
1121
+ path: Subdirectory path (relative to base_directory)
1122
+ branch: Ignored for filesystem (compatibility with git-based toolkits)
1123
+
1124
+ Returns:
1125
+ List of file paths relative to base_directory
1126
+ """
1127
+ base = Path(self.base_directory)
1128
+ search_path = base / path if path else base
1129
+
1130
+ if not search_path.exists():
1131
+ return []
1132
+
1133
+ files = []
1134
+
1135
+ if self.recursive:
1136
+ for root, dirs, filenames in os.walk(search_path, followlinks=self.follow_symlinks):
1137
+ # Skip hidden directories
1138
+ dirs[:] = [d for d in dirs if not d.startswith('.')]
1139
+
1140
+ for filename in filenames:
1141
+ if filename.startswith('.'):
1142
+ continue
1143
+
1144
+ full_path = Path(root) / filename
1145
+ try:
1146
+ rel_path = str(full_path.relative_to(base))
1147
+ files.append(rel_path)
1148
+ except ValueError:
1149
+ continue
1150
+ else:
1151
+ for entry in search_path.iterdir():
1152
+ if entry.is_file() and not entry.name.startswith('.'):
1153
+ try:
1154
+ rel_path = str(entry.relative_to(base))
1155
+ files.append(rel_path)
1156
+ except ValueError:
1157
+ continue
1158
+
1159
+ return sorted(files)
1160
+
1161
+ def _is_binary_file(self, file_path: str) -> bool:
1162
+ """Check if file is a binary document (PDF, DOCX, etc.)."""
1163
+ ext = Path(file_path).suffix.lower()
1164
+ return ext in self.BINARY_EXTENSIONS
1165
+
1166
+ def _is_image_file(self, file_path: str) -> bool:
1167
+ """Check if file is an image."""
1168
+ ext = Path(file_path).suffix.lower()
1169
+ return ext in self.IMAGE_EXTENSIONS
1170
+
1171
+ def _read_binary_file(self, file_path: str) -> Optional[str]:
1172
+ """
1173
+ Read binary file (PDF, DOCX, PPTX, Excel) and convert to text/markdown.
1174
+
1175
+ Uses the SDK's content_parser for document conversion.
1176
+
1177
+ Args:
1178
+ file_path: Path relative to base_directory
1179
+
1180
+ Returns:
1181
+ Converted text content, or None if conversion fails
1182
+ """
1183
+ full_path = Path(self.base_directory) / file_path
1184
+
1185
+ try:
1186
+ from alita_sdk.tools.utils.content_parser import parse_file_content
1187
+
1188
+ result = parse_file_content(
1189
+ file_path=str(full_path),
1190
+ is_capture_image=bool(self.llm), # Capture images if LLM available
1191
+ llm=self.llm
1192
+ )
1193
+
1194
+ if isinstance(result, Exception):
1195
+ logger.warning(f"Failed to parse {file_path}: {result}")
1196
+ return None
1197
+
1198
+ return result
1199
+
1200
+ except ImportError:
1201
+ logger.warning("content_parser not available, skipping binary file")
1202
+ return None
1203
+ except Exception as e:
1204
+ logger.warning(f"Error parsing {file_path}: {e}")
1205
+ return None
1206
+
1207
+ def _read_image_file(self, file_path: str) -> Optional[str]:
1208
+ """
1209
+ Read image file and convert to text representation.
1210
+
1211
+ If LLM is available, uses it to describe the image.
1212
+ Otherwise, returns base64-encoded data URI.
1213
+
1214
+ Args:
1215
+ file_path: Path relative to base_directory
1216
+
1217
+ Returns:
1218
+ Image description or base64 data URI
1219
+ """
1220
+ full_path = Path(self.base_directory) / file_path
1221
+
1222
+ if not full_path.exists():
1223
+ return None
1224
+
1225
+ ext = full_path.suffix.lower()
1226
+
1227
+ try:
1228
+ # Read image bytes
1229
+ image_bytes = full_path.read_bytes()
1230
+
1231
+ if self.llm:
1232
+ # Use content_parser with LLM for image description
1233
+ try:
1234
+ from alita_sdk.tools.utils.content_parser import parse_file_content
1235
+
1236
+ result = parse_file_content(
1237
+ file_path=str(full_path),
1238
+ is_capture_image=True,
1239
+ llm=self.llm
1240
+ )
1241
+
1242
+ if isinstance(result, Exception):
1243
+ logger.warning(f"Failed to describe image {file_path}: {result}")
1244
+ else:
1245
+ return f"[Image: {Path(file_path).name}]\n\n{result}"
1246
+
1247
+ except ImportError:
1248
+ pass
1249
+
1250
+ # Fallback: return base64 data URI
1251
+ mime_types = {
1252
+ '.png': 'image/png',
1253
+ '.jpg': 'image/jpeg',
1254
+ '.jpeg': 'image/jpeg',
1255
+ '.gif': 'image/gif',
1256
+ '.webp': 'image/webp',
1257
+ '.bmp': 'image/bmp',
1258
+ '.svg': 'image/svg+xml',
1259
+ }
1260
+ mime_type = mime_types.get(ext, 'application/octet-stream')
1261
+ b64_data = base64.b64encode(image_bytes).decode('utf-8')
1262
+
1263
+ return f"[Image: {Path(file_path).name}]\ndata:{mime_type};base64,{b64_data}"
1264
+
1265
+ except Exception as e:
1266
+ logger.warning(f"Error reading image {file_path}: {e}")
1267
+ return None
1268
+
1269
+ def _read_file(
1270
+ self,
1271
+ file_path: str,
1272
+ branch: str = None,
1273
+ offset: Optional[int] = None,
1274
+ limit: Optional[int] = None,
1275
+ head: Optional[int] = None,
1276
+ tail: Optional[int] = None,
1277
+ ) -> Optional[str]:
1278
+ """
1279
+ Read file content, handling text, binary documents, and images.
1280
+
1281
+ Supports:
1282
+ - Text files: Read directly with encoding detection
1283
+ - Binary documents (PDF, DOCX, PPTX, Excel): Convert to markdown
1284
+ - Images: Return LLM description or base64 data URI
1285
+
1286
+ Args:
1287
+ file_path: Path relative to base_directory
1288
+ branch: Ignored for filesystem (compatibility with git-based toolkits)
1289
+ offset: Start line number (1-indexed). If None, start from beginning.
1290
+ limit: Maximum number of lines to read. If None, read to end.
1291
+ head: Read only first N lines (alternative to offset/limit)
1292
+ tail: Read only last N lines (alternative to offset/limit)
1293
+
1294
+ Returns:
1295
+ File content as string, or None if unreadable
1296
+ """
1297
+ full_path = Path(self.base_directory) / file_path
1298
+
1299
+ # Security check - prevent path traversal
1300
+ try:
1301
+ full_path.resolve().relative_to(Path(self.base_directory).resolve())
1302
+ except ValueError:
1303
+ logger.warning(f"Access denied: {file_path} is outside base directory")
1304
+ return None
1305
+
1306
+ if not full_path.exists() or not full_path.is_file():
1307
+ return None
1308
+
1309
+ # Route to appropriate reader based on file type
1310
+ # Note: offset/limit only apply to text files
1311
+ if self._is_binary_file(file_path):
1312
+ return self._read_binary_file(file_path)
1313
+
1314
+ if self._is_image_file(file_path):
1315
+ return self._read_image_file(file_path)
1316
+
1317
+ # Default: read as text with encoding detection
1318
+ encodings = ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252']
1319
+
1320
+ for encoding in encodings:
1321
+ try:
1322
+ content = full_path.read_text(encoding=encoding)
1323
+
1324
+ # Apply line filtering if specified
1325
+ if offset is not None or limit is not None or head is not None or tail is not None:
1326
+ lines = content.splitlines(keepends=True)
1327
+
1328
+ if head is not None:
1329
+ # Read first N lines
1330
+ lines = lines[:head]
1331
+ elif tail is not None:
1332
+ # Read last N lines
1333
+ lines = lines[-tail:] if tail > 0 else []
1334
+ else:
1335
+ # Use offset/limit
1336
+ start_idx = (offset - 1) if offset and offset > 0 else 0
1337
+ if limit is not None:
1338
+ end_idx = start_idx + limit
1339
+ lines = lines[start_idx:end_idx]
1340
+ else:
1341
+ lines = lines[start_idx:]
1342
+
1343
+ content = ''.join(lines)
1344
+
1345
+ return content
1346
+
1347
+ except UnicodeDecodeError:
1348
+ continue
1349
+ except Exception as e:
1350
+ logger.warning(f"Failed to read {file_path}: {e}")
1351
+ return None
1352
+
1353
+ logger.warning(f"Could not decode {file_path} with any known encoding")
1354
+ return None
1355
+
1356
+ def read_file(
1357
+ self,
1358
+ file_path: str,
1359
+ offset: Optional[int] = None,
1360
+ limit: Optional[int] = None,
1361
+ head: Optional[int] = None,
1362
+ tail: Optional[int] = None,
1363
+ ) -> Optional[str]:
1364
+ """
1365
+ Public method to read file content with optional line range.
1366
+
1367
+ Args:
1368
+ file_path: Path relative to base_directory
1369
+ offset: Start line number (1-indexed)
1370
+ limit: Maximum number of lines to read
1371
+ head: Read only first N lines
1372
+ tail: Read only last N lines
1373
+
1374
+ Returns:
1375
+ File content as string
1376
+ """
1377
+ return self._read_file(file_path, offset=offset, limit=limit, head=head, tail=tail)
1378
+
1379
+ def loader(
1380
+ self,
1381
+ branch: Optional[str] = None,
1382
+ whitelist: Optional[List[str]] = None,
1383
+ blacklist: Optional[List[str]] = None,
1384
+ chunked: bool = True,
1385
+ ) -> Generator[Document, None, None]:
1386
+ """
1387
+ Load documents from the filesystem.
1388
+
1389
+ Mirrors BaseCodeToolApiWrapper.loader() interface for compatibility.
1390
+
1391
+ Args:
1392
+ branch: Ignored (kept for API compatibility with git-based loaders)
1393
+ whitelist: File patterns to include (e.g., ['*.py', 'src/**/*.js'])
1394
+ blacklist: File patterns to exclude (e.g., ['*test*', 'node_modules/**'])
1395
+ chunked: If True, applies universal chunker based on file type
1396
+
1397
+ Yields:
1398
+ Document objects with page_content and metadata
1399
+ """
1400
+ import glob as glob_module
1401
+
1402
+ base = Path(self.base_directory)
1403
+
1404
+ def is_blacklisted(file_path: str) -> bool:
1405
+ if not blacklist:
1406
+ return False
1407
+ return (
1408
+ any(fnmatch.fnmatch(file_path, p) for p in blacklist) or
1409
+ any(fnmatch.fnmatch(Path(file_path).name, p) for p in blacklist)
1410
+ )
1411
+
1412
+ # Optimization: Use glob directly when whitelist has path patterns
1413
+ # This avoids scanning 100K+ files in node_modules etc.
1414
+ def get_files_via_glob() -> Generator[str, None, None]:
1415
+ """Use glob patterns directly - much faster than scanning all files."""
1416
+ seen = set()
1417
+ for pattern in whitelist:
1418
+ # Handle glob patterns
1419
+ full_pattern = str(base / pattern)
1420
+ for match in glob_module.glob(full_pattern, recursive=True):
1421
+ match_path = Path(match)
1422
+ if match_path.is_file():
1423
+ try:
1424
+ rel_path = str(match_path.relative_to(base))
1425
+ if rel_path not in seen and not is_blacklisted(rel_path):
1426
+ seen.add(rel_path)
1427
+ yield rel_path
1428
+ except ValueError:
1429
+ continue
1430
+
1431
+ def get_files_via_scan() -> Generator[str, None, None]:
1432
+ """Fall back to scanning all files when no whitelist or simple extension patterns."""
1433
+ _files = self._get_files()
1434
+ self._log_tool_event(f"Found {len(_files)} files in {self.base_directory}", "loader")
1435
+
1436
+ def is_whitelisted(file_path: str) -> bool:
1437
+ if not whitelist:
1438
+ return True
1439
+ return (
1440
+ any(fnmatch.fnmatch(file_path, p) for p in whitelist) or
1441
+ any(fnmatch.fnmatch(Path(file_path).name, p) for p in whitelist) or
1442
+ any(file_path.endswith(f'.{p.lstrip("*.")}') for p in whitelist if p.startswith('*.'))
1443
+ )
1444
+
1445
+ for file_path in _files:
1446
+ if is_whitelisted(file_path) and not is_blacklisted(file_path):
1447
+ yield file_path
1448
+
1449
+ # Decide strategy: use glob if whitelist has path patterns (contains / or **)
1450
+ use_glob = whitelist and any('/' in p or '**' in p for p in whitelist)
1451
+
1452
+ if use_glob:
1453
+ self._log_tool_event(f"Using glob patterns: {whitelist}", "loader")
1454
+ file_iterator = get_files_via_glob()
1455
+ else:
1456
+ file_iterator = get_files_via_scan()
1457
+
1458
+ def raw_document_generator() -> Generator[Document, None, None]:
1459
+ self._log_tool_event("Reading files...", "loader")
1460
+ processed = 0
1461
+
1462
+ for file_path in file_iterator:
1463
+ content = self._read_file(file_path)
1464
+ if not content:
1465
+ continue
1466
+
1467
+ content_hash = hashlib.sha256(content.encode('utf-8')).hexdigest()
1468
+ processed += 1
1469
+
1470
+ yield Document(
1471
+ page_content=content,
1472
+ metadata={
1473
+ 'file_path': file_path,
1474
+ 'file_name': Path(file_path).name,
1475
+ 'source': file_path,
1476
+ 'commit_hash': content_hash,
1477
+ }
1478
+ )
1479
+
1480
+ # Log progress every 100 files
1481
+ if processed % 100 == 0:
1482
+ logger.debug(f"[loader] Read {processed} files...")
1483
+
1484
+ self._log_tool_event(f"Loaded {processed} files", "loader")
1485
+
1486
+ if not chunked:
1487
+ return raw_document_generator()
1488
+
1489
+ try:
1490
+ from alita_sdk.tools.chunkers.universal_chunker import universal_chunker
1491
+ return universal_chunker(raw_document_generator())
1492
+ except ImportError:
1493
+ logger.warning("Universal chunker not available, returning raw documents")
1494
+ return raw_document_generator()
1495
+
1496
+ def chunker(self, documents: Generator[Document, None, None]) -> Generator[Document, None, None]:
1497
+ """Apply universal chunker to documents."""
1498
+ try:
1499
+ from alita_sdk.tools.chunkers.universal_chunker import universal_chunker
1500
+ return universal_chunker(documents)
1501
+ except ImportError:
1502
+ return documents
1503
+
1504
+ def get_files_content(self, file_path: str) -> Optional[str]:
1505
+ """Get file content (compatibility alias for retrieval toolkit)."""
1506
+ return self._read_file(file_path)
1507
+
1508
+
1509
+ # Predefined tool presets for common use cases
1510
+ FILESYSTEM_TOOL_PRESETS = {
1511
+ 'read_only': {
1512
+ 'exclude_tools': [
1513
+ 'filesystem_write_file',
1514
+ 'filesystem_append_file',
1515
+ 'filesystem_edit_file',
1516
+ 'filesystem_apply_patch',
1517
+ 'filesystem_delete_file',
1518
+ 'filesystem_move_file',
1519
+ 'filesystem_create_directory',
1520
+ ]
1521
+ },
1522
+ 'no_delete': {
1523
+ 'exclude_tools': ['filesystem_delete_file']
1524
+ },
1525
+ 'basic': {
1526
+ 'include_tools': [
1527
+ 'filesystem_read_file',
1528
+ 'filesystem_write_file',
1529
+ 'filesystem_append_file',
1530
+ 'filesystem_list_directory',
1531
+ 'filesystem_create_directory',
1532
+ ]
1533
+ },
1534
+ 'minimal': {
1535
+ 'include_tools': [
1536
+ 'filesystem_read_file',
1537
+ 'filesystem_list_directory',
1538
+ ]
1539
+ },
1540
+ }
1541
+
1542
+
1543
+ def get_filesystem_tools(
1544
+ base_directory: str,
1545
+ include_tools: Optional[List[str]] = None,
1546
+ exclude_tools: Optional[List[str]] = None,
1547
+ preset: Optional[str] = None,
1548
+ allowed_directories: Optional[List[str]] = None
1549
+ ) -> List[BaseTool]:
1550
+ """
1551
+ Get filesystem tools for the specified directories.
1552
+
1553
+ Args:
1554
+ base_directory: Absolute or relative path to the primary directory to restrict access to
1555
+ include_tools: Optional list of tool names to include. If provided, only these tools are returned.
1556
+ If None, all tools are included (unless excluded).
1557
+ exclude_tools: Optional list of tool names to exclude. Applied after include_tools.
1558
+ preset: Optional preset name to use predefined tool sets. Presets:
1559
+ - 'read_only': Excludes all write/modify operations
1560
+ - 'no_delete': All tools except delete
1561
+ - 'basic': Read, write, append, list, create directory
1562
+ - 'minimal': Only read and list
1563
+ Note: If preset is used with include_tools or exclude_tools,
1564
+ preset is applied first, then custom filters.
1565
+
1566
+ Returns:
1567
+ List of filesystem tools based on preset and/or include/exclude filters
1568
+
1569
+ Available tool names:
1570
+ - filesystem_read_file
1571
+ - filesystem_read_file_chunk
1572
+ - filesystem_read_multiple_files
1573
+ - filesystem_write_file
1574
+ - filesystem_append_file (for incremental file creation)
1575
+ - filesystem_edit_file
1576
+ - filesystem_apply_patch
1577
+ - filesystem_list_directory
1578
+ - filesystem_directory_tree
1579
+ - filesystem_search_files
1580
+ - filesystem_delete_file
1581
+ - filesystem_move_file
1582
+ - filesystem_create_directory
1583
+ - filesystem_get_file_info
1584
+ - filesystem_list_allowed_directories
1585
+
1586
+ Examples:
1587
+ # Get all tools
1588
+ get_filesystem_tools('/path/to/dir')
1589
+
1590
+ # Only read operations
1591
+ get_filesystem_tools('/path/to/dir',
1592
+ include_tools=['filesystem_read_file', 'filesystem_list_directory'])
1593
+
1594
+ # All tools except delete and write
1595
+ get_filesystem_tools('/path/to/dir',
1596
+ exclude_tools=['filesystem_delete_file', 'filesystem_write_file'])
1597
+
1598
+ # Use preset for read-only mode
1599
+ get_filesystem_tools('/path/to/dir', preset='read_only')
1600
+
1601
+ # Use preset and add custom exclusions
1602
+ get_filesystem_tools('/path/to/dir', preset='read_only',
1603
+ exclude_tools=['filesystem_search_files'])
1604
+
1605
+ # Multiple allowed directories
1606
+ get_filesystem_tools('/path/to/primary',
1607
+ allowed_directories=['/path/to/other1', '/path/to/other2'])
1608
+ """
1609
+ # Apply preset if specified
1610
+ preset_include = None
1611
+ preset_exclude = None
1612
+ if preset:
1613
+ if preset not in FILESYSTEM_TOOL_PRESETS:
1614
+ raise ValueError(f"Unknown preset '{preset}'. Available: {list(FILESYSTEM_TOOL_PRESETS.keys())}")
1615
+ preset_config = FILESYSTEM_TOOL_PRESETS[preset]
1616
+ preset_include = preset_config.get('include_tools')
1617
+ preset_exclude = preset_config.get('exclude_tools')
1618
+
1619
+ # Merge preset with custom filters
1620
+ # Priority: custom include_tools > preset include > all tools
1621
+ final_include = include_tools if include_tools is not None else preset_include
1622
+
1623
+ # Priority: custom exclude_tools + preset exclude
1624
+ final_exclude = []
1625
+ if preset_exclude:
1626
+ final_exclude.extend(preset_exclude)
1627
+ if exclude_tools:
1628
+ final_exclude.extend(exclude_tools)
1629
+ final_exclude = list(set(final_exclude)) if final_exclude else None
1630
+
1631
+ # Resolve to absolute paths
1632
+ base_dir = str(Path(base_directory).resolve())
1633
+ extra_dirs = [str(Path(d).resolve()) for d in (allowed_directories or [])]
1634
+
1635
+ # Define all available tools with their names
1636
+ all_tools = {
1637
+ 'filesystem_read_file': ReadFileTool(base_directory=base_dir, allowed_directories=extra_dirs),
1638
+ 'filesystem_read_file_chunk': ReadFileChunkTool(base_directory=base_dir, allowed_directories=extra_dirs),
1639
+ 'filesystem_read_multiple_files': ReadMultipleFilesTool(base_directory=base_dir, allowed_directories=extra_dirs),
1640
+ 'filesystem_write_file': WriteFileTool(base_directory=base_dir, allowed_directories=extra_dirs),
1641
+ 'filesystem_append_file': AppendFileTool(base_directory=base_dir, allowed_directories=extra_dirs),
1642
+ 'filesystem_edit_file': EditFileTool(base_directory=base_dir, allowed_directories=extra_dirs),
1643
+ 'filesystem_apply_patch': ApplyPatchTool(base_directory=base_dir, allowed_directories=extra_dirs),
1644
+ 'filesystem_list_directory': ListDirectoryTool(base_directory=base_dir, allowed_directories=extra_dirs),
1645
+ 'filesystem_directory_tree': DirectoryTreeTool(base_directory=base_dir, allowed_directories=extra_dirs),
1646
+ 'filesystem_search_files': SearchFilesTool(base_directory=base_dir, allowed_directories=extra_dirs),
1647
+ 'filesystem_delete_file': DeleteFileTool(base_directory=base_dir, allowed_directories=extra_dirs),
1648
+ 'filesystem_move_file': MoveFileTool(base_directory=base_dir, allowed_directories=extra_dirs),
1649
+ 'filesystem_create_directory': CreateDirectoryTool(base_directory=base_dir, allowed_directories=extra_dirs),
1650
+ 'filesystem_get_file_info': GetFileInfoTool(base_directory=base_dir, allowed_directories=extra_dirs),
1651
+ 'filesystem_list_allowed_directories': ListAllowedDirectoriesTool(base_directory=base_dir, allowed_directories=extra_dirs),
1652
+ }
1653
+
1654
+ # Start with all tools or only included ones
1655
+ if final_include is not None:
1656
+ selected_tools = {name: tool for name, tool in all_tools.items() if name in final_include}
1657
+ else:
1658
+ selected_tools = all_tools.copy()
1659
+
1660
+ # Remove excluded tools
1661
+ if final_exclude is not None:
1662
+ for name in final_exclude:
1663
+ selected_tools.pop(name, None)
1664
+
1665
+ return list(selected_tools.values())