code-lm 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.
@@ -0,0 +1,86 @@
1
+ """
2
+ Base tool implementation and interfaces.
3
+ """
4
+
5
+ import shlex
6
+ import inspect
7
+ from abc import ABC, abstractmethod
8
+ from google.generativeai.types import FunctionDeclaration
9
+ import logging
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ class BaseTool(ABC):
14
+ """Base class for all tools."""
15
+
16
+ name = None
17
+ description = "Base tool"
18
+
19
+ @abstractmethod
20
+ def execute(self, *args, **kwargs):
21
+ """Execute the tool with the given arguments."""
22
+ pass
23
+
24
+ @classmethod
25
+ def get_function_declaration(cls) -> FunctionDeclaration | None:
26
+ """Generates FunctionDeclaration based on the execute method's signature."""
27
+ if not cls.name or not cls.description:
28
+ log.warning(f"Tool {cls.__name__} is missing name or description. Cannot generate declaration.")
29
+ return None
30
+
31
+ try:
32
+ exec_sig = inspect.signature(cls.execute)
33
+ parameters = {}
34
+ required = []
35
+
36
+ for param_name, param in exec_sig.parameters.items():
37
+ # Skip 'self' if it's the first parameter
38
+ if param_name == 'self':
39
+ continue
40
+
41
+ # Basic type mapping (can be enhanced)
42
+ param_type = "string" # Default to string
43
+ if param.annotation == str: param_type = "string"
44
+ elif param.annotation == int: param_type = "integer"
45
+ elif param.annotation == float: param_type = "number"
46
+ elif param.annotation == bool: param_type = "boolean"
47
+ elif param.annotation == list: param_type = "array" # Note: items type not specified here
48
+ elif param.annotation == dict: param_type = "object" # Note: properties not specified here
49
+ # Add more complex type handling if needed (e.g., from typing import List, Dict)
50
+
51
+ # Assume description from docstring or default
52
+ # (More advanced: parse docstring for arg descriptions)
53
+ param_description = f"Parameter {param_name}"
54
+
55
+ parameters[param_name] = {
56
+ "type": param_type,
57
+ "description": param_description
58
+ }
59
+
60
+ # Check if parameter is required (no default value)
61
+ if param.default is inspect.Parameter.empty:
62
+ required.append(param_name)
63
+
64
+ # Handle case where no parameters are needed (besides self)
65
+ if not parameters:
66
+ schema = None
67
+ else:
68
+ schema = {
69
+ "type": "object",
70
+ "properties": parameters,
71
+ # Ensure required list is not empty if there are required params
72
+ "required": required if required else None
73
+ }
74
+ # Clean up schema if required is None/empty
75
+ if schema["required"] is None:
76
+ del schema["required"]
77
+
78
+ return FunctionDeclaration(
79
+ name=cls.name,
80
+ description=cls.description,
81
+ parameters=schema
82
+ )
83
+
84
+ except Exception as e:
85
+ log.error(f"Error generating FunctionDeclaration for tool '{cls.name}': {e}", exc_info=True)
86
+ return None
@@ -0,0 +1,120 @@
1
+ """
2
+ Tools for directory operations.
3
+ """
4
+ import os
5
+ import logging
6
+ import subprocess
7
+ from .base import BaseTool
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ class CreateDirectoryTool(BaseTool):
12
+ """Tool to create a new directory."""
13
+ name = "create_directory"
14
+ description = "Creates a new directory, including any necessary parent directories."
15
+
16
+ def execute(self, dir_path: str) -> str:
17
+ """
18
+ Creates a directory.
19
+
20
+ Args:
21
+ dir_path: The path of the directory to create.
22
+
23
+ Returns:
24
+ A success or error message.
25
+ """
26
+ try:
27
+ # Basic path safety
28
+ if ".." in dir_path.split(os.path.sep):
29
+ log.warning(f"Attempted to access parent directory in create_directory path: {dir_path}")
30
+ return f"Error: Invalid path '{dir_path}'. Cannot access parent directories."
31
+
32
+ target_path = os.path.abspath(os.path.expanduser(dir_path))
33
+ log.info(f"Attempting to create directory: {target_path}")
34
+
35
+ if os.path.exists(target_path):
36
+ if os.path.isdir(target_path):
37
+ log.warning(f"Directory already exists: {target_path}")
38
+ return f"Directory already exists: {dir_path}"
39
+ else:
40
+ log.error(f"Path exists but is not a directory: {target_path}")
41
+ return f"Error: Path exists but is not a directory: {dir_path}"
42
+
43
+ os.makedirs(target_path, exist_ok=True) # exist_ok=True handles race conditions slightly better
44
+ log.info(f"Successfully created directory: {target_path}")
45
+ return f"Successfully created directory: {dir_path}"
46
+
47
+ except OSError as e:
48
+ log.error(f"Error creating directory '{dir_path}': {e}", exc_info=True)
49
+ return f"Error creating directory: {str(e)}"
50
+ except Exception as e:
51
+ log.error(f"Unexpected error creating directory '{dir_path}': {e}", exc_info=True)
52
+ return f"Error creating directory: {str(e)}"
53
+
54
+ class LsTool(BaseTool):
55
+ """Tool to list directory contents using 'ls -lA'."""
56
+ name = "ls"
57
+ description = "Lists the contents of a specified directory (long format, including hidden files)."
58
+ args_schema: dict = {
59
+ "path": {
60
+ "type": "string",
61
+ "description": "Optional path to a specific directory relative to the workspace root. If omitted, uses the current directory.",
62
+ }
63
+ }
64
+ required_args: list[str] = []
65
+
66
+ def execute(self, path: str | None = None) -> str:
67
+ """Executes the 'ls -lA' command."""
68
+ target_path = "." # Default to current directory
69
+ if path:
70
+ # Basic path safety - prevent navigating outside workspace root if needed
71
+ # For simplicity, assuming relative paths are okay for now
72
+ target_path = os.path.normpath(path) # Normalize path
73
+ if target_path.startswith(".."):
74
+ log.warning(f"Attempted to access parent directory in ls path: {path}")
75
+ return f"Error: Invalid path '{path}'. Cannot access parent directories."
76
+
77
+ command = ['ls', '-lA', target_path]
78
+ log.info(f"Executing ls command: {' '.join(command)}")
79
+
80
+ try:
81
+ process = subprocess.run(
82
+ command,
83
+ capture_output=True,
84
+ text=True,
85
+ check=False, # Don't raise exception on non-zero exit code
86
+ timeout=15 # Add a timeout
87
+ )
88
+
89
+ if process.returncode == 0:
90
+ log.info(f"ls command successful for path '{target_path}'.")
91
+ # Limit output size? ls -l can be long.
92
+ output = process.stdout.strip()
93
+ # Example truncation (adjust as needed)
94
+ if len(output.splitlines()) > 100:
95
+ log.warning(f"ls output for '{target_path}' exceeded 100 lines. Truncating.")
96
+ output = "\n".join(output.splitlines()[:100]) + "\n... (output truncated)"
97
+ return output
98
+ else:
99
+ # Handle cases like directory not found specifically if possible
100
+ stderr_lower = process.stderr.lower()
101
+ if "no such file or directory" in stderr_lower:
102
+ log.error(f"ls command failed: Directory not found '{target_path}'. Stderr: {process.stderr.strip()}")
103
+ return f"Error: Directory not found: '{target_path}'"
104
+ else:
105
+ log.error(f"ls command failed with return code {process.returncode}. Path: '{target_path}'. Stderr: {process.stderr.strip()}")
106
+ error_detail = process.stderr.strip() if process.stderr else "(No stderr)"
107
+ return f"Error executing ls command (Code: {process.returncode}): {error_detail}"
108
+
109
+ except FileNotFoundError:
110
+ # This means 'ls' itself wasn't found - unlikely but possible
111
+ log.error("'ls' command not found (FileNotFoundError). It might not be installed or in PATH.")
112
+ return "Error: 'ls' command not found. Please ensure it is installed and in the system's PATH."
113
+ except subprocess.TimeoutExpired:
114
+ log.error(f"ls command timed out for path '{target_path}' after 15 seconds.")
115
+ return f"Error: ls command timed out for path '{target_path}'."
116
+ except Exception as e:
117
+ log.exception(f"An unexpected error occurred while executing ls command for path '{target_path}': {e}")
118
+ return f"An unexpected error occurred while executing ls: {str(e)}"
119
+
120
+ # Assuming BaseTool provides a working get_function_declaration implementation
@@ -0,0 +1,207 @@
1
+ """
2
+ File operation tools.
3
+ """
4
+ # --- ADDED IMPORT ---
5
+ from .base import BaseTool
6
+ # --- END IMPORT ---
7
+
8
+ import os
9
+ import glob
10
+ import re
11
+ import logging
12
+ from pathlib import Path
13
+ # Note: MAX_CHARS_FOR_FULL_CONTENT is now defined in summarizer_tool.py,
14
+ # so we import it or redefine it here if ViewTool uses it independently.
15
+ # Let's import it for consistency.
16
+ try:
17
+ from .summarizer_tool import MAX_CHARS_FOR_FULL_CONTENT
18
+ except ImportError:
19
+ # Fallback if summarizer_tool doesn't exist or fails import
20
+ MAX_CHARS_FOR_FULL_CONTENT = 50 * 1024
21
+ logging.warning("Could not import MAX_CHARS_FOR_FULL_CONTENT from summarizer_tool, using fallback.")
22
+
23
+
24
+ log = logging.getLogger(__name__)
25
+
26
+ class ViewTool(BaseTool):
27
+ """Tool to view specific sections or small files. For large files, use summarize_code."""
28
+ name = "view"
29
+ description = "View specific sections of a file using offset/limit, or view small files entirely. Use summarize_code for large files."
30
+
31
+ def execute(self, file_path: str, offset: int | None = None, limit: int | None = None) -> str:
32
+ """
33
+ View specific parts or small files. Suggests summarize_code for large files if no offset/limit.
34
+
35
+ Args:
36
+ file_path: Path to the file to view.
37
+ offset: Line number to start reading from (1-based index, optional).
38
+ limit: Maximum number of lines to read (optional).
39
+ Returns:
40
+ The requested content or an error/suggestion message.
41
+ """
42
+ try:
43
+ # Basic path safety
44
+ if ".." in file_path.split(os.path.sep):
45
+ log.warning(f"Attempted to access parent directory in path: {file_path}")
46
+ return f"Error: Invalid file path '{file_path}'. Cannot access parent directories."
47
+
48
+ path = os.path.abspath(os.path.expanduser(file_path))
49
+ log.info(f"Viewing file: {path} (Offset: {offset}, Limit: {limit})")
50
+
51
+ if not os.path.exists(path):
52
+ log.warning(f"File not found for view: {file_path}")
53
+ return f"Error: File not found: {file_path}"
54
+ if not os.path.isfile(path):
55
+ log.warning(f"Attempted to view a directory: {file_path}")
56
+ return f"Error: Cannot view a directory: {file_path}"
57
+
58
+ # Check size if offset/limit are NOT provided
59
+ if offset is None and limit is None:
60
+ file_size = os.path.getsize(path)
61
+ if file_size > MAX_CHARS_FOR_FULL_CONTENT:
62
+ log.warning(f"File '{file_path}' is large ({file_size} bytes) and no offset/limit provided for view.")
63
+ return f"Error: File '{file_path}' is large. Use the 'summarize_code' tool for an overview, or 'view' with offset/limit for specific sections."
64
+
65
+ # Proceed with reading
66
+ with open(path, 'r', encoding='utf-8', errors='ignore') as f:
67
+ lines = f.readlines()
68
+
69
+ start_index = 0
70
+ if offset is not None:
71
+ start_index = max(0, int(offset) - 1)
72
+
73
+ end_index = len(lines)
74
+ if limit is not None:
75
+ end_index = start_index + max(0, int(limit))
76
+
77
+ content_slice = lines[start_index:end_index]
78
+
79
+ result = []
80
+ for i, line in enumerate(content_slice):
81
+ original_line_num = start_index + i + 1
82
+ result.append(f"{original_line_num:6d} {line}")
83
+
84
+ prefix = f"--- Content of {file_path} (Lines {start_index+1}-{start_index+len(content_slice)}) ---" if offset is not None or limit is not None else f"--- Full Content of {file_path} ---"
85
+ return prefix + "\n" + "".join(result) if result else f"{prefix}\n(File is empty or slice resulted in no lines)"
86
+
87
+ except Exception as e:
88
+ log.error(f"Error viewing file '{file_path}': {e}", exc_info=True)
89
+ return f"Error viewing file: {str(e)}"
90
+
91
+
92
+ class EditTool(BaseTool):
93
+ """Tool to edit/create files. Can overwrite, replace strings, or create new."""
94
+ name = "edit"
95
+ description = (
96
+ "Edit or create a file. Use 'content' to provide the **entire** new file content (for creation or full overwrite). "
97
+ "Use 'old_string' and 'new_string' to replace the **first** occurrence of an exact string. "
98
+ "For precise changes, it's best to first `view` the relevant section, then use `edit` with the exact `old_string` and `new_string`, "
99
+ "or provide the complete, modified content using the `content` parameter."
100
+ )
101
+
102
+ def execute(self, file_path: str, content: str | None = None, old_string: str | None = None, new_string: str | None = None) -> str:
103
+ """
104
+ Edits or creates a file.
105
+
106
+ Args:
107
+ file_path: Path to the file to edit or create.
108
+ content: The full content to write to the file. Prioritized over old/new string.
109
+ old_string: The exact string to find for replacement.
110
+ new_string: The string to replace old_string with. Use '' to delete.
111
+ Returns:
112
+ A success message or an error message.
113
+ """
114
+ try:
115
+ if ".." in file_path.split(os.path.sep):
116
+ log.warning(f"Attempted access to parent directory: {file_path}")
117
+ return f"Error: Invalid file path '{file_path}'."
118
+ path = os.path.abspath(os.path.expanduser(file_path))
119
+ log.info(f"Editing file: {path}")
120
+ directory = os.path.dirname(path)
121
+ if directory and not os.path.exists(directory):
122
+ log.info(f"Creating directory: {directory}"); os.makedirs(directory, exist_ok=True)
123
+
124
+ if content is not None:
125
+ if old_string is not None or new_string is not None: log.warning("Prioritizing 'content' over 'old/new_string'.")
126
+ log.info(f"Writing content (length: {len(content)}) to {path}.")
127
+ with open(path, 'w', encoding='utf-8') as f: f.write(content)
128
+ return f"Successfully wrote content to {file_path}."
129
+ elif old_string is not None and new_string is not None:
130
+ log.info(f"Replacing '{old_string[:50]}...' with '{new_string[:50]}...' in {path}.")
131
+ if not os.path.exists(path): return f"Error: File not found for replacement: {file_path}"
132
+ try:
133
+ with open(path, 'r', encoding='utf-8') as f: original_content = f.read()
134
+ except Exception as read_err: return f"Error reading file for replacement: {read_err}"
135
+ if old_string not in original_content: return f"Error: `old_string` not found in {file_path}."
136
+ new_content = original_content.replace(old_string, new_string, 1)
137
+ with open(path, 'w', encoding='utf-8') as f: f.write(new_content)
138
+ return f"Successfully replaced first occurrence in {file_path}." if new_string else f"Successfully deleted first occurrence in {file_path}."
139
+ elif old_string is None and new_string is None and content is None:
140
+ log.info(f"Creating empty file: {path}")
141
+ with open(path, 'w', encoding='utf-8') as f: f.write("")
142
+ return f"Successfully created/emptied file {file_path}."
143
+ else: return "Error: Invalid arguments. Use 'content' OR ('old_string' and 'new_string')."
144
+ except IsADirectoryError: return f"Error: Cannot edit a directory: {file_path}"
145
+ except Exception as e: log.error(f"Error editing file '{file_path}': {e}", exc_info=True); return f"Error editing file: {str(e)}"
146
+
147
+
148
+ class GrepTool(BaseTool):
149
+ """Tool to search for patterns in files."""
150
+ name = "grep"
151
+ description = "Search for a pattern (regex) in files within a directory."
152
+ def execute(self, pattern: str, path: str = '.', include: str | None = None) -> str:
153
+ # No CWD logging needed here for now, focusing on ls/glob/summarize
154
+ try:
155
+ if ".." in path.split(os.path.sep): return f"Error: Invalid path '{path}'."
156
+ target_path = os.path.abspath(os.path.expanduser(path)); log.info(f"Grepping in {target_path} for '{pattern}' (Include: {include})")
157
+ if not os.path.isdir(target_path): return f"Error: Path is not a directory: {path}"
158
+ try: regex = re.compile(pattern)
159
+ except re.error as re_err: return f"Error: Invalid regex pattern: {pattern} ({re_err})"
160
+ results = []; files_to_search = []
161
+ if include:
162
+ recursive = '**' in include; glob_pattern = os.path.join(target_path, include)
163
+ try: files_to_search = glob.glob(glob_pattern, recursive=recursive)
164
+ except Exception as glob_err: return f"Error finding files with include pattern: {glob_err}"
165
+ else:
166
+ for root, _, filenames in os.walk(target_path):
167
+ basename = os.path.basename(root)
168
+ if basename.startswith('.') or basename == '__pycache__': continue
169
+ files_to_search.extend(os.path.join(root, filename) for filename in filenames)
170
+ log.info(f"Found {len(files_to_search)} potential files to search.")
171
+ files_searched_count = 0; matches_found_count = 0; max_matches = 500
172
+ for file_path in files_to_search:
173
+ if not os.path.isfile(file_path): continue
174
+ files_searched_count += 1
175
+ try:
176
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
177
+ for i, line in enumerate(f, 1):
178
+ if regex.search(line):
179
+ matches_found_count += 1; rel_path = os.path.relpath(file_path, target_path)
180
+ if os.path.dirname(rel_path) == '': rel_path = f"./{rel_path}"
181
+ results.append(f"{rel_path}:{i}: {line.rstrip()}")
182
+ if matches_found_count >= max_matches: results.append("--- Match limit reached ---"); break
183
+ except OSError: continue
184
+ except Exception as e: log.warning(f"Error grepping file {file_path}: {e}"); continue
185
+ if matches_found_count >= max_matches: break
186
+ log.info(f"Searched {files_searched_count} files, found {matches_found_count} matches.")
187
+ return "\n".join(results) if results else f"No matches found for pattern: {pattern}"
188
+ except Exception as e: log.error(f"Error during grep: {e}", exc_info=True); return f"Error searching files: {str(e)}"
189
+
190
+ class GlobTool(BaseTool):
191
+ """Tool to find files using glob patterns."""
192
+ name = "glob"
193
+ description = "Find files/directories matching specific glob patterns recursively."
194
+ def execute(self, pattern: str, path: str = '.') -> str:
195
+ log.debug(f"[GlobTool] Current working directory: {os.getcwd()}")
196
+ try:
197
+ if ".." in path.split(os.path.sep): return f"Error: Invalid path '{path}'."
198
+ target_path = os.path.abspath(os.path.expanduser(path)); log.info(f"Globbing in {target_path} for '{pattern}'")
199
+ if not os.path.isdir(target_path): return f"Error: Path is not a directory: {path}"
200
+ search_pattern = os.path.join(target_path, pattern)
201
+ matches = glob.glob(search_pattern, recursive=True)
202
+ if matches:
203
+ relative_matches = sorted([os.path.relpath(m, target_path) for m in matches])
204
+ formatted_matches = [f"./{m}" if os.path.dirname(m) == '' else m for m in relative_matches]
205
+ return "\n".join(formatted_matches)
206
+ else: return f"No files or directories found matching pattern: {pattern}"
207
+ except Exception as e: log.error(f"Error finding files with glob: {e}", exc_info=True); return f"Error finding files: {str(e)}"
@@ -0,0 +1,108 @@
1
+ """
2
+ Tools for code quality (linting, formatting).
3
+ Requires external tools like 'ruff', 'black', 'flake8' etc. to be installed
4
+ in the environment where this CLI runs.
5
+ """
6
+ import subprocess
7
+ import logging
8
+ import shlex
9
+ import os
10
+ from .base import BaseTool
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ # --- Helper for running commands ---
15
+ def _run_quality_command(command: list[str], tool_name: str) -> str:
16
+ log.info(f"Executing {tool_name} command: {' '.join(command)}")
17
+ try:
18
+ process = subprocess.run(
19
+ command,
20
+ capture_output=True,
21
+ text=True,
22
+ check=False, # Check return code manually
23
+ timeout=120 # 2 minute timeout
24
+ )
25
+ stdout = process.stdout.strip()
26
+ stderr = process.stderr.strip()
27
+ log.info(f"{tool_name} completed. Exit Code: {process.returncode}")
28
+ log.debug(f"{tool_name} stdout:\n{stdout}")
29
+ if stderr: log.debug(f"{tool_name} stderr:\n{stderr}")
30
+
31
+ result = f"{tool_name} Result (Exit Code: {process.returncode}):\n"
32
+ if stdout: result += f"-- Output --\n{stdout}\n"
33
+ if stderr: result += f"-- Errors --\n{stderr}\n"
34
+ if not stdout and not stderr: result += "(No output)"
35
+
36
+ # Truncate long results?
37
+ max_len = 2000
38
+ if len(result) > max_len:
39
+ result = result[:max_len] + "\n... (output truncated)"
40
+
41
+ return result
42
+
43
+ except FileNotFoundError:
44
+ cmd_str = command[0]
45
+ log.error(f"{tool_name} command '{cmd_str}' not found.")
46
+ return f"Error: Command '{cmd_str}' not found. Is '{cmd_str}' installed and in PATH?"
47
+ except subprocess.TimeoutExpired:
48
+ log.error(f"{tool_name} run timed out.")
49
+ return f"Error: {tool_name} run timed out (2 minutes)."
50
+ except Exception as e:
51
+ log.error(f"Unexpected error running {tool_name}: {e}", exc_info=True)
52
+ return f"Error running {tool_name}: {str(e)}"
53
+
54
+
55
+ class LinterCheckerTool(BaseTool):
56
+ """Tool to run a code linter (e.g., ruff, flake8)."""
57
+ name = "linter_checker"
58
+ description = "Runs a code linter (default: 'ruff check') on a specified path to find potential issues."
59
+
60
+ def execute(self, path: str = '.', linter_command: str = 'ruff check') -> str:
61
+ """
62
+ Runs the linter.
63
+
64
+ Args:
65
+ path: The file or directory path to lint (default: current directory).
66
+ linter_command: The base command for the linter (default: 'ruff check'). Arguments like the path will be appended.
67
+
68
+ Returns:
69
+ The output from the linter.
70
+ """
71
+ if ".." in path.split(os.path.sep):
72
+ log.warning(f"Attempted to access parent directory in linter path: {path}")
73
+ return f"Error: Invalid path '{path}'. Cannot access parent directories."
74
+ target_path = os.path.abspath(os.path.expanduser(path))
75
+
76
+ # Basic command splitting, assumes simple command name possibly with one arg
77
+ command_parts = shlex.split(linter_command)
78
+ command = command_parts + [target_path]
79
+
80
+ return _run_quality_command(command, "Linter")
81
+
82
+
83
+ class FormatterTool(BaseTool):
84
+ """Tool to run a code formatter (e.g., black, prettier)."""
85
+ name = "formatter"
86
+ description = "Runs a code formatter (default: 'black') on a specified path to automatically fix styling."
87
+
88
+ def execute(self, path: str = '.', formatter_command: str = 'black') -> str:
89
+ """
90
+ Runs the formatter.
91
+
92
+ Args:
93
+ path: The file or directory path to format (default: current directory).
94
+ formatter_command: The base command for the formatter (default: 'black'). Arguments like the path will be appended.
95
+
96
+ Returns:
97
+ The output from the formatter.
98
+ """
99
+ if ".." in path.split(os.path.sep):
100
+ log.warning(f"Attempted to access parent directory in formatter path: {path}")
101
+ return f"Error: Invalid path '{path}'. Cannot access parent directories."
102
+ target_path = os.path.abspath(os.path.expanduser(path))
103
+
104
+ # Basic command splitting
105
+ command_parts = shlex.split(formatter_command)
106
+ command = command_parts + [target_path]
107
+
108
+ return _run_quality_command(command, "Formatter")
@@ -0,0 +1,144 @@
1
+ """
2
+ Tool for summarizing code files using an LLM.
3
+ """
4
+ import google.generativeai as genai
5
+ import logging
6
+ import os
7
+ from .base import BaseTool
8
+
9
+ log = logging.getLogger(__name__)
10
+
11
+ # Define thresholds for summarization vs. full view
12
+ MAX_LINES_FOR_FULL_CONTENT = 1000 # View files smaller than this directly
13
+ MAX_CHARS_FOR_FULL_CONTENT = 50 * 1024 # 50 KB
14
+
15
+ # Simple summarization prompt
16
+ SUMMARIZATION_SYSTEM_PROMPT = """You are an expert code summarizer. Given the following code file content, provide a concise summary focusing on:
17
+ - The file's main purpose.
18
+ - Key classes and functions defined (names and brief purpose).
19
+ - Any major dependencies imported or used.
20
+ - Overall structure.
21
+ Keep the summary brief and informative, suitable for providing context to another AI agent."""
22
+
23
+ class SummarizeCodeTool(BaseTool):
24
+ """
25
+ Tool to summarize a code file, especially useful for large files.
26
+ Returns full content for small files.
27
+ """
28
+ name = "summarize_code"
29
+ description = "Provides a summary of a code file's purpose, key functions/classes, and structure. Use for large files or when only an overview is needed."
30
+
31
+ def __init__(self, model_instance: genai.GenerativeModel | None = None):
32
+ """
33
+ Requires the initialized Gemini model instance for performing summarization.
34
+ (This implies the tool needs access to the model from the main class)
35
+ """
36
+ super().__init__()
37
+ # This creates a dependency: the tool needs the model.
38
+ # We'll need to modify how tools are instantiated or pass the model reference.
39
+ self.model = model_instance
40
+
41
+ def execute(self, file_path: str | None = None, directory_path: str | None = None, query: str | None = None, glob_pattern: str | None = None) -> str:
42
+ """
43
+ Summarizes code based on path, directory, or query.
44
+ # ... (rest of docstring)
45
+ """
46
+ log.debug(f"[SummarizeCodeTool] Current working directory: {os.getcwd()}")
47
+ log.info(f"SummarizeCodeTool called with file='{file_path}', dir='{directory_path}', query='{query}', glob='{glob_pattern}'")
48
+
49
+ if not self.model:
50
+ # This check is important if the model wasn't passed during init
51
+ log.error("SummarizeCodeTool cannot execute: Model instance not provided.")
52
+ return "Error: Summarization tool not properly configured (missing model instance)."
53
+
54
+ try:
55
+ # Basic path safety
56
+ if ".." in file_path.split(os.path.sep):
57
+ log.warning(f"Attempted access to parent directory: {file_path}")
58
+ return f"Error: Invalid file path '{file_path}'."
59
+
60
+ target_path = os.path.abspath(os.path.expanduser(file_path))
61
+ log.info(f"Summarize/View file: {target_path}")
62
+
63
+ if not os.path.exists(target_path):
64
+ return f"Error: File not found: {file_path}"
65
+ if not os.path.isfile(target_path):
66
+ return f"Error: Path is not a file: {file_path}"
67
+
68
+ # Check file size/lines
69
+ file_size = os.path.getsize(target_path)
70
+ line_count = 0
71
+ try:
72
+ with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
73
+ for _ in f:
74
+ line_count += 1
75
+ except Exception:
76
+ pass # Ignore line count errors, rely on size
77
+
78
+ log.debug(f"File '{file_path}': Size={file_size} bytes, Lines={line_count}")
79
+
80
+ # Return full content if file is small
81
+ if line_count < MAX_LINES_FOR_FULL_CONTENT and file_size < MAX_CHARS_FOR_FULL_CONTENT:
82
+ log.info(f"File '{file_path}' is small, returning full content.")
83
+ try:
84
+ with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
85
+ content = f.read()
86
+ # Add a prefix to indicate it's full content
87
+ return f"--- Full Content of {file_path} ---\n{content}"
88
+ except Exception as read_err:
89
+ log.error(f"Error reading small file '{target_path}': {read_err}", exc_info=True)
90
+ return f"Error reading file: {read_err}"
91
+
92
+ # Generate summary if file is large
93
+ else:
94
+ log.info(f"File '{file_path}' is large, attempting summarization...")
95
+ try:
96
+ with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
97
+ # Limit content sent for summarization to avoid exceeding limits there too?
98
+ # E.g., read only first/last N lines or first X KB. For now, read all.
99
+ content_to_summarize = f.read()
100
+
101
+ if not content_to_summarize.strip():
102
+ return f"--- Summary of {file_path} ---\n(File is empty)"
103
+
104
+ # Prepare prompt for internal summarization call
105
+ summarization_prompt = f"Please summarize the following code from the file '{file_path}':\n\n```\n{content_to_summarize[:20000]}\n```" # Limit sent content
106
+
107
+ # Make the internal LLM call for summarization
108
+ # Use a simpler generation config? No tools needed here.
109
+ summary_config = genai.types.GenerationConfig(temperature=0.3) # Low temp for factual summary
110
+ summary_response = self.model.generate_content(
111
+ contents=[
112
+ # Provide system prompt separately if API supports it, otherwise prepend
113
+ {'role': 'user', 'parts': [SUMMARIZATION_SYSTEM_PROMPT, summarization_prompt]}
114
+ ],
115
+ generation_config=summary_config,
116
+ # safety_settings= ... # Use parent's safety settings?
117
+ )
118
+
119
+ summary_text = self._extract_text_from_summary_response(summary_response)
120
+
121
+ # Add a prefix to indicate it's a summary
122
+ return f"--- Summary of {file_path} ---\n{summary_text}"
123
+
124
+ except Exception as summary_err:
125
+ log.error(f"Error generating summary for '{target_path}': {summary_err}", exc_info=True)
126
+ return f"Error generating summary: {summary_err}"
127
+
128
+ except Exception as e:
129
+ log.error(f"Error in SummarizeCodeTool for '{file_path}': {e}", exc_info=True)
130
+ return f"Error processing file for summary/view: {str(e)}"
131
+
132
+ # Helper to extract text from the internal summarization call's response
133
+ def _extract_text_from_summary_response(self, response):
134
+ try:
135
+ if response.candidates:
136
+ if response.candidates[0].finish_reason.name == "STOP":
137
+ return "".join(part.text for part in response.candidates[0].content.parts if hasattr(part, 'text'))
138
+ else:
139
+ return f"(Summarization failed: {response.candidates[0].finish_reason.name})"
140
+ else:
141
+ return "(Summarization failed: No candidates)"
142
+ except Exception as e:
143
+ log.error(f"Error extracting summary text: {e}")
144
+ return "(Error extracting summary text)"