amd-gaia 0.15.2__py3-none-any.whl → 0.15.3__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.
@@ -0,0 +1,715 @@
1
+ # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ Shared File Search and Management Tools.
5
+
6
+ Provides common file search and read operations that can be used across multiple agents.
7
+ These tools are agent-agnostic and don't depend on specific agent functionality.
8
+ """
9
+
10
+ import ast
11
+ import logging
12
+ import os
13
+ import platform
14
+ from pathlib import Path
15
+ from typing import Any, Dict
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FileSearchToolsMixin:
21
+ """
22
+ Mixin providing shared file search and read operations.
23
+
24
+ Tools provided:
25
+ - search_file: Search filesystem for files by name/pattern
26
+ - search_directory: Search filesystem for directories by name
27
+ - read_file: Read any file with intelligent type-based analysis
28
+ """
29
+
30
+ def _format_file_list(self, file_paths: list) -> list:
31
+ """Format file paths for numbered display to user."""
32
+ file_list = []
33
+ for i, fpath in enumerate(file_paths, 1):
34
+ p = Path(fpath)
35
+ file_list.append(
36
+ {
37
+ "number": i,
38
+ "name": p.name,
39
+ "path": str(fpath),
40
+ "directory": str(p.parent),
41
+ }
42
+ )
43
+ return file_list
44
+
45
+ def register_file_search_tools(self) -> None:
46
+ """Register shared file search tools."""
47
+ from gaia.agents.base.tools import tool
48
+
49
+ @tool(
50
+ atomic=True,
51
+ name="search_file",
52
+ description="Search for files by name/pattern across entire drive(s). Searches common locations first, then does deep search. Use when user asks 'find X on my drive'.",
53
+ parameters={
54
+ "file_pattern": {
55
+ "type": "str",
56
+ "description": "File name pattern to search for (e.g., 'oil', 'manual', '*.pdf'). Supports partial matches.",
57
+ "required": True,
58
+ },
59
+ "search_all_drives": {
60
+ "type": "bool",
61
+ "description": "Search all available drives (default: True on Windows)",
62
+ "required": False,
63
+ },
64
+ "file_types": {
65
+ "type": "str",
66
+ "description": "Comma-separated file extensions to filter (e.g., 'pdf,docx,txt'). Default: all document types",
67
+ "required": False,
68
+ },
69
+ },
70
+ )
71
+ def search_file(
72
+ file_pattern: str, search_all_drives: bool = True, file_types: str = None
73
+ ) -> Dict[str, Any]:
74
+ """
75
+ Search for files with intelligent prioritization.
76
+
77
+ Strategy:
78
+ 1. Search common document locations first (fast)
79
+ 2. If not found, search entire drive(s) (thorough)
80
+ 3. Filter by document file types for speed
81
+ """
82
+ try:
83
+ # Document file extensions to search
84
+ if file_types:
85
+ doc_extensions = {
86
+ f".{ext.strip().lower()}" for ext in file_types.split(",")
87
+ }
88
+ else:
89
+ doc_extensions = {
90
+ ".pdf",
91
+ ".doc",
92
+ ".docx",
93
+ ".txt",
94
+ ".md",
95
+ ".csv",
96
+ ".json",
97
+ ".xlsx",
98
+ ".xls",
99
+ }
100
+
101
+ matching_files = []
102
+ pattern_lower = file_pattern.lower()
103
+ searched_locations = []
104
+
105
+ def matches_pattern_and_type(file_path: Path) -> bool:
106
+ """Check if file matches pattern and is a document type."""
107
+ name_match = pattern_lower in file_path.name.lower()
108
+ type_match = file_path.suffix.lower() in doc_extensions
109
+ return name_match and type_match
110
+
111
+ def search_location(location: Path, max_depth: int = 999):
112
+ """Search a specific location up to max_depth."""
113
+ if not location.exists():
114
+ return
115
+
116
+ searched_locations.append(str(location))
117
+ logger.debug(f"Searching {location}...")
118
+
119
+ def search_recursive(current_path: Path, depth: int):
120
+ if depth > max_depth or len(matching_files) >= 20:
121
+ return
122
+
123
+ try:
124
+ for item in current_path.iterdir():
125
+ # Skip system/hidden directories
126
+ if item.name.startswith(
127
+ (".", "$", "Windows", "Program Files")
128
+ ):
129
+ continue
130
+
131
+ if item.is_file():
132
+ if matches_pattern_and_type(item):
133
+ matching_files.append(str(item.resolve()))
134
+ logger.debug(f"Found: {item.name}")
135
+ elif item.is_dir() and depth < max_depth:
136
+ search_recursive(item, depth + 1)
137
+ except (PermissionError, OSError) as e:
138
+ logger.debug(f"Skipping {current_path}: {e}")
139
+
140
+ search_recursive(location, 0)
141
+
142
+ # Phase 0: Search CURRENT WORKING DIRECTORY first and thoroughly
143
+ cwd = Path.cwd()
144
+ home = Path.home()
145
+
146
+ # Show progress to user
147
+ if hasattr(self, "console") and hasattr(self.console, "start_progress"):
148
+ self.console.start_progress(
149
+ f"🔍 Searching current directory ({cwd.name}) for '{file_pattern}'..."
150
+ )
151
+
152
+ logger.debug(
153
+ f"Phase 0: Deep search of current directory for '{file_pattern}'..."
154
+ )
155
+ logger.debug(f"Current directory: {cwd}")
156
+
157
+ # Search current directory thoroughly (unlimited depth)
158
+ search_location(cwd, max_depth=999)
159
+
160
+ # If found in CWD, return immediately
161
+ if matching_files:
162
+ if hasattr(self, "console") and hasattr(
163
+ self.console, "stop_progress"
164
+ ):
165
+ self.console.stop_progress()
166
+
167
+ # Add helpful context about where it was found
168
+ return {
169
+ "status": "success",
170
+ "files": matching_files[:10],
171
+ "file_list": self._format_file_list(matching_files[:10]),
172
+ "count": len(matching_files),
173
+ "search_context": "current_directory",
174
+ "display_message": f"✓ Found {len(matching_files)} file(s) in current directory ({cwd.name})",
175
+ }
176
+
177
+ # Phase 1: Search common locations
178
+ if hasattr(self, "console") and hasattr(self.console, "start_progress"):
179
+ self.console.start_progress(
180
+ "🔍 Searching common folders (Documents, Downloads, Desktop)..."
181
+ )
182
+
183
+ logger.debug("Phase 1: Searching common document locations...")
184
+
185
+ common_locations = [
186
+ home / "Documents",
187
+ home / "Downloads",
188
+ home / "Desktop",
189
+ home / "OneDrive",
190
+ home / "Google Drive",
191
+ home / "Dropbox",
192
+ ]
193
+
194
+ for location in common_locations:
195
+ if len(matching_files) >= 10:
196
+ break
197
+ search_location(location, max_depth=5)
198
+
199
+ # If found in common locations, return
200
+ if matching_files:
201
+ if hasattr(self, "console") and hasattr(
202
+ self.console, "stop_progress"
203
+ ):
204
+ self.console.stop_progress()
205
+
206
+ return {
207
+ "status": "success",
208
+ "files": matching_files[:10],
209
+ "file_list": self._format_file_list(matching_files[:10]),
210
+ "count": len(matching_files),
211
+ "total_locations_searched": len(searched_locations),
212
+ "search_context": "common_locations",
213
+ "display_message": f"✓ Found {len(matching_files)} file(s) in common locations",
214
+ }
215
+
216
+ # Phase 2: Deep drive search if still not found
217
+ if hasattr(self, "console") and hasattr(self.console, "start_progress"):
218
+ self.console.start_progress(
219
+ "🔍 Deep search across all drives (this may take a minute)..."
220
+ )
221
+
222
+ logger.debug("Phase 2: Deep search across drive(s)...")
223
+
224
+ if platform.system() == "Windows" and search_all_drives:
225
+ # Search all available drives on Windows
226
+ import string
227
+
228
+ for drive_letter in string.ascii_uppercase:
229
+ drive = Path(f"{drive_letter}:/")
230
+ if drive.exists():
231
+ logger.debug(f"Searching drive {drive_letter}:...")
232
+ search_location(drive, max_depth=999)
233
+ if len(matching_files) >= 10:
234
+ break
235
+ else:
236
+ # On Linux/Mac, search from root
237
+ search_location(Path("/"), max_depth=999)
238
+
239
+ # Stop progress indicator
240
+ if hasattr(self, "console") and hasattr(self.console, "stop_progress"):
241
+ self.console.stop_progress()
242
+
243
+ # Return final results
244
+ if matching_files:
245
+ return {
246
+ "status": "success",
247
+ "files": matching_files[:10],
248
+ "file_list": self._format_file_list(matching_files[:10]),
249
+ "count": len(matching_files),
250
+ "total_locations_searched": len(searched_locations),
251
+ "display_message": f"✓ Found {len(matching_files)} file(s) after deep search",
252
+ "user_instruction": "If multiple files found, display numbered list and ask user to select one.",
253
+ }
254
+ else:
255
+ # Build helpful message about what was searched
256
+ search_summary = []
257
+ if str(cwd) in searched_locations:
258
+ search_summary.append(f"current directory ({cwd.name})")
259
+ if len(searched_locations) > 1:
260
+ search_summary.append(
261
+ f"{len(searched_locations)} total locations"
262
+ )
263
+
264
+ searched_str = (
265
+ ", ".join(search_summary)
266
+ if search_summary
267
+ else f"{len(searched_locations)} locations"
268
+ )
269
+
270
+ return {
271
+ "status": "success",
272
+ "files": [],
273
+ "count": 0,
274
+ "total_locations_searched": len(searched_locations),
275
+ "search_summary": searched_str,
276
+ "display_message": f"❌ No files found matching '{file_pattern}'",
277
+ "searched": f"Searched {searched_str}",
278
+ "suggestion": "Try a different search term, check spelling, or provide the full file path if you know it.",
279
+ }
280
+
281
+ except Exception as e:
282
+ logger.error(f"Error searching for files: {e}")
283
+ import traceback
284
+
285
+ logger.error(traceback.format_exc())
286
+ return {
287
+ "status": "error",
288
+ "error": str(e),
289
+ "has_errors": True,
290
+ "operation": "search_file",
291
+ }
292
+
293
+ @tool(
294
+ atomic=True,
295
+ name="search_directory",
296
+ description="Search for a directory by name starting from a root path. Use when user asks to find or index 'my data folder' or similar.",
297
+ parameters={
298
+ "directory_name": {
299
+ "type": "str",
300
+ "description": "Name of directory to search for (e.g., 'data', 'documents')",
301
+ "required": True,
302
+ },
303
+ "search_root": {
304
+ "type": "str",
305
+ "description": "Root path to start search from (default: user's home directory)",
306
+ "required": False,
307
+ },
308
+ "max_depth": {
309
+ "type": "int",
310
+ "description": "Maximum depth to search (default: 4)",
311
+ "required": False,
312
+ },
313
+ },
314
+ )
315
+ def search_directory(
316
+ directory_name: str, search_root: str = None, max_depth: int = 4
317
+ ) -> Dict[str, Any]:
318
+ """
319
+ Search for directories by name.
320
+
321
+ Returns list of matching directory paths.
322
+ """
323
+ try:
324
+ # Default to home directory if no root specified
325
+ if search_root is None:
326
+ search_root = str(Path.home())
327
+
328
+ search_root = Path(search_root).resolve()
329
+
330
+ if not search_root.exists():
331
+ return {
332
+ "status": "error",
333
+ "error": f"Search root does not exist: {search_root}",
334
+ "has_errors": True,
335
+ }
336
+
337
+ logger.debug(
338
+ f"Searching for directory '{directory_name}' from {search_root}"
339
+ )
340
+
341
+ matching_dirs = []
342
+
343
+ def search_recursive(current_path: Path, depth: int):
344
+ """Recursively search for matching directories."""
345
+ if depth > max_depth:
346
+ return
347
+
348
+ try:
349
+ for item in current_path.iterdir():
350
+ if item.is_dir():
351
+ # Check if name matches (case-insensitive)
352
+ if directory_name.lower() in item.name.lower():
353
+ matching_dirs.append(str(item.resolve()))
354
+ logger.debug(f"Found matching directory: {item}")
355
+
356
+ # Continue searching subdirectories
357
+ if depth < max_depth:
358
+ search_recursive(item, depth + 1)
359
+ except (PermissionError, OSError) as e:
360
+ # Skip directories we can't access
361
+ logger.debug(f"Skipping {current_path}: {e}")
362
+
363
+ search_recursive(search_root, 0)
364
+
365
+ if matching_dirs:
366
+ return {
367
+ "status": "success",
368
+ "directories": matching_dirs[:10], # Limit to 10 results
369
+ "count": len(matching_dirs),
370
+ "message": f"Found {len(matching_dirs)} matching directories",
371
+ }
372
+ else:
373
+ return {
374
+ "status": "success",
375
+ "directories": [],
376
+ "count": 0,
377
+ "message": f"No directories matching '{directory_name}' found",
378
+ }
379
+
380
+ except Exception as e:
381
+ logger.error(f"Error searching for directory: {e}")
382
+ return {
383
+ "status": "error",
384
+ "error": str(e),
385
+ "has_errors": True,
386
+ "operation": "search_directory",
387
+ }
388
+
389
+ @tool(
390
+ atomic=True,
391
+ name="read_file",
392
+ description="Read any file and intelligently analyze based on file type. Supports Python, Markdown, and other text files.",
393
+ parameters={
394
+ "file_path": {
395
+ "type": "str",
396
+ "description": "Path to the file to read",
397
+ "required": True,
398
+ }
399
+ },
400
+ )
401
+ def read_file(file_path: str) -> Dict[str, Any]:
402
+ """Read any file and intelligently analyze based on file type.
403
+
404
+ Automatically detects file type and provides appropriate analysis:
405
+ - Python files (.py): Syntax validation + symbol extraction (functions/classes)
406
+ - Markdown files (.md): Headers + code blocks + links
407
+ - Other text files: Raw content
408
+
409
+ Args:
410
+ file_path: Path to the file to read
411
+
412
+ Returns:
413
+ Dictionary with file content and type-specific metadata
414
+ """
415
+ try:
416
+ if not os.path.exists(file_path):
417
+ return {"status": "error", "error": f"File not found: {file_path}"}
418
+
419
+ # Read file content
420
+ try:
421
+ with open(file_path, "r", encoding="utf-8") as f:
422
+ content = f.read()
423
+ except UnicodeDecodeError:
424
+ # Binary file
425
+ with open(file_path, "rb") as f:
426
+ content_bytes = f.read()
427
+ return {
428
+ "status": "success",
429
+ "file_path": file_path,
430
+ "file_type": "binary",
431
+ "content": f"[Binary file, {len(content_bytes)} bytes]",
432
+ "is_binary": True,
433
+ "size_bytes": len(content_bytes),
434
+ }
435
+
436
+ # Detect file type by extension
437
+ ext = os.path.splitext(file_path)[1].lower()
438
+
439
+ # Base result with common fields
440
+ result = {
441
+ "status": "success",
442
+ "file_path": file_path,
443
+ "content": content,
444
+ "line_count": len(content.splitlines()),
445
+ "size_bytes": len(content.encode("utf-8")),
446
+ }
447
+
448
+ # Python file - add symbol extraction
449
+ if ext == ".py":
450
+ result["file_type"] = "python"
451
+
452
+ try:
453
+ tree = ast.parse(content)
454
+ result["is_valid"] = True
455
+ result["errors"] = []
456
+
457
+ # Extract symbols
458
+ symbols = []
459
+ for node in ast.walk(tree):
460
+ if isinstance(
461
+ node, (ast.FunctionDef, ast.AsyncFunctionDef)
462
+ ):
463
+ symbols.append(
464
+ {
465
+ "name": node.name,
466
+ "type": "function",
467
+ "line": node.lineno,
468
+ }
469
+ )
470
+ elif isinstance(node, ast.ClassDef):
471
+ symbols.append(
472
+ {
473
+ "name": node.name,
474
+ "type": "class",
475
+ "line": node.lineno,
476
+ }
477
+ )
478
+ result["symbols"] = symbols
479
+ except SyntaxError as e:
480
+ result["is_valid"] = False
481
+ result["errors"] = [str(e)]
482
+
483
+ # Markdown file - extract structure
484
+ elif ext == ".md":
485
+ import re
486
+
487
+ result["file_type"] = "markdown"
488
+
489
+ # Extract headers
490
+ headers = re.findall(r"^#{1,6}\s+(.+)$", content, re.MULTILINE)
491
+ result["headers"] = headers
492
+
493
+ # Extract code blocks
494
+ code_blocks = re.findall(r"```(\w*)\n(.*?)```", content, re.DOTALL)
495
+ result["code_blocks"] = [
496
+ {"language": lang, "code": code} for lang, code in code_blocks
497
+ ]
498
+
499
+ # Extract links
500
+ links = re.findall(r"\[([^\]]+)\]\(([^)]+)\)", content)
501
+ result["links"] = [
502
+ {"text": text, "url": url} for text, url in links
503
+ ]
504
+
505
+ # Other text files
506
+ else:
507
+ result["file_type"] = ext[1:] if ext else "text"
508
+
509
+ return result
510
+
511
+ except Exception as e:
512
+ return {"status": "error", "error": str(e)}
513
+
514
+ @tool(
515
+ atomic=True,
516
+ name="search_file_content",
517
+ description="Search for text patterns within files on disk (like grep). Searches actual file contents, not indexed documents.",
518
+ parameters={
519
+ "pattern": {
520
+ "type": "str",
521
+ "description": "Text pattern or keyword to search for",
522
+ "required": True,
523
+ },
524
+ "directory": {
525
+ "type": "str",
526
+ "description": "Directory to search in (default: current directory)",
527
+ "required": False,
528
+ },
529
+ "file_pattern": {
530
+ "type": "str",
531
+ "description": "File pattern to filter (e.g., '*.py', '*.txt'). Default: all text files",
532
+ "required": False,
533
+ },
534
+ "case_sensitive": {
535
+ "type": "bool",
536
+ "description": "Whether search should be case-sensitive (default: False)",
537
+ "required": False,
538
+ },
539
+ },
540
+ )
541
+ def search_file_content(
542
+ pattern: str,
543
+ directory: str = ".",
544
+ file_pattern: str = None,
545
+ case_sensitive: bool = False,
546
+ ) -> Dict[str, Any]:
547
+ """
548
+ Search for text patterns within files (grep-like functionality).
549
+
550
+ Searches actual file contents on disk, not RAG indexed documents.
551
+ """
552
+ try:
553
+ import fnmatch
554
+
555
+ directory = Path(directory).resolve()
556
+
557
+ if not directory.exists():
558
+ return {
559
+ "status": "error",
560
+ "error": f"Directory not found: {directory}",
561
+ }
562
+
563
+ # Text file extensions to search
564
+ text_extensions = {
565
+ ".txt",
566
+ ".md",
567
+ ".py",
568
+ ".js",
569
+ ".java",
570
+ ".c",
571
+ ".cpp",
572
+ ".h",
573
+ ".json",
574
+ ".xml",
575
+ ".yaml",
576
+ ".yml",
577
+ ".csv",
578
+ ".log",
579
+ ".ini",
580
+ ".conf",
581
+ ".sh",
582
+ ".bat",
583
+ ".html",
584
+ ".css",
585
+ ".sql",
586
+ }
587
+
588
+ matches = []
589
+ files_searched = 0
590
+ search_pattern = pattern if case_sensitive else pattern.lower()
591
+
592
+ def search_file(file_path: Path):
593
+ """Search within a single file."""
594
+ try:
595
+ with open(
596
+ file_path, "r", encoding="utf-8", errors="ignore"
597
+ ) as f:
598
+ for line_num, line in enumerate(f, 1):
599
+ search_line = line if case_sensitive else line.lower()
600
+ if search_pattern in search_line:
601
+ matches.append(
602
+ {
603
+ "file": str(file_path),
604
+ "line": line_num,
605
+ "content": line.strip()[
606
+ :200
607
+ ], # Limit line length
608
+ }
609
+ )
610
+ if len(matches) >= 100: # Limit total matches
611
+ return False
612
+ return True
613
+ except Exception:
614
+ return True # Continue searching
615
+
616
+ # Search files
617
+ for file_path in directory.rglob("*"):
618
+ if not file_path.is_file():
619
+ continue
620
+
621
+ # Filter by file pattern if provided
622
+ if file_pattern:
623
+ if not fnmatch.fnmatch(file_path.name, file_pattern):
624
+ continue
625
+ else:
626
+ # Only search text files
627
+ if file_path.suffix.lower() not in text_extensions:
628
+ continue
629
+
630
+ files_searched += 1
631
+ if not search_file(file_path):
632
+ break # Hit match limit
633
+
634
+ if matches:
635
+ return {
636
+ "status": "success",
637
+ "pattern": pattern,
638
+ "matches": matches[:50], # Return first 50
639
+ "total_matches": len(matches),
640
+ "files_searched": files_searched,
641
+ "message": f"Found {len(matches)} matches in {files_searched} files",
642
+ }
643
+ else:
644
+ return {
645
+ "status": "success",
646
+ "pattern": pattern,
647
+ "matches": [],
648
+ "total_matches": 0,
649
+ "files_searched": files_searched,
650
+ "message": f"No matches found for '{pattern}' in {files_searched} files",
651
+ }
652
+
653
+ except Exception as e:
654
+ logger.error(f"Error searching file content: {e}")
655
+ return {
656
+ "status": "error",
657
+ "error": str(e),
658
+ "has_errors": True,
659
+ "operation": "search_file_content",
660
+ }
661
+
662
+ @tool(
663
+ atomic=True,
664
+ name="write_file",
665
+ description="Write content to any file. Creates parent directories if needed.",
666
+ parameters={
667
+ "file_path": {
668
+ "type": "str",
669
+ "description": "Path where to write the file",
670
+ "required": True,
671
+ },
672
+ "content": {
673
+ "type": "str",
674
+ "description": "Content to write to the file",
675
+ "required": True,
676
+ },
677
+ "create_dirs": {
678
+ "type": "bool",
679
+ "description": "Whether to create parent directories (default: True)",
680
+ "required": False,
681
+ },
682
+ },
683
+ )
684
+ def write_file(
685
+ file_path: str, content: str, create_dirs: bool = True
686
+ ) -> Dict[str, Any]:
687
+ """
688
+ Write content to a file.
689
+
690
+ Generic file writer for any file type.
691
+ """
692
+ try:
693
+ file_path = Path(file_path)
694
+
695
+ # Create parent directories if needed
696
+ if create_dirs and file_path.parent:
697
+ file_path.parent.mkdir(parents=True, exist_ok=True)
698
+
699
+ # Write the file
700
+ with open(file_path, "w", encoding="utf-8") as f:
701
+ f.write(content)
702
+
703
+ return {
704
+ "status": "success",
705
+ "file_path": str(file_path),
706
+ "bytes_written": len(content.encode("utf-8")),
707
+ "line_count": len(content.splitlines()),
708
+ }
709
+ except Exception as e:
710
+ logger.error(f"Error writing file: {e}")
711
+ return {
712
+ "status": "error",
713
+ "error": str(e),
714
+ "operation": "write_file",
715
+ }