diff --git a/aider/__init__.py b/aider/__init__.py index 2c48bccaf57..f4541f52532 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.88.27.dev" +__version__ = "0.88.28.dev" safe_version = __version__ try: diff --git a/aider/coders/agent_coder.py b/aider/coders/agent_coder.py index c613d045184..ea8e2f7e72e 100644 --- a/aider/coders/agent_coder.py +++ b/aider/coders/agent_coder.py @@ -117,7 +117,7 @@ def __init__(self, *args, **kwargs): # Initialize tool registry self.args = kwargs.get("args") - self._tool_registry = self._build_tool_registry() + self.tool_registry = self._build_tool_registry() # Track files added during current exploration self.files_added_in_exploration = set() @@ -201,8 +201,9 @@ def _build_tool_registry(self): # Always include essential tools regardless of includelist/excludelist essential_tools = {"makeeditable", "replacetext", "view", "finished"} for module in tool_modules: - if hasattr(module, "NORM_NAME") and hasattr(module, "process_response"): - tool_name = module.NORM_NAME + if hasattr(module, "Tool"): + tool_class = module.Tool + tool_name = tool_class.NORM_NAME # Check if tool should be included based on configuration should_include = True @@ -220,7 +221,7 @@ def _build_tool_registry(self): should_include = False if should_include: - registry[tool_name] = module + registry[tool_name] = tool_class return registry @@ -267,9 +268,9 @@ def get_local_tool_schemas(self): schemas = [] # Get schemas from the tool registry - for tool_module in self._tool_registry.values(): - if hasattr(tool_module, "schema"): - schemas.append(tool_module.schema) + for tool_module in self.tool_registry.values(): + if hasattr(tool_module, "SCHEMA"): + schemas.append(tool_module.SCHEMA) return schemas @@ -324,8 +325,8 @@ async def _execute_local_tool_calls(self, tool_calls_list): tasks = [] # Use the tool registry for execution - if norm_tool_name in self._tool_registry: - tool_module = self._tool_registry[norm_tool_name] + if norm_tool_name in self.tool_registry: + tool_module = self.tool_registry[norm_tool_name] for params in parsed_args_list: # Use the process_response function from the tool module result = tool_module.process_response(self, params) @@ -1137,8 +1138,8 @@ async def _execute_tool_with_registry(self, norm_tool_name, params): str: Result message """ # Check if tool exists in registry - if norm_tool_name in self._tool_registry: - tool_module = self._tool_registry[norm_tool_name] + if norm_tool_name in self.tool_registry: + tool_module = self.tool_registry[norm_tool_name] try: # Use the process_response function from the tool module result = tool_module.process_response(self, params) diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index f9564a61b5b..8409f3e3bee 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -61,6 +61,7 @@ from aider.repomap import RepoMap from aider.run_cmd import run_cmd from aider.sessions import SessionManager +from aider.tools.utils.output import print_tool_response from aider.utils import format_tokens, is_image_file from ..dump import dump # noqa: F401 @@ -1125,7 +1126,7 @@ async def _run_linear(self, with_message=None, preproc=True): return self.partial_response_content user_message = None - await self.io.cancel_task_streams() + await self.io.stop_task_streams() while True: try: @@ -1150,22 +1151,17 @@ async def _run_linear(self, with_message=None, preproc=True): await self.auto_save_session() except KeyboardInterrupt: - if self.io.input_task: - self.io.set_placeholder("") - await self.io.cancel_input_task() - - if self.io.output_task: - await self.io.cancel_output_task() - self.io.stop_spinner() - + self.io.set_placeholder("") + self.io.stop_spinner() self.keyboard_interrupt() + await self.io.stop_task_streams() except (asyncio.CancelledError, IndexError): pass except EOFError: return finally: - await self.io.cancel_task_streams() + await self.io.stop_task_streams() async def _run_parallel(self, with_message=None, preproc=True): try: @@ -1180,7 +1176,7 @@ async def _run_parallel(self, with_message=None, preproc=True): self.user_message = "" # Cancel any existing tasks - await self.io.cancel_task_streams() + await self.io.stop_task_streams() # Start the input and output tasks input_task = asyncio.create_task(self.input_task(preproc)) @@ -1221,13 +1217,13 @@ async def _run_parallel(self, with_message=None, preproc=True): pass # Ensure IO tasks are properly cancelled - await self.io.cancel_task_streams() + await self.io.stop_task_streams() await self.auto_save_session() except EOFError: return finally: - await self.io.cancel_task_streams() + await self.io.stop_task_streams() async def input_task(self, preproc): """ @@ -1253,11 +1249,11 @@ async def input_task(self, preproc): await self.auto_save_session() else: self.user_message = "" - await self.io.cancel_task_streams() + await self.io.stop_task_streams() except (asyncio.CancelledError, KeyboardInterrupt): self.user_message = "" - await self.io.cancel_task_streams() + await self.io.stop_task_streams() # Check if we should show announcements if ( @@ -1284,7 +1280,7 @@ async def input_task(self, preproc): except KeyboardInterrupt: self.io.set_placeholder("") self.keyboard_interrupt() - await self.io.cancel_task_streams() + await self.io.stop_task_streams() except (SwitchCoder, SystemExit): raise except Exception as e: @@ -1324,16 +1320,25 @@ async def output_task(self, preproc): await self.io.output_task raise exception + self.io.tool_error(f"Error during generation: {exception}") + if self.verbose: + traceback.print_exception( + type(exception), exception, exception.__traceback__ + ) + # Stop spinner when processing task completes self.io.stop_spinner() + # And stop monitoring the output task + await self.io.stop_output_task() + await self.auto_save_session() await asyncio.sleep(0.01) # Small yield to prevent tight loop except KeyboardInterrupt: self.io.stop_spinner() self.keyboard_interrupt() - await self.io.cancel_task_streams() + await self.io.stop_task_streams() except (SwitchCoder, SystemExit): raise except Exception as e: @@ -2355,54 +2360,14 @@ def _print_tool_call_info(self, server_tool_calls): for server, tool_calls in server_tool_calls.items(): for tool_call in tool_calls: - color_start = "[blue]" if self.pretty else "" - color_end = "[/blue]" if self.pretty else "" - - self.io.tool_output( - f"{color_start}Tool Call:{color_end} {server.name} • {tool_call.function.name}" - ) - # Parse and format arguments as headers with values - if tool_call.function.arguments: - # Only do JSON unwrapping for tools containing "replace" in their name - if tool_call.get("function", {}).get("name") is not None and ( - "replace" in tool_call.function.name.lower() - or "insert" in tool_call.function.name.lower() - or "update" in tool_call.function.name.lower() - ): - try: - args_dict = json.loads(tool_call.function.arguments) - first_key = True - for key, value in args_dict.items(): - # Convert explicit \\n sequences to actual newlines using regex - # Only match \\n that is not preceded by any other backslashes - if isinstance(value, str): - value = re.sub(r"(? output_limit: - # Truncate and add a clear message using the constant value - output_content = ( - output_content[:output_limit] - + f"\n... (output truncated at {output_limit} characters, based on" - " large_file_token_threshold)" + # Use run_cmd_subprocess for non-interactive execution + exit_status, combined_output = run_cmd_subprocess( + command_string, + verbose=coder.verbose, + cwd=coder.root, # Execute in the project root ) - if exit_status == 0: - return f"Shell command executed successfully (exit code 0). Output:\n{output_content}" - else: - return f"Shell command failed with exit code {exit_status}. Output:\n{output_content}" + # Format the output for the result message, include more content + output_content = combined_output or "" + # Use the existing token threshold constant as the character limit for truncation + output_limit = coder.large_file_token_threshold + if len(output_content) > output_limit: + # Truncate and add a clear message using the constant value + output_content = ( + output_content[:output_limit] + + f"\n... (output truncated at {output_limit} characters, based on" + " large_file_token_threshold)" + ) - except Exception as e: - coder.io.tool_error( - f"Error executing non-interactive shell command '{command_string}': {str(e)}" - ) - # Optionally include traceback for debugging if verbose - # if coder.verbose: - # coder.io.tool_error(traceback.format_exc()) - return f"Error executing command: {str(e)}" + if exit_status == 0: + return ( + f"Shell command executed successfully (exit code 0). Output:\n{output_content}" + ) + else: + return ( + f"Shell command failed with exit code {exit_status}. Output:\n{output_content}" + ) - -async def process_response(coder, params): - """ - Process the Command tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - command_string = params.get("command_string") - if command_string is not None: - return await _execute_command(coder, command_string) - else: - return "Error: Missing 'command_string' parameter for Command" + except Exception as e: + coder.io.tool_error( + f"Error executing non-interactive shell command '{command_string}': {str(e)}" + ) + # Optionally include traceback for debugging if verbose + # if coder.verbose: + # coder.io.tool_error(traceback.format_exc()) + return f"Error executing command: {str(e)}" diff --git a/aider/tools/command_interactive.py b/aider/tools/command_interactive.py index f47245dcf14..5d25a36f1af 100644 --- a/aider/tools/command_interactive.py +++ b/aider/tools/command_interactive.py @@ -2,119 +2,102 @@ import asyncio from aider.run_cmd import run_cmd - -schema = { - "type": "function", - "function": { - "name": "CommandInteractive", - "description": "Execute a shell command interactively.", - "parameters": { - "type": "object", - "properties": { - "command_string": { - "type": "string", - "description": "The interactive shell command to execute.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "commandinteractive" + SCHEMA = { + "type": "function", + "function": { + "name": "CommandInteractive", + "description": "Execute a shell command interactively.", + "parameters": { + "type": "object", + "properties": { + "command_string": { + "type": "string", + "description": "The interactive shell command to execute.", + }, }, + "required": ["command_string"], }, - "required": ["command_string"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "commandinteractive" - - -async def _execute_command_interactive(coder, command_string): - """ - Execute an interactive shell command using run_cmd (which uses pexpect/PTY). - """ - try: - confirmed = ( - True - if coder.skip_cli_confirmations - else await coder.io.confirm_ask( - "Allow execution of this command?", - subject=command_string, - explicit_yes_required=True, # Require explicit 'yes' or 'always' - allow_never=True, # Enable the 'Always' option - group_response="Command Interactive Tool", + } + + @classmethod + async def execute(cls, coder, command_string): + """ + Execute an interactive shell command using run_cmd (which uses pexpect/PTY). + """ + try: + confirmed = ( + True + if coder.skip_cli_confirmations + else await coder.io.confirm_ask( + "Allow execution of this command?", + subject=command_string, + explicit_yes_required=True, # Require explicit 'yes' or 'always' + allow_never=True, # Enable the 'Always' option + group_response="Command Interactive Tool", + ) ) - ) - - if not confirmed: - # This happens if the user explicitly says 'no' this time. - # If 'Always' was chosen previously, confirm_ask returns True directly. - coder.io.tool_output(f"Skipped execution of shell command: {command_string}") - return "Shell command execution skipped by user." - - coder.io.tool_output(f"⚙️ Starting interactive shell command: {command_string}") - coder.io.tool_output(">>> You may need to interact with the command below <<<") - coder.io.tool_output(" \n") - - await coder.io.cancel_input_task() - await asyncio.sleep(1) - - # Use run_cmd which handles PTY logic - exit_status, combined_output = run_cmd( - command_string, - verbose=coder.verbose, # Pass verbose flag - error_print=coder.io.tool_error, # Use io for error printing - cwd=coder.root, # Execute in the project root - ) - await asyncio.sleep(1) - - coder.io.tool_output(" \n") - coder.io.tool_output(" \n") - coder.io.tool_output(">>> Interactive command finished <<<") - - # Format the output for the result message, include more content - output_content = combined_output or "" - # Use the existing token threshold constant as the character limit for truncation - output_limit = coder.large_file_token_threshold - if len(output_content) > output_limit: - # Truncate and add a clear message using the constant value - output_content = ( - output_content[:output_limit] - + f"\n... (output truncated at {output_limit} characters, based on" - " large_file_token_threshold)" + if not confirmed: + # This happens if the user explicitly says 'no' this time. + # If 'Always' was chosen previously, confirm_ask returns True directly. + coder.io.tool_output(f"Skipped execution of shell command: {command_string}") + return "Shell command execution skipped by user." + + coder.io.tool_output(f"⚙️ Starting interactive shell command: {command_string}") + coder.io.tool_output(">>> You may need to interact with the command below <<<") + coder.io.tool_output(" \n") + + await coder.io.stop_input_task() + await asyncio.sleep(1) + + # Use run_cmd which handles PTY logic + exit_status, combined_output = run_cmd( + command_string, + verbose=coder.verbose, # Pass verbose flag + error_print=coder.io.tool_error, # Use io for error printing + cwd=coder.root, # Execute in the project root ) - if exit_status == 0: - return ( - "Interactive command finished successfully (exit code 0)." - f" Output:\n{output_content}" + await asyncio.sleep(1) + + coder.io.tool_output(" \n") + coder.io.tool_output(" \n") + coder.io.tool_output(">>> Interactive command finished <<<") + + # Format the output for the result message, include more content + output_content = combined_output or "" + # Use the existing token threshold constant as the character limit for truncation + output_limit = coder.large_file_token_threshold + if len(output_content) > output_limit: + # Truncate and add a clear message using the constant value + output_content = ( + output_content[:output_limit] + + f"\n... (output truncated at {output_limit} characters, based on" + " large_file_token_threshold)" + ) + + if exit_status == 0: + return ( + "Interactive command finished successfully (exit code 0)." + f" Output:\n{output_content}" + ) + else: + return ( + f"Interactive command finished with exit code {exit_status}." + f" Output:\n{output_content}" + ) + + except Exception as e: + coder.io.tool_error( + f"Error executing interactive shell command '{command_string}': {str(e)}" ) - else: - return ( - f"Interactive command finished with exit code {exit_status}." - f" Output:\n{output_content}" - ) - - except Exception as e: - coder.io.tool_error( - f"Error executing interactive shell command '{command_string}': {str(e)}" - ) - # Optionally include traceback for debugging if verbose - # if coder.verbose: - # coder.io.tool_error(traceback.format_exc()) - return f"Error executing interactive command: {str(e)}" - - -async def process_response(coder, params): - """ - Process the CommandInteractive tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - command_string = params.get("command_string") - if command_string is not None: - return await _execute_command_interactive(coder, command_string) - else: - return "Error: Missing 'command_string' parameter for CommandInteractive" + # Optionally include traceback for debugging if verbose + # if coder.verbose: + # coder.io.tool_error(traceback.format_exc()) + return f"Error executing interactive command: {str(e)}" diff --git a/aider/tools/delete_block.py b/aider/tools/delete_block.py index 80a1f5b5a98..3f231ba04ae 100644 --- a/aider/tools/delete_block.py +++ b/aider/tools/delete_block.py @@ -1,4 +1,5 @@ -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, determine_line_range, @@ -10,173 +11,144 @@ validate_file_for_edit, ) -schema = { - "type": "function", - "function": { - "name": "DeleteBlock", - "description": "Delete a block of lines from a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_pattern": {"type": "string"}, - "end_pattern": {"type": "string"}, - "line_count": {"type": "integer"}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, + +class Tool(BaseTool): + NORM_NAME = "deleteblock" + SCHEMA = { + "type": "function", + "function": { + "name": "DeleteBlock", + "description": "Delete a block of lines from a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "start_pattern": {"type": "string"}, + "end_pattern": {"type": "string"}, + "line_count": {"type": "integer"}, + "near_context": {"type": "string"}, + "occurrence": {"type": "integer", "default": 1}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "start_pattern"], }, - "required": ["file_path", "start_pattern"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "deleteblock" - - -def _execute_delete_block( - coder, - file_path, - start_pattern, - end_pattern=None, - line_count=None, - near_context=None, - occurrence=1, - change_id=None, - dry_run=False, -): - """ - Delete a block of text between start_pattern and end_pattern (inclusive). - Uses utility functions for validation, finding lines, and applying changes. - """ - tool_name = "DeleteBlock" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - lines = original_content.splitlines() - - # 2. Find the start line - pattern_desc = f"Start pattern '{start_pattern}'" - if near_context: - pattern_desc += f" near context '{near_context}'" - start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) - start_line_idx = select_occurrence_index(start_pattern_indices, occurrence, pattern_desc) - - # 3. Determine the end line, passing pattern_desc for better error messages - start_line, end_line = determine_line_range( - coder=coder, - file_path=rel_path, - lines=lines, - start_pattern_line_index=start_line_idx, - end_pattern=end_pattern, - line_count=line_count, - target_symbol=None, # DeleteBlock uses patterns, not symbols - pattern_desc=pattern_desc, - ) - - # 4. Prepare the deletion - deleted_lines = lines[start_line : end_line + 1] - new_lines = lines[:start_line] + lines[end_line + 1 :] - new_content = "\n".join(new_lines) + } + + @classmethod + def execute( + cls, + coder, + file_path, + start_pattern, + end_pattern=None, + line_count=None, + near_context=None, + occurrence=1, + change_id=None, + dry_run=False, + ): + """ + Delete a block of text between start_pattern and end_pattern (inclusive). + Uses utility functions for validation, finding lines, and applying changes. + """ + tool_name = "DeleteBlock" + try: + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() + + # 2. Find the start line + pattern_desc = f"Start pattern '{start_pattern}'" + if near_context: + pattern_desc += f" near context '{near_context}'" + start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) + start_line_idx = select_occurrence_index( + start_pattern_indices, occurrence, pattern_desc + ) - if original_content == new_content: - coder.io.tool_warning("No changes made: deletion would not change file") - return "Warning: No changes made (deletion would not change file)" + # 3. Determine the end line, passing pattern_desc for better error messages + start_line, end_line = determine_line_range( + coder=coder, + file_path=rel_path, + lines=lines, + start_pattern_line_index=start_line_idx, + end_pattern=end_pattern, + line_count=line_count, + target_symbol=None, # DeleteBlock uses patterns, not symbols + pattern_desc=pattern_desc, + ) - # 5. Generate diff for feedback - diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) - num_deleted = end_line - start_line + 1 - num_occurrences = len(start_pattern_indices) - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + # 4. Prepare the deletion + deleted_lines = lines[start_line : end_line + 1] + new_lines = lines[:start_line] + lines[end_line + 1 :] + new_content = "\n".join(new_lines) + + if original_content == new_content: + coder.io.tool_warning("No changes made: deletion would not change file") + return "Warning: No changes made (deletion would not change file)" + + # 5. Generate diff for feedback + diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + num_deleted = end_line - start_line + 1 + num_occurrences = len(start_pattern_indices) + occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + + # 6. Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would delete {num_deleted} lines ({start_line + 1}-{end_line + 1})" + f" based on {occurrence_str}start pattern '{start_pattern}' in {file_path}." + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) + + # 7. Apply Change (Not dry run) + metadata = { + "start_line": start_line + 1, + "end_line": end_line + 1, + "start_pattern": start_pattern, + "end_pattern": end_pattern, + "line_count": line_count, + "near_context": near_context, + "occurrence": occurrence, + "deleted_content": "\n".join(deleted_lines), + } + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "deleteblock", + metadata, + change_id, + ) - # 6. Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would delete {num_deleted} lines ({start_line + 1}-{end_line + 1}) based" - f" on {occurrence_str}start pattern '{start_pattern}' in {file_path}." + coder.files_edited_by_tools.add(rel_path) + # 8. Format and return result, adding line range to success message + success_message = ( + f"Deleted {num_deleted} lines ({start_line + 1}-{end_line + 1}) (from" + f" {occurrence_str}start pattern) in {file_path}" ) return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # 7. Apply Change (Not dry run) - metadata = { - "start_line": start_line + 1, - "end_line": end_line + 1, - "start_pattern": start_pattern, - "end_pattern": end_pattern, - "line_count": line_count, - "near_context": near_context, - "occurrence": occurrence, - "deleted_content": "\n".join(deleted_lines), - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "deleteblock", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - # 8. Format and return result, adding line range to success message - success_message = ( - f"Deleted {num_deleted} lines ({start_line + 1}-{end_line + 1}) (from" - f" {occurrence_str}start pattern) in {file_path}" - ) - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the DeleteBlock tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - start_pattern = params.get("start_pattern") - end_pattern = params.get("end_pattern") - line_count = params.get("line_count") - near_context = params.get("near_context") - occurrence = params.get("occurrence", 1) - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and start_pattern is not None: - return _execute_delete_block( - coder, - file_path, - start_pattern, - end_pattern, - line_count, - near_context, - occurrence, - change_id, - dry_run, - ) - else: - return "Error: Missing required parameters for DeleteBlock (file_path, start_pattern)" + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/delete_line.py b/aider/tools/delete_line.py index c0cd38f9488..64470860c77 100644 --- a/aider/tools/delete_line.py +++ b/aider/tools/delete_line.py @@ -1,155 +1,120 @@ -import os - -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, format_tool_result, generate_unified_diff_snippet, handle_tool_error, + validate_file_for_edit, ) -schema = { - "type": "function", - "function": { - "name": "DeleteLine", - "description": "Delete a single line from a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "line_number": {"type": "integer"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, + +class Tool(BaseTool): + NORM_NAME = "deleteline" + SCHEMA = { + "type": "function", + "function": { + "name": "DeleteLine", + "description": "Delete a single line from a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "line_number": {"type": "integer"}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "line_number"], }, - "required": ["file_path", "line_number"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "deleteline" - - -def _execute_delete_line(coder, file_path, line_number, change_id=None, dry_run=False): - """ - Delete a specific line number (1-based). + } - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - line_number: The 1-based line number to delete - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file + @classmethod + def execute(cls, coder, file_path, line_number, change_id=None, dry_run=False): + """ + Delete a specific line number (1-based). - Returns a result message. - """ + Parameters: + - coder: The Coder instance + - file_path: Path to the file to modify + - line_number: The 1-based line number to delete + - change_id: Optional ID for tracking the change + - dry_run: If True, simulate the change without modifying the file - tool_name = "DeleteLine" - try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) + Returns a result message. + """ - # Check if file exists - if not os.path.isfile(abs_path): - raise ToolError(f"File '{file_path}' not found") - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - raise ToolError(f"File '{file_path}' is read-only. Use MakeEditable first.") - else: - raise ToolError(f"File '{file_path}' not in context") - - # Reread file content immediately before modification - file_content = coder.io.read_text(abs_path) - if file_content is None: - raise ToolError(f"Could not read file '{file_path}'") - - lines = file_content.splitlines() - original_content = file_content - - # Validate line number + tool_name = "DeleteLine" try: - line_num_int = int(line_number) - if line_num_int < 1 or line_num_int > len(lines): - raise ToolError(f"Line number {line_num_int} is out of range (1-{len(lines)})") - line_idx = line_num_int - 1 # Convert to 0-based index - except ValueError: - raise ToolError(f"Invalid line_number value: '{line_number}'. Must be an integer.") - - # Prepare the deletion - deleted_line = lines[line_idx] - new_lines = lines[:line_idx] + lines[line_idx + 1 :] - new_content = "\n".join(new_lines) - - if original_content == new_content: - coder.io.tool_warning( - f"No changes made: deleting line {line_num_int} would not change file" + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() + + # Validate line number + try: + line_num_int = int(line_number) + if line_num_int < 1 or line_num_int > len(lines): + raise ToolError(f"Line number {line_num_int} is out of range (1-{len(lines)})") + line_idx = line_num_int - 1 # Convert to 0-based index + except ValueError: + raise ToolError(f"Invalid line_number value: '{line_number}'. Must be an integer.") + + # Prepare the deletion + deleted_line = lines[line_idx] + new_lines = lines[:line_idx] + lines[line_idx + 1 :] + new_content = "\n".join(new_lines) + + if original_content == new_content: + coder.io.tool_warning( + f"No changes made: deleting line {line_num_int} would not change file" + ) + return ( + f"Warning: No changes made (deleting line {line_num_int} would not change file)" + ) + + # Generate diff snippet + diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + + # Handle dry run + if dry_run: + dry_run_message = f"Dry run: Would delete line {line_num_int} in {file_path}" + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) + + # --- Apply Change (Not dry run) --- + metadata = {"line_number": line_num_int, "deleted_content": deleted_line} + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "deleteline", + metadata, + change_id, ) - return f"Warning: No changes made (deleting line {line_num_int} would not change file)" - # Generate diff snippet - diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + coder.files_edited_by_tools.add(rel_path) - # Handle dry run - if dry_run: - dry_run_message = f"Dry run: Would delete line {line_num_int} in {file_path}" + # Format and return result + success_message = f"Deleted line {line_num_int} in {file_path}" return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # --- Apply Change (Not dry run) --- - metadata = {"line_number": line_num_int, "deleted_content": deleted_line} - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "deleteline", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # Format and return result - success_message = f"Deleted line {line_num_int} in {file_path}" - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the DeleteLine tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - line_number = params.get("line_number") - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and line_number is not None: - return _execute_delete_line(coder, file_path, line_number, change_id, dry_run) - else: - return "Error: Missing required parameters for DeleteLine (file_path, line_number)" + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/delete_lines.py b/aider/tools/delete_lines.py index d139233ba89..93dbc76fc1b 100644 --- a/aider/tools/delete_lines.py +++ b/aider/tools/delete_lines.py @@ -1,184 +1,144 @@ -import os - -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, format_tool_result, generate_unified_diff_snippet, handle_tool_error, + validate_file_for_edit, ) -schema = { - "type": "function", - "function": { - "name": "DeleteLines", - "description": "Delete a range of lines from a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_line": {"type": "integer"}, - "end_line": {"type": "integer"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, + +class Tool(BaseTool): + NORM_NAME = "deletelines" + SCHEMA = { + "type": "function", + "function": { + "name": "DeleteLines", + "description": "Delete a range of lines from a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "start_line": {"type": "integer"}, + "end_line": {"type": "integer"}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "start_line", "end_line"], }, - "required": ["file_path", "start_line", "end_line"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "deletelines" - - -def _execute_delete_lines(coder, file_path, start_line, end_line, change_id=None, dry_run=False): - """ - Delete a range of lines (1-based, inclusive). - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - start_line: The 1-based starting line number to delete - - end_line: The 1-based ending line number to delete - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - tool_name = "DeleteLines" - try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - raise ToolError(f"File '{file_path}' not found") - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - raise ToolError(f"File '{file_path}' is read-only. Use MakeEditable first.") - else: - raise ToolError(f"File '{file_path}' not in context") - - # Reread file content immediately before modification - file_content = coder.io.read_text(abs_path) - if file_content is None: - raise ToolError(f"Could not read file '{file_path}'") - - lines = file_content.splitlines() - original_content = file_content - - # Validate line numbers + } + + @classmethod + def execute(cls, coder, file_path, start_line, end_line, change_id=None, dry_run=False): + """ + Delete a range of lines (1-based, inclusive). + + Parameters: + - coder: The Coder instance + - file_path: Path to the file to modify + - start_line: The 1-based starting line number to delete + - end_line: The 1-based ending line number to delete + - change_id: Optional ID for tracking the change + - dry_run: If True, simulate the change without modifying the file + + Returns a result message. + """ + tool_name = "DeleteLines" try: - start_line_int = int(start_line) - end_line_int = int(end_line) - - if start_line_int < 1 or start_line_int > len(lines): - raise ToolError(f"Start line {start_line_int} is out of range (1-{len(lines)})") - if end_line_int < 1 or end_line_int > len(lines): - raise ToolError(f"End line {end_line_int} is out of range (1-{len(lines)})") - if start_line_int > end_line_int: + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() + + # Validate line numbers + try: + start_line_int = int(start_line) + end_line_int = int(end_line) + + if start_line_int < 1 or start_line_int > len(lines): + raise ToolError(f"Start line {start_line_int} is out of range (1-{len(lines)})") + if end_line_int < 1 or end_line_int > len(lines): + raise ToolError(f"End line {end_line_int} is out of range (1-{len(lines)})") + if start_line_int > end_line_int: + raise ToolError( + f"Start line {start_line_int} cannot be after end line {end_line_int}" + ) + + start_idx = start_line_int - 1 # Convert to 0-based index + end_idx = end_line_int - 1 # Convert to 0-based index + except ValueError: raise ToolError( - f"Start line {start_line_int} cannot be after end line {end_line_int}" + f"Invalid line numbers: '{start_line}', '{end_line}'. Must be integers." ) - start_idx = start_line_int - 1 # Convert to 0-based index - end_idx = end_line_int - 1 # Convert to 0-based index - except ValueError: - raise ToolError( - f"Invalid line numbers: '{start_line}', '{end_line}'. Must be integers." - ) + # Prepare the deletion + deleted_lines = lines[start_idx : end_idx + 1] + new_lines = lines[:start_idx] + lines[end_idx + 1 :] + new_content = "\n".join(new_lines) - # Prepare the deletion - deleted_lines = lines[start_idx : end_idx + 1] - new_lines = lines[:start_idx] + lines[end_idx + 1 :] - new_content = "\n".join(new_lines) + if original_content == new_content: + coder.io.tool_warning( + f"No changes made: deleting lines {start_line_int}-{end_line_int} would not" + " change file" + ) + return ( + "Warning: No changes made (deleting lines" + f" {start_line_int}-{end_line_int} would not change file)" + ) - if original_content == new_content: - coder.io.tool_warning( - f"No changes made: deleting lines {start_line_int}-{end_line_int} would not change" - " file" - ) - return ( - f"Warning: No changes made (deleting lines {start_line_int}-{end_line_int} would" - " not change file)" - ) + # Generate diff snippet + diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) - # Generate diff snippet - diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + # Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would delete lines {start_line_int}-{end_line_int} in {file_path}" + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) + + # --- Apply Change (Not dry run) --- + metadata = { + "start_line": start_line_int, + "end_line": end_line_int, + "deleted_content": "\n".join(deleted_lines), + } + + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "deletelines", + metadata, + change_id, + ) - # Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would delete lines {start_line_int}-{end_line_int} in {file_path}" + coder.files_edited_by_tools.add(rel_path) + num_deleted = end_idx - start_idx + 1 + # Format and return result + success_message = ( + f"Deleted {num_deleted} lines ({start_line_int}-{end_line_int}) in {file_path}" ) return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # --- Apply Change (Not dry run) --- - metadata = { - "start_line": start_line_int, - "end_line": end_line_int, - "deleted_content": "\n".join(deleted_lines), - } - - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "deletelines", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - num_deleted = end_idx - start_idx + 1 - # Format and return result - success_message = ( - f"Deleted {num_deleted} lines ({start_line_int}-{end_line_int}) in {file_path}" - ) - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the DeleteLines tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - start_line = params.get("start_line") - end_line = params.get("end_line") - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and start_line is not None and end_line is not None: - return _execute_delete_lines(coder, file_path, start_line, end_line, change_id, dry_run) - else: - return ( - "Error: Missing required parameters for DeleteLines (file_path, start_line, end_line)" - ) + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/extract_lines.py b/aider/tools/extract_lines.py index 25c3b55e342..84d35e82b5b 100644 --- a/aider/tools/extract_lines.py +++ b/aider/tools/extract_lines.py @@ -1,341 +1,317 @@ import os import traceback -from .tool_utils import generate_unified_diff_snippet - -schema = { - "type": "function", - "function": { - "name": "ExtractLines", - "description": "Extract lines from a source file and append them to a target file.", - "parameters": { - "type": "object", - "properties": { - "source_file_path": {"type": "string"}, - "target_file_path": {"type": "string"}, - "start_pattern": {"type": "string"}, - "end_pattern": {"type": "string"}, - "line_count": {"type": "integer"}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, - "dry_run": {"type": "boolean", "default": False}, +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import generate_unified_diff_snippet + + +class Tool(BaseTool): + NORM_NAME = "extractlines" + SCHEMA = { + "type": "function", + "function": { + "name": "ExtractLines", + "description": "Extract lines from a source file and append them to a target file.", + "parameters": { + "type": "object", + "properties": { + "source_file_path": {"type": "string"}, + "target_file_path": {"type": "string"}, + "start_pattern": {"type": "string"}, + "end_pattern": {"type": "string"}, + "line_count": {"type": "integer"}, + "near_context": {"type": "string"}, + "occurrence": {"type": "integer", "default": 1}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["source_file_path", "target_file_path", "start_pattern"], }, - "required": ["source_file_path", "target_file_path", "start_pattern"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "extractlines" - - -def _execute_extract_lines( - coder, - source_file_path, - target_file_path, - start_pattern, - end_pattern=None, - line_count=None, - near_context=None, - occurrence=1, - dry_run=False, -): - """ - Extract a range of lines from a source file and move them to a target file. - - Parameters: - - coder: The Coder instance - - source_file_path: Path to the file to extract lines from - - target_file_path: Path to the file to append extracted lines to (will be created if needed) - - start_pattern: Pattern marking the start of the block to extract - - end_pattern: Optional pattern marking the end of the block - - line_count: Optional number of lines to extract (alternative to end_pattern) - - near_context: Optional text nearby to help locate the correct instance of the start_pattern - - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) - - dry_run: If True, simulate the change without modifying files - - Returns a result message. - """ - try: - # --- Validate Source File --- - abs_source_path = coder.abs_root_path(source_file_path) - rel_source_path = coder.get_rel_fname(abs_source_path) - - if not os.path.isfile(abs_source_path): - coder.io.tool_error(f"Source file '{source_file_path}' not found") - return "Error: Source file not found" - - if abs_source_path not in coder.abs_fnames: - if abs_source_path in coder.abs_read_only_fnames: - coder.io.tool_error( - f"Source file '{source_file_path}' is read-only. Use MakeEditable first." - ) - return "Error: Source file is read-only. Use MakeEditable first." - else: - coder.io.tool_error(f"Source file '{source_file_path}' not in context") - return "Error: Source file not in context" - - # --- Validate Target File --- - abs_target_path = coder.abs_root_path(target_file_path) - rel_target_path = coder.get_rel_fname(abs_target_path) - target_exists = os.path.isfile(abs_target_path) - target_is_editable = abs_target_path in coder.abs_fnames - target_is_readonly = abs_target_path in coder.abs_read_only_fnames - - if target_exists and not target_is_editable: - if target_is_readonly: - coder.io.tool_error( - f"Target file '{target_file_path}' exists but is read-only. Use MakeEditable" - " first." - ) - return "Error: Target file exists but is read-only. Use MakeEditable first." - else: - # This case shouldn't happen if file exists, but handle defensively + } + + @classmethod + def execute( + cls, + coder, + source_file_path, + target_file_path, + start_pattern, + end_pattern=None, + line_count=None, + near_context=None, + occurrence=1, + dry_run=False, + ): + """ + Extract a range of lines from a source file and move them to a target file. + + Parameters: + - coder: The Coder instance + - source_file_path: Path to the file to extract lines from + - target_file_path: Path to the file to append extracted lines to (will be created if needed) + - start_pattern: Pattern marking the start of the block to extract + - end_pattern: Optional pattern marking the end of the block + - line_count: Optional number of lines to extract (alternative to end_pattern) + - near_context: Optional text nearby to help locate the correct instance of the start_pattern + - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) + - dry_run: If True, simulate the change without modifying files + + Returns a result message. + """ + try: + # --- Validate Source File --- + abs_source_path = coder.abs_root_path(source_file_path) + rel_source_path = coder.get_rel_fname(abs_source_path) + + if not os.path.isfile(abs_source_path): + coder.io.tool_error(f"Source file '{source_file_path}' not found") + return "Error: Source file not found" + + if abs_source_path not in coder.abs_fnames: + if abs_source_path in coder.abs_read_only_fnames: + coder.io.tool_error( + f"Source file '{source_file_path}' is read-only. Use MakeEditable first." + ) + return "Error: Source file is read-only. Use MakeEditable first." + else: + coder.io.tool_error(f"Source file '{source_file_path}' not in context") + return "Error: Source file not in context" + + # --- Validate Target File --- + abs_target_path = coder.abs_root_path(target_file_path) + rel_target_path = coder.get_rel_fname(abs_target_path) + target_exists = os.path.isfile(abs_target_path) + target_is_editable = abs_target_path in coder.abs_fnames + target_is_readonly = abs_target_path in coder.abs_read_only_fnames + + if target_exists and not target_is_editable: + if target_is_readonly: + coder.io.tool_error( + f"Target file '{target_file_path}' exists but is read-only. Use" + " MakeEditable first." + ) + return "Error: Target file exists but is read-only. Use MakeEditable first." + else: + # This case shouldn't happen if file exists, but handle defensively + coder.io.tool_error( + f"Target file '{target_file_path}' exists but is not in context. Add it" + " first." + ) + return "Error: Target file exists but is not in context." + + # --- Read Source Content --- + source_content = coder.io.read_text(abs_source_path) + if source_content is None: coder.io.tool_error( - f"Target file '{target_file_path}' exists but is not in context. Add it first." + f"Could not read source file '{source_file_path}' before ExtractLines" + " operation." ) - return "Error: Target file exists but is not in context." - - # --- Read Source Content --- - source_content = coder.io.read_text(abs_source_path) - if source_content is None: - coder.io.tool_error( - f"Could not read source file '{source_file_path}' before ExtractLines operation." - ) - return f"Error: Could not read source file '{source_file_path}'" - - # --- Find Extraction Range --- - if end_pattern and line_count: - coder.io.tool_error("Cannot specify both end_pattern and line_count") - return "Error: Cannot specify both end_pattern and line_count" - - source_lines = source_content.splitlines() - original_source_content = source_content - - start_pattern_line_indices = [] - for i, line in enumerate(source_lines): - if start_pattern in line: - if near_context: - context_window_start = max(0, i - 5) - context_window_end = min(len(source_lines), i + 6) - context_block = "\n".join(source_lines[context_window_start:context_window_end]) - if near_context in context_block: + return f"Error: Could not read source file '{source_file_path}'" + + # --- Find Extraction Range --- + if end_pattern and line_count: + coder.io.tool_error("Cannot specify both end_pattern and line_count") + return "Error: Cannot specify both end_pattern and line_count" + + source_lines = source_content.splitlines() + original_source_content = source_content + + start_pattern_line_indices = [] + for i, line in enumerate(source_lines): + if start_pattern in line: + if near_context: + context_window_start = max(0, i - 5) + context_window_end = min(len(source_lines), i + 6) + context_block = "\n".join( + source_lines[context_window_start:context_window_end] + ) + if near_context in context_block: + start_pattern_line_indices.append(i) + else: start_pattern_line_indices.append(i) - else: - start_pattern_line_indices.append(i) - - if not start_pattern_line_indices: - err_msg = f"Start pattern '{start_pattern}' not found" - if near_context: - err_msg += f" near context '{near_context}'" - err_msg += f" in source file '{source_file_path}'." - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - num_occurrences = len(start_pattern_line_indices) - try: - occurrence = int(occurrence) - if occurrence == -1: - target_idx = num_occurrences - 1 - elif occurrence > 0 and occurrence <= num_occurrences: - target_idx = occurrence - 1 - else: - err_msg = ( - f"Occurrence number {occurrence} is out of range for start pattern" - f" '{start_pattern}'. Found {num_occurrences} occurrences" - ) + if not start_pattern_line_indices: + err_msg = f"Start pattern '{start_pattern}' not found" if near_context: - err_msg += f" near '{near_context}'" - err_msg += f" in '{source_file_path}'." + err_msg += f" near context '{near_context}'" + err_msg += f" in source file '{source_file_path}'." coder.io.tool_error(err_msg) return f"Error: {err_msg}" - except ValueError: - coder.io.tool_error(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") - return f"Error: Invalid occurrence value '{occurrence}'" - start_line = start_pattern_line_indices[target_idx] - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" - - end_line = -1 - if end_pattern: - for i in range(start_line, len(source_lines)): - if end_pattern in source_lines[i]: - end_line = i - break - if end_line == -1: - err_msg = ( - f"End pattern '{end_pattern}' not found after {occurrence_str}start pattern" - f" '{start_pattern}' (line {start_line + 1}) in '{source_file_path}'." - ) - coder.io.tool_error(err_msg) - return f"Error: {err_msg}" - elif line_count: + num_occurrences = len(start_pattern_line_indices) try: - line_count = int(line_count) - if line_count <= 0: - raise ValueError("Line count must be positive") - end_line = min(start_line + line_count - 1, len(source_lines) - 1) + occurrence = int(occurrence) + if occurrence == -1: + target_idx = num_occurrences - 1 + elif occurrence > 0 and occurrence <= num_occurrences: + target_idx = occurrence - 1 + else: + err_msg = ( + f"Occurrence number {occurrence} is out of range for start pattern" + f" '{start_pattern}'. Found {num_occurrences} occurrences" + ) + if near_context: + err_msg += f" near '{near_context}'" + err_msg += f" in '{source_file_path}'." + coder.io.tool_error(err_msg) + return f"Error: {err_msg}" except ValueError: coder.io.tool_error( - f"Invalid line_count value: '{line_count}'. Must be a positive integer." + f"Invalid occurrence value: '{occurrence}'. Must be an integer." ) - return f"Error: Invalid line_count value '{line_count}'" - else: - end_line = start_line # Extract just the start line if no end specified + return f"Error: Invalid occurrence value '{occurrence}'" + + start_line = start_pattern_line_indices[target_idx] + occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + + end_line = -1 + if end_pattern: + for i in range(start_line, len(source_lines)): + if end_pattern in source_lines[i]: + end_line = i + break + if end_line == -1: + err_msg = ( + f"End pattern '{end_pattern}' not found after {occurrence_str}start" + f" pattern '{start_pattern}' (line {start_line + 1}) in" + f" '{source_file_path}'." + ) + coder.io.tool_error(err_msg) + return f"Error: {err_msg}" + elif line_count: + try: + line_count = int(line_count) + if line_count <= 0: + raise ValueError("Line count must be positive") + end_line = min(start_line + line_count - 1, len(source_lines) - 1) + except ValueError: + coder.io.tool_error( + f"Invalid line_count value: '{line_count}'. Must be a positive integer." + ) + return f"Error: Invalid line_count value '{line_count}'" + else: + end_line = start_line # Extract just the start line if no end specified + + # --- Prepare Content Changes --- + extracted_lines = source_lines[start_line : end_line + 1] + new_source_lines = source_lines[:start_line] + source_lines[end_line + 1 :] + new_source_content = "\n".join(new_source_lines) + + target_content = "" + if target_exists: + target_content = coder.io.read_text(abs_target_path) + if target_content is None: + coder.io.tool_error( + f"Could not read existing target file '{target_file_path}'." + ) + return f"Error: Could not read target file '{target_file_path}'" + original_target_content = target_content # For tracking + + # Append extracted lines to target content, ensuring a newline if target wasn't empty + extracted_block = "\n".join(extracted_lines) + if target_content and not target_content.endswith("\n"): + target_content += "\n" # Add newline before appending if needed + new_target_content = target_content + extracted_block + + # --- Generate Diffs --- + source_diff_snippet = generate_unified_diff_snippet( + original_source_content, new_source_content, rel_source_path + ) + target_insertion_line = len(target_content.splitlines()) if target_content else 0 + target_diff_snippet = generate_unified_diff_snippet( + original_target_content, new_target_content, rel_target_path + ) - # --- Prepare Content Changes --- - extracted_lines = source_lines[start_line : end_line + 1] - new_source_lines = source_lines[:start_line] + source_lines[end_line + 1 :] - new_source_content = "\n".join(new_source_lines) + # --- Handle Dry Run --- + if dry_run: + num_extracted = end_line - start_line + 1 + target_action = "append to" if target_exists else "create" + coder.io.tool_output( + f"Dry run: Would extract {num_extracted} lines (from {occurrence_str}start" + f" pattern '{start_pattern}') in {source_file_path} and {target_action}" + f" {target_file_path}" + ) + # Provide more informative dry run response with diffs + return ( + f"Dry run: Would extract {num_extracted} lines from {rel_source_path} and" + f" {target_action} {rel_target_path}.\nSource Diff" + f" (Deletion):\n{source_diff_snippet}\nTarget Diff" + f" (Insertion):\n{target_diff_snippet}" + ) - target_content = "" - if target_exists: - target_content = coder.io.read_text(abs_target_path) - if target_content is None: - coder.io.tool_error(f"Could not read existing target file '{target_file_path}'.") - return f"Error: Could not read target file '{target_file_path}'" - original_target_content = target_content # For tracking + # --- Apply Changes (Not Dry Run) --- + coder.io.write_text(abs_source_path, new_source_content) + coder.io.write_text(abs_target_path, new_target_content) - # Append extracted lines to target content, ensuring a newline if target wasn't empty - extracted_block = "\n".join(extracted_lines) - if target_content and not target_content.endswith("\n"): - target_content += "\n" # Add newline before appending if needed - new_target_content = target_content + extracted_block + # --- Track Changes --- + source_change_id = "TRACKING_FAILED" + target_change_id = "TRACKING_FAILED" + try: + source_metadata = { + "start_line": start_line + 1, + "end_line": end_line + 1, + "start_pattern": start_pattern, + "end_pattern": end_pattern, + "line_count": line_count, + "near_context": near_context, + "occurrence": occurrence, + "extracted_content": extracted_block, + "target_file": rel_target_path, + } + source_change_id = coder.change_tracker.track_change( + file_path=rel_source_path, + change_type="extractlines_source", + original_content=original_source_content, + new_content=new_source_content, + metadata=source_metadata, + ) + except Exception as track_e: + coder.io.tool_error(f"Error tracking source change for ExtractLines: {track_e}") - # --- Generate Diffs --- - source_diff_snippet = generate_unified_diff_snippet( - original_source_content, new_source_content, rel_source_path - ) - target_insertion_line = len(target_content.splitlines()) if target_content else 0 - target_diff_snippet = generate_unified_diff_snippet( - original_target_content, new_target_content, rel_target_path - ) + try: + target_metadata = { + "insertion_line": target_insertion_line + 1, + "inserted_content": extracted_block, + "source_file": rel_source_path, + } + target_change_id = coder.change_tracker.track_change( + file_path=rel_target_path, + change_type="extractlines_target", + original_content=original_target_content, + new_content=new_target_content, + metadata=target_metadata, + ) + except Exception as track_e: + coder.io.tool_error(f"Error tracking target change for ExtractLines: {track_e}") + + # --- Update Context --- + coder.files_edited_by_tools.add(rel_source_path) + coder.files_edited_by_tools.add(rel_target_path) + + if not target_exists: + # Add the newly created file to editable context + coder.abs_fnames.add(abs_target_path) + coder.io.tool_output( + f"✨ Created and added '{target_file_path}' to editable context." + ) - # --- Handle Dry Run --- - if dry_run: + # --- Return Result --- num_extracted = end_line - start_line + 1 - target_action = "append to" if target_exists else "create" + target_action = "appended to" if target_exists else "created" coder.io.tool_output( - f"Dry run: Would extract {num_extracted} lines (from {occurrence_str}start pattern" - f" '{start_pattern}') in {source_file_path} and {target_action} {target_file_path}" + f"✅ Extracted {num_extracted} lines from {rel_source_path} (change_id:" + f" {source_change_id}) and {target_action} {rel_target_path} (change_id:" + f" {target_change_id})" ) - # Provide more informative dry run response with diffs + # Provide more informative success response with change IDs and diffs return ( - f"Dry run: Would extract {num_extracted} lines from {rel_source_path} and" - f" {target_action} {rel_target_path}.\nSource Diff" - f" (Deletion):\n{source_diff_snippet}\nTarget Diff" + f"Successfully extracted {num_extracted} lines from {rel_source_path} and" + f" {target_action} {rel_target_path}.\nSource Change ID:" + f" {source_change_id}\nSource Diff (Deletion):\n{source_diff_snippet}\nTarget" + f" Change ID: {target_change_id}\nTarget Diff" f" (Insertion):\n{target_diff_snippet}" ) - # --- Apply Changes (Not Dry Run) --- - coder.io.write_text(abs_source_path, new_source_content) - coder.io.write_text(abs_target_path, new_target_content) - - # --- Track Changes --- - source_change_id = "TRACKING_FAILED" - target_change_id = "TRACKING_FAILED" - try: - source_metadata = { - "start_line": start_line + 1, - "end_line": end_line + 1, - "start_pattern": start_pattern, - "end_pattern": end_pattern, - "line_count": line_count, - "near_context": near_context, - "occurrence": occurrence, - "extracted_content": extracted_block, - "target_file": rel_target_path, - } - source_change_id = coder.change_tracker.track_change( - file_path=rel_source_path, - change_type="extractlines_source", - original_content=original_source_content, - new_content=new_source_content, - metadata=source_metadata, - ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking source change for ExtractLines: {track_e}") - - try: - target_metadata = { - "insertion_line": target_insertion_line + 1, - "inserted_content": extracted_block, - "source_file": rel_source_path, - } - target_change_id = coder.change_tracker.track_change( - file_path=rel_target_path, - change_type="extractlines_target", - original_content=original_target_content, - new_content=new_target_content, - metadata=target_metadata, - ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking target change for ExtractLines: {track_e}") - - # --- Update Context --- - coder.files_edited_by_tools.add(rel_source_path) - coder.files_edited_by_tools.add(rel_target_path) - - if not target_exists: - # Add the newly created file to editable context - coder.abs_fnames.add(abs_target_path) - coder.io.tool_output(f"✨ Created and added '{target_file_path}' to editable context.") - - # --- Return Result --- - num_extracted = end_line - start_line + 1 - target_action = "appended to" if target_exists else "created" - coder.io.tool_output( - f"✅ Extracted {num_extracted} lines from {rel_source_path} (change_id:" - f" {source_change_id}) and {target_action} {rel_target_path} (change_id:" - f" {target_change_id})" - ) - # Provide more informative success response with change IDs and diffs - return ( - f"Successfully extracted {num_extracted} lines from {rel_source_path} and" - f" {target_action} {rel_target_path}.\nSource Change ID: {source_change_id}\nSource" - f" Diff (Deletion):\n{source_diff_snippet}\nTarget Change ID:" - f" {target_change_id}\nTarget Diff (Insertion):\n{target_diff_snippet}" - ) - - except Exception as e: - coder.io.tool_error(f"Error in ExtractLines: {str(e)}\n{traceback.format_exc()}") - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the ExtractLines tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - source_file_path = params.get("source_file_path") - target_file_path = params.get("target_file_path") - start_pattern = params.get("start_pattern") - end_pattern = params.get("end_pattern") - line_count = params.get("line_count") - near_context = params.get("near_context") - occurrence = params.get("occurrence", 1) - dry_run = params.get("dry_run", False) - - if source_file_path and target_file_path and start_pattern: - return _execute_extract_lines( - coder, - source_file_path, - target_file_path, - start_pattern, - end_pattern, - line_count, - near_context, - occurrence, - dry_run, - ) - else: - return ( - "Error: Missing required parameters for ExtractLines (source_file_path," - " target_file_path, start_pattern)" - ) + except Exception as e: + coder.io.tool_error(f"Error in ExtractLines: {str(e)}\n{traceback.format_exc()}") + return f"Error: {str(e)}" diff --git a/aider/tools/finished.py b/aider/tools/finished.py index 564daf3c442..1a4a8ab458b 100644 --- a/aider/tools/finished.py +++ b/aider/tools/finished.py @@ -1,48 +1,35 @@ -schema = { - "type": "function", - "function": { - "name": "Finished", - "description": ( - "Declare that we are done with every single sub goal and no further work is needed." - ), - "parameters": { - "type": "object", - "properties": {}, - "required": [], +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "finished" + SCHEMA = { + "type": "function", + "function": { + "name": "Finished", + "description": ( + "Declare that we are done with every single sub goal and no further work is needed." + ), + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, }, - }, -} + } -# Normalized tool name for lookup -NORM_NAME = "finished" + @classmethod + def execute(cls, coder): + """ + Mark that the current generation task needs no further effort. + This gives the LLM explicit control over when it can stop looping + """ -def _execute_finished(coder): - """ - Mark that the current generation task needs no further effort. + if coder: + coder.agent_finished = True + # coder.io.tool_output("Task Finished!") + return "Task Finished!" - This gives the LLM explicit control over when it can stop looping - """ - - if coder: - coder.agent_finished = True - # coder.io.tool_output("Task Finished!") - return "Task Finished!" - - # coder.io.tool_Error("Error: Could not mark agent task as finished") - return "Error: Could not mark agent task as finished" - - -def process_response(coder, params): - """ - Process the Finished tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters (should be empty for Finished) - - Returns: - str: Result message - """ - # Finished tool has no parameters to validate - return _execute_finished(coder) + # coder.io.tool_Error("Error: Could not mark agent task as finished") + return "Error: Could not mark agent task as finished" diff --git a/aider/tools/git_branch.py b/aider/tools/git_branch.py index 6a8d2c75d3d..4f3bd72cb68 100644 --- a/aider/tools/git_branch.py +++ b/aider/tools/git_branch.py @@ -1,129 +1,127 @@ from aider.repo import ANY_GIT_ERROR +from aider.tools.utils.base_tool import BaseTool -schema = { - "type": "function", - "function": { - "name": "GitBranch", - "description": ( - "List branches in the repository with various filtering and formatting options." - ), - "parameters": { - "type": "object", - "properties": { - "remotes": { - "type": "boolean", - "description": "List remote-tracking branches (-r/--remotes flag)", - }, - "all": { - "type": "boolean", - "description": "List both local and remote branches (-a/--all flag)", - }, - "verbose": { - "type": "boolean", - "description": ( - "Show verbose information including commit hash and subject (-v flag)" - ), - }, - "very_verbose": { - "type": "boolean", - "description": ( - "Show very verbose information including upstream branch (-vv flag)" - ), - }, - "merged": { - "type": "string", - "description": "Show branches merged into specified commit (--merged flag)", - }, - "no_merged": { - "type": "string", - "description": ( - "Show branches not merged into specified commit (--no-merged flag)" - ), - }, - "sort": { - "type": "string", - "description": ( - "Sort branches by key (committerdate, authordate, refname, etc.) (--sort" - " flag)" - ), - }, - "format": { - "type": "string", - "description": "Custom output format using placeholders (--format flag)", - }, - "show_current": { - "type": "boolean", - "description": "Show only current branch name (--show-current flag)", + +class Tool(BaseTool): + NORM_NAME = "gitbranch" + SCHEMA = { + "type": "function", + "function": { + "name": "GitBranch", + "description": ( + "List branches in the repository with various filtering and formatting options." + ), + "parameters": { + "type": "object", + "properties": { + "remotes": { + "type": "boolean", + "description": "List remote-tracking branches (-r/--remotes flag)", + }, + "all": { + "type": "boolean", + "description": "List both local and remote branches (-a/--all flag)", + }, + "verbose": { + "type": "boolean", + "description": ( + "Show verbose information including commit hash and subject (-v flag)" + ), + }, + "very_verbose": { + "type": "boolean", + "description": ( + "Show very verbose information including upstream branch (-vv flag)" + ), + }, + "merged": { + "type": "string", + "description": "Show branches merged into specified commit (--merged flag)", + }, + "no_merged": { + "type": "string", + "description": ( + "Show branches not merged into specified commit (--no-merged flag)" + ), + }, + "sort": { + "type": "string", + "description": ( + "Sort branches by key (committerdate, authordate, refname, etc.)" + " (--sort flag)" + ), + }, + "format": { + "type": "string", + "description": "Custom output format using placeholders (--format flag)", + }, + "show_current": { + "type": "boolean", + "description": "Show only current branch name (--show-current flag)", + }, }, + "required": [], }, - "required": [], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "gitbranch" + } + @classmethod + def execute( + cls, + coder, + remotes=False, + all=False, + verbose=False, + very_verbose=False, + merged=None, + no_merged=None, + sort=None, + format=None, + show_current=False, + ): + """ + List branches in the repository with various filtering and formatting options. + """ + if not coder.repo: + return "Not in a git repository." -def _execute_git_branch(coder, params=None): - """ - List branches in the repository with various filtering and formatting options. - """ - if not coder.repo: - return "Not in a git repository." + try: + # Build git command arguments + args = [] - try: - # Build git command arguments - args = [] - - # Handle boolean flags - if params: - if params.get("remotes"): + # Handle boolean flags + if remotes: args.append("--remotes") - if params.get("all"): + if all: args.append("--all") - if params.get("verbose"): + if verbose: args.append("--verbose") - if params.get("very_verbose"): + if very_verbose: args.append("--verbose") args.append("--verbose") - if params.get("show_current"): + if show_current: args.append("--show-current") # Handle string parameters - if params.get("merged"): - args.extend(["--merged", params["merged"]]) - if params.get("no_merged"): - args.extend(["--no-merged", params["no_merged"]]) - if params.get("sort"): - args.extend(["--sort", params["sort"]]) - if params.get("format"): - args.extend(["--format", params["format"]]) - - # Execute git command - result = coder.repo.repo.git.branch(*args) - - # If no result and show_current was used, get current branch directly - if not result and params and params.get("show_current"): - current_branch = coder.repo.repo.active_branch.name - return current_branch - - return result if result else "No branches found matching the criteria." - - except ANY_GIT_ERROR as e: - coder.io.tool_error(f"Error running git branch: {e}") - return f"Error running git branch: {e}" + if merged: + args.extend(["--merged", merged]) + if no_merged: + args.extend(["--no-merged", no_merged]) + if sort: + args.extend(["--sort", sort]) + if format: + args.extend(["--format", format]) + # Execute git command + result = coder.repo.repo.git.branch(*args) -def process_response(coder, params): - """ - Process the GitBranch tool response. + # If no result and show_current was used, get current branch directly + if not result and show_current: + current_branch = coder.repo.repo.active_branch.name + return current_branch - Args: - coder: The Coder instance - params: Dictionary of parameters for GitBranch + return result if result else "No branches found matching the criteria." - Returns: - str: Result message - """ - return _execute_git_branch(coder, params) + except ANY_GIT_ERROR as e: + coder.io.tool_error(f"Error running git branch: {e}") + return f"Error running git branch: {e}" diff --git a/aider/tools/git_diff.py b/aider/tools/git_diff.py index 6a7632b69ec..d90577853e1 100644 --- a/aider/tools/git_diff.py +++ b/aider/tools/git_diff.py @@ -1,60 +1,48 @@ from aider.repo import ANY_GIT_ERROR - -schema = { - "type": "function", - "function": { - "name": "GitDiff", - "description": ( - "Show the diff between the current working directory and a git branch or commit." - ), - "parameters": { - "type": "object", - "properties": { - "branch": { - "type": "string", - "description": "The branch or commit hash to diff against. Defaults to HEAD.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "gitdiff" + SCHEMA = { + "type": "function", + "function": { + "name": "GitDiff", + "description": ( + "Show the diff between the current working directory and a git branch or commit." + ), + "parameters": { + "type": "object", + "properties": { + "branch": { + "type": "string", + "description": ( + "The branch or commit hash to diff against. Defaults to HEAD." + ), + }, }, + "required": [], }, - "required": [], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "gitdiff" - - -def _execute_git_diff(coder, branch=None): - """ - Show the diff between the current working directory and a git branch or commit. - """ - if not coder.repo: - return "Not in a git repository." - - try: - if branch: - diff = coder.repo.diff_commits(False, branch, "HEAD") - else: - diff = coder.repo.diff_commits(False, "HEAD", None) - - if not diff: - return "No differences found." - return diff - except ANY_GIT_ERROR as e: - coder.io.tool_error(f"Error running git diff: {e}") - return f"Error running git diff: {e}" - - -def process_response(coder, params): - """ - Process the GitDiff tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - branch = params.get("branch") - return _execute_git_diff(coder, branch) + } + + @classmethod + def execute(cls, coder, branch=None): + """ + Show the diff between the current working directory and a git branch or commit. + """ + if not coder.repo: + return "Not in a git repository." + + try: + if branch: + diff = coder.repo.diff_commits(False, branch, "HEAD") + else: + diff = coder.repo.diff_commits(False, "HEAD", None) + + if not diff: + return "No differences found." + return diff + except ANY_GIT_ERROR as e: + coder.io.tool_error(f"Error running git diff: {e}") + return f"Error running git diff: {e}" diff --git a/aider/tools/git_log.py b/aider/tools/git_log.py index 4db90e4428d..93d6fcf748f 100644 --- a/aider/tools/git_log.py +++ b/aider/tools/git_log.py @@ -1,57 +1,43 @@ from aider.repo import ANY_GIT_ERROR - -schema = { - "type": "function", - "function": { - "name": "GitLog", - "description": "Show the git log.", - "parameters": { - "type": "object", - "properties": { - "limit": { - "type": "integer", - "description": "The maximum number of commits to show. Defaults to 10.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "gitlog" + SCHEMA = { + "type": "function", + "function": { + "name": "GitLog", + "description": "Show the git log.", + "parameters": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "The maximum number of commits to show. Defaults to 10.", + }, }, + "required": [], }, - "required": [], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "gitlog" - - -def _execute_git_log(coder, limit=10): - """ - Show the git log. - """ - if not coder.repo: - return "Not in a git repository." - - try: - commits = list(coder.repo.repo.iter_commits(max_count=limit)) - log_output = [] - for commit in commits: - short_hash = commit.hexsha[:8] - message = commit.message.strip().split("\n")[0] - log_output.append(f"{short_hash} {message}") - return "\n".join(log_output) - except ANY_GIT_ERROR as e: - coder.io.tool_error(f"Error running git log: {e}") - return f"Error running git log: {e}" - - -def process_response(coder, params): - """ - Process the GitLog tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - limit = params.get("limit", 10) - return _execute_git_log(coder, limit) + } + + @classmethod + def execute(cls, coder, limit=10): + """ + Show the git log. + """ + if not coder.repo: + return "Not in a git repository." + + try: + commits = list(coder.repo.repo.iter_commits(max_count=limit)) + log_output = [] + for commit in commits: + short_hash = commit.hexsha[:8] + message = commit.message.strip().split("\n")[0] + log_output.append(f"{short_hash} {message}") + return "\n".join(log_output) + except ANY_GIT_ERROR as e: + coder.io.tool_error(f"Error running git log: {e}") + return f"Error running git log: {e}" diff --git a/aider/tools/git_remote.py b/aider/tools/git_remote.py index 5f839683c17..edcf2fac464 100644 --- a/aider/tools/git_remote.py +++ b/aider/tools/git_remote.py @@ -1,53 +1,39 @@ from aider.repo import ANY_GIT_ERROR - -schema = { - "type": "function", - "function": { - "name": "GitRemote", - "description": "List remote repositories.", - "parameters": { - "type": "object", - "properties": {}, - "required": [], +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "gitremote" + SCHEMA = { + "type": "function", + "function": { + "name": "GitRemote", + "description": "List remote repositories.", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "gitremote" - - -def _execute_git_remote(coder): - """ - List remote repositories. - """ - if not coder.repo: - return "Not in a git repository." - - try: - remotes = coder.repo.repo.remotes - if not remotes: - return "No remotes configured." - - result = [] - for remote in remotes: - result.append(f"{remote.name}\t{remote.url}") - return "\n".join(result) - except ANY_GIT_ERROR as e: - coder.io.tool_error(f"Error running git remote: {e}") - return f"Error running git remote: {e}" - - -def process_response(coder, params): - """ - Process the GitRemote tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters (should be empty for GitRemote) - - Returns: - str: Result message - """ - # GitRemote tool has no parameters to validate - return _execute_git_remote(coder) + } + + @classmethod + def execute(cls, coder): + """ + List remote repositories. + """ + if not coder.repo: + return "Not in a git repository." + + try: + remotes = coder.repo.repo.remotes + if not remotes: + return "No remotes configured." + + result = [] + for remote in remotes: + result.append(f"{remote.name}\t{remote.url}") + return "\n".join(result) + except ANY_GIT_ERROR as e: + coder.io.tool_error(f"Error running git remote: {e}") + return f"Error running git remote: {e}" diff --git a/aider/tools/git_show.py b/aider/tools/git_show.py index 69a702b9a72..5a66e612e42 100644 --- a/aider/tools/git_show.py +++ b/aider/tools/git_show.py @@ -1,51 +1,37 @@ from aider.repo import ANY_GIT_ERROR - -schema = { - "type": "function", - "function": { - "name": "GitShow", - "description": "Show various types of objects (blobs, trees, tags, and commits).", - "parameters": { - "type": "object", - "properties": { - "object": { - "type": "string", - "description": "The object to show. Defaults to HEAD.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "gitshow" + SCHEMA = { + "type": "function", + "function": { + "name": "GitShow", + "description": "Show various types of objects (blobs, trees, tags, and commits).", + "parameters": { + "type": "object", + "properties": { + "object": { + "type": "string", + "description": "The object to show. Defaults to HEAD.", + }, }, + "required": [], }, - "required": [], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "gitshow" - - -def _execute_git_show(coder, object="HEAD"): - """ - Show various types of objects (blobs, trees, tags, and commits). - """ - if not coder.repo: - return "Not in a git repository." - - try: - return coder.repo.repo.git.show(object) - except ANY_GIT_ERROR as e: - coder.io.tool_error(f"Error running git show: {e}") - return f"Error running git show: {e}" - - -def process_response(coder, params): - """ - Process the GitShow tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - object = params.get("object", "HEAD") - return _execute_git_show(coder, object) + } + + @classmethod + def execute(cls, coder, object="HEAD"): + """ + Show various types of objects (blobs, trees, tags, and commits). + """ + if not coder.repo: + return "Not in a git repository." + + try: + return coder.repo.repo.git.show(object) + except ANY_GIT_ERROR as e: + coder.io.tool_error(f"Error running git show: {e}") + return f"Error running git show: {e}" diff --git a/aider/tools/git_status.py b/aider/tools/git_status.py index 3e1c855119a..a3b054f418e 100644 --- a/aider/tools/git_status.py +++ b/aider/tools/git_status.py @@ -1,46 +1,32 @@ from aider.repo import ANY_GIT_ERROR - -schema = { - "type": "function", - "function": { - "name": "GitStatus", - "description": "Show the working tree status.", - "parameters": { - "type": "object", - "properties": {}, - "required": [], +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "gitstatus" + SCHEMA = { + "type": "function", + "function": { + "name": "GitStatus", + "description": "Show the working tree status.", + "parameters": { + "type": "object", + "properties": {}, + "required": [], + }, }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "gitstatus" - - -def _execute_git_status(coder): - """ - Show the working tree status. - """ - if not coder.repo: - return "Not in a git repository." - - try: - return coder.repo.repo.git.status() - except ANY_GIT_ERROR as e: - coder.io.tool_error(f"Error running git status: {e}") - return f"Error running git status: {e}" - - -def process_response(coder, params): - """ - Process the GitStatus tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters (should be empty for GitStatus) - - Returns: - str: Result message - """ - # GitStatus tool has no parameters to validate - return _execute_git_status(coder) + } + + @classmethod + def execute(cls, coder): + """ + Show the working tree status. + """ + if not coder.repo: + return "Not in a git repository." + + try: + return coder.repo.repo.git.status() + except ANY_GIT_ERROR as e: + coder.io.tool_error(f"Error running git status: {e}") + return f"Error running git status: {e}" diff --git a/aider/tools/grep.py b/aider/tools/grep.py index 158e75826e6..0686c6d8b3f 100644 --- a/aider/tools/grep.py +++ b/aider/tools/grep.py @@ -4,253 +4,225 @@ import oslex from aider.run_cmd import run_cmd_subprocess - -schema = { - "type": "function", - "function": { - "name": "Grep", - "description": "Search for a pattern in files.", - "parameters": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "description": "The pattern to search for.", - }, - "file_pattern": { - "type": "string", - "description": "Glob pattern for files to search. Defaults to '*'.", - }, - "directory": { - "type": "string", - "description": "Directory to search in. Defaults to '.'.", - }, - "use_regex": { - "type": "boolean", - "description": "Whether to use regex. Defaults to False.", - }, - "case_insensitive": { - "type": "boolean", - "description": ( - "Whether to perform a case-insensitive search. Defaults to False." - ), - }, - "context_before": { - "type": "integer", - "description": "Number of lines to show before a match. Defaults to 5.", - }, - "context_after": { - "type": "integer", - "description": "Number of lines to show after a match. Defaults to 5.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "grep" + SCHEMA = { + "type": "function", + "function": { + "name": "Grep", + "description": "Search for a pattern in files.", + "parameters": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "The pattern to search for.", + }, + "file_pattern": { + "type": "string", + "description": "Glob pattern for files to search. Defaults to '*'.", + }, + "directory": { + "type": "string", + "description": "Directory to search in. Defaults to '.'.", + }, + "use_regex": { + "type": "boolean", + "description": "Whether to use regex. Defaults to False.", + }, + "case_insensitive": { + "type": "boolean", + "description": ( + "Whether to perform a case-insensitive search. Defaults to False." + ), + }, + "context_before": { + "type": "integer", + "description": "Number of lines to show before a match. Defaults to 5.", + }, + "context_after": { + "type": "integer", + "description": "Number of lines to show after a match. Defaults to 5.", + }, }, + "required": ["pattern"], }, - "required": ["pattern"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "grep" - - -def _find_search_tool(): - """Find the best available command-line search tool (rg, ag, grep).""" - if shutil.which("rg"): - return "rg", shutil.which("rg") - elif shutil.which("ag"): - return "ag", shutil.which("ag") - elif shutil.which("grep"): - return "grep", shutil.which("grep") - else: - return None, None - - -def _execute_grep( - coder, - pattern, - file_pattern="*", - directory=".", - use_regex=False, - case_insensitive=False, - context_before=5, - context_after=5, -): - """ - Search for lines matching a pattern in files within the project repository. - Uses rg (ripgrep), ag (the silver searcher), or grep, whichever is available. - - Args: - coder: The Coder instance. - pattern (str): The pattern to search for. - file_pattern (str, optional): Glob pattern to filter files. Defaults to "*". - directory (str, optional): Directory to search within relative to repo root. Defaults to ".". - use_regex (bool, optional): Whether the pattern is a regular expression. Defaults to False. - case_insensitive (bool, optional): Whether the search should be case-insensitive. Defaults to False. - context_before (int, optional): Number of context lines to show before matches. Defaults to 5. - context_after (int, optional): Number of context lines to show after matches. Defaults to 5. - - Returns: - str: Formatted result indicating success or failure, including matching lines or error message. - """ - repo = coder.repo - if not repo: - coder.io.tool_error("Not in a git repository.") - return "Error: Not in a git repository." - - tool_name, tool_path = _find_search_tool() - if not tool_path: - coder.io.tool_error("No search tool (rg, ag, grep) found in PATH.") - return "Error: No search tool (rg, ag, grep) found." - - try: - search_dir_path = Path(repo.root) / directory - if not search_dir_path.is_dir(): - coder.io.tool_error(f"Directory not found: {directory}") - return f"Error: Directory not found: {directory}" - - # Build the command arguments based on the available tool - cmd_args = [tool_path] - - # Common options or tool-specific equivalents - if tool_name in ["rg", "grep"]: - cmd_args.append("-n") # Line numbers for rg and grep - # ag includes line numbers by default - - if tool_name in ["rg"]: - cmd_args.append("--heading") # Filename above output for ripgrep - - # Context lines (Before and After) - if context_before > 0: - # All tools use -B for lines before - cmd_args.extend(["-B", str(context_before)]) - if context_after > 0: - # All tools use -A for lines after - cmd_args.extend(["-A", str(context_after)]) - - # Case sensitivity - if case_insensitive: - cmd_args.append("-i") # Add case-insensitivity flag for all tools - - # Pattern type (regex vs fixed string) - if use_regex: - if tool_name == "grep": - cmd_args.append("-E") # Use extended regex for grep - # rg and ag use regex by default, no flag needed for basic ERE + } + + @classmethod + def _find_search_tool(self): + """Find the best available command-line search tool (rg, ag, grep).""" + if shutil.which("rg"): + return "rg", shutil.which("rg") + elif shutil.which("ag"): + return "ag", shutil.which("ag") + elif shutil.which("grep"): + return "grep", shutil.which("grep") else: - if tool_name == "rg": - cmd_args.append("-F") # Fixed strings for rg - elif tool_name == "ag": - cmd_args.append("-Q") # Literal/fixed strings for ag - elif tool_name == "grep": - cmd_args.append("-F") # Fixed strings for grep - - # File filtering - if ( - file_pattern != "*" - ): # Avoid adding glob if it's the default '*' which might behave differently - if tool_name == "rg": - cmd_args.extend(["-g", file_pattern]) - elif tool_name == "ag": - cmd_args.extend(["-G", file_pattern]) + return None, None + + @classmethod + def execute( + cls, + coder, + pattern, + file_pattern="*", + directory=".", + use_regex=False, + case_insensitive=False, + context_before=5, + context_after=5, + ): + """ + Search for lines matching a pattern in files within the project repository. + Uses rg (ripgrep), ag (the silver searcher), or grep, whichever is available. + + Args: + coder: The Coder instance. + pattern (str): The pattern to search for. + file_pattern (str, optional): Glob pattern to filter files. Defaults to "*". + directory (str, optional): Directory to search within relative to repo root. Defaults to ".". + use_regex (bool, optional): Whether the pattern is a regular expression. Defaults to False. + case_insensitive (bool, optional): Whether the search should be case-insensitive. Defaults to False. + context_before (int, optional): Number of context lines to show before matches. Defaults to 5. + context_after (int, optional): Number of context lines to show after matches. Defaults to 5. + + Returns: + str: Formatted result indicating success or failure, including matching lines or error message. + """ + repo = coder.repo + if not repo: + coder.io.tool_error("Not in a git repository.") + return "Error: Not in a git repository." + + tool_name, tool_path = cls._find_search_tool() + if not tool_path: + coder.io.tool_error("No search tool (rg, ag, grep) found in PATH.") + return "Error: No search tool (rg, ag, grep) found." + + try: + search_dir_path = Path(repo.root) / directory + if not search_dir_path.is_dir(): + coder.io.tool_error(f"Directory not found: {directory}") + return f"Error: Directory not found: {directory}" + + # Build the command arguments based on the available tool + cmd_args = [tool_path] + + # Common options or tool-specific equivalents + if tool_name in ["rg", "grep"]: + cmd_args.append("-n") # Line numbers for rg and grep + # ag includes line numbers by default + + if tool_name in ["rg"]: + cmd_args.append("--heading") # Filename above output for ripgrep + + # Context lines (Before and After) + if context_before > 0: + # All tools use -B for lines before + cmd_args.extend(["-B", str(context_before)]) + if context_after > 0: + # All tools use -A for lines after + cmd_args.extend(["-A", str(context_after)]) + + # Case sensitivity + if case_insensitive: + cmd_args.append("-i") # Add case-insensitivity flag for all tools + + # Pattern type (regex vs fixed string) + if use_regex: + if tool_name == "grep": + cmd_args.append("-E") # Use extended regex for grep + # rg and ag use regex by default, no flag needed for basic ERE + else: + if tool_name == "rg": + cmd_args.append("-F") # Fixed strings for rg + elif tool_name == "ag": + cmd_args.append("-Q") # Literal/fixed strings for ag + elif tool_name == "grep": + cmd_args.append("-F") # Fixed strings for grep + + # File filtering + if ( + file_pattern != "*" + ): # Avoid adding glob if it's the default '*' which might behave differently + if tool_name == "rg": + cmd_args.extend(["-g", file_pattern]) + elif tool_name == "ag": + cmd_args.extend(["-G", file_pattern]) + elif tool_name == "grep": + # grep needs recursive flag when filtering + cmd_args.append("-r") + cmd_args.append(f"--include={file_pattern}") elif tool_name == "grep": - # grep needs recursive flag when filtering + # grep needs recursive flag even without include filter cmd_args.append("-r") - cmd_args.append(f"--include={file_pattern}") - elif tool_name == "grep": - # grep needs recursive flag even without include filter - cmd_args.append("-r") - - # Directory exclusion (rg and ag respect .gitignore/.git by default) - if tool_name == "grep": - cmd_args.append("--exclude-dir=.git") - - # Add pattern and directory path - cmd_args.extend([pattern, str(search_dir_path)]) - # Convert list to command string for run_cmd_subprocess - command_string = oslex.join(cmd_args) - - coder.io.tool_output(f"⚙️ Executing {tool_name}: {command_string}") - - # Use run_cmd_subprocess for execution - # Note: rg, ag, and grep return 1 if no matches are found, which is not an error for this tool. - exit_status, combined_output = run_cmd_subprocess( - command_string, verbose=coder.verbose, cwd=coder.root # Execute in the project root - ) - - # Format the output for the result message - output_content = combined_output or "" - - # Handle exit codes (consistent across rg, ag, grep) - if exit_status == 0: - # Limit output size if necessary - max_output_lines = 50 # Consider making this configurable - output_lines = output_content.splitlines() - if len(output_lines) > max_output_lines: - truncated_output = "\n".join(output_lines[:max_output_lines]) - result_message = ( - f"Found matches (truncated):\n```text\n{truncated_output}\n..." - f" ({len(output_lines) - max_output_lines} more lines)\n```" - ) - elif not output_content: - # Should not happen if return code is 0, but handle defensively - coder.io.tool_warning(f"{tool_name} returned 0 but produced no output.") - result_message = "No matches found (unexpected)." + # Directory exclusion (rg and ag respect .gitignore/.git by default) + if tool_name == "grep": + cmd_args.append("--exclude-dir=.git") + + # Add pattern and directory path + cmd_args.extend([pattern, str(search_dir_path)]) + + # Convert list to command string for run_cmd_subprocess + command_string = oslex.join(cmd_args) + + coder.io.tool_output(f"⚙️ Executing {tool_name}: {command_string}") + + # Use run_cmd_subprocess for execution + # Note: rg, ag, and grep return 1 if no matches are found, which is not an error for this tool. + exit_status, combined_output = run_cmd_subprocess( + command_string, verbose=coder.verbose, cwd=coder.root # Execute in the project root + ) + + # Format the output for the result message + output_content = combined_output or "" + + # Handle exit codes (consistent across rg, ag, grep) + if exit_status == 0: + # Limit output size if necessary + max_output_lines = 50 # Consider making this configurable + output_lines = output_content.splitlines() + if len(output_lines) > max_output_lines: + truncated_output = "\n".join(output_lines[:max_output_lines]) + result_message = ( + f"Found matches (truncated):\n```text\n{truncated_output}\n..." + f" ({len(output_lines) - max_output_lines} more lines)\n```" + ) + elif not output_content: + # Should not happen if return code is 0, but handle defensively + coder.io.tool_warning(f"{tool_name} returned 0 but produced no output.") + result_message = "No matches found (unexpected)." + else: + result_message = f"Found matches:\n```text\n{output_content}\n```" + return result_message + + elif exit_status == 1: + # Exit code 1 means no matches found - this is expected behavior, not an error. + return "No matches found." else: - result_message = f"Found matches:\n```text\n{output_content}\n```" - return result_message - - elif exit_status == 1: - # Exit code 1 means no matches found - this is expected behavior, not an error. - return "No matches found." - else: - # Exit code > 1 indicates an actual error - error_message = f"{tool_name.capitalize()} command failed with exit code {exit_status}." - if output_content: - # Truncate error output as well if it's too long - error_limit = 1000 # Example limit for error output - if len(output_content) > error_limit: - output_content = output_content[:error_limit] + "\n... (error output truncated)" - error_message += f" Output:\n{output_content}" - coder.io.tool_error(error_message) - return f"Error: {error_message}" - - except Exception as e: - # Add command_string to the error message if it's defined - cmd_str_info = f"'{command_string}' " if "command_string" in locals() else "" - coder.io.tool_error(f"Error executing {tool_name} command {cmd_str_info}: {str(e)}") - return f"Error executing {tool_name}: {str(e)}" - - -def process_response(coder, params): - """ - Process the Grep tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - pattern = params.get("pattern") - file_pattern = params.get("file_pattern", "*") # Default to all files - directory = params.get("directory", ".") # Default to current directory - use_regex = params.get("use_regex", False) # Default to literal search - case_insensitive = params.get("case_insensitive", False) # Default to case-sensitive - context_before = params.get("context_before", 5) - context_after = params.get("context_after", 5) - - if pattern is not None: - return _execute_grep( - coder, - pattern, - file_pattern, - directory, - use_regex, - case_insensitive, - context_before, - context_after, - ) - else: - return "Error: Missing required 'pattern' parameter for Grep" + # Exit code > 1 indicates an actual error + error_message = ( + f"{tool_name.capitalize()} command failed with exit code {exit_status}." + ) + if output_content: + # Truncate error output as well if it's too long + error_limit = 1000 # Example limit for error output + if len(output_content) > error_limit: + output_content = ( + output_content[:error_limit] + "\n... (error output truncated)" + ) + error_message += f" Output:\n{output_content}" + coder.io.tool_error(error_message) + return f"Error: {error_message}" + + except Exception as e: + # Add command_string to the error message if it's defined + cmd_str_info = f"'{command_string}' " if "command_string" in locals() else "" + coder.io.tool_error(f"Error executing {tool_name} command {cmd_str_info}: {str(e)}") + return f"Error executing {tool_name}: {str(e)}" diff --git a/aider/tools/indent_lines.py b/aider/tools/indent_lines.py index 6dd9380a48d..f232746825c 100644 --- a/aider/tools/indent_lines.py +++ b/aider/tools/indent_lines.py @@ -1,4 +1,5 @@ -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, determine_line_range, @@ -10,212 +11,185 @@ validate_file_for_edit, ) -schema = { - "type": "function", - "function": { - "name": "IndentLines", - "description": "Indent a block of lines in a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_pattern": {"type": "string"}, - "end_pattern": {"type": "string"}, - "line_count": {"type": "integer"}, - "indent_levels": {"type": "integer", "default": 1}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, + +class Tool(BaseTool): + NORM_NAME = "indentlines" + SCHEMA = { + "type": "function", + "function": { + "name": "IndentLines", + "description": "Indent a block of lines in a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "start_pattern": {"type": "string"}, + "end_pattern": {"type": "string"}, + "line_count": {"type": "integer"}, + "indent_levels": {"type": "integer", "default": 1}, + "near_context": {"type": "string"}, + "occurrence": {"type": "integer", "default": 1}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "start_pattern"], }, - "required": ["file_path", "start_pattern"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "indentlines" - - -def _execute_indent_lines( - coder, - file_path, - start_pattern, - end_pattern=None, - line_count=None, - indent_levels=1, - near_context=None, - occurrence=1, - change_id=None, - dry_run=False, -): - """ - Indent or unindent a block of lines in a file using utility functions. - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - start_pattern: Pattern marking the start of the block to indent (line containing this pattern) - - end_pattern: Optional pattern marking the end of the block (line containing this pattern) - - line_count: Optional number of lines to indent (alternative to end_pattern) - - indent_levels: Number of levels to indent (positive) or unindent (negative) - - near_context: Optional text nearby to help locate the correct instance of the start_pattern - - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - tool_name = "IndentLines" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - lines = original_content.splitlines() - - # 2. Find the start line - pattern_desc = f"Start pattern '{start_pattern}'" - if near_context: - pattern_desc += f" near context '{near_context}'" - start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) - start_line_idx = select_occurrence_index(start_pattern_indices, occurrence, pattern_desc) - - # 3. Determine the end line - start_line, end_line = determine_line_range( - coder=coder, - file_path=rel_path, - lines=lines, - start_pattern_line_index=start_line_idx, - end_pattern=end_pattern, - line_count=line_count, - target_symbol=None, # IndentLines uses patterns, not symbols - pattern_desc=pattern_desc, - ) - - # 4. Validate and prepare indentation + } + + @classmethod + def execute( + cls, + coder, + file_path, + start_pattern, + end_pattern=None, + line_count=None, + indent_levels=1, + near_context=None, + occurrence=1, + change_id=None, + dry_run=False, + ): + """ + Indent or unindent a block of lines in a file using utility functions. + + Parameters: + - coder: The Coder instance + - file_path: Path to the file to modify + - start_pattern: Pattern marking the start of the block to indent (line containing this pattern) + - end_pattern: Optional pattern marking the end of the block (line containing this pattern) + - line_count: Optional number of lines to indent (alternative to end_pattern) + - indent_levels: Number of levels to indent (positive) or unindent (negative) + - near_context: Optional text nearby to help locate the correct instance of the start_pattern + - occurrence: Which occurrence of the start_pattern to use (1-based index, or -1 for last) + - change_id: Optional ID for tracking the change + - dry_run: If True, simulate the change without modifying the file + + Returns a result message. + """ + tool_name = "IndentLines" try: - indent_levels = int(indent_levels) - except ValueError: - raise ToolError(f"Invalid indent_levels value: '{indent_levels}'. Must be an integer.") - - indent_str = " " * 4 # Assume 4 spaces per level - modified_lines = list(lines) - - # Apply indentation logic (core logic remains) - for i in range(start_line, end_line + 1): - if indent_levels > 0: - modified_lines[i] = (indent_str * indent_levels) + modified_lines[i] - elif indent_levels < 0: - spaces_to_remove = abs(indent_levels) * len(indent_str) - current_leading_spaces = len(modified_lines[i]) - len(modified_lines[i].lstrip(" ")) - actual_remove = min(spaces_to_remove, current_leading_spaces) - if actual_remove > 0: - modified_lines[i] = modified_lines[i][actual_remove:] - - new_content = "\n".join(modified_lines) - - if original_content == new_content: - coder.io.tool_warning("No changes made: indentation would not change file") - return "Warning: No changes made (indentation would not change file)" - - # 5. Generate diff for feedback - diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) - num_occurrences = len(start_pattern_indices) - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" - action = "indent" if indent_levels > 0 else "unindent" - levels = abs(indent_levels) - level_text = "level" if levels == 1 else "levels" - num_lines = end_line - start_line + 1 - - # 6. Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would {action} {num_lines} lines ({start_line + 1}-{end_line + 1}) by" - f" {levels} {level_text} (based on {occurrence_str}start pattern '{start_pattern}')" - f" in {file_path}." + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() + + # 2. Find the start line + pattern_desc = f"Start pattern '{start_pattern}'" + if near_context: + pattern_desc += f" near context '{near_context}'" + start_pattern_indices = find_pattern_indices(lines, start_pattern, near_context) + start_line_idx = select_occurrence_index( + start_pattern_indices, occurrence, pattern_desc + ) + + # 3. Determine the end line + start_line, end_line = determine_line_range( + coder=coder, + file_path=rel_path, + lines=lines, + start_pattern_line_index=start_line_idx, + end_pattern=end_pattern, + line_count=line_count, + target_symbol=None, # IndentLines uses patterns, not symbols + pattern_desc=pattern_desc, + ) + + # 4. Validate and prepare indentation + try: + indent_levels = int(indent_levels) + except ValueError: + raise ToolError( + f"Invalid indent_levels value: '{indent_levels}'. Must be an integer." + ) + + indent_str = " " * 4 # Assume 4 spaces per level + modified_lines = list(lines) + + # Apply indentation logic (core logic remains) + for i in range(start_line, end_line + 1): + if indent_levels > 0: + modified_lines[i] = (indent_str * indent_levels) + modified_lines[i] + elif indent_levels < 0: + spaces_to_remove = abs(indent_levels) * len(indent_str) + current_leading_spaces = len(modified_lines[i]) - len( + modified_lines[i].lstrip(" ") + ) + actual_remove = min(spaces_to_remove, current_leading_spaces) + if actual_remove > 0: + modified_lines[i] = modified_lines[i][actual_remove:] + + new_content = "\n".join(modified_lines) + + if original_content == new_content: + coder.io.tool_warning("No changes made: indentation would not change file") + return "Warning: No changes made (indentation would not change file)" + + # 5. Generate diff for feedback + diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + num_occurrences = len(start_pattern_indices) + occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + action = "indent" if indent_levels > 0 else "unindent" + levels = abs(indent_levels) + level_text = "level" if levels == 1 else "levels" + num_lines = end_line - start_line + 1 + + # 6. Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would {action} {num_lines} lines ({start_line + 1}-{end_line + 1})" + f" by {levels} {level_text} (based on {occurrence_str}start pattern" + f" '{start_pattern}') in {file_path}." + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) + + # 7. Apply Change (Not dry run) + metadata = { + "start_line": start_line + 1, + "end_line": end_line + 1, + "start_pattern": start_pattern, + "end_pattern": end_pattern, + "line_count": line_count, + "indent_levels": indent_levels, + "near_context": near_context, + "occurrence": occurrence, + } + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "indentlines", + metadata, + change_id, + ) + + coder.files_edited_by_tools.add(rel_path) + + # 8. Format and return result + action_past = "Indented" if indent_levels > 0 else "Unindented" + success_message = ( + f"{action_past} {num_lines} lines by {levels} {level_text} (from" + f" {occurrence_str}start pattern) in {file_path}" ) return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - - # 7. Apply Change (Not dry run) - metadata = { - "start_line": start_line + 1, - "end_line": end_line + 1, - "start_pattern": start_pattern, - "end_pattern": end_pattern, - "line_count": line_count, - "indent_levels": indent_levels, - "near_context": near_context, - "occurrence": occurrence, - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "indentlines", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # 8. Format and return result - action_past = "Indented" if indent_levels > 0 else "Unindented" - success_message = ( - f"{action_past} {num_lines} lines by {levels} {level_text} (from {occurrence_str}start" - f" pattern) in {file_path}" - ) - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the IndentLines tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - start_pattern = params.get("start_pattern") - end_pattern = params.get("end_pattern") - line_count = params.get("line_count") - indent_levels = params.get("indent_levels", 1) - near_context = params.get("near_context") - occurrence = params.get("occurrence", 1) - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and start_pattern is not None: - return _execute_indent_lines( - coder, - file_path, - start_pattern, - end_pattern, - line_count, - indent_levels, - near_context, - occurrence, - change_id, - dry_run, - ) - else: - return "Error: Missing required parameters for IndentLines (file_path, start_pattern)" + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/insert_block.py b/aider/tools/insert_block.py index a8fb622fe4f..6fc72835eab 100644 --- a/aider/tools/insert_block.py +++ b/aider/tools/insert_block.py @@ -1,7 +1,8 @@ import re import traceback -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, find_pattern_indices, @@ -11,278 +12,244 @@ select_occurrence_index, validate_file_for_edit, ) - -schema = { - "type": "function", - "function": { - "name": "InsertBlock", - "description": "Insert a block of content into a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "content": {"type": "string"}, - "after_pattern": {"type": "string"}, - "before_pattern": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, - "position": {"type": "string", "enum": ["top", "bottom"]}, - "auto_indent": {"type": "boolean", "default": True}, - "use_regex": {"type": "boolean", "default": False}, +from aider.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header + + +class Tool(BaseTool): + NORM_NAME = "insertblock" + SCHEMA = { + "type": "function", + "function": { + "name": "InsertBlock", + "description": "Insert a block of content into a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "content": {"type": "string"}, + "after_pattern": {"type": "string"}, + "before_pattern": {"type": "string"}, + "occurrence": {"type": "integer", "default": 1}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + "position": {"type": "string", "enum": ["top", "bottom"]}, + "auto_indent": {"type": "boolean", "default": True}, + "use_regex": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "content"], }, - "required": ["file_path", "content"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "insertblock" - - -def _execute_insert_block( - coder, - file_path, - content, - after_pattern=None, - before_pattern=None, - occurrence=1, - change_id=None, - dry_run=False, - position=None, - auto_indent=True, - use_regex=False, -): - """ - Insert a block of text after or before a specified pattern using utility functions. - - Args: - coder: The coder instance - file_path: Path to the file to modify - content: The content to insert - after_pattern: Pattern to insert after (mutually exclusive with before_pattern and position) - before_pattern: Pattern to insert before (mutually exclusive with after_pattern and position) - occurrence: Which occurrence of the pattern to use (1-based, or -1 for last) - change_id: Optional ID for tracking changes - dry_run: If True, only simulate the change - position: Special position like "start_of_file" or "end_of_file" - auto_indent: If True, automatically adjust indentation of inserted content - use_regex: If True, treat patterns as regular expressions - """ - tool_name = "InsertBlock" - try: - # 1. Validate parameters - if sum(x is not None for x in [after_pattern, before_pattern, position]) != 1: - raise ToolError( - "Must specify exactly one of: after_pattern, before_pattern, or position" - ) - - # 2. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - lines = original_content.splitlines() - - # Handle empty files - if not lines: - lines = [""] - - # 3. Determine insertion point - insertion_line_idx = 0 - pattern_type = "" - pattern_desc = "" - occurrence_str = "" - - if position: - # Handle special positions - if position == "start_of_file" or position == "top": - insertion_line_idx = 0 - pattern_type = "at start of" - elif position == "end_of_file" or position == "bottom": - insertion_line_idx = len(lines) - pattern_type = "at end of" - else: + } + + @classmethod + def execute( + cls, + coder, + file_path, + content, + after_pattern=None, + before_pattern=None, + occurrence=1, + change_id=None, + dry_run=False, + position=None, + auto_indent=True, + use_regex=False, + ): + """ + Insert a block of text after or before a specified pattern using utility functions. + + Args: + coder: The coder instance + file_path: Path to the file to modify + content: The content to insert + after_pattern: Pattern to insert after (mutually exclusive with before_pattern and position) + before_pattern: Pattern to insert before (mutually exclusive with after_pattern and position) + occurrence: Which occurrence of the pattern to use (1-based, or -1 for last) + change_id: Optional ID for tracking changes + dry_run: If True, only simulate the change + position: Special position like "start_of_file" or "end_of_file" + auto_indent: If True, automatically adjust indentation of inserted content + use_regex: If True, treat patterns as regular expressions + """ + tool_name = "InsertBlock" + try: + # 1. Validate parameters + if sum(x is not None for x in [after_pattern, before_pattern, position]) != 1: raise ToolError( - f"Invalid position: '{position}'. Valid values are 'start_of_file' or" - " 'end_of_file'" + "Must specify exactly one of: after_pattern, before_pattern, or position" ) - else: - # Handle pattern-based insertion - pattern = after_pattern if after_pattern else before_pattern - pattern_type = "after" if after_pattern else "before" - pattern_desc = f"Pattern '{pattern}'" - # Find pattern matches - pattern_line_indices = find_pattern_indices(lines, pattern, use_regex=use_regex) - - # Select the target occurrence - target_line_idx = select_occurrence_index( - pattern_line_indices, occurrence, pattern_desc - ) + # 2. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() - # Determine insertion point - insertion_line_idx = target_line_idx - if pattern_type == "after": - insertion_line_idx += 1 # Insert on the line *after* the matched line + # Handle empty files + if not lines: + lines = [""] - # Format occurrence info for output - num_occurrences = len(pattern_line_indices) - occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + # 3. Determine insertion point + insertion_line_idx = 0 + pattern_type = "" + pattern_desc = "" + occurrence_str = "" - # 4. Handle indentation if requested - content_lines = content.splitlines() - - if auto_indent and content_lines: - # Determine base indentation level - base_indent = "" - if insertion_line_idx > 0 and lines: - # Use indentation from the line before insertion point - reference_line_idx = min(insertion_line_idx - 1, len(lines) - 1) - reference_line = lines[reference_line_idx] - base_indent = re.match(r"^(\s*)", reference_line).group(1) - - # Apply indentation to content lines, preserving relative indentation - if content_lines: - # Find minimum indentation in content to preserve relative indentation - content_indents = [ - len(re.match(r"^(\s*)", line).group(1)) - for line in content_lines - if line.strip() - ] - min_content_indent = min(content_indents) if content_indents else 0 + if position: + # Handle special positions + if position == "start_of_file" or position == "top": + insertion_line_idx = 0 + pattern_type = "at start of" + elif position == "end_of_file" or position == "bottom": + insertion_line_idx = len(lines) + pattern_type = "at end of" + else: + raise ToolError( + f"Invalid position: '{position}'. Valid values are 'start_of_file' or" + " 'end_of_file'" + ) + else: + # Handle pattern-based insertion + pattern = after_pattern if after_pattern else before_pattern + pattern_type = "after" if after_pattern else "before" + pattern_desc = f"Pattern '{pattern}'" - # Apply base indentation while preserving relative indentation - indented_content_lines = [] - for line in content_lines: - if not line.strip(): # Empty or whitespace-only line - indented_content_lines.append("") - else: - # Remove existing indentation and add new base indentation - stripped_line = ( - line[min_content_indent:] if min_content_indent <= len(line) else line - ) - indented_content_lines.append(base_indent + stripped_line) + # Find pattern matches + pattern_line_indices = find_pattern_indices(lines, pattern, use_regex=use_regex) - content_lines = indented_content_lines + # Select the target occurrence + target_line_idx = select_occurrence_index( + pattern_line_indices, occurrence, pattern_desc + ) - # 5. Prepare the insertion - new_lines = lines[:insertion_line_idx] + content_lines + lines[insertion_line_idx:] - new_content = "\n".join(new_lines) + # Determine insertion point + insertion_line_idx = target_line_idx + if pattern_type == "after": + insertion_line_idx += 1 # Insert on the line *after* the matched line + + # Format occurrence info for output + num_occurrences = len(pattern_line_indices) + occurrence_str = f"occurrence {occurrence} of " if num_occurrences > 1 else "" + + # 4. Handle indentation if requested + content_lines = content.splitlines() + + if auto_indent and content_lines: + # Determine base indentation level + base_indent = "" + if insertion_line_idx > 0 and lines: + # Use indentation from the line before insertion point + reference_line_idx = min(insertion_line_idx - 1, len(lines) - 1) + reference_line = lines[reference_line_idx] + base_indent = re.match(r"^(\s*)", reference_line).group(1) + + # Apply indentation to content lines, preserving relative indentation + if content_lines: + # Find minimum indentation in content to preserve relative indentation + content_indents = [ + len(re.match(r"^(\s*)", line).group(1)) + for line in content_lines + if line.strip() + ] + min_content_indent = min(content_indents) if content_indents else 0 + + # Apply base indentation while preserving relative indentation + indented_content_lines = [] + for line in content_lines: + if not line.strip(): # Empty or whitespace-only line + indented_content_lines.append("") + else: + # Remove existing indentation and add new base indentation + stripped_line = ( + line[min_content_indent:] + if min_content_indent <= len(line) + else line + ) + indented_content_lines.append(base_indent + stripped_line) + + content_lines = indented_content_lines + + # 5. Prepare the insertion + new_lines = lines[:insertion_line_idx] + content_lines + lines[insertion_line_idx:] + new_content = "\n".join(new_lines) + + if original_content == new_content: + coder.io.tool_warning("No changes made: insertion would not change file") + return "Warning: No changes made (insertion would not change file)" + + # 6. Generate diff for feedback + diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + + # 7. Handle dry run + if dry_run: + if position: + dry_run_message = f"Dry run: Would insert block {pattern_type} {file_path}." + else: + dry_run_message = ( + f"Dry run: Would insert block {pattern_type} {occurrence_str}pattern" + f" '{pattern}' in {file_path} at line {insertion_line_idx + 1}." + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) - if original_content == new_content: - coder.io.tool_warning("No changes made: insertion would not change file") - return "Warning: No changes made (insertion would not change file)" + # 8. Apply Change (Not dry run) + metadata = { + "insertion_line_idx": insertion_line_idx, + "after_pattern": after_pattern, + "before_pattern": before_pattern, + "position": position, + "occurrence": occurrence, + "content": content, + "auto_indent": auto_indent, + "use_regex": use_regex, + } + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "insertblock", + metadata, + change_id, + ) - # 6. Generate diff for feedback - diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + coder.files_edited_by_tools.add(rel_path) - # 7. Handle dry run - if dry_run: + # 9. Format and return result if position: - dry_run_message = f"Dry run: Would insert block {pattern_type} {file_path}." + success_message = f"Inserted block {pattern_type} {file_path}" else: - dry_run_message = ( - f"Dry run: Would insert block {pattern_type} {occurrence_str}pattern" - f" '{pattern}' in {file_path} at line {insertion_line_idx + 1}." + success_message = ( + f"Inserted block {pattern_type} {occurrence_str}pattern in {file_path} at line" + f" {insertion_line_idx + 1}" ) + return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # 8. Apply Change (Not dry run) - metadata = { - "insertion_line_idx": insertion_line_idx, - "after_pattern": after_pattern, - "before_pattern": before_pattern, - "position": position, - "occurrence": occurrence, - "content": content, - "auto_indent": auto_indent, - "use_regex": use_regex, - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "insertblock", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # 9. Format and return result - if position: - success_message = f"Inserted block {pattern_type} {file_path}" - else: - success_message = ( - f"Inserted block {pattern_type} {occurrence_str}pattern in {file_path} at line" - f" {insertion_line_idx + 1}" - ) - - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - - except Exception as e: - coder.io.tool_error( - f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}" - ) # Add traceback - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the InsertBlock tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - content = params.get("content") - after_pattern = params.get("after_pattern") - before_pattern = params.get("before_pattern") - occurrence = params.get("occurrence", 1) - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - position = params.get("position") - auto_indent = params.get("auto_indent", True) - use_regex = params.get("use_regex", False) - - if ( - file_path is not None - and content is not None - and (after_pattern is not None or before_pattern is not None or position is not None) - ): - return _execute_insert_block( - coder, - file_path, - content, - after_pattern, - before_pattern, - occurrence, - change_id, - dry_run, - position, - auto_indent, - use_regex, - ) - - else: - return ( - "Error: Missing required parameters for InsertBlock (file_path," - " content, and either after_pattern or before_pattern)" - ) + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) + + except Exception as e: + coder.io.tool_error( + f"Error in InsertBlock: {str(e)}\n{traceback.format_exc()}" + ) # Add traceback + return f"Error: {str(e)}" + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + tool_body_unwrapped(coder=coder, tool_response=tool_response) + tool_footer(coder=coder, tool_response=tool_response) diff --git a/aider/tools/list_changes.py b/aider/tools/list_changes.py index 1a1b054c452..234d6bac543 100644 --- a/aider/tools/list_changes.py +++ b/aider/tools/list_changes.py @@ -1,86 +1,71 @@ import traceback from datetime import datetime -schema = { - "type": "function", - "function": { - "name": "ListChanges", - "description": "List recent changes made.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "limit": {"type": "integer", "default": 10}, +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "listchanges" + SCHEMA = { + "type": "function", + "function": { + "name": "ListChanges", + "description": "List recent changes made.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "limit": {"type": "integer", "default": 10}, + }, }, }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "listchanges" - - -def _execute_list_changes(coder, file_path=None, limit=10): - """ - List recent changes made to files. - - Parameters: - - coder: The Coder instance - - file_path: Optional path to filter changes by file - - limit: Maximum number of changes to list - - Returns a formatted list of changes. - """ - try: - # If file_path is specified, get the absolute path - rel_file_path = None - if file_path: - abs_path = coder.abs_root_path(file_path) - rel_file_path = coder.get_rel_fname(abs_path) - - # Get the list of changes - changes = coder.change_tracker.list_changes(rel_file_path, limit) - - if not changes: + } + + @classmethod + def execute(cls, coder, file_path=None, limit=10): + """ + List recent changes made to files. + + Parameters: + - coder: The Coder instance + - file_path: Optional path to filter changes by file + - limit: Maximum number of changes to list + + Returns a formatted list of changes. + """ + try: + # If file_path is specified, get the absolute path + rel_file_path = None if file_path: - return f"No changes found for file '{file_path}'" - else: - return "No changes have been made yet" - - # Format the changes into a readable list - result = "Recent changes:\n" - for i, change in enumerate(changes): - change_time = datetime.fromtimestamp(change["timestamp"]).strftime("%H:%M:%S") - change_type = change["type"] - file_path = change["file_path"] - change_id = change["id"] - - result += ( - f"{i + 1}. [{change_id}] {change_time} - {change_type.upper()} on {file_path}\n" - ) - - coder.io.tool_output(result) # Also print to console for user - return result - - except Exception as e: - coder.io.tool_error( - f"Error in ListChanges: {str(e)}\n{traceback.format_exc()}" - ) # Add traceback - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the ListChanges tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - limit = params.get("limit", 10) - - return _execute_list_changes(coder, file_path, limit) + abs_path = coder.abs_root_path(file_path) + rel_file_path = coder.get_rel_fname(abs_path) + + # Get the list of changes + changes = coder.change_tracker.list_changes(rel_file_path, limit) + + if not changes: + if file_path: + return f"No changes found for file '{file_path}'" + else: + return "No changes have been made yet" + + # Format the changes into a readable list + result = "Recent changes:\n" + for i, change in enumerate(changes): + change_time = datetime.fromtimestamp(change["timestamp"]).strftime("%H:%M:%S") + change_type = change["type"] + file_path = change["file_path"] + change_id = change["id"] + + result += ( + f"{i + 1}. [{change_id}] {change_time} - {change_type.upper()} on {file_path}\n" + ) + + coder.io.tool_output(result) # Also print to console for user + return result + + except Exception as e: + coder.io.tool_error( + f"Error in ListChanges: {str(e)}\n{traceback.format_exc()}" + ) # Add traceback + return f"Error: {str(e)}" diff --git a/aider/tools/ls.py b/aider/tools/ls.py index 96119d9f4ff..96d489e5323 100644 --- a/aider/tools/ls.py +++ b/aider/tools/ls.py @@ -1,93 +1,77 @@ import os -schema = { - "type": "function", - "function": { - "name": "Ls", - "description": "List files in a directory.", - "parameters": { - "type": "object", - "properties": { - "directory": { - "type": "string", - "description": "The directory to list.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "ls" + SCHEMA = { + "type": "function", + "function": { + "name": "Ls", + "description": "List files in a directory.", + "parameters": { + "type": "object", + "properties": { + "directory": { + "type": "string", + "description": "The directory to list.", + }, }, + "required": ["directory"], }, - "required": ["directory"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "ls" - - -def execute_ls(coder, dir_path=None, directory=None): - # Handle both positional and keyword arguments for backward compatibility - if dir_path is None and directory is not None: - dir_path = directory - elif dir_path is None: - return "Error: Missing directory parameter" - """ - List files in directory and optionally add some to context. + } - This provides information about the structure of the codebase, - similar to how a developer would explore directories. - """ - try: - # Make the path relative to root if it's absolute - if dir_path.startswith("/"): - rel_dir = os.path.relpath(dir_path, coder.root) - else: - rel_dir = dir_path + @classmethod + def execute(cls, coder, dir_path=None, directory=None): + # Handle both positional and keyword arguments for backward compatibility + if dir_path is None and directory is not None: + dir_path = directory + elif dir_path is None: + return "Error: Missing directory parameter" + """ + List files in directory and optionally add some to context. - # Get absolute path - abs_dir = coder.abs_root_path(rel_dir) - - # Check if path exists - if not os.path.exists(abs_dir): - coder.io.tool_output(f"⚠️ Directory '{dir_path}' not found") - return "Directory not found" - - # Get directory contents - contents = [] + This provides information about the structure of the codebase, + similar to how a developer would explore directories. + """ try: - with os.scandir(abs_dir) as entries: - for entry in entries: - if entry.is_file() and not entry.name.startswith("."): - rel_path = os.path.join(rel_dir, entry.name) - contents.append(rel_path) - except NotADirectoryError: - # If it's a file, just return the file - contents = [rel_dir] - - if contents: - coder.io.tool_output(f"📋 Listed {len(contents)} file(s) in '{dir_path}'") - if len(contents) > 10: - return f"Found {len(contents)} files: {', '.join(contents[:10])}..." + # Make the path relative to root if it's absolute + if dir_path.startswith("/"): + rel_dir = os.path.relpath(dir_path, coder.root) else: - return f"Found {len(contents)} files: {', '.join(contents)}" - else: - coder.io.tool_output(f"📋 No files found in '{dir_path}'") - return "No files found in directory" - except Exception as e: - coder.io.tool_error(f"Error in ls: {str(e)}") - return f"Error: {str(e)}" + rel_dir = dir_path + # Get absolute path + abs_dir = coder.abs_root_path(rel_dir) -def process_response(coder, params): - """ - Process the Ls tool response. + # Check if path exists + if not os.path.exists(abs_dir): + coder.io.tool_output(f"⚠️ Directory '{dir_path}' not found") + return "Directory not found" - Args: - coder: The Coder instance - params: Dictionary of parameters + # Get directory contents + contents = [] + try: + with os.scandir(abs_dir) as entries: + for entry in entries: + if entry.is_file() and not entry.name.startswith("."): + rel_path = os.path.join(rel_dir, entry.name) + contents.append(rel_path) + except NotADirectoryError: + # If it's a file, just return the file + contents = [rel_dir] - Returns: - str: Result message - """ - directory = params.get("directory") - if directory is not None: - return execute_ls(coder, directory) - else: - return "Error: Missing 'directory' parameter for Ls" + if contents: + coder.io.tool_output(f"📋 Listed {len(contents)} file(s) in '{dir_path}'") + if len(contents) > 10: + return f"Found {len(contents)} files: {', '.join(contents[:10])}..." + else: + return f"Found {len(contents)} files: {', '.join(contents)}" + else: + coder.io.tool_output(f"📋 No files found in '{dir_path}'") + return "No files found in directory" + except Exception as e: + coder.io.tool_error(f"Error in ls: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/make_editable.py b/aider/tools/make_editable.py index f84c9831cf3..cdd51daa732 100644 --- a/aider/tools/make_editable.py +++ b/aider/tools/make_editable.py @@ -1,85 +1,69 @@ import os -schema = { - "type": "function", - "function": { - "name": "MakeEditable", - "description": "Make a read-only file editable.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The path to the file to make editable.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "makeeditable" + SCHEMA = { + "type": "function", + "function": { + "name": "MakeEditable", + "description": "Make a read-only file editable.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The path to the file to make editable.", + }, }, + "required": ["file_path"], }, - "required": ["file_path"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "makeeditable" - - -# Keep the underscore prefix as this function is primarily for internal coder use -def _execute_make_editable(coder, file_path): - """ - Convert a read-only file to an editable file. - - This allows the LLM to upgrade a file from read-only to editable - when it determines it needs to make changes to that file. - """ - try: - # Get absolute path - abs_path = coder.abs_root_path(file_path) - - # Check if file is already editable - if abs_path in coder.abs_fnames: - coder.io.tool_output(f"📝 File '{file_path}' is already editable") - return "File is already editable" - - # Check if file exists on disk - if not os.path.isfile(abs_path): - coder.io.tool_output(f"⚠️ File '{file_path}' not found") - return "Error: File not found" + } - # File exists, is not editable, might be read-only or not in context yet - was_read_only = False - if abs_path in coder.abs_read_only_fnames: - coder.abs_read_only_fnames.remove(abs_path) - was_read_only = True + # Keep the underscore prefix as this function is primarily for internal coder use + @classmethod + def execute(cls, coder, file_path): + """ + Convert a read-only file to an editable file. - # Add to editable files - coder.abs_fnames.add(abs_path) + This allows the LLM to upgrade a file from read-only to editable + when it determines it needs to make changes to that file. + """ + try: + # Get absolute path + abs_path = coder.abs_root_path(file_path) - if was_read_only: - coder.io.tool_output(f"📝 Moved '{file_path}' from read-only to editable") - return "File is now editable (moved from read-only)" - else: - # File was not previously in context at all - coder.io.tool_output(f"📝 Added '{file_path}' directly to editable context") - # Track if added during exploration? Maybe not needed for direct MakeEditable. - # coder.files_added_in_exploration.add(rel_path) # Consider if needed - return "File is now editable (added directly)" - except Exception as e: - coder.io.tool_error(f"Error in MakeEditable for '{file_path}': {str(e)}") - return f"Error: {str(e)}" + # Check if file is already editable + if abs_path in coder.abs_fnames: + coder.io.tool_output(f"📝 File '{file_path}' is already editable") + return "File is already editable" + # Check if file exists on disk + if not os.path.isfile(abs_path): + coder.io.tool_output(f"⚠️ File '{file_path}' not found") + return "Error: File not found" -def process_response(coder, params): - """ - Process the MakeEditable tool response. + # File exists, is not editable, might be read-only or not in context yet + was_read_only = False + if abs_path in coder.abs_read_only_fnames: + coder.abs_read_only_fnames.remove(abs_path) + was_read_only = True - Args: - coder: The Coder instance - params: Dictionary of parameters + # Add to editable files + coder.abs_fnames.add(abs_path) - Returns: - str: Result message - """ - file_path = params.get("file_path") - if file_path is not None: - return _execute_make_editable(coder, file_path) - else: - return "Error: Missing 'file_path' parameter for MakeEditable" + if was_read_only: + coder.io.tool_output(f"📝 Moved '{file_path}' from read-only to editable") + return "File is now editable (moved from read-only)" + else: + # File was not previously in context at all + coder.io.tool_output(f"📝 Added '{file_path}' directly to editable context") + # Track if added during exploration? Maybe not needed for direct MakeEditable. + # coder.files_added_in_exploration.add(rel_path) # Consider if needed + return "File is now editable (added directly)" + except Exception as e: + coder.io.tool_error(f"Error in MakeEditable for '{file_path}': {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/make_readonly.py b/aider/tools/make_readonly.py index 3dc3247f627..33095b02047 100644 --- a/aider/tools/make_readonly.py +++ b/aider/tools/make_readonly.py @@ -1,69 +1,53 @@ -schema = { - "type": "function", - "function": { - "name": "MakeReadonly", - "description": "Make an editable file read-only.", - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The path to the file to make read-only.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "makereadonly" + SCHEMA = { + "type": "function", + "function": { + "name": "MakeReadonly", + "description": "Make an editable file read-only.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The path to the file to make read-only.", + }, }, + "required": ["file_path"], }, - "required": ["file_path"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "makereadonly" - - -def _execute_make_readonly(coder, file_path): - """ - Convert an editable file to a read-only file. - - This allows the LLM to downgrade a file from editable to read-only - when it determines it no longer needs to make changes to that file. - """ - try: - # Get absolute path - abs_path = coder.abs_root_path(file_path) - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - coder.io.tool_output(f"📚 File '{file_path}' is already read-only") - return "File is already read-only" - else: - coder.io.tool_output(f"⚠️ File '{file_path}' not in context") - return "File not in context" - - # Move from editable to read-only - coder.abs_fnames.remove(abs_path) - coder.abs_read_only_fnames.add(abs_path) - - coder.io.tool_output(f"📚 Made '{file_path}' read-only") - return "File is now read-only" - except Exception as e: - coder.io.tool_error(f"Error making file read-only: {str(e)}") - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the MakeReadonly tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - if file_path is not None: - return _execute_make_readonly(coder, file_path) - else: - return "Error: Missing 'file_path' parameter for MakeReadonly" + } + + @classmethod + def execute(cls, coder, file_path): + """ + Convert an editable file to a read-only file. + + This allows the LLM to downgrade a file from editable to read-only + when it determines it no longer needs to make changes to that file. + """ + try: + # Get absolute path + abs_path = coder.abs_root_path(file_path) + + # Check if file is in editable context + if abs_path not in coder.abs_fnames: + if abs_path in coder.abs_read_only_fnames: + coder.io.tool_output(f"📚 File '{file_path}' is already read-only") + return "File is already read-only" + else: + coder.io.tool_output(f"⚠️ File '{file_path}' not in context") + return "File not in context" + + # Move from editable to read-only + coder.abs_fnames.remove(abs_path) + coder.abs_read_only_fnames.add(abs_path) + + coder.io.tool_output(f"📚 Made '{file_path}' read-only") + return "File is now read-only" + except Exception as e: + coder.io.tool_error(f"Error making file read-only: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/remove.py b/aider/tools/remove.py index d236eef2a5b..b8a861447cc 100644 --- a/aider/tools/remove.py +++ b/aider/tools/remove.py @@ -1,91 +1,75 @@ import time -schema = { - "type": "function", - "function": { - "name": "Remove", - "description": ( - "Remove a file from the chat context. Should be used proactively to keep con" - "Should be used after editing a file when all edits are done " - "and the file is no longer necessary in context." - ), - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The path to the file to remove.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "remove" + SCHEMA = { + "type": "function", + "function": { + "name": "Remove", + "description": ( + "Remove a file from the chat context. Should be used proactively to keep con" + "Should be used after editing a file when all edits are done " + "and the file is no longer necessary in context." + ), + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The path to the file to remove.", + }, }, + "required": ["file_path"], }, - "required": ["file_path"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "remove" - - -def _execute_remove(coder, file_path): - """ - Explicitly remove a file from context. - - This allows the LLM to clean up its context when files are no - longer needed, keeping the context focused and efficient. - """ - try: - # Get absolute path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file is in context (either editable or read-only) - removed = False - if abs_path in coder.abs_fnames: - # Don't remove if it's the last editable file and there are no read-only files - if len(coder.abs_fnames) <= 1 and not coder.abs_read_only_fnames: - coder.io.tool_output( - f"⚠️ Cannot remove '{file_path}' - it's the only file in context" - ) - return "Cannot remove - last file in context" - coder.abs_fnames.remove(abs_path) - removed = True - elif abs_path in coder.abs_read_only_fnames: - # Don't remove if it's the last read-only file and there are no editable files - if len(coder.abs_read_only_fnames) <= 1 and not coder.abs_fnames: - coder.io.tool_output( - f"⚠️ Cannot remove '{file_path}' - it's the only file in context" - ) - return "Cannot remove - last file in context" - coder.abs_read_only_fnames.remove(abs_path) - removed = True - - if not removed: - coder.io.tool_output(f"⚠️ File '{file_path}' not in context") - return "File not in context" + } - # Track in recently removed - coder.recently_removed[rel_path] = {"removed_at": time.time()} + @classmethod + def execute(cls, coder, file_path): + """ + Explicitly remove a file from context. - coder.io.tool_output(f"🗑️ Explicitly removed '{file_path}' from context") - return "Removed file from context" - except Exception as e: - coder.io.tool_error(f"Error removing file: {str(e)}") - return f"Error: {str(e)}" + This allows the LLM to clean up its context when files are no + longer needed, keeping the context focused and efficient. + """ + try: + # Get absolute path + abs_path = coder.abs_root_path(file_path) + rel_path = coder.get_rel_fname(abs_path) + # Check if file is in context (either editable or read-only) + removed = False + if abs_path in coder.abs_fnames: + # Don't remove if it's the last editable file and there are no read-only files + if len(coder.abs_fnames) <= 1 and not coder.abs_read_only_fnames: + coder.io.tool_output( + f"⚠️ Cannot remove '{file_path}' - it's the only file in context" + ) + return "Cannot remove - last file in context" + coder.abs_fnames.remove(abs_path) + removed = True + elif abs_path in coder.abs_read_only_fnames: + # Don't remove if it's the last read-only file and there are no editable files + if len(coder.abs_read_only_fnames) <= 1 and not coder.abs_fnames: + coder.io.tool_output( + f"⚠️ Cannot remove '{file_path}' - it's the only file in context" + ) + return "Cannot remove - last file in context" + coder.abs_read_only_fnames.remove(abs_path) + removed = True -def process_response(coder, params): - """ - Process the Remove tool response. + if not removed: + coder.io.tool_output(f"⚠️ File '{file_path}' not in context") + return "File not in context" - Args: - coder: The Coder instance - params: Dictionary of parameters + # Track in recently removed + coder.recently_removed[rel_path] = {"removed_at": time.time()} - Returns: - str: Result message - """ - file_path = params.get("file_path") - if file_path is not None: - return _execute_remove(coder, file_path) - else: - return "Error: Missing 'file_path' parameter for Remove" + coder.io.tool_output(f"🗑️ Explicitly removed '{file_path}' from context") + return "Removed file from context" + except Exception as e: + coder.io.tool_error(f"Error removing file: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/replace_all.py b/aider/tools/replace_all.py index ca6fdaa7b0d..99ed0837f63 100644 --- a/aider/tools/replace_all.py +++ b/aider/tools/replace_all.py @@ -1,4 +1,5 @@ -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, format_tool_result, @@ -6,121 +7,107 @@ handle_tool_error, validate_file_for_edit, ) - -schema = { - "type": "function", - "function": { - "name": "ReplaceAll", - "description": "Replace all occurrences of text in a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "find_text": {"type": "string"}, - "replace_text": {"type": "string"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, +from aider.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header + + +class Tool(BaseTool): + NORM_NAME = "replaceall" + SCHEMA = { + "type": "function", + "function": { + "name": "ReplaceAll", + "description": "Replace all occurrences of text in a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "find_text": {"type": "string"}, + "replace_text": {"type": "string"}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "find_text", "replace_text"], }, - "required": ["file_path", "find_text", "replace_text"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "replaceall" - - -def _execute_replace_all(coder, file_path, find_text, replace_text, change_id=None, dry_run=False): - """ - Replace all occurrences of text in a file using utility functions. - """ - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - tool_name = "ReplaceAll" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - - # 2. Count occurrences - count = original_content.count(find_text) - if count == 0: - coder.io.tool_warning(f"Text '{find_text}' not found in file '{file_path}'") - return "Warning: Text not found in file" - - # 3. Perform the replacement - new_content = original_content.replace(find_text, replace_text) - - if original_content == new_content: - coder.io.tool_warning("No changes made: replacement text is identical to original") - return "Warning: No changes made (replacement identical to original)" + } + + @classmethod + def execute(cls, coder, file_path, find_text, replace_text, change_id=None, dry_run=False): + """ + Replace all occurrences of text in a file using utility functions. + """ + # Get absolute file path + abs_path = coder.abs_root_path(file_path) + rel_path = coder.get_rel_fname(abs_path) + tool_name = "ReplaceAll" + try: + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + + # 2. Count occurrences + count = original_content.count(find_text) + if count == 0: + coder.io.tool_warning(f"Text '{find_text}' not found in file '{file_path}'") + return "Warning: Text not found in file" + + # 3. Perform the replacement + new_content = original_content.replace(find_text, replace_text) + + if original_content == new_content: + coder.io.tool_warning("No changes made: replacement text is identical to original") + return "Warning: No changes made (replacement identical to original)" + + # 4. Generate diff for feedback + diff_examples = generate_unified_diff_snippet(original_content, new_content, rel_path) + + # 5. Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would replace {count} occurrences of '{find_text}' in {file_path}." + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_examples, + ) + + # 6. Apply Change (Not dry run) + metadata = {"find_text": find_text, "replace_text": replace_text, "occurrences": count} + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "replaceall", + metadata, + change_id, + ) - # 4. Generate diff for feedback - diff_examples = generate_unified_diff_snippet(original_content, new_content, rel_path) + coder.files_edited_by_tools.add(rel_path) - # 5. Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would replace {count} occurrences of '{find_text}' in {file_path}." - ) + # 7. Format and return result + success_message = f"Replaced {count} occurrences in {file_path}" return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_examples, ) - # 6. Apply Change (Not dry run) - metadata = {"find_text": find_text, "replace_text": replace_text, "occurrences": count} - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "replaceall", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - - # 7. Format and return result - success_message = f"Replaced {count} occurrences in {file_path}" - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_examples - ) - - except ToolError as e: - # Handle errors raised by utility functions - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the ReplaceAll tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - find_text = params.get("find_text") - replace_text = params.get("replace_text") - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and find_text is not None and replace_text is not None: - return _execute_replace_all(coder, file_path, find_text, replace_text, change_id, dry_run) - else: - return ( - "Error: Missing required parameters for ReplaceAll (file_path, find_text, replace_text)" - ) + except ToolError as e: + # Handle errors raised by utility functions + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + tool_body_unwrapped(coder=coder, tool_response=tool_response) + tool_footer(coder=coder, tool_response=tool_response) diff --git a/aider/tools/replace_line.py b/aider/tools/replace_line.py index 1e45497ab00..81cba75c0d2 100644 --- a/aider/tools/replace_line.py +++ b/aider/tools/replace_line.py @@ -1,173 +1,135 @@ -import os import traceback -schema = { - "type": "function", - "function": { - "name": "ReplaceLine", - "description": "Replace a single line in a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "line_number": {"type": "integer"}, - "new_content": {"type": "string"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ToolError, validate_file_for_edit +from aider.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header + + +class Tool(BaseTool): + NORM_NAME = "replaceline" + SCHEMA = { + "type": "function", + "function": { + "name": "ReplaceLine", + "description": "Replace a single line in a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "line_number": {"type": "integer"}, + "new_content": {"type": "string"}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "line_number", "new_content"], }, - "required": ["file_path", "line_number", "new_content"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "replaceline" - - -def _execute_replace_line( - coder, file_path, line_number, new_content, change_id=None, dry_run=False -): - """ - Replace a specific line identified by line number. - Useful for fixing errors identified by error messages or linters. - - Parameters: - - coder: The Coder instance - - file_path: Path to the file to modify - - line_number: The line number to replace (1-based) - - new_content: New content for the line - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - coder.io.tool_error(f"File '{file_path}' not found") - return "Error: File not found" - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - coder.io.tool_error(f"File '{file_path}' is read-only. Use MakeEditable first.") - return "Error: File is read-only. Use MakeEditable first." - else: - coder.io.tool_error(f"File '{file_path}' not in context") - return "Error: File not in context" - - # Reread file content immediately before modification - file_content = coder.io.read_text(abs_path) - if file_content is None: - coder.io.tool_error(f"Could not read file '{file_path}' before ReplaceLine operation.") - return f"Error: Could not read file '{file_path}'" - - # Split into lines - lines = file_content.splitlines() - - # Validate line number - if not isinstance(line_number, int): - try: - line_number = int(line_number) - except ValueError: - coder.io.tool_error(f"Line number must be an integer, got '{line_number}'") + } + + @classmethod + def execute(cls, coder, file_path, line_number, new_content, change_id=None, dry_run=False): + """ + Replace a specific line identified by line number. + Useful for fixing errors identified by error messages or linters. + + Parameters: + - coder: The Coder instance + - file_path: Path to the file to modify + - line_number: The line number to replace (1-based) + - new_content: New content for the line + - change_id: Optional ID for tracking the change + - dry_run: If True, simulate the change without modifying the file + + Returns a result message. + """ + try: + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + lines = original_content.splitlines() + + # Validate line number + if not isinstance(line_number, int): + try: + line_number = int(line_number) + except ValueError: + coder.io.tool_error(f"Line number must be an integer, got '{line_number}'") + coder.io.tool_error( + f"Invalid line_number value: '{line_number}'. Must be an integer." + ) + return f"Error: Invalid line_number value '{line_number}'" + + # Convert 1-based line number to 0-based index + idx = line_number - 1 + + if idx < 0 or idx >= len(lines): coder.io.tool_error( - f"Invalid line_number value: '{line_number}'. Must be an integer." + f"Line number {line_number} is out of range for file '{file_path}' (has" + f" {len(lines)} lines)." ) - return f"Error: Invalid line_number value '{line_number}'" + return f"Error: Line number {line_number} out of range" - # Convert 1-based line number to 0-based index - idx = line_number - 1 + # Store original content for change tracking + original_line = lines[idx] - if idx < 0 or idx >= len(lines): - coder.io.tool_error( - f"Line number {line_number} is out of range for file '{file_path}' (has" - f" {len(lines)} lines)." - ) - return f"Error: Line number {line_number} out of range" + # Replace the line + lines[idx] = new_content - # Store original content for change tracking - original_content = file_content - original_line = lines[idx] + # Join lines back into a string + new_content_full = "\n".join(lines) - # Replace the line - lines[idx] = new_content + if original_content == new_content_full: + coder.io.tool_warning("No changes made: new line content is identical to original") + return "Warning: No changes made (new content identical to original)" - # Join lines back into a string - new_content_full = "\n".join(lines) + # Create a readable diff for the line replacement + diff = f"Line {line_number}:\n- {original_line}\n+ {new_content}" - if original_content == new_content_full: - coder.io.tool_warning("No changes made: new line content is identical to original") - return "Warning: No changes made (new content identical to original)" + # Handle dry run + if dry_run: + coder.io.tool_output(f"Dry run: Would replace line {line_number} in {file_path}") + return f"Dry run: Would replace line {line_number}. Diff:\n{diff}" - # Create a readable diff for the line replacement - diff = f"Line {line_number}:\n- {original_line}\n+ {new_content}" + # --- Apply Change (Not dry run) --- + coder.io.write_text(abs_path, new_content_full) - # Handle dry run - if dry_run: - coder.io.tool_output(f"Dry run: Would replace line {line_number} in {file_path}") - return f"Dry run: Would replace line {line_number}. Diff:\n{diff}" + # Track the change + try: + metadata = { + "line_number": line_number, + "original_line": original_line, + "new_line": new_content, + } + change_id = coder.change_tracker.track_change( + file_path=rel_path, + change_type="replaceline", + original_content=original_content, + new_content=new_content_full, + metadata=metadata, + change_id=change_id, + ) + except Exception as track_e: + coder.io.tool_error(f"Error tracking change for ReplaceLine: {track_e}") + change_id = "TRACKING_FAILED" - # --- Apply Change (Not dry run) --- - coder.io.write_text(abs_path, new_content_full) + coder.files_edited_by_tools.add(rel_path) - # Track the change - try: - metadata = { - "line_number": line_number, - "original_line": original_line, - "new_line": new_content, - } - change_id = coder.change_tracker.track_change( - file_path=rel_path, - change_type="replaceline", - original_content=original_content, - new_content=new_content_full, - metadata=metadata, - change_id=change_id, + # Improve feedback + coder.io.tool_output( + f"✅ Replaced line {line_number} in {file_path} (change_id: {change_id})" ) - except Exception as track_e: - coder.io.tool_error(f"Error tracking change for ReplaceLine: {track_e}") - change_id = "TRACKING_FAILED" - - coder.files_edited_by_tools.add(rel_path) - - # Improve feedback - coder.io.tool_output( - f"✅ Replaced line {line_number} in {file_path} (change_id: {change_id})" - ) - return f"Successfully replaced line {line_number} (change_id: {change_id}). Diff:\n{diff}" - - except Exception as e: - coder.io.tool_error(f"Error in ReplaceLine: {str(e)}\n{traceback.format_exc()}") - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the ReplaceLine tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - line_number = params.get("line_number") - new_content = params.get("new_content") - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and line_number is not None and new_content is not None: - return _execute_replace_line(coder, file_path, line_number, new_content, change_id, dry_run) - else: - return ( - "Error: Missing required parameters for ReplaceLine (file_path," - " line_number, new_content)" - ) + return ( + f"Successfully replaced line {line_number} (change_id: {change_id}). Diff:\n{diff}" + ) + + except ToolError as e: + coder.io.tool_error(f"Error in ReplaceLine: {str(e)}") + return f"Error: {str(e)}" + except Exception as e: + coder.io.tool_error(f"Error in ReplaceLine: {str(e)}\n{traceback.format_exc()}") + return f"Error: {str(e)}" + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + tool_body_unwrapped(coder=coder, tool_response=tool_response) + tool_footer(coder=coder, tool_response=tool_response) diff --git a/aider/tools/replace_lines.py b/aider/tools/replace_lines.py index 9815bb28754..3321c75cd0b 100644 --- a/aider/tools/replace_lines.py +++ b/aider/tools/replace_lines.py @@ -1,217 +1,180 @@ -import os - -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, format_tool_result, generate_unified_diff_snippet, handle_tool_error, + validate_file_for_edit, ) - -schema = { - "type": "function", - "function": { - "name": "ReplaceLines", - "description": "Replace a range of lines in a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "start_line": {"type": "integer"}, - "end_line": {"type": "integer"}, - "new_content": {"type": "string"}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, +from aider.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header + + +class Tool(BaseTool): + NORM_NAME = "replacelines" + SCHEMA = { + "type": "function", + "function": { + "name": "ReplaceLines", + "description": "Replace a range of lines in a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "start_line": {"type": "integer"}, + "end_line": {"type": "integer"}, + "new_content": {"type": "string"}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "start_line", "end_line", "new_content"], }, - "required": ["file_path", "start_line", "end_line", "new_content"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "replacelines" - - -def _execute_replace_lines( - coder, file_path, start_line, end_line, new_content, change_id=None, dry_run=False -): - """ - Replace a range of lines identified by line numbers. - Useful for fixing errors identified by error messages or linters. - - Parameters: - - file_path: Path to the file to modify - - start_line: The first line number to replace (1-based) - - end_line: The last line number to replace (1-based) - - new_content: New content for the lines (can be multi-line) - - change_id: Optional ID for tracking the change - - dry_run: If True, simulate the change without modifying the file - - Returns a result message. - """ - tool_name = "ReplaceLines" - try: - # Get absolute file path - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - # Check if file exists - if not os.path.isfile(abs_path): - raise ToolError(f"File '{file_path}' not found") - - # Check if file is in editable context - if abs_path not in coder.abs_fnames: - if abs_path in coder.abs_read_only_fnames: - raise ToolError(f"File '{file_path}' is read-only. Use MakeEditable first.") - else: - raise ToolError(f"File '{file_path}' not in context") - - # Reread file content immediately before modification - file_content = coder.io.read_text(abs_path) - if file_content is None: - raise ToolError(f"Could not read file '{file_path}'") - - # Convert line numbers to integers if needed - try: - start_line = int(start_line) - except ValueError: - raise ToolError(f"Invalid start_line value: '{start_line}'. Must be an integer.") + } + @classmethod + def execute( + cls, coder, file_path, start_line, end_line, new_content, change_id=None, dry_run=False + ): + """ + Replace a range of lines identified by line numbers. + Useful for fixing errors identified by error messages or linters. + + Parameters: + - file_path: Path to the file to modify + - start_line: The first line number to replace (1-based) + - end_line: The last line number to replace (1-based) + - new_content: New content for the lines (can be multi-line) + - change_id: Optional ID for tracking the change + - dry_run: If True, simulate the change without modifying the file + + Returns a result message. + """ + tool_name = "ReplaceLines" try: - end_line = int(end_line) - except ValueError: - raise ToolError(f"Invalid end_line value: '{end_line}'. Must be an integer.") - - # Split into lines - lines = file_content.splitlines() - - # Convert 1-based line numbers to 0-based indices - start_idx = start_line - 1 - end_idx = end_line - 1 - - # Validate line numbers - if start_idx < 0 or start_idx >= len(lines): - raise ToolError( - f"Start line {start_line} is out of range for file '{file_path}' (has" - f" {len(lines)} lines)." + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + + # Convert line numbers to integers if needed + try: + start_line = int(start_line) + except ValueError: + raise ToolError(f"Invalid start_line value: '{start_line}'. Must be an integer.") + + try: + end_line = int(end_line) + except ValueError: + raise ToolError(f"Invalid end_line value: '{end_line}'. Must be an integer.") + + # Split into lines + lines = original_content.splitlines() + + # Convert 1-based line numbers to 0-based indices + start_idx = start_line - 1 + end_idx = end_line - 1 + + # Validate line numbers + if start_idx < 0 or start_idx >= len(lines): + raise ToolError( + f"Start line {start_line} is out of range for file '{file_path}' (has" + f" {len(lines)} lines)." + ) + + if end_idx < start_idx or end_idx >= len(lines): + raise ToolError( + f"End line {end_line} is out of range for file '{file_path}' (must be >= start" + f" line {start_line} and <= {len(lines)})." + ) + + # Store original content for change tracking + replaced_lines = lines[start_idx : end_idx + 1] + + # Split the new content into lines + new_lines = new_content.splitlines() + + # Perform the replacement + new_full_lines = lines[:start_idx] + new_lines + lines[end_idx + 1 :] + new_content_full = "\n".join(new_full_lines) + + if original_content == new_content_full: + coder.io.tool_warning("No changes made: new content is identical to original") + return "Warning: No changes made (new content identical to original)" + + # Generate diff snippet + diff_snippet = generate_unified_diff_snippet( + original_content, new_content_full, rel_path ) - if end_idx < start_idx or end_idx >= len(lines): - raise ToolError( - f"End line {end_line} is out of range for file '{file_path}' (must be >= start line" - f" {start_line} and <= {len(lines)})." + # Create a readable diff for the lines replacement + diff = f"Lines {start_line}-{end_line}:\n" + # Add removed lines with - prefix + for line in replaced_lines: + diff += f"- {line}\n" + # Add separator + diff += "---\n" + # Add new lines with + prefix + for line in new_lines: + diff += f"+ {line}\n" + + # Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would replace lines {start_line}-{end_line} in {file_path}" + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) + + # --- Apply Change (Not dry run) --- + metadata = { + "start_line": start_line, + "end_line": end_line, + "replaced_lines": replaced_lines, + "new_lines": new_lines, + } + + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content_full, + "replacelines", + metadata, + change_id, ) - # Store original content for change tracking - original_content = file_content - replaced_lines = lines[start_idx : end_idx + 1] - - # Split the new content into lines - new_lines = new_content.splitlines() - - # Perform the replacement - new_full_lines = lines[:start_idx] + new_lines + lines[end_idx + 1 :] - new_content_full = "\n".join(new_full_lines) - - if original_content == new_content_full: - coder.io.tool_warning("No changes made: new content is identical to original") - return "Warning: No changes made (new content identical to original)" - - # Generate diff snippet - diff_snippet = generate_unified_diff_snippet(original_content, new_content_full, rel_path) - - # Create a readable diff for the lines replacement - diff = f"Lines {start_line}-{end_line}:\n" - # Add removed lines with - prefix - for line in replaced_lines: - diff += f"- {line}\n" - # Add separator - diff += "---\n" - # Add new lines with + prefix - for line in new_lines: - diff += f"+ {line}\n" - - # Handle dry run - if dry_run: - dry_run_message = f"Dry run: Would replace lines {start_line}-{end_line} in {file_path}" + coder.files_edited_by_tools.add(rel_path) + replaced_count = end_line - start_line + 1 + new_count = len(new_lines) + + # Format and return result + success_message = ( + f"Replaced lines {start_line}-{end_line} ({replaced_count} lines) with {new_count}" + f" new lines in {file_path}" + ) return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # --- Apply Change (Not dry run) --- - metadata = { - "start_line": start_line, - "end_line": end_line, - "replaced_lines": replaced_lines, - "new_lines": new_lines, - } - - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content_full, - "replacelines", - metadata, - change_id, - ) - - coder.files_edited_by_tools.add(rel_path) - replaced_count = end_line - start_line + 1 - new_count = len(new_lines) - - # Format and return result - success_message = ( - f"Replaced lines {start_line}-{end_line} ({replaced_count} lines) with {new_count} new" - f" lines in {file_path}" - ) - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - - except ToolError as e: - # Handle errors raised by utility functions (expected errors) - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the ReplaceLines tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - start_line = params.get("start_line") - end_line = params.get("end_line") - new_content = params.get("new_content") - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if ( - file_path is not None - and start_line is not None - and end_line is not None - and new_content is not None - ): - return _execute_replace_lines( - coder, file_path, start_line, end_line, new_content, change_id, dry_run - ) - else: - return ( - "Error: Missing required parameters for ReplaceLines (file_path," - " start_line, end_line, new_content)" - ) + except ToolError as e: + # Handle errors raised by utility functions (expected errors) + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + tool_body_unwrapped(coder=coder, tool_response=tool_response) + tool_footer(coder=coder, tool_response=tool_response) diff --git a/aider/tools/replace_text.py b/aider/tools/replace_text.py index 724736cdf35..199459f9a61 100644 --- a/aider/tools/replace_text.py +++ b/aider/tools/replace_text.py @@ -1,4 +1,8 @@ -from .tool_utils import ( +import difflib +import json + +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, apply_change, format_tool_result, @@ -6,182 +10,177 @@ handle_tool_error, validate_file_for_edit, ) - -schema = { - "type": "function", - "function": { - "name": "ReplaceText", - "description": "Replace text in a file.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "find_text": {"type": "string"}, - "replace_text": {"type": "string"}, - "near_context": {"type": "string"}, - "occurrence": {"type": "integer", "default": 1}, - "change_id": {"type": "string"}, - "dry_run": {"type": "boolean", "default": False}, +from aider.tools.utils.output import color_markers, tool_footer, tool_header + + +class Tool(BaseTool): + NORM_NAME = "replacetext" + SCHEMA = { + "type": "function", + "function": { + "name": "ReplaceText", + "description": "Replace text in a file.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "find_text": {"type": "string"}, + "replace_text": {"type": "string"}, + "near_context": {"type": "string"}, + "occurrence": {"type": "integer", "default": 1}, + "change_id": {"type": "string"}, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["file_path", "find_text", "replace_text"], }, - "required": ["file_path", "find_text", "replace_text"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "replacetext" - - -def _execute_replace_text( - coder, - file_path, - find_text, - replace_text, - near_context=None, - occurrence=1, - change_id=None, - dry_run=False, -): - """ - Replace specific text with new text, optionally using nearby context for disambiguation. - Uses utility functions for validation, finding occurrences, and applying changes. - """ - tool_name = "ReplaceText" - try: - # 1. Validate file and get content - abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) - - # 2. Find occurrences using helper function - # Note: _find_occurrences is currently on the Coder class, not in tool_utils - occurrences = coder._find_occurrences(original_content, find_text, near_context) - - if not occurrences: - err_msg = f"Text '{find_text}' not found" - if near_context: - err_msg += f" near context '{near_context}'" - err_msg += f" in file '{file_path}'." - raise ToolError(err_msg) - - # 3. Select the occurrence index - num_occurrences = len(occurrences) + } + + @classmethod + def execute( + cls, + coder, + file_path, + find_text, + replace_text, + near_context=None, + occurrence=1, + change_id=None, + dry_run=False, + ): + """ + Replace specific text with new text, optionally using nearby context for disambiguation. + Uses utility functions for validation, finding occurrences, and applying changes. + """ + tool_name = "ReplaceText" try: - occurrence = int(occurrence) - if occurrence == -1: - if num_occurrences == 0: - raise ToolError(f"Text '{find_text}' not found, cannot select last occurrence.") - target_idx = num_occurrences - 1 - elif 1 <= occurrence <= num_occurrences: - target_idx = occurrence - 1 # Convert 1-based to 0-based - else: - err_msg = ( - f"Occurrence number {occurrence} is out of range. Found" - f" {num_occurrences} occurrences of '{find_text}'" - ) + # 1. Validate file and get content + abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) + + # 2. Find occurrences using helper function + # Note: _find_occurrences is currently on the Coder class, not in tool_utils + occurrences = coder._find_occurrences(original_content, find_text, near_context) + + if not occurrences: + err_msg = f"Text '{find_text}' not found" if near_context: - err_msg += f" near '{near_context}'" - err_msg += f" in '{file_path}'." + err_msg += f" near context '{near_context}'" + err_msg += f" in file '{file_path}'." raise ToolError(err_msg) - except ValueError: - raise ToolError(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") - start_index = occurrences[target_idx] + # 3. Select the occurrence index + num_occurrences = len(occurrences) + try: + occurrence = int(occurrence) + if occurrence == -1: + if num_occurrences == 0: + raise ToolError( + f"Text '{find_text}' not found, cannot select last occurrence." + ) + target_idx = num_occurrences - 1 + elif 1 <= occurrence <= num_occurrences: + target_idx = occurrence - 1 # Convert 1-based to 0-based + else: + err_msg = ( + f"Occurrence number {occurrence} is out of range. Found" + f" {num_occurrences} occurrences of '{find_text}'" + ) + if near_context: + err_msg += f" near '{near_context}'" + err_msg += f" in '{file_path}'." + raise ToolError(err_msg) + except ValueError: + raise ToolError(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") + + start_index = occurrences[target_idx] + + # 4. Perform the replacement + new_content = ( + original_content[:start_index] + + replace_text + + original_content[start_index + len(find_text) :] + ) - # 4. Perform the replacement - new_content = ( - original_content[:start_index] - + replace_text - + original_content[start_index + len(find_text) :] - ) + if original_content == new_content: + coder.io.tool_warning("No changes made: replacement text is identical to original") + return "Warning: No changes made (replacement identical to original)" - if original_content == new_content: - coder.io.tool_warning("No changes made: replacement text is identical to original") - return "Warning: No changes made (replacement identical to original)" + # 5. Generate diff for feedback + # Note: _generate_diff_snippet is currently on the Coder class + diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) + occurrence_str = f"occurrence {occurrence}" if num_occurrences > 1 else "text" - # 5. Generate diff for feedback - # Note: _generate_diff_snippet is currently on the Coder class - diff_snippet = generate_unified_diff_snippet(original_content, new_content, rel_path) - occurrence_str = f"occurrence {occurrence}" if num_occurrences > 1 else "text" + # 6. Handle dry run + if dry_run: + dry_run_message = ( + f"Dry run: Would replace {occurrence_str} of '{find_text}' in {file_path}." + ) + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) - # 6. Handle dry run - if dry_run: - dry_run_message = ( - f"Dry run: Would replace {occurrence_str} of '{find_text}' in {file_path}." + # 7. Apply Change (Not dry run) + metadata = { + "start_index": start_index, + "find_text": find_text, + "replace_text": replace_text, + "near_context": near_context, + "occurrence": occurrence, + } + final_change_id = apply_change( + coder, + abs_path, + rel_path, + original_content, + new_content, + "replacetext", + metadata, + change_id, ) + + coder.files_edited_by_tools.add(rel_path) + # 8. Format and return result + success_message = f"Replaced {occurrence_str} in {file_path}" return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # 7. Apply Change (Not dry run) - metadata = { - "start_index": start_index, - "find_text": find_text, - "replace_text": replace_text, - "near_context": near_context, - "occurrence": occurrence, - } - final_change_id = apply_change( - coder, - abs_path, - rel_path, - original_content, - new_content, - "replacetext", - metadata, - change_id, + except ToolError as e: + # Handle errors raised by utility functions or explicitly raised here + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors + return handle_tool_error(coder, tool_name, e) + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + color_start, color_end = color_markers(coder) + params = json.loads(tool_response.function.arguments) + diff = difflib.unified_diff( + params["find_text"].splitlines(), + params["replace_text"].splitlines(), + lineterm="", + n=float("inf"), ) - coder.files_edited_by_tools.add(rel_path) - # 8. Format and return result - success_message = f"Replaced {occurrence_str} in {file_path}" - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) - except ToolError as e: - # Handle errors raised by utility functions or explicitly raised here - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the ReplaceText tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - find_text = params.get("find_text") - replace_text = params.get("replace_text") - near_context = params.get("near_context") - occurrence = params.get("occurrence", 1) - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if file_path is not None and find_text is not None and replace_text is not None: - return _execute_replace_text( - coder, - file_path, - find_text, - replace_text, - near_context, - occurrence, - change_id, - dry_run, - ) - else: - return ( - "Error: Missing required parameters for ReplaceText (file_path," - " find_text, replace_text)" - ) + coder.io.tool_output("") + coder.io.tool_output(f"{color_start}file_path:{color_end}") + coder.io.tool_output(params["file_path"]) + coder.io.tool_output("") + + coder.io.tool_output(f"{color_start}diff:{color_end}") + coder.io.tool_output("\n".join(list(diff)[2:])) + coder.io.tool_output("") + + tool_footer(coder=coder, tool_response=tool_response) diff --git a/aider/tools/show_numbered_context.py b/aider/tools/show_numbered_context.py index 160697fbdac..c5376853851 100644 --- a/aider/tools/show_numbered_context.py +++ b/aider/tools/show_numbered_context.py @@ -1,147 +1,121 @@ import os -from .tool_utils import ToolError, handle_tool_error, resolve_paths - -schema = { - "type": "function", - "function": { - "name": "ShowNumberedContext", - "description": "Show numbered lines of context around a pattern or line number.", - "parameters": { - "type": "object", - "properties": { - "file_path": {"type": "string"}, - "pattern": {"type": "string"}, - "line_number": {"type": "integer"}, - "context_lines": {"type": "integer", "default": 3}, +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ToolError, handle_tool_error, resolve_paths + + +class Tool(BaseTool): + NORM_NAME = "shownumberedcontext" + SCHEMA = { + "type": "function", + "function": { + "name": "ShowNumberedContext", + "description": "Show numbered lines of context around a pattern or line number.", + "parameters": { + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "pattern": {"type": "string"}, + "line_number": {"type": "integer"}, + "context_lines": {"type": "integer", "default": 3}, + }, + "required": ["file_path"], }, - "required": ["file_path"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "shownumberedcontext" - - -def execute_show_numbered_context( - coder, file_path, pattern=None, line_number=None, context_lines=3 -): - """ - Displays numbered lines from file_path centered around a target location - (pattern or line_number), without adding the file to context. - Uses utility functions for path resolution and error handling. - """ - tool_name = "ShowNumberedContext" - try: - # 1. Validate arguments - if not (pattern is None) ^ (line_number is None): - raise ToolError("Provide exactly one of 'pattern' or 'line_number'.") - - # 2. Resolve path - abs_path, rel_path = resolve_paths(coder, file_path) - if not os.path.exists(abs_path): - # Check existence after resolving, as resolve_paths doesn't guarantee existence - raise ToolError(f"File not found: {file_path}") - - # 3. Read file content - content = coder.io.read_text(abs_path) - if content is None: - raise ToolError(f"Could not read file: {file_path}") - lines = content.splitlines() - num_lines = len(lines) - - # 4. Determine center line index - center_line_idx = -1 - found_by = "" - - if line_number is not None: - try: - line_number_int = int(line_number) - if 1 <= line_number_int <= num_lines: - center_line_idx = line_number_int - 1 # Convert to 0-based index - found_by = f"line {line_number_int}" + } + + @classmethod + def execute(cls, coder, file_path, pattern=None, line_number=None, context_lines=3): + """ + Displays numbered lines from file_path centered around a target location + (pattern or line_number), without adding the file to context. + Uses utility functions for path resolution and error handling. + """ + tool_name = "ShowNumberedContext" + try: + # 1. Validate arguments + if not (pattern is None) ^ (line_number is None): + raise ToolError("Provide exactly one of 'pattern' or 'line_number'.") + + # 2. Resolve path + abs_path, rel_path = resolve_paths(coder, file_path) + if not os.path.exists(abs_path): + # Check existence after resolving, as resolve_paths doesn't guarantee existence + raise ToolError(f"File not found: {file_path}") + + # 3. Read file content + content = coder.io.read_text(abs_path) + if content is None: + raise ToolError(f"Could not read file: {file_path}") + lines = content.splitlines() + num_lines = len(lines) + + # 4. Determine center line index + center_line_idx = -1 + found_by = "" + + if line_number is not None: + try: + line_number_int = int(line_number) + if 1 <= line_number_int <= num_lines: + center_line_idx = line_number_int - 1 # Convert to 0-based index + found_by = f"line {line_number_int}" + else: + raise ToolError( + f"Line number {line_number_int} is out of range (1-{num_lines}) for" + f" {file_path}." + ) + except ValueError: + raise ToolError(f"Invalid line number '{line_number}'. Must be an integer.") + + elif pattern is not None: + # TODO: Update this section for multiline pattern support later + first_match_line_idx = -1 + for i, line in enumerate(lines): + if pattern in line: + first_match_line_idx = i + break + + if first_match_line_idx != -1: + center_line_idx = first_match_line_idx + found_by = f"pattern '{pattern}' on line {center_line_idx + 1}" else: - raise ToolError( - f"Line number {line_number_int} is out of range (1-{num_lines}) for" - f" {file_path}." - ) + raise ToolError(f"Pattern '{pattern}' not found in {file_path}.") + + if center_line_idx == -1: + # Should not happen if logic above is correct, but as a safeguard + raise ToolError("Internal error: Could not determine center line.") + + # 5. Calculate context window + try: + context_lines_int = int(context_lines) + if context_lines_int < 0: + raise ValueError("Context lines must be non-negative") except ValueError: - raise ToolError(f"Invalid line number '{line_number}'. Must be an integer.") - - elif pattern is not None: - # TODO: Update this section for multiline pattern support later - first_match_line_idx = -1 - for i, line in enumerate(lines): - if pattern in line: - first_match_line_idx = i - break - - if first_match_line_idx != -1: - center_line_idx = first_match_line_idx - found_by = f"pattern '{pattern}' on line {center_line_idx + 1}" - else: - raise ToolError(f"Pattern '{pattern}' not found in {file_path}.") - - if center_line_idx == -1: - # Should not happen if logic above is correct, but as a safeguard - raise ToolError("Internal error: Could not determine center line.") - - # 5. Calculate context window - try: - context_lines_int = int(context_lines) - if context_lines_int < 0: - raise ValueError("Context lines must be non-negative") - except ValueError: - coder.io.tool_warning( - f"Invalid context_lines value '{context_lines}', using default 3." - ) - context_lines_int = 3 - - start_line_idx = max(0, center_line_idx - context_lines_int) - end_line_idx = min(num_lines - 1, center_line_idx + context_lines_int) - - # 6. Format output - # Use rel_path for user-facing messages - output_lines = [f"Displaying context around {found_by} in {rel_path}:"] - max_line_num_width = len(str(end_line_idx + 1)) # Width for padding - - for i in range(start_line_idx, end_line_idx + 1): - line_num_str = str(i + 1).rjust(max_line_num_width) - output_lines.append(f"{line_num_str} | {lines[i]}") - - # Log success and return the formatted context directly - coder.io.tool_output(f"Successfully retrieved context for {rel_path}") - return "\n".join(output_lines) - - except ToolError as e: - # Handle expected errors raised by utility functions or validation - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - # Handle unexpected errors during processing - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the ShowNumberedContext tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - file_path = params.get("file_path") - pattern = params.get("pattern") - line_number = params.get("line_number") - context_lines = params.get("context_lines", 3) - - if file_path is not None and (pattern is not None or line_number is not None): - return execute_show_numbered_context(coder, file_path, pattern, line_number, context_lines) - else: - return ( - "Error: Missing required parameters for ViewNumberedContext (file_path" - " and either pattern or line_number)" - ) + coder.io.tool_warning( + f"Invalid context_lines value '{context_lines}', using default 3." + ) + context_lines_int = 3 + + start_line_idx = max(0, center_line_idx - context_lines_int) + end_line_idx = min(num_lines - 1, center_line_idx + context_lines_int) + + # 6. Format output + # Use rel_path for user-facing messages + output_lines = [f"Displaying context around {found_by} in {rel_path}:"] + max_line_num_width = len(str(end_line_idx + 1)) # Width for padding + + for i in range(start_line_idx, end_line_idx + 1): + line_num_str = str(i + 1).rjust(max_line_num_width) + output_lines.append(f"{line_num_str} | {lines[i]}") + + # Log success and return the formatted context directly + coder.io.tool_output(f"Successfully retrieved context for {rel_path}") + return "\n".join(output_lines) + + except ToolError as e: + # Handle expected errors raised by utility functions or validation + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + # Handle unexpected errors during processing + return handle_tool_error(coder, tool_name, e) diff --git a/aider/tools/undo_change.py b/aider/tools/undo_change.py index 923919601d3..66f7528ef47 100644 --- a/aider/tools/undo_change.py +++ b/aider/tools/undo_change.py @@ -1,95 +1,82 @@ import traceback -schema = { - "type": "function", - "function": { - "name": "UndoChange", - "description": "Undo a previously applied change.", - "parameters": { - "type": "object", - "properties": { - "change_id": {"type": "string"}, - "file_path": {"type": "string"}, +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "undochange" + SCHEMA = { + "type": "function", + "function": { + "name": "UndoChange", + "description": "Undo a previously applied change.", + "parameters": { + "type": "object", + "properties": { + "change_id": {"type": "string"}, + "file_path": {"type": "string"}, + }, }, }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "undochange" - - -def _execute_undo_change(coder, change_id=None, file_path=None): - """ - Undo a specific change by ID, or the last change to a file. - - Parameters: - - coder: The Coder instance - - change_id: ID of the change to undo - - file_path: Path to file where the last change should be undone - - Returns a result message. - """ - # Note: Undo does not have a dry_run parameter as it's inherently about reverting a previous action. - try: - # Validate parameters - if change_id is None and file_path is None: - coder.io.tool_error("Must specify either change_id or file_path for UndoChange") - return "Error: Must specify either change_id or file_path" - - # If file_path is specified, get the most recent change for that file - if file_path: - abs_path = coder.abs_root_path(file_path) - rel_path = coder.get_rel_fname(abs_path) - - change_id = coder.change_tracker.get_last_change(rel_path) - if not change_id: - coder.io.tool_error(f"No tracked changes found for file '{file_path}' to undo.") - return f"Error: No changes found for file '{file_path}'" - - # Attempt to get undo information from the tracker - success, message, change_info = coder.change_tracker.undo_change(change_id) - - if not success: - coder.io.tool_error(f"Failed to undo change '{change_id}': {message}") - return f"Error: {message}" - - # Apply the undo by restoring the original content - if change_info: - file_path = change_info["file_path"] - abs_path = coder.abs_root_path(file_path) - # Write the original content back to the file - coder.io.write_text(abs_path, change_info["original"]) - coder.aider_edited_files.add(file_path) # Track that the file was modified by the undo - - change_type = change_info["type"] - coder.io.tool_output(f"✅ Undid {change_type} change '{change_id}' in {file_path}") - return f"Successfully undid {change_type} change '{change_id}'." - else: - # This case should ideally not be reached if tracker returns success - coder.io.tool_error( - f"Failed to undo change '{change_id}': Change info missing after successful tracker" - " update." - ) - return f"Error: Failed to undo change '{change_id}' (missing change info)" - - except Exception as e: - coder.io.tool_error(f"Error in UndoChange: {str(e)}\n{traceback.format_exc()}") - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the UndoChange tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - change_id = params.get("change_id") - file_path = params.get("file_path") - - return _execute_undo_change(coder, change_id, file_path) + } + + @classmethod + def execute(cls, coder, change_id=None, file_path=None): + """ + Undo a specific change by ID, or the last change to a file. + + Parameters: + - coder: The Coder instance + - change_id: ID of the change to undo + - file_path: Path to file where the last change should be undone + + Returns a result message. + """ + # Note: Undo does not have a dry_run parameter as it's inherently about reverting a previous action. + try: + # Validate parameters + if change_id is None and file_path is None: + coder.io.tool_error("Must specify either change_id or file_path for UndoChange") + return "Error: Must specify either change_id or file_path" + + # If file_path is specified, get the most recent change for that file + if file_path: + abs_path = coder.abs_root_path(file_path) + rel_path = coder.get_rel_fname(abs_path) + + change_id = coder.change_tracker.get_last_change(rel_path) + if not change_id: + coder.io.tool_error(f"No tracked changes found for file '{file_path}' to undo.") + return f"Error: No changes found for file '{file_path}'" + + # Attempt to get undo information from the tracker + success, message, change_info = coder.change_tracker.undo_change(change_id) + + if not success: + coder.io.tool_error(f"Failed to undo change '{change_id}': {message}") + return f"Error: {message}" + + # Apply the undo by restoring the original content + if change_info: + file_path = change_info["file_path"] + abs_path = coder.abs_root_path(file_path) + # Write the original content back to the file + coder.io.write_text(abs_path, change_info["original"]) + coder.aider_edited_files.add( + file_path + ) # Track that the file was modified by the undo + + change_type = change_info["type"] + coder.io.tool_output(f"✅ Undid {change_type} change '{change_id}' in {file_path}") + return f"Successfully undid {change_type} change '{change_id}'." + else: + # This case should ideally not be reached if tracker returns success + coder.io.tool_error( + f"Failed to undo change '{change_id}': Change info missing after successful" + " tracker update." + ) + return f"Error: Failed to undo change '{change_id}' (missing change info)" + + except Exception as e: + coder.io.tool_error(f"Error in UndoChange: {str(e)}\n{traceback.format_exc()}") + return f"Error: {str(e)}" diff --git a/aider/tools/update_todo_list.py b/aider/tools/update_todo_list.py index 4dcf765950a..a1586bf502c 100644 --- a/aider/tools/update_todo_list.py +++ b/aider/tools/update_todo_list.py @@ -1,156 +1,148 @@ -from .tool_utils import ( +from aider.tools.utils.base_tool import BaseTool +from aider.tools.utils.helpers import ( ToolError, format_tool_result, generate_unified_diff_snippet, handle_tool_error, ) - -schema = { - "type": "function", - "function": { - "name": "UpdateTodoList", - "description": "Update the todo list with new items or modify existing ones.", - "parameters": { - "type": "object", - "properties": { - "content": { - "type": "string", - "description": "The new content for the todo list.", - }, - "append": { - "type": "boolean", - "description": ( - "Whether to append to existing content instead of replacing it. Defaults to" - " False." - ), - }, - "change_id": { - "type": "string", - "description": "Optional change ID for tracking.", - }, - "dry_run": { - "type": "boolean", - "description": ( - "Whether to perform a dry run without actually updating the file. Defaults" - " to False." - ), +from aider.tools.utils.output import tool_body_unwrapped, tool_footer, tool_header + + +class Tool(BaseTool): + NORM_NAME = "updatetodolist" + SCHEMA = { + "type": "function", + "function": { + "name": "UpdateTodoList", + "description": "Update the todo list with new items or modify existing ones.", + "parameters": { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The new content for the todo list.", + }, + "append": { + "type": "boolean", + "description": ( + "Whether to append to existing content instead of replacing it." + " Defaults to False." + ), + }, + "change_id": { + "type": "string", + "description": "Optional change ID for tracking.", + }, + "dry_run": { + "type": "boolean", + "description": ( + "Whether to perform a dry run without actually updating the file." + " Defaults to False." + ), + }, }, + "required": ["content"], }, - "required": ["content"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "updatetodolist" - - -def _execute_update_todo_list(coder, content, append=False, change_id=None, dry_run=False): - """ - Update the todo list file (.aider.todo.txt) with new content. - Can either replace the entire content or append to it. - """ - tool_name = "UpdateTodoList" - try: - # Define the todo file path - todo_file_path = ".aider.todo.txt" - abs_path = coder.abs_root_path(todo_file_path) - - # Get existing content if appending - existing_content = "" - import os - - if os.path.isfile(abs_path): - existing_content = coder.io.read_text(abs_path) or "" - - # Prepare new content - if append: - if existing_content and not existing_content.endswith("\n"): - existing_content += "\n" - new_content = existing_content + content - else: - new_content = content - - # Check if content exceeds 4096 characters and warn - if len(new_content) > 4096: - coder.io.tool_warning( - "⚠️ Todo list content exceeds 4096 characters. Consider summarizing the plan before" - " proceeding." + } + + @classmethod + def execute(cls, coder, content, append=False, change_id=None, dry_run=False): + """ + Update the todo list file (.aider.todo.txt) with new content. + Can either replace the entire content or append to it. + """ + tool_name = "UpdateTodoList" + try: + # Define the todo file path + todo_file_path = ".aider.todo.txt" + abs_path = coder.abs_root_path(todo_file_path) + + # Get existing content if appending + existing_content = "" + import os + + if os.path.isfile(abs_path): + existing_content = coder.io.read_text(abs_path) or "" + + # Prepare new content + if append: + if existing_content and not existing_content.endswith("\n"): + existing_content += "\n" + new_content = existing_content + content + else: + new_content = content + + # Check if content exceeds 4096 characters and warn + if len(new_content) > 4096: + coder.io.tool_warning( + "⚠️ Todo list content exceeds 4096 characters. Consider summarizing the plan" + " before proceeding." + ) + + # Check if content actually changed + if existing_content == new_content: + coder.io.tool_warning("No changes made: new content is identical to existing") + return "Warning: No changes made (content identical to existing)" + + # Generate diff for feedback + diff_snippet = generate_unified_diff_snippet( + existing_content, new_content, todo_file_path ) - # Check if content actually changed - if existing_content == new_content: - coder.io.tool_warning("No changes made: new content is identical to existing") - return "Warning: No changes made (content identical to existing)" + # Handle dry run + if dry_run: + action = "append to" if append else "replace" + dry_run_message = f"Dry run: Would {action} todo list in {todo_file_path}." + return format_tool_result( + coder, + tool_name, + "", + dry_run=True, + dry_run_message=dry_run_message, + diff_snippet=diff_snippet, + ) + + # Apply change + metadata = { + "append": append, + "existing_length": len(existing_content), + "new_length": len(new_content), + } + + # Write the file directly since it's a special file + coder.io.write_text(abs_path, new_content) + + # Track the change + final_change_id = coder.change_tracker.track_change( + file_path=todo_file_path, + change_type="updatetodolist", + original_content=existing_content, + new_content=new_content, + metadata=metadata, + change_id=change_id, + ) - # Generate diff for feedback - diff_snippet = generate_unified_diff_snippet(existing_content, new_content, todo_file_path) + coder.aider_edited_files.add(todo_file_path) - # Handle dry run - if dry_run: - action = "append to" if append else "replace" - dry_run_message = f"Dry run: Would {action} todo list in {todo_file_path}." + # Format and return result + action = "appended to" if append else "updated" + success_message = f"Successfully {action} todo list in {todo_file_path}" return format_tool_result( coder, tool_name, - "", - dry_run=True, - dry_run_message=dry_run_message, + success_message, + change_id=final_change_id, diff_snippet=diff_snippet, ) - # Apply change - metadata = { - "append": append, - "existing_length": len(existing_content), - "new_length": len(new_content), - } - - # Write the file directly since it's a special file - coder.io.write_text(abs_path, new_content) - - # Track the change - final_change_id = coder.change_tracker.track_change( - file_path=todo_file_path, - change_type="updatetodolist", - original_content=existing_content, - new_content=new_content, - metadata=metadata, - change_id=change_id, - ) - - coder.aider_edited_files.add(todo_file_path) - - # Format and return result - action = "appended to" if append else "updated" - success_message = f"Successfully {action} todo list in {todo_file_path}" - return format_tool_result( - coder, tool_name, success_message, change_id=final_change_id, diff_snippet=diff_snippet - ) - - except ToolError as e: - return handle_tool_error(coder, tool_name, e, add_traceback=False) - except Exception as e: - return handle_tool_error(coder, tool_name, e) - - -def process_response(coder, params): - """ - Process the UpdateTodoList tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - content = params.get("content") - append = params.get("append", False) - change_id = params.get("change_id") - dry_run = params.get("dry_run", False) - - if content is not None: - return _execute_update_todo_list(coder, content, append, change_id, dry_run) - else: - return "Error: Missing required 'content' parameter for UpdateTodoList" + except ToolError as e: + return handle_tool_error(coder, tool_name, e, add_traceback=False) + except Exception as e: + return handle_tool_error(coder, tool_name, e) + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + tool_body_unwrapped(coder=coder, tool_response=tool_response) + tool_footer(coder=coder, tool_response=tool_response) diff --git a/aider/tools/utils/base_tool.py b/aider/tools/utils/base_tool.py new file mode 100644 index 00000000000..be82950fa32 --- /dev/null +++ b/aider/tools/utils/base_tool.py @@ -0,0 +1,64 @@ +from abc import ABC, abstractmethod + +from aider.tools.utils.helpers import handle_tool_error +from aider.tools.utils.output import print_tool_response + + +class BaseTool(ABC): + """Abstract base class for all tools.""" + + # Class properties that must be defined by subclasses + # Note: NORM_NAME should be the lowercase version of the function name in the SCHEMA + NORM_NAME = None + SCHEMA = None + + @classmethod + @abstractmethod + def execute(cls, coder, **params): + """ + Execute the tool with the given parameters. + + Args: + coder: The Coder instance + **params: Tool-specific parameters + + Returns: + str: Result message + """ + pass + + @classmethod + def process_response(cls, coder, params): + """ + Process the tool response by creating an instance and calling execute. + + Args: + coder: The Coder instance + params: Dictionary of parameters + + Returns: + str: Result message + """ + + # Validate required parameters from SCHEMA + if cls.SCHEMA and "function" in cls.SCHEMA: + function_schema = cls.SCHEMA["function"] + + if "parameters" in function_schema and "required" in function_schema["parameters"]: + required_params = function_schema["parameters"]["required"] + missing_params = [param for param in required_params if param not in params] + if missing_params: + tool_name = function_schema.get("name", "Unknown Tool") + error_msg = ( + f"Missing required parameters for {tool_name}: {', '.join(missing_params)}" + ) + return handle_tool_error(coder, tool_name, ValueError(error_msg)) + + try: + return cls.execute(coder, **params) + except Exception as e: + return handle_tool_error(coder, cls.SCHEMA.get("function").get("name"), e) + + @classmethod + def format_output(cls, coder, mcp_server, tool_response): + print_tool_response(coder=coder, mcp_server=mcp_server, tool_response=tool_response) diff --git a/aider/tools/tool_utils.py b/aider/tools/utils/helpers.py similarity index 100% rename from aider/tools/tool_utils.py rename to aider/tools/utils/helpers.py diff --git a/aider/tools/utils/output.py b/aider/tools/utils/output.py new file mode 100644 index 00000000000..2e21545e8f1 --- /dev/null +++ b/aider/tools/utils/output.py @@ -0,0 +1,118 @@ +import json +import re + + +def print_tool_response(coder, mcp_server, tool_response): + """ + Format the output for display. + Prints a Header to identify the tool, a body for the relevant information + for the user and a footer for verbose information + + Args: + coder: An instance of base_coder + mcp_server: An mcp server instance + tool_response: a tool_response dictionary + """ + tool_header(coder=coder, mcp_server=mcp_server, tool_response=tool_response) + tool_body(coder=coder, tool_response=tool_response) + tool_footer(coder=coder, tool_response=tool_response) + + +def tool_header(coder, mcp_server, tool_response): + """ + Prints the header for the tool call output + + Args: + coder: An instance of base_coder + mcp_server: An mcp server instance + tool_response: a tool_response dictionary + """ + color_start, color_end = color_markers(coder) + + coder.io.tool_output( + f"{color_start}Tool Call:{color_end} {mcp_server.name} • {tool_response.function.name}" + ) + + +def tool_body(coder, tool_response): + """ + Prints the output body of a tool call as the raw json returned from the model + + Args: + coder: An instance of base_coder + tool_response: a tool_response dictionary + """ + color_start, color_end = color_markers(coder) + + # Parse and format arguments as headers with values + if tool_response.function.arguments: + # For non-replace tools, show raw arguments + raw_args = tool_response.function.arguments + coder.io.tool_output(f"{color_start}Arguments:{color_end} {raw_args}") + + +def tool_body_unwrapped(coder, tool_response): + """ + Prints the output body of a tool call with the argument + and content sections separated + + Args: + coder: An instance of base_coder + tool_response: a tool_response dictionary + """ + + color_start, color_end = color_markers(coder) + + try: + args_dict = json.loads(tool_response.function.arguments) + first_key = True + for key, value in args_dict.items(): + # Convert explicit \\n sequences to actual newlines using regex + # Only match \\n that is not preceded by any other backslashes + if isinstance(value, str): + value = re.sub(r"(?= 25: - inspecific_search_flag = True - - try: - if coder.repo.ignored_file(abs_path): - continue - - with open(abs_path, "r", encoding="utf-8") as f: - content = f.read() - match_count = 0 - if regex: - try: - matches_found = re.findall(pattern, content) - match_count = len(matches_found) - except re.error as e: - # Handle invalid regex patterns gracefully - coder.io.tool_error(f"Invalid regex pattern '{pattern}': {e}") - # Skip this file for this search if regex is invalid - continue - else: - # Exact string matching - match_count = content.count(pattern) - - if match_count > 0: - matches[file] = match_count - num_matches += 1 - except Exception: - # Skip files that can't be read (binary, etc.) - pass - - # Return formatted text instead of adding to context - if matches: - # Sort by number of matches (most matches first) - sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True) - match_list = [f"{file} ({count} matches)" for file, count in sorted_matches] - - if len(matches) > 10: - result = ( - f"Found '{pattern}' in {len(matches)} files: {', '.join(match_list[:10])} and" - f" {len(matches) - 10} more" - "\nTry more specific search terms going forward" - if inspecific_search_flag - else "" - ) - coder.io.tool_output(f"🔍 Found '{pattern}' in {len(matches)} files") + } + + @classmethod + def execute(cls, coder, pattern, file_pattern=None, regex=False): + """ + Search for pattern (literal string or regex) in files and return matching files as text. + + Args: + coder: The Coder instance. + pattern (str): The pattern to search for. + Treated as a literal string by default. + file_pattern (str, optional): Glob pattern to filter which files are searched. + Defaults to None (search all files). + regex (bool, optional): If True, treat pattern as a regular expression. + Defaults to False. + + This tool lets the LLM search for content within files, mimicking + how a developer would use grep or regex search to find relevant code. + """ + try: + # Get list of files to search + if file_pattern: + # Use glob pattern to filter files + all_files = coder.get_all_relative_files() + files_to_search = [] + for file in all_files: + if fnmatch.fnmatch(file, file_pattern): + files_to_search.append(file) + + if not files_to_search: + return f"No files matching '{file_pattern}' to search for pattern '{pattern}'" + else: + # Search all files if no pattern provided + files_to_search = coder.get_all_relative_files() + + # Search for pattern in files + matches = {} + num_matches = 0 + inspecific_search_flag = False + + for file in files_to_search: + abs_path = coder.abs_root_path(file) + + if num_matches >= 25: + inspecific_search_flag = True + + try: + if coder.repo.ignored_file(abs_path): + continue + + with open(abs_path, "r", encoding="utf-8") as f: + content = f.read() + match_count = 0 + if regex: + try: + matches_found = re.findall(pattern, content) + match_count = len(matches_found) + except re.error as e: + # Handle invalid regex patterns gracefully + coder.io.tool_error(f"Invalid regex pattern '{pattern}': {e}") + # Skip this file for this search if regex is invalid + continue + else: + # Exact string matching + match_count = content.count(pattern) + + if match_count > 0: + matches[file] = match_count + num_matches += 1 + except Exception: + # Skip files that can't be read (binary, etc.) + pass + + # Return formatted text instead of adding to context + if matches: + # Sort by number of matches (most matches first) + sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True) + match_list = [f"{file} ({count} matches)" for file, count in sorted_matches] + + if len(matches) > 10: + result = ( + f"Found '{pattern}' in {len(matches)} files:" + f" {', '.join(match_list[:10])} and {len(matches) - 10} more" + "\nTry more specific search terms going forward" + if inspecific_search_flag + else "" + ) + coder.io.tool_output(f"🔍 Found '{pattern}' in {len(matches)} files") + else: + result = f"Found '{pattern}' in {len(matches)} files: {', '.join(match_list)}" + coder.io.tool_output( + f"🔍 Found '{pattern}' in:" + f" {', '.join(match_list[:5])}{' and more' if len(matches) > 5 else ''}" + ) + + return result else: - result = f"Found '{pattern}' in {len(matches)} files: {', '.join(match_list)}" - coder.io.tool_output( - f"🔍 Found '{pattern}' in:" - f" {', '.join(match_list[:5])}{' and more' if len(matches) > 5 else ''}" - ) - - return result - else: - coder.io.tool_output(f"⚠️ Pattern '{pattern}' not found in any files") - return "Pattern not found in any files" - except Exception as e: - coder.io.tool_error(f"Error in ViewFilesMatching: {str(e)}") - return f"Error: {str(e)}" - - -def process_response(coder, params): - """ - Process the ViewFilesMatching tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - pattern = params.get("pattern") - file_pattern = params.get("file_pattern") - regex = params.get("regex", False) - - if pattern is not None: - return execute_view_files_matching(coder, pattern, file_pattern, regex) - else: - return "Error: Missing 'pattern' parameter for ViewFilesMatching" + coder.io.tool_output(f"⚠️ Pattern '{pattern}' not found in any files") + return "Pattern not found in any files" + except Exception as e: + coder.io.tool_error(f"Error in ViewFilesMatching: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/view_files_with_symbol.py b/aider/tools/view_files_with_symbol.py index 8d012e9fbbf..59eedca69a6 100644 --- a/aider/tools/view_files_with_symbol.py +++ b/aider/tools/view_files_with_symbol.py @@ -1,129 +1,117 @@ -schema = { - "type": "function", - "function": { - "name": "ViewFilesWithSymbol", - "description": "View files that contain a specific symbol (e.g., class, function).", - "parameters": { - "type": "object", - "properties": { - "symbol": { - "type": "string", - "description": "The symbol to search for.", +from aider.tools.utils.base_tool import BaseTool + + +class Tool(BaseTool): + NORM_NAME = "viewfileswithsymbol" + SCHEMA = { + "type": "function", + "function": { + "name": "ViewFilesWithSymbol", + "description": "View files that contain a specific symbol (e.g., class, function).", + "parameters": { + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "The symbol to search for.", + }, }, + "required": ["symbol"], }, - "required": ["symbol"], }, - }, -} - -# Normalized tool name for lookup -NORM_NAME = "viewfileswithsymbol" - - -def _execute_view_files_with_symbol(coder, symbol): - """ - Find files containing a symbol using RepoMap and return them as text. - Checks files already in context first. - """ - if not coder.repo_map: - coder.io.tool_output("⚠️ Repo map not available, cannot use ViewFilesWithSymbol tool.") - return "Repo map not available" - - if not symbol: - return "Error: Missing 'symbol' parameter for ViewFilesWithSymbol" - - # 1. Check files already in context - files_in_context = list(coder.abs_fnames) + list(coder.abs_read_only_fnames) - found_in_context = [] - for abs_fname in files_in_context: - rel_fname = coder.get_rel_fname(abs_fname) - try: - # Use get_tags for consistency with RepoMap usage elsewhere for now. - tags = coder.repo_map.get_tags(abs_fname, rel_fname) - for tag in tags: - if tag.name == symbol: - found_in_context.append(rel_fname) - break # Found in this file, move to next - except Exception as e: - coder.io.tool_warning( - f"Could not get symbols for {rel_fname} while checking context: {e}" - ) - - if found_in_context: - # Symbol found in already loaded files. Report this and stop. - file_list = ", ".join(sorted(list(set(found_in_context)))) - coder.io.tool_output(f"Symbol '{symbol}' found in already loaded file(s): {file_list}") - return f"Symbol '{symbol}' found in already loaded file(s): {file_list}" - - # 2. If not found in context, search the repository using RepoMap - coder.io.tool_output(f"🔎 Searching for symbol '{symbol}' in repository...") - try: - found_files = set() - current_context_files = coder.abs_fnames | coder.abs_read_only_fnames - files_to_search = set(coder.get_all_abs_files()) - current_context_files - - rel_fname_to_abs = {} - all_tags = [] - - for fname in files_to_search: - rel_fname = coder.get_rel_fname(fname) - rel_fname_to_abs[rel_fname] = fname + } + + @classmethod + def execute(cls, coder, symbol): + """ + Find files containing a symbol using RepoMap and return them as text. + Checks files already in context first. + """ + if not coder.repo_map: + coder.io.tool_output("⚠️ Repo map not available, cannot use ViewFilesWithSymbol tool.") + return "Repo map not available" + + if not symbol: + return "Error: Missing 'symbol' parameter for ViewFilesWithSymbol" + + # 1. Check files already in context + files_in_context = list(coder.abs_fnames) + list(coder.abs_read_only_fnames) + found_in_context = [] + for abs_fname in files_in_context: + rel_fname = coder.get_rel_fname(abs_fname) try: - tags = coder.repo_map.get_tags(fname, rel_fname) - all_tags.extend(tags) + # Use get_tags for consistency with RepoMap usage elsewhere for now. + tags = coder.repo_map.get_tags(abs_fname, rel_fname) + for tag in tags: + if tag.name == symbol: + found_in_context.append(rel_fname) + break # Found in this file, move to next except Exception as e: - coder.io.tool_warning(f"Could not get tags for {rel_fname}: {e}") - - # Find matching symbols - for tag in all_tags: - if tag.name == symbol: - # Use absolute path directly if available, otherwise resolve from relative path - abs_fname = rel_fname_to_abs.get(tag.rel_fname) or coder.abs_root_path(tag.fname) - if abs_fname in files_to_search: # Ensure we only add files we intended to search - found_files.add(coder.get_rel_fname(abs_fname)) - - # Return formatted text instead of adding to context - if found_files: - found_files_list = sorted(list(found_files)) - if len(found_files) > 10: - result = ( - f"Found symbol '{symbol}' in {len(found_files)} files:" - f" {', '.join(found_files_list[:10])} and {len(found_files) - 10} more" - ) - coder.io.tool_output(f"🔎 Found '{symbol}' in {len(found_files)} files") - else: - result = ( - f"Found symbol '{symbol}' in {len(found_files)} files:" - f" {', '.join(found_files_list)}" - ) - coder.io.tool_output( - f"🔎 Found '{symbol}' in files:" - f" {', '.join(found_files_list[:5])}{' and more' if len(found_files) > 5 else ''}" + coder.io.tool_warning( + f"Could not get symbols for {rel_fname} while checking context: {e}" ) - return result - else: - coder.io.tool_output(f"⚠️ Symbol '{symbol}' not found in searchable files") - return f"Symbol '{symbol}' not found in searchable files" - - except Exception as e: - coder.io.tool_error(f"Error in ViewFilesWithSymbol: {str(e)}") - return f"Error: {str(e)}" + if found_in_context: + # Symbol found in already loaded files. Report this and stop. + file_list = ", ".join(sorted(list(set(found_in_context)))) + coder.io.tool_output(f"Symbol '{symbol}' found in already loaded file(s): {file_list}") + return f"Symbol '{symbol}' found in already loaded file(s): {file_list}" + # 2. If not found in context, search the repository using RepoMap + coder.io.tool_output(f"🔎 Searching for symbol '{symbol}' in repository...") + try: + found_files = set() + current_context_files = coder.abs_fnames | coder.abs_read_only_fnames + files_to_search = set(coder.get_all_abs_files()) - current_context_files + + rel_fname_to_abs = {} + all_tags = [] + + for fname in files_to_search: + rel_fname = coder.get_rel_fname(fname) + rel_fname_to_abs[rel_fname] = fname + try: + tags = coder.repo_map.get_tags(fname, rel_fname) + all_tags.extend(tags) + except Exception as e: + coder.io.tool_warning(f"Could not get tags for {rel_fname}: {e}") + + # Find matching symbols + for tag in all_tags: + if tag.name == symbol: + # Use absolute path directly if available, otherwise resolve from relative path + abs_fname = rel_fname_to_abs.get(tag.rel_fname) or coder.abs_root_path( + tag.fname + ) + if ( + abs_fname in files_to_search + ): # Ensure we only add files we intended to search + found_files.add(coder.get_rel_fname(abs_fname)) + + # Return formatted text instead of adding to context + if found_files: + found_files_list = sorted(list(found_files)) + if len(found_files) > 10: + result = ( + f"Found symbol '{symbol}' in {len(found_files)} files:" + f" {', '.join(found_files_list[:10])} and {len(found_files) - 10} more" + ) + coder.io.tool_output(f"🔎 Found '{symbol}' in {len(found_files)} files") + else: + result = ( + f"Found symbol '{symbol}' in {len(found_files)} files:" + f" {', '.join(found_files_list)}" + ) + coder.io.tool_output( + f"🔎 Found '{symbol}' in files:" + f" {', '.join(found_files_list[:5])}{' and more' if len(found_files) > 5 else ''}" + ) + + return result + else: + coder.io.tool_output(f"⚠️ Symbol '{symbol}' not found in searchable files") + return f"Symbol '{symbol}' not found in searchable files" -def process_response(coder, params): - """ - Process the ViewFilesWithSymbol tool response. - - Args: - coder: The Coder instance - params: Dictionary of parameters - - Returns: - str: Result message - """ - symbol = params.get("symbol") - if symbol is not None: - return _execute_view_files_with_symbol(coder, symbol) - else: - return "Error: Missing 'symbol' parameter for ViewFilesWithSymbol" + except Exception as e: + coder.io.tool_error(f"Error in ViewFilesWithSymbol: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/website/assets/sample-analytics.jsonl b/aider/website/assets/sample-analytics.jsonl index 22fe1a3f9f2..c7dbd7e58c3 100644 --- a/aider/website/assets/sample-analytics.jsonl +++ b/aider/website/assets/sample-analytics.jsonl @@ -1,20 +1,3 @@ -{"event": "no-repo", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591513} -{"event": "auto_commits", "properties": {"enabled": true}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591513} -{"event": "exit", "properties": {"reason": "Unknown edit format"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591513} -{"event": "launched", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591513} -{"event": "model warning", "properties": {"main_model": "None", "weak_model": "None", "editor_model": "None"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "no-repo", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "auto_commits", "properties": {"enabled": true}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "exit", "properties": {"reason": "Unknown edit format"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "launched", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "model warning", "properties": {"main_model": "None", "weak_model": "None", "editor_model": "None"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "no-repo", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "auto_commits", "properties": {"enabled": true}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "exit", "properties": {"reason": "Unknown edit format"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "launched", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "model warning", "properties": {"main_model": "None", "weak_model": "None", "editor_model": "None"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "no-repo", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} -{"event": "auto_commits", "properties": {"enabled": true}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} {"event": "exit", "properties": {"reason": "Unknown edit format"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} {"event": "launched", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591514} {"event": "model warning", "properties": {"main_model": "None", "weak_model": "None", "editor_model": "None"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1754591515} @@ -998,3 +981,20 @@ {"event": "launched", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763666132} {"event": "gui session", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763666132} {"event": "exit", "properties": {"reason": "GUI session ended"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763666132} +{"event": "launched", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766579} +{"event": "repo", "properties": {"num_files": 635}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766579} +{"event": "auto_commits", "properties": {"enabled": true}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766579} +{"event": "cli session", "properties": {"main_model": "gemini/gemini-3-pro-preview", "weak_model": "gemini/gemini-2.5-flash-lite", "editor_model": "gemini/gemini-3-pro-preview", "edit_format": "diff-fenced"}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766579} +{"event": "command_run", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766585} +{"event": "command_add", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766594} +{"event": "command_add", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766596} +{"event": "command_ask", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766598} +{"event": "message_send_starting", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766615} +{"event": "message_send", "properties": {"main_model": "gemini/gemini-3-pro-preview", "weak_model": "gemini/gemini-2.5-flash-lite", "editor_model": "gemini/gemini-3-pro-preview", "edit_format": "ask", "prompt_tokens": 25709, "completion_tokens": 321, "total_tokens": 26030, "cost": 0.05527, "total_cost": 0.05527}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763766666} +{"event": "command_reasoning-effort", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767274} +{"event": "command_run", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767286} +{"event": "message_send_starting", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767289} +{"event": "message_send", "properties": {"main_model": "gemini/gemini-3-pro-preview", "weak_model": "gemini/gemini-2.5-flash-lite", "editor_model": "gemini/gemini-3-pro-preview", "edit_format": "ask", "prompt_tokens": 26356, "completion_tokens": 275, "total_tokens": 26631, "cost": 0.05601199999999999, "total_cost": 0.11128199999999999}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767312} +{"event": "command_code", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767322} +{"event": "message_send_starting", "properties": {}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767322} +{"event": "message_send", "properties": {"main_model": "gemini/gemini-3-pro-preview", "weak_model": "gemini/gemini-2.5-flash-lite", "editor_model": "gemini/gemini-3-pro-preview", "edit_format": "diff-fenced", "prompt_tokens": 28932, "completion_tokens": 258, "total_tokens": 29190, "cost": 0.06096, "total_cost": 0.172242}, "user_id": "c42c4e6b-f054-44d7-ae1f-6726cc41da88", "time": 1763767333} diff --git a/aider/website/docs/config/adv-model-settings.md b/aider/website/docs/config/adv-model-settings.md index 6baa137a5d9..1e44cff9a7b 100644 --- a/aider/website/docs/config/adv-model-settings.md +++ b/aider/website/docs/config/adv-model-settings.md @@ -383,6 +383,7 @@ cog.out("```\n") weak_model_name: azure/gpt-5-mini use_repo_map: true examples_as_sys_msg: true + use_temperature: false streaming: false editor_model_name: azure/gpt-5 editor_edit_format: editor-diff @@ -1305,6 +1306,7 @@ cog.out("```\n") weak_model_name: gpt-5-mini use_repo_map: true examples_as_sys_msg: true + use_temperature: false streaming: false editor_model_name: gpt-5 editor_edit_format: editor-diff @@ -1567,6 +1569,7 @@ cog.out("```\n") weak_model_name: openai/gpt-5-mini use_repo_map: true examples_as_sys_msg: true + use_temperature: false streaming: false editor_model_name: openai/gpt-5 editor_edit_format: editor-diff @@ -2046,6 +2049,7 @@ cog.out("```\n") weak_model_name: openrouter/openai/gpt-5-mini use_repo_map: true examples_as_sys_msg: true + use_temperature: false streaming: false editor_model_name: openrouter/openai/gpt-5 editor_edit_format: editor-diff diff --git a/aider/website/docs/config/agent-mode.md b/aider/website/docs/config/agent-mode.md index 060b9624aa0..56991db409d 100644 --- a/aider/website/docs/config/agent-mode.md +++ b/aider/website/docs/config/agent-mode.md @@ -164,6 +164,17 @@ Certain tools are always available regardless of includelist/excludelist setting - `view` - View files - `finished` - Complete the task +#### Other Aider-CE CLI/Config Options for Agent Mode + +- `preserve-todo-list` - Preserve todo list across sessions +- `use-enhanced-map` - Use enhanced repo map that takes into account import relationships between files + +```yaml +preserve-todo-list: true +use-enhanced-map: true +``` + + #### Usage Examples ```bash @@ -178,6 +189,9 @@ aider-ce --agent --agent-config '{"large_file_token_threshold": 10000}' # Combined configuration aider-ce --agent --agent-config '{"large_file_token_threshold": 10000, "tools_includelist": ["view", "makeeditable", "replacetext", "finished", "gitdiff"]}' + +# Command Line Options +aider-ce --agent --agent-config '{"large_file_token_threshold": 10000, "tools_includelist": ["view", "makeeditable", "replacetext", "finished", "gitdiff"]}' --preserve-todo-list --use-enhanced-map ``` This configuration system allows for fine-grained control over which tools are available in Agent Mode, enabling security-conscious deployments and specialized workflows while maintaining essential functionality. diff --git a/aider/website/docs/faq.md b/aider/website/docs/faq.md index 73970e981ed..46d702667a2 100644 --- a/aider/website/docs/faq.md +++ b/aider/website/docs/faq.md @@ -264,12 +264,13 @@ tr:hover { background-color: #f5f5f5; }
| Model Name | Total Tokens | Percent |
|---|---|---|
| gemini/gemini-2.5-pro | 222,047 | 33.4% |
| gpt-5 | 211,072 | 31.7% |
| None | 168,988 | 25.4% |
| o3-pro | 36,620 | 5.5% |
| gemini/gemini-2.5-flash-lite | 15,470 | 2.3% |
| gemini/gemini-2.5-flash-lite-preview-06-17 | 11,371 | 1.7% |
| gemini/gemini-2.5-pro | 222,047 | 29.7% |
| gpt-5 | 211,072 | 28.2% |
| None | 168,988 | 22.6% |
| gemini/gemini-3-pro-preview | 81,851 | 11.0% |
| o3-pro | 36,620 | 4.9% |
| gemini/gemini-2.5-flash-lite | 15,470 | 2.1% |
| gemini/gemini-2.5-flash-lite-preview-06-17 | 11,371 | 1.5% |