diff --git a/aider/args.py b/aider/args.py index 08c9bde76..591eac40a 100644 --- a/aider/args.py +++ b/aider/args.py @@ -172,6 +172,13 @@ def get_parser(default_config_files, git_root): const="architect", help="Use architect edit format for the main chat", ) + group.add_argument( + "--navigator", + action="store_const", + dest="edit_format", + const="navigator", + help="Use navigator edit format for the main chat (autonomous file management)", + ) group.add_argument( "--auto-accept-architect", action=argparse.BooleanOptionalAction, diff --git a/aider/change_tracker.py b/aider/change_tracker.py new file mode 100644 index 000000000..f826c3975 --- /dev/null +++ b/aider/change_tracker.py @@ -0,0 +1,133 @@ +import time +import uuid +from collections import defaultdict +from datetime import datetime + +class ChangeTracker: + """ + Tracks changes made to files for the undo functionality. + This enables granular editing operations with the ability to undo specific changes. + """ + + def __init__(self): + self.changes = {} # change_id -> change_info + self.files_changed = defaultdict(list) # file_path -> [change_ids] + + def track_change(self, file_path, change_type, original_content, new_content, + metadata=None, change_id=None): + """ + Record a change to enable future undo operations. + + Parameters: + - file_path: Path to the file that was changed + - change_type: Type of change (e.g., 'replacetext', 'insertlines') + - original_content: Original content before the change + - new_content: New content after the change + - metadata: Additional information about the change (line numbers, positions, etc.) + - change_id: Optional custom ID for the change (if None, one will be generated) + + Returns: + - change_id: Unique identifier for the change + """ + if change_id is None: + generated_id = self._generate_change_id() + # Ensure the generated ID is treated as a string + current_change_id = str(generated_id) + else: + # If an ID is provided, ensure it's treated as a string key/value + current_change_id = str(change_id) + + # Defensive check: Ensure the ID isn't literally the string 'False' or boolean False + # which might indicate an upstream issue or unexpected input. + if current_change_id == 'False' or current_change_id is False: + # Log a warning? For now, generate a new ID to prevent storing False. + print(f"Warning: change_id evaluated to False for {file_path}. Generating new ID.") + current_change_id = self._generate_change_id() + + + change = { + # Use the confirmed string ID here + 'id': current_change_id, + 'file_path': file_path, + 'type': change_type, + 'original': original_content, + 'new': new_content, + 'metadata': metadata or {}, + 'timestamp': time.time() + } + + # Use the confirmed string ID for storage and return + self.changes[current_change_id] = change + self.files_changed[file_path].append(current_change_id) + return current_change_id + + def undo_change(self, change_id): + """ + Get information needed to reverse a specific change by ID. + + Parameters: + - change_id: ID of the change to undo + + Returns: + - (success, message, change_info): Tuple with success flag, message, and change information + """ + if change_id not in self.changes: + return False, f"Change ID {change_id} not found", None + + change = self.changes[change_id] + + # Mark this change as undone by removing it from the tracking dictionaries + self.files_changed[change['file_path']].remove(change_id) + if not self.files_changed[change['file_path']]: + del self.files_changed[change['file_path']] + + # Keep the change in the changes dict but mark it as undone + change['undone'] = True + change['undone_at'] = time.time() + + return True, f"Undid change {change_id} in {change['file_path']}", change + + def get_last_change(self, file_path): + """ + Get the most recent change for a specific file. + + Parameters: + - file_path: Path to the file + + Returns: + - change_id or None if no changes found + """ + changes = self.files_changed.get(file_path, []) + if not changes: + return None + return changes[-1] + + def list_changes(self, file_path=None, limit=10): + """ + List recent changes, optionally filtered by file. + + Parameters: + - file_path: Optional path to filter changes by file + - limit: Maximum number of changes to list + + Returns: + - List of change dictionaries + """ + if file_path: + # Get changes only for the specified file + change_ids = self.files_changed.get(file_path, []) + changes = [self.changes[cid] for cid in change_ids if cid in self.changes] + else: + # Get all changes + changes = list(self.changes.values()) + + # Filter out undone changes and sort by timestamp (most recent first) + changes = [c for c in changes if not c.get('undone', False)] + changes = sorted(changes, key=lambda c: c['timestamp'], reverse=True) + + # Apply limit + return changes[:limit] + + def _generate_change_id(self): + """Generate a unique ID for a change.""" + return str(uuid.uuid4())[:8] # Short, readable ID diff --git a/aider/coders/__init__.py b/aider/coders/__init__.py index 88bcddfaa..138540c61 100644 --- a/aider/coders/__init__.py +++ b/aider/coders/__init__.py @@ -9,6 +9,7 @@ from .editor_editblock_coder import EditorEditBlockCoder from .editor_whole_coder import EditorWholeFileCoder from .help_coder import HelpCoder from .patch_coder import PatchCoder +from .navigator_coder import NavigatorCoder from .udiff_coder import UnifiedDiffCoder from .udiff_simple import UnifiedDiffSimpleCoder from .wholefile_coder import WholeFileCoder @@ -31,4 +32,5 @@ __all__ = [ EditorWholeFileCoder, EditorDiffFencedCoder, ContextCoder, + NavigatorCoder, ] diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 5dbe03cf9..cb65fcaca 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -119,6 +119,10 @@ class Coder: ignore_mentions = None chat_language = None file_watcher = None + + # Context management settings (for all modes) + context_management_enabled = False # Disabled by default except for navigator mode + large_file_token_threshold = 25000 # Files larger than this will be truncated when context management is enabled @classmethod def create( @@ -641,11 +645,36 @@ class Coder: prompt += relative_fname prompt += f"\n{self.fence[0]}\n" - prompt += content - - # lines = content.splitlines(keepends=True) - # lines = [f"{i+1:03}:{line}" for i, line in enumerate(lines)] - # prompt += "".join(lines) + # Apply context management if enabled for large files + if self.context_management_enabled: + # Calculate tokens for this file + file_tokens = self.main_model.token_count(content) + + if file_tokens > self.large_file_token_threshold: + # Truncate the file content + lines = content.splitlines() + total_lines = len(lines) + + # Keep the first and last parts of the file with a marker in between + keep_lines = self.large_file_token_threshold // 40 # Rough estimate of tokens per line + first_chunk = lines[:keep_lines//2] + last_chunk = lines[-(keep_lines//2):] + + truncated_content = "\n".join(first_chunk) + truncated_content += f"\n\n... [File truncated due to size ({file_tokens} tokens). Use /context-management to toggle truncation off] ...\n\n" + truncated_content += "\n".join(last_chunk) + + # Add message about truncation + self.io.tool_output( + f"āš ļø '{relative_fname}' is very large ({file_tokens} tokens). " + "Use /context-management to toggle truncation off if needed." + ) + + prompt += truncated_content + else: + prompt += content + else: + prompt += content prompt += f"{self.fence[1]}\n" @@ -660,7 +689,38 @@ class Coder: prompt += "\n" prompt += relative_fname prompt += f"\n{self.fence[0]}\n" - prompt += content + + # Apply context management if enabled for large files (same as get_files_content) + if self.context_management_enabled: + # Calculate tokens for this file + file_tokens = self.main_model.token_count(content) + + if file_tokens > self.large_file_token_threshold: + # Truncate the file content + lines = content.splitlines() + total_lines = len(lines) + + # Keep the first and last parts of the file with a marker in between + keep_lines = self.large_file_token_threshold // 40 # Rough estimate of tokens per line + first_chunk = lines[:keep_lines//2] + last_chunk = lines[-(keep_lines//2):] + + truncated_content = "\n".join(first_chunk) + truncated_content += f"\n\n... [File truncated due to size ({file_tokens} tokens). Use /context-management to toggle truncation off] ...\n\n" + truncated_content += "\n".join(last_chunk) + + # Add message about truncation + self.io.tool_output( + f"āš ļø '{relative_fname}' is very large ({file_tokens} tokens). " + "Use /context-management to toggle truncation off if needed." + ) + + prompt += truncated_content + else: + prompt += content + else: + prompt += content + prompt += f"{self.fence[1]}\n" return prompt diff --git a/aider/coders/navigator_coder.py b/aider/coders/navigator_coder.py new file mode 100644 index 000000000..8fbcb7284 --- /dev/null +++ b/aider/coders/navigator_coder.py @@ -0,0 +1,1798 @@ +import ast +import re +import fnmatch +import os +import time +import random +import subprocess +import traceback +import platform +import locale +from datetime import datetime +from pathlib import Path +import xml.etree.ElementTree as ET +from xml.etree.ElementTree import ParseError +# Add necessary imports if not already present +from collections import defaultdict + +from .base_coder import Coder, ChatChunks +from .editblock_coder import find_original_update_blocks, do_replace, find_similar_lines +from .navigator_prompts import NavigatorPrompts +from .navigator_legacy_prompts import NavigatorLegacyPrompts +from aider.repo import ANY_GIT_ERROR +from aider import urls +# Import run_cmd for potentially interactive execution and run_cmd_subprocess for guaranteed non-interactive +from aider.run_cmd import run_cmd, run_cmd_subprocess +# Import the change tracker +from aider.change_tracker import ChangeTracker +# Import tool functions +from aider.tools.view_files_at_glob import execute_view_files_at_glob +from aider.tools.view_files_matching import execute_view_files_matching +from aider.tools.ls import execute_ls +from aider.tools.view import execute_view +from aider.tools.remove import _execute_remove +from aider.tools.make_editable import _execute_make_editable +from aider.tools.make_readonly import _execute_make_readonly +from aider.tools.view_files_with_symbol import _execute_view_files_with_symbol +from aider.tools.command import _execute_command +from aider.tools.command_interactive import _execute_command_interactive +from aider.tools.replace_text import _execute_replace_text +from aider.tools.replace_all import _execute_replace_all +from aider.tools.insert_block import _execute_insert_block +from aider.tools.delete_block import _execute_delete_block +from aider.tools.replace_line import _execute_replace_line +from aider.tools.replace_lines import _execute_replace_lines +from aider.tools.indent_lines import _execute_indent_lines +from aider.tools.delete_line import _execute_delete_line +from aider.tools.delete_lines import _execute_delete_lines +from aider.tools.undo_change import _execute_undo_change +from aider.tools.list_changes import _execute_list_changes +from aider.tools.extract_lines import _execute_extract_lines +from aider.tools.show_numbered_context import execute_show_numbered_context + + +class NavigatorCoder(Coder): + """Mode where the LLM autonomously manages which files are in context.""" + + edit_format = "navigator" + + # TODO: We'll turn on granular editing by default once those tools stabilize + use_granular_editing = False + + def __init__(self, *args, **kwargs): + # Initialize appropriate prompt set before calling parent constructor + # This needs to happen before super().__init__ so the parent class has access to gpt_prompts + self.gpt_prompts = NavigatorPrompts() if self.use_granular_editing else NavigatorLegacyPrompts() + + # Dictionary to track recently removed files + self.recently_removed = {} + + # Configuration parameters + self.max_tool_calls = 100 # Maximum number of tool calls per response + + # Context management parameters + self.large_file_token_threshold = 25000 # Files larger than this in tokens are considered large + self.max_files_per_glob = 50 # Maximum number of files to add at once via glob/grep + + # Enable context management by default only in navigator mode + self.context_management_enabled = True # Enabled by default for navigator mode + + # Initialize change tracker for granular editing + self.change_tracker = ChangeTracker() + + # Track files added during current exploration + self.files_added_in_exploration = set() + + # Counter for tool calls + self.tool_call_count = 0 + + # Set high max reflections to allow many exploration rounds + # This controls how many automatic iterations the LLM can do + self.max_reflections = 15 + + # Enable enhanced context blocks by default + self.use_enhanced_context = True + + # Initialize empty token tracking dictionary and cache structures + # but don't populate yet to avoid startup delay + self.context_block_tokens = {} + self.context_blocks_cache = {} + self.tokens_calculated = False + + super().__init__(*args, **kwargs) + + def _calculate_context_block_tokens(self, force=False): + """ + Calculate token counts for all enhanced context blocks. + This is the central method for calculating token counts, + ensuring they're consistent across all parts of the code. + + This method populates the cache for context blocks and calculates tokens. + + Args: + force: If True, recalculate tokens even if already calculated + """ + # Skip if already calculated and not forced + if hasattr(self, 'tokens_calculated') and self.tokens_calculated and not force: + return + + # Clear existing token counts + self.context_block_tokens = {} + + # Initialize the cache for context blocks if needed + if not hasattr(self, 'context_blocks_cache'): + self.context_blocks_cache = {} + + if not self.use_enhanced_context: + return + + try: + # First, clear the cache to force regeneration of all blocks + self.context_blocks_cache = {} + + # Generate all context blocks and calculate token counts + block_types = ["environment_info", "directory_structure", "git_status", "symbol_outline"] + + for block_type in block_types: + block_content = self._generate_context_block(block_type) + if block_content: + self.context_block_tokens[block_type] = self.main_model.token_count(block_content) + + # Mark as calculated + self.tokens_calculated = True + except Exception as e: + # Silently handle errors during calculation + # This prevents errors in token counting from breaking the main functionality + pass + + def _generate_context_block(self, block_name): + """ + Generate a specific context block and cache it. + This is a helper method for get_cached_context_block. + """ + content = None + + if block_name == "environment_info": + content = self.get_environment_info() + elif block_name == "directory_structure": + content = self.get_directory_structure() + elif block_name == "git_status": + content = self.get_git_status() + elif block_name == "symbol_outline": + content = self.get_context_symbol_outline() + elif block_name == "context_summary": + content = self.get_context_summary() + + # Cache the result if it's not None + if content is not None: + self.context_blocks_cache[block_name] = content + + return content + + def get_cached_context_block(self, block_name): + """ + Get a context block from the cache, or generate it if not available. + This should be used by format_chat_chunks to avoid regenerating blocks. + + This will ensure tokens are calculated if they haven't been yet. + """ + # Make sure tokens have been calculated at least once + if not hasattr(self, 'tokens_calculated') or not self.tokens_calculated: + self._calculate_context_block_tokens() + + # Return from cache if available + if hasattr(self, 'context_blocks_cache') and block_name in self.context_blocks_cache: + return self.context_blocks_cache[block_name] + + # Otherwise generate and cache the block + return self._generate_context_block(block_name) + + def set_granular_editing(self, enabled): + """ + Switch between granular editing tools and legacy search/replace. + + Args: + enabled (bool): True to use granular editing tools, False to use legacy search/replace + """ + self.use_granular_editing = enabled + self.gpt_prompts = NavigatorPrompts() if enabled else NavigatorLegacyPrompts() + + def get_context_symbol_outline(self): + """ + Generate a symbol outline for files currently in context using Tree-sitter, + bypassing the cache for freshness. + """ + if not self.use_enhanced_context or not self.repo_map: + return None + + try: + result = "\n" + result += "## Symbol Outline (Current Context)\n\n" + result += "Code definitions (classes, functions, methods, etc.) found in files currently in chat context.\n\n" + + files_to_outline = list(self.abs_fnames) + list(self.abs_read_only_fnames) + if not files_to_outline: + result += "No files currently in context.\n" + result += "" + return result + + all_tags_by_file = defaultdict(list) + has_symbols = False + + # Use repo_map which should be initialized in BaseCoder + if not self.repo_map: + self.io.tool_warning("RepoMap not initialized, cannot generate symbol outline.") + return None # Or return a message indicating repo map is unavailable + + for abs_fname in sorted(files_to_outline): + rel_fname = self.get_rel_fname(abs_fname) + try: + # Call get_tags_raw directly to bypass cache and ensure freshness + tags = list(self.repo_map.get_tags_raw(abs_fname, rel_fname)) + if tags: + all_tags_by_file[rel_fname].extend(tags) + has_symbols = True + except Exception as e: + self.io.tool_warning(f"Could not get symbols for {rel_fname}: {e}") + + if not has_symbols: + result += "No symbols found in the current context files.\n" + else: + for rel_fname in sorted(all_tags_by_file.keys()): + tags = sorted(all_tags_by_file[rel_fname], key=lambda t: (t.line, t.name)) + + definition_tags = [] + for tag in tags: + # Use specific_kind first if available, otherwise fall back to kind + kind_to_check = tag.specific_kind or tag.kind + # Check if the kind represents a definition using the set from RepoMap + if kind_to_check and kind_to_check.lower() in self.repo_map.definition_kinds: + definition_tags.append(tag) + + if definition_tags: + result += f"### {rel_fname}\n" + # Simple list format for now, could be enhanced later (e.g., indentation for scope) + for tag in definition_tags: + # Display line number if available + line_info = f", line {tag.line + 1}" if tag.line >= 0 else "" + # Display the specific kind (which we checked) + kind_to_check = tag.specific_kind or tag.kind # Recalculate for safety + result += f"- {tag.name} ({kind_to_check}{line_info})\n" + result += "\n" # Add space between files + + result += "" + return result.strip() # Remove trailing newline if any + + except Exception as e: + self.io.tool_error(f"Error generating symbol outline: {str(e)}") + # Optionally include traceback for debugging if verbose + # if self.verbose: + # self.io.tool_error(traceback.format_exc()) + return None + + def format_chat_chunks(self): + """ + Override parent's format_chat_chunks to include enhanced context blocks with a + cleaner, more hierarchical structure for better organization. + + Optimized for prompt caching by placing context blocks strategically: + 1. Relatively static blocks (directory structure, environment info) before done_messages + 2. Dynamic blocks (context summary, symbol outline, git status) after chat_files + + This approach preserves prefix caching while providing fresh context information. + """ + # First get the normal chat chunks from the parent method without calling super + # We'll manually build the chunks to control placement of context blocks + chunks = self.format_chat_chunks_base() + + # If enhanced context blocks are not enabled, just return the base chunks + if not self.use_enhanced_context: + return chunks + + # Make sure token counts are updated - using centralized method + # This also populates the context block cache + self._calculate_context_block_tokens() + + # Get blocks from cache to avoid regenerating them + env_context = self.get_cached_context_block("environment_info") + dir_structure = self.get_cached_context_block("directory_structure") + git_status = self.get_cached_context_block("git_status") + symbol_outline = self.get_cached_context_block("symbol_outline") + + # Context summary needs special handling because it depends on other blocks + context_summary = self.get_context_summary() + + # 1. Add relatively static blocks BEFORE done_messages + # These blocks change less frequently and can be part of the cacheable prefix + static_blocks = [] + if dir_structure: + static_blocks.append(dir_structure) + if env_context: + static_blocks.append(env_context) + + if static_blocks: + static_message = "\n\n".join(static_blocks) + # Insert as a system message right before done_messages + chunks.done.insert(0, dict(role="system", content=static_message)) + + # 2. Add dynamic blocks AFTER chat_files + # These blocks change with the current files in context + dynamic_blocks = [] + if context_summary: + dynamic_blocks.append(context_summary) + if symbol_outline: + dynamic_blocks.append(symbol_outline) + if git_status: + dynamic_blocks.append(git_status) + + if dynamic_blocks: + dynamic_message = "\n\n".join(dynamic_blocks) + # Append as a system message after chat_files + chunks.chat_files.append(dict(role="system", content=dynamic_message)) + + return chunks + + def format_chat_chunks_base(self): + """ + Create base chat chunks without enhanced context blocks. + This is a copy of the parent's format_chat_chunks method to avoid + calling super() which would create a recursive loop. + """ + self.choose_fence() + main_sys = self.fmt_system_prompt(self.gpt_prompts.main_system) + + example_messages = [] + if self.main_model.examples_as_sys_msg: + if self.gpt_prompts.example_messages: + main_sys += "\n# Example conversations:\n\n" + for msg in self.gpt_prompts.example_messages: + role = msg["role"] + content = self.fmt_system_prompt(msg["content"]) + main_sys += f"## {role.upper()}: {content}\n\n" + main_sys = main_sys.strip() + else: + for msg in self.gpt_prompts.example_messages: + example_messages.append( + dict( + role=msg["role"], + content=self.fmt_system_prompt(msg["content"]), + ) + ) + if self.gpt_prompts.example_messages: + example_messages += [ + dict( + role="user", + content=( + "I switched to a new code base. Please don't consider the above files" + " or try to edit them any longer." + ), + ), + dict(role="assistant", content="Ok."), + ] + + if self.gpt_prompts.system_reminder: + main_sys += "\n" + self.fmt_system_prompt(self.gpt_prompts.system_reminder) + + chunks = ChatChunks() + + if self.main_model.use_system_prompt: + chunks.system = [ + dict(role="system", content=main_sys), + ] + else: + chunks.system = [ + dict(role="user", content=main_sys), + dict(role="assistant", content="Ok."), + ] + + chunks.examples = example_messages + + self.summarize_end() + chunks.done = self.done_messages + + chunks.repo = self.get_repo_messages() + chunks.readonly_files = self.get_readonly_files_messages() + chunks.chat_files = self.get_chat_files_messages() + + if self.gpt_prompts.system_reminder: + reminder_message = [ + dict( + role="system", content=self.fmt_system_prompt(self.gpt_prompts.system_reminder) + ), + ] + else: + reminder_message = [] + + chunks.cur = list(self.cur_messages) + chunks.reminder = [] + + # Use accurate token counting method that considers enhanced context blocks + base_messages = chunks.all_messages() + messages_tokens = self.main_model.token_count(base_messages) + reminder_tokens = self.main_model.token_count(reminder_message) + cur_tokens = self.main_model.token_count(chunks.cur) + + if None not in (messages_tokens, reminder_tokens, cur_tokens): + total_tokens = messages_tokens + # Only add tokens for reminder and cur if they're not already included + # in the messages_tokens calculation + if not chunks.reminder: + total_tokens += reminder_tokens + if not chunks.cur: + total_tokens += cur_tokens + else: + # add the reminder anyway + total_tokens = 0 + + if chunks.cur: + final = chunks.cur[-1] + else: + final = None + + max_input_tokens = self.main_model.info.get("max_input_tokens") or 0 + # Add the reminder prompt if we still have room to include it. + if ( + not max_input_tokens + or total_tokens < max_input_tokens + and self.gpt_prompts.system_reminder + ): + if self.main_model.reminder == "sys": + chunks.reminder = reminder_message + elif self.main_model.reminder == "user" and final and final["role"] == "user": + # stuff it into the user message + new_content = ( + final["content"] + + "\n\n" + + self.fmt_system_prompt(self.gpt_prompts.system_reminder) + ) + chunks.cur[-1] = dict(role=final["role"], content=new_content) + + return chunks + + def get_context_summary(self): + """ + Generate a summary of the current context, including file content tokens and additional context blocks, + with an accurate total token count. + """ + if not self.use_enhanced_context: + return None + + # If context_summary is already in the cache, return it + if hasattr(self, 'context_blocks_cache') and "context_summary" in self.context_blocks_cache: + return self.context_blocks_cache["context_summary"] + + try: + # Make sure token counts are updated before generating the summary + if not hasattr(self, 'context_block_tokens') or not self.context_block_tokens: + self._calculate_context_block_tokens() + + result = "\n" + result += "## Current Context Overview\n\n" + max_input_tokens = self.main_model.info.get("max_input_tokens") or 0 + max_output_tokens = self.main_model.info.get("max_output_tokens") or 0 + if max_input_tokens: + result += f"Model context limit: {max_input_tokens:,} tokens\n\n" + + total_file_tokens = 0 + editable_tokens = 0 + readonly_tokens = 0 + editable_files = [] + readonly_files = [] + + # Editable files + if self.abs_fnames: + result += "### Editable Files\n\n" + for fname in sorted(self.abs_fnames): + rel_fname = self.get_rel_fname(fname) + content = self.io.read_text(fname) + if content is not None: + tokens = self.main_model.token_count(content) + total_file_tokens += tokens + editable_tokens += tokens + size_indicator = "šŸ”“ Large" if tokens > 5000 else ("🟔 Medium" if tokens > 1000 else "🟢 Small") + editable_files.append(f"- {rel_fname}: {tokens:,} tokens ({size_indicator})") + if editable_files: + result += "\n".join(editable_files) + "\n\n" + result += f"**Total editable: {len(editable_files)} files, {editable_tokens:,} tokens**\n\n" + else: + result += "No editable files in context\n\n" + + # Read-only files + if self.abs_read_only_fnames: + result += "### Read-Only Files\n\n" + for fname in sorted(self.abs_read_only_fnames): + rel_fname = self.get_rel_fname(fname) + content = self.io.read_text(fname) + if content is not None: + tokens = self.main_model.token_count(content) + total_file_tokens += tokens + readonly_tokens += tokens + size_indicator = "šŸ”“ Large" if tokens > 5000 else ("🟔 Medium" if tokens > 1000 else "🟢 Small") + readonly_files.append(f"- {rel_fname}: {tokens:,} tokens ({size_indicator})") + if readonly_files: + result += "\n".join(readonly_files) + "\n\n" + result += f"**Total read-only: {len(readonly_files)} files, {readonly_tokens:,} tokens**\n\n" + else: + result += "No read-only files in context\n\n" + + # Use the pre-calculated context block tokens + extra_tokens = sum(self.context_block_tokens.values()) + total_tokens = total_file_tokens + extra_tokens + + result += f"**Total files usage: {total_file_tokens:,} tokens**\n\n" + result += f"**Additional context usage: {extra_tokens:,} tokens**\n\n" + result += f"**Total context usage: {total_tokens:,} tokens**" + if max_input_tokens: + percentage = (total_tokens / max_input_tokens) * 100 + result += f" ({percentage:.1f}% of limit)" + if percentage > 80: + result += "\n\nāš ļø **Context is getting full!** Remove non-essential files via:\n" + result += "- `[tool_call(Remove, file_path=\"path/to/large_file.ext\")]`\n" + result += "- Keep only essential files in context for best performance" + result += "\n" + + # Cache the result + if not hasattr(self, 'context_blocks_cache'): + self.context_blocks_cache = {} + self.context_blocks_cache["context_summary"] = result + + return result + except Exception as e: + self.io.tool_error(f"Error generating context summary: {str(e)}") + return None + + def get_environment_info(self): + """ + Generate an environment information context block with key system details. + Returns formatted string with working directory, platform, date, and other relevant environment details. + """ + if not self.use_enhanced_context: + return None + + try: + # Get current date in ISO format + current_date = datetime.now().strftime("%Y-%m-%d") + + # Get platform information + platform_info = platform.platform() + + # Get language preference + language = self.chat_language or locale.getlocale()[0] or "en-US" + + result = "\n" + result += "## Environment Information\n\n" + result += f"- Working directory: {self.root}\n" + result += f"- Current date: {current_date}\n" + result += f"- Platform: {platform_info}\n" + result += f"- Language preference: {language}\n" + + # Add git repo information if available + if self.repo: + try: + rel_repo_dir = self.repo.get_rel_repo_dir() + num_files = len(self.repo.get_tracked_files()) + result += f"- Git repository: {rel_repo_dir} with {num_files:,} files\n" + except Exception: + result += "- Git repository: active but details unavailable\n" + else: + result += "- Git repository: none\n" + + # Add enabled features information + features = [] + if self.context_management_enabled: + features.append("context management") + if self.use_enhanced_context: + features.append("enhanced context blocks") + if features: + result += f"- Enabled features: {', '.join(features)}\n" + + result += "" + return result + except Exception as e: + self.io.tool_error(f"Error generating environment info: {str(e)}") + return None + + def reply_completed(self): + """Process the completed response from the LLM. + + This is a key method that: + 1. Processes any tool commands in the response (only after a '---' line) + 2. Processes any SEARCH/REPLACE blocks in the response (only before the '---' line if one exists) + 3. If tool commands were found, sets up for another automatic round + + This enables the "auto-exploration" workflow where the LLM can + iteratively discover and analyze relevant files before providing + a final answer to the user's question. + """ + content = self.partial_response_content + if not content or not content.strip(): + return True + original_content = content # Keep the original response + + # Process tool commands: returns content with tool calls removed, results, flag if any tool calls were found, + # and the content before the last '---' line + processed_content, result_messages, tool_calls_found, content_before_last_separator = self._process_tool_commands(content) + + # Since we are no longer suppressing, the partial_response_content IS the final content. + # We might want to update it to the processed_content (without tool calls) if we don't + # want the raw tool calls to remain in the final assistant message history. + # Let's update it for cleaner history. + self.partial_response_content = processed_content.strip() + + # Process implicit file mentions using the content *after* tool calls were removed + self._process_file_mentions(processed_content) + + # Check if the content contains the SEARCH/REPLACE markers + has_search = "<<<<<<< SEARCH" in self.partial_response_content + has_divider = "=======" in self.partial_response_content + has_replace = ">>>>>>> REPLACE" in self.partial_response_content + edit_match = has_search and has_divider and has_replace + + # Check if there's a '---' line - if yes, SEARCH/REPLACE blocks can only appear before it + separator_marker = "\n---\n" + if separator_marker in original_content and edit_match: + # Check if the edit blocks are only in the part before the last '---' line + has_search_before = "<<<<<<< SEARCH" in content_before_last_separator + has_divider_before = "=======" in content_before_last_separator + has_replace_before = ">>>>>>> REPLACE" in content_before_last_separator + edit_match = has_search_before and has_divider_before and has_replace_before + + if edit_match: + self.io.tool_output("Detected edit blocks, applying changes within Navigator...") + edited_files = self._apply_edits_from_response() + # If _apply_edits_from_response set a reflected_message (due to errors), + # return False to trigger a reflection loop. + if self.reflected_message: + return False + + # If edits were successfully applied and we haven't exceeded reflection limits, + # set up for another iteration (similar to tool calls) + if edited_files and self.num_reflections < self.max_reflections: + # Get the original user question from the most recent user message + if self.cur_messages and len(self.cur_messages) >= 1: + for msg in reversed(self.cur_messages): + if msg["role"] == "user": + original_question = msg["content"] + break + else: + # Default if no user message found + original_question = "Please continue your exploration and provide a final answer." + + # Construct the message for the next turn + next_prompt = ( + "I have applied the edits you suggested. " + f"The following files were modified: {', '.join(edited_files)}. " + "Let me continue working on your request.\n\n" + f"Your original question was: {original_question}" + ) + + self.reflected_message = next_prompt + self.io.tool_output("Continuing after applying edits...") + return False # Indicate that we need another iteration + + # If any tool calls were found and we haven't exceeded reflection limits, set up for another iteration + # This is implicit continuation when any tool calls are present, rather than requiring Continue explicitly + if tool_calls_found and self.num_reflections < self.max_reflections: + # Reset tool counter for next iteration + self.tool_call_count = 0 + # Clear exploration files for the next round + self.files_added_in_exploration = set() + + # Get the original user question from the most recent user message + if self.cur_messages and len(self.cur_messages) >= 1: + for msg in reversed(self.cur_messages): + if msg["role"] == "user": + original_question = msg["content"] + break + else: + # Default if no user message found + original_question = "Please continue your exploration and provide a final answer." + + # Construct the message for the next turn, including tool results + next_prompt_parts = [] + next_prompt_parts.append( + "I have processed the results of the previous tool calls. " + "Let me analyze them and continue working towards your request." + ) + + if result_messages: + next_prompt_parts.append("\nResults from previous tool calls:") + # result_messages already have [Result (...): ...] format + next_prompt_parts.extend(result_messages) + next_prompt_parts.append("\nBased on these results and the updated file context, I will proceed.") + else: + next_prompt_parts.append("\nNo specific results were returned from the previous tool calls, but the file context may have been updated. I will proceed based on the current context.") + + next_prompt_parts.append(f"\nYour original question was: {original_question}") + + self.reflected_message = "\n".join(next_prompt_parts) + + self.io.tool_output("Continuing exploration...") + return False # Indicate that we need another iteration + else: + # Exploration finished for this turn. + # Append results to the content that will be stored in history. + if result_messages: + results_block = "\n\n" + "\n".join(result_messages) + # Append results to the cleaned content + self.partial_response_content += results_block + + # After applying edits OR determining no edits were needed (and no reflection needed), + # the turn is complete. Reset counters and finalize history. + self.tool_call_count = 0 + self.files_added_in_exploration = set() + # Move cur_messages to done_messages + self.move_back_cur_messages(None) # Pass None as we handled commit message earlier if needed + return True # Indicate exploration is finished for this round + + def _process_tool_commands(self, content): + """ + Process tool commands in the `[tool_call(name, param=value)]` format within the content. + + Rules: + 1. Tool calls must appear after the LAST '---' line separator in the content + 2. Any tool calls before this last separator are treated as text (not executed) + 3. SEARCH/REPLACE blocks can only appear before this last separator + + Returns processed content, result messages, and a flag indicating if any tool calls were found. + Also returns the content before the last separator for SEARCH/REPLACE block validation. + """ + result_messages = [] + modified_content = content # Start with original content + tool_calls_found = False + call_count = 0 + max_calls = self.max_tool_calls + + # Check if there's a '---' separator and only process tool calls after the LAST one + separator_marker = "\n---\n" + content_parts = content.split(separator_marker) + + # If there's no separator, treat the entire content as before the separator + if len(content_parts) == 1: + # Return the original content with no tool calls processed, and the content itself as before_separator + return content, result_messages, False, content + + # Take everything before the last separator (including intermediate separators) + content_before_separator = separator_marker.join(content_parts[:-1]) + # Take only what comes after the last separator + content_after_separator = content_parts[-1] + + # Find tool calls using a more robust method, but only in the content after separator + processed_content = content_before_separator + separator_marker + last_index = 0 + start_marker = "[tool_call(" + end_marker = "]" # The parenthesis balancing finds the ')', we just need the final ']' + + while True: + start_pos = content_after_separator.find(start_marker, last_index) + if start_pos == -1: + processed_content += content_after_separator[last_index:] + break + + # Check for escaped tool call: \[tool_call( + if start_pos > 0 and content_after_separator[start_pos - 1] == '\\': + # Append the content including the escaped marker + # We append up to start_pos + len(start_marker) to include the marker itself. + processed_content += content_after_separator[last_index : start_pos + len(start_marker)] + # Update last_index to search after this escaped marker + last_index = start_pos + len(start_marker) + continue # Continue searching for the next potential marker + + # Append content before the (non-escaped) tool call + processed_content += content_after_separator[last_index:start_pos] + + scan_start_pos = start_pos + len(start_marker) + paren_level = 1 + in_single_quotes = False + in_double_quotes = False + escaped = False + end_paren_pos = -1 + + # Scan to find the matching closing parenthesis, respecting quotes + for i in range(scan_start_pos, len(content_after_separator)): + char = content_after_separator[i] + + if escaped: + escaped = False + elif char == '\\': + escaped = True + elif char == "'" and not in_double_quotes: + in_single_quotes = not in_single_quotes + elif char == '"' and not in_single_quotes: + in_double_quotes = not in_double_quotes + elif char == '(' and not in_single_quotes and not in_double_quotes: + paren_level += 1 + elif char == ')' and not in_single_quotes and not in_double_quotes: + paren_level -= 1 + if paren_level == 0: + end_paren_pos = i + break + + # Check for the end marker after the closing parenthesis, skipping whitespace + expected_end_marker_start = end_paren_pos + 1 + actual_end_marker_start = -1 + end_marker_found = False + if end_paren_pos != -1: # Only search if we found a closing parenthesis + for j in range(expected_end_marker_start, len(content_after_separator)): + if not content_after_separator[j].isspace(): + actual_end_marker_start = j + # Check if the found character is the end marker ']' + if content_after_separator[actual_end_marker_start] == end_marker: + end_marker_found = True + break # Stop searching after first non-whitespace char + + if not end_marker_found: + # Try to extract the tool name for better error message + tool_name = "unknown" + try: + # Look for the first comma after the tool call start + partial_content = content_after_separator[scan_start_pos:scan_start_pos+100] # Limit to avoid huge strings + comma_pos = partial_content.find(',') + if comma_pos > 0: + tool_name = partial_content[:comma_pos].strip() + else: + # If no comma, look for opening parenthesis or first whitespace + space_pos = partial_content.find(' ') + paren_pos = partial_content.find('(') + if space_pos > 0 and (paren_pos < 0 or space_pos < paren_pos): + tool_name = partial_content[:space_pos].strip() + elif paren_pos > 0: + tool_name = partial_content[:paren_pos].strip() + except: + pass # Silently fail if we can't extract the name + + # Malformed call: couldn't find matching ')' or the subsequent ']' + self.io.tool_warning(f"Malformed tool call for '{tool_name}'. Missing closing parenthesis or bracket. Skipping.") + # Append the start marker itself to processed content so it's not lost + processed_content += start_marker + last_index = scan_start_pos # Continue searching after the marker + continue + + # Found a potential tool call + # Adjust full_match_str and last_index based on the actual end marker ']' position + full_match_str = content_after_separator[start_pos : actual_end_marker_start + 1] # End marker ']' is 1 char + inner_content = content_after_separator[scan_start_pos:end_paren_pos].strip() + last_index = actual_end_marker_start + 1 # Move past the processed call (including ']') + + + call_count += 1 + if call_count > max_calls: + self.io.tool_warning(f"Exceeded maximum tool calls ({max_calls}). Skipping remaining calls.") + # Don't append the skipped call to processed_content + continue # Skip processing this call + + tool_calls_found = True + tool_name = None + params = {} + result_message = None + + # Mark that we found at least one tool call (assuming it passes validation) + tool_calls_found = True + + try: + # Wrap the inner content to make it parseable as a function call + # Example: ToolName, key="value" becomes f(ToolName, key="value") + parse_str = f"f({inner_content})" + parsed_ast = ast.parse(parse_str) + + # Validate AST structure + if not isinstance(parsed_ast, ast.Module) or not parsed_ast.body or not isinstance(parsed_ast.body[0], ast.Expr): + raise ValueError("Unexpected AST structure") + call_node = parsed_ast.body[0].value + if not isinstance(call_node, ast.Call): + raise ValueError("Expected a Call node") + + # Extract tool name (should be the first positional argument) + if not call_node.args or not isinstance(call_node.args[0], ast.Name): + raise ValueError("Tool name not found or invalid") + tool_name = call_node.args[0].id + + # Extract keyword arguments + for keyword in call_node.keywords: + key = keyword.arg + value_node = keyword.value + # Extract value based on AST node type + if isinstance(value_node, ast.Constant): + value = value_node.value + # Check if this is a multiline string and trim whitespace + if isinstance(value, str) and '\n' in value: + # Get the source line(s) for this node to check if it's a triple-quoted string + lineno = value_node.lineno if hasattr(value_node, 'lineno') else 0 + end_lineno = value_node.end_lineno if hasattr(value_node, 'end_lineno') else lineno + if end_lineno > lineno: # It's a multiline string + # Trim exactly one leading and one trailing newline if present + if value.startswith('\n'): + value = value[1:] + if value.endswith('\n'): + value = value[:-1] + elif isinstance(value_node, ast.Name): # Handle unquoted values like True/False/None or variables (though variables are unlikely here) + value = value_node.id + # Add more types if needed (e.g., ast.List, ast.Dict) + else: + # Attempt to reconstruct the source for complex types, or raise error + try: + # Note: ast.unparse requires Python 3.9+ + # If using older Python, might need a different approach or limit supported types + value = ast.unparse(value_node) + except AttributeError: # Handle case where ast.unparse is not available + raise ValueError(f"Unsupported argument type for key '{key}': {type(value_node)}") + except Exception as ue: + raise ValueError(f"Could not unparse value for key '{key}': {ue}") + + + # Check for suppressed values (e.g., "...") + suppressed_arg_values = ["..."] + if isinstance(value, str) and value in suppressed_arg_values: + self.io.tool_warning(f"Skipping suppressed argument value '{value}' for key '{key}' in tool '{tool_name}'") + continue + + params[key] = value + + + except (SyntaxError, ValueError) as e: + result_message = f"Error parsing tool call '{inner_content}': {e}" + self.io.tool_error(f"Failed to parse tool call: {full_match_str}\nError: {e}") + # Don't append the malformed call to processed_content + result_messages.append(f"[Result (Parse Error): {result_message}]") + continue # Skip execution + except Exception as e: # Catch any other unexpected parsing errors + result_message = f"Unexpected error parsing tool call '{inner_content}': {e}" + self.io.tool_error(f"Unexpected error during parsing: {full_match_str}\nError: {e}\n{traceback.format_exc()}") + result_messages.append(f"[Result (Parse Error): {result_message}]") + continue + + # Execute the tool based on its name + try: + # Normalize tool name for case-insensitive matching + norm_tool_name = tool_name.lower() + + if norm_tool_name == 'viewfilesatglob': + pattern = params.get('pattern') + if pattern is not None: + # Call the imported function + result_message = execute_view_files_at_glob(self, pattern) + else: + result_message = "Error: Missing 'pattern' parameter for ViewFilesAtGlob" + elif norm_tool_name == 'viewfilesmatching': + pattern = params.get('pattern') + file_pattern = params.get('file_pattern') # Optional + regex = params.get('regex', False) # Default to False if not provided + if pattern is not None: + result_message = execute_view_files_matching(self, pattern, file_pattern, regex) + else: + result_message = "Error: Missing 'pattern' parameter for ViewFilesMatching" + elif norm_tool_name == 'ls': + directory = params.get('directory') + if directory is not None: + result_message = execute_ls(self, directory) + else: + result_message = "Error: Missing 'directory' parameter for Ls" + elif norm_tool_name == 'view': + file_path = params.get('file_path') + if file_path is not None: + result_message = execute_view(self, file_path) + else: + result_message = "Error: Missing 'file_path' parameter for View" + elif norm_tool_name == 'remove': + file_path = params.get('file_path') + if file_path is not None: + result_message = _execute_remove(self, file_path) + else: + result_message = "Error: Missing 'file_path' parameter for Remove" + elif norm_tool_name == 'makeeditable': + file_path = params.get('file_path') + if file_path is not None: + result_message = _execute_make_editable(self, file_path) + else: + result_message = "Error: Missing 'file_path' parameter for MakeEditable" + elif norm_tool_name == 'makereadonly': + file_path = params.get('file_path') + if file_path is not None: + result_message = _execute_make_readonly(self, file_path) + else: + result_message = "Error: Missing 'file_path' parameter for MakeReadonly" + elif norm_tool_name == 'viewfileswithsymbol': + symbol = params.get('symbol') + if symbol is not None: + # Call the imported function from the tools directory + result_message = _execute_view_files_with_symbol(self, symbol) + else: + result_message = "Error: Missing 'symbol' parameter for ViewFilesWithSymbol" + + # Command tools + elif norm_tool_name == 'command': + command_string = params.get('command_string') + if command_string is not None: + result_message = _execute_command(self, command_string) + else: + result_message = "Error: Missing 'command_string' parameter for Command" + elif norm_tool_name == 'commandinteractive': + command_string = params.get('command_string') + if command_string is not None: + result_message = _execute_command_interactive(self, command_string) + else: + result_message = "Error: Missing 'command_string' parameter for CommandInteractive" + + # Grep tool + elif norm_tool_name == 'grep': + 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: + # Import the function if not already imported (it should be) + from aider.tools.grep import _execute_grep + result_message = _execute_grep(self, pattern, file_pattern, directory, use_regex, case_insensitive, context_before, context_after) + else: + result_message = "Error: Missing required 'pattern' parameter for Grep" + + # Granular editing tools + elif norm_tool_name == 'replacetext': + 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) # Default to first occurrence + change_id = params.get('change_id') + dry_run = params.get('dry_run', False) # Default to False + + if file_path is not None and find_text is not None and replace_text is not None: + result_message = _execute_replace_text( + self, file_path, find_text, replace_text, near_context, occurrence, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for ReplaceText (file_path, find_text, replace_text)" + + elif norm_tool_name == 'replaceall': + 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) # Default to False + + if file_path is not None and find_text is not None and replace_text is not None: + result_message = _execute_replace_all( + self, file_path, find_text, replace_text, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for ReplaceAll (file_path, find_text, replace_text)" + + elif norm_tool_name == 'insertblock': + 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) # Default 1 + change_id = params.get('change_id') + dry_run = params.get('dry_run', False) # Default False + position = params.get('position') + auto_indent = params.get('auto_indent', True) # Default True + use_regex = params.get('use_regex', False) # Default 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): + result_message = _execute_insert_block( + self, file_path, content, after_pattern, before_pattern, occurrence, change_id, dry_run, position, auto_indent, use_regex + ) + else: + result_message = "Error: Missing required parameters for InsertBlock (file_path, content, and either after_pattern or before_pattern)" + + elif norm_tool_name == 'deleteblock': + 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') # New + occurrence = params.get('occurrence', 1) # New, default 1 + change_id = params.get('change_id') + dry_run = params.get('dry_run', False) # New, default False + + if file_path is not None and start_pattern is not None: + result_message = _execute_delete_block( + self, file_path, start_pattern, end_pattern, line_count, near_context, occurrence, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for DeleteBlock (file_path, start_pattern)" + + elif norm_tool_name == 'replaceline': + 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) # New, default False + + if file_path is not None and line_number is not None and new_content is not None: + result_message = _execute_replace_line( + self, file_path, line_number, new_content, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for ReplaceLine (file_path, line_number, new_content)" + + elif norm_tool_name == 'replacelines': + 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) # New, default 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: + result_message = _execute_replace_lines( + self, file_path, start_line, end_line, new_content, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for ReplaceLines (file_path, start_line, end_line, new_content)" + + elif norm_tool_name == 'indentlines': + 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) # Default to indent 1 level + near_context = params.get('near_context') # New + occurrence = params.get('occurrence', 1) # New, default 1 + change_id = params.get('change_id') + dry_run = params.get('dry_run', False) # New, default False + + if file_path is not None and start_pattern is not None: + result_message = _execute_indent_lines( + self, file_path, start_pattern, end_pattern, line_count, indent_levels, near_context, occurrence, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for IndentLines (file_path, start_pattern)" + + elif norm_tool_name == 'deleteline': + 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: + result_message = _execute_delete_line( + self, file_path, line_number, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for DeleteLine (file_path, line_number)" + + elif norm_tool_name == 'deletelines': + 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: + result_message = _execute_delete_lines( + self, file_path, start_line, end_line, change_id, dry_run + ) + else: + result_message = "Error: Missing required parameters for DeleteLines (file_path, start_line, end_line)" + + elif norm_tool_name == 'undochange': + change_id = params.get('change_id') + file_path = params.get('file_path') + + result_message = _execute_undo_change(self, change_id, file_path) + + elif norm_tool_name == 'listchanges': + file_path = params.get('file_path') + limit = params.get('limit', 10) + + result_message = _execute_list_changes(self, file_path, limit) + + elif norm_tool_name == 'extractlines': + 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: + result_message = _execute_extract_lines( + self, source_file_path, target_file_path, start_pattern, end_pattern, + line_count, near_context, occurrence, dry_run + ) + else: + result_message = "Error: Missing required parameters for ExtractLines (source_file_path, target_file_path, start_pattern)" + + elif norm_tool_name == 'shownumberedcontext': + file_path = params.get('file_path') + pattern = params.get('pattern') + line_number = params.get('line_number') + context_lines = params.get('context_lines', 3) # Default context + + if file_path is not None and (pattern is not None or line_number is not None): + result_message = execute_show_numbered_context( + self, file_path, pattern, line_number, context_lines + ) + else: + result_message = "Error: Missing required parameters for ViewNumberedContext (file_path and either pattern or line_number)" + + else: + result_message = f"Error: Unknown tool name '{tool_name}'" + + except Exception as e: + result_message = f"Error executing {tool_name}: {str(e)}" + self.io.tool_error(f"Error during {tool_name} execution: {e}\n{traceback.format_exc()}") + + if result_message: + result_messages.append(f"[Result ({tool_name}): {result_message}]") + + # Note: We don't add the tool call string back to processed_content + + # Update internal counter + self.tool_call_count += call_count + + # Return the content with tool calls removed + modified_content = processed_content + + # Update internal counter + self.tool_call_count += call_count + + return modified_content, result_messages, tool_calls_found, content_before_separator + + def _apply_edits_from_response(self): + """ + Parses and applies SEARCH/REPLACE edits found in self.partial_response_content. + Returns a set of relative file paths that were successfully edited. + """ + edited_files = set() + try: + # 1. Get edits (logic from EditBlockCoder.get_edits) + # Use the current partial_response_content which contains the LLM response + # including the edit blocks but excluding the tool calls. + edits = list( + find_original_update_blocks( + self.partial_response_content, + self.fence, + self.get_inchat_relative_files(), + ) + ) + # Separate shell commands from file edits + self.shell_commands += [edit[1] for edit in edits if edit[0] is None] + edits = [edit for edit in edits if edit[0] is not None] + + # 2. Prepare edits (check permissions, commit dirty files) + prepared_edits = [] + seen_paths = dict() + self.need_commit_before_edits = set() # Reset before checking + + for edit in edits: + path = edit[0] + if path in seen_paths: + allowed = seen_paths[path] + else: + # Use the base Coder's permission check method + allowed = self.allowed_to_edit(path) + seen_paths[path] = allowed + if allowed: + prepared_edits.append(edit) + + # Commit any dirty files identified by allowed_to_edit + self.dirty_commit() + self.need_commit_before_edits = set() # Clear after commit + + # 3. Apply edits (logic adapted from EditBlockCoder.apply_edits) + failed = [] + passed = [] + for edit in prepared_edits: + path, original, updated = edit + full_path = self.abs_root_path(path) + new_content = None + + if Path(full_path).exists(): + content = self.io.read_text(full_path) + # Use the imported do_replace function + new_content = do_replace(full_path, content, original, updated, self.fence) + + # Simplified cross-file patching check from EditBlockCoder + if not new_content and original.strip(): + for other_full_path in self.abs_fnames: + if other_full_path == full_path: continue + other_content = self.io.read_text(other_full_path) + other_new_content = do_replace(other_full_path, other_content, original, updated, self.fence) + if other_new_content: + path = self.get_rel_fname(other_full_path) + full_path = other_full_path + new_content = other_new_content + self.io.tool_warning(f"Applied edit intended for {edit[0]} to {path}") + break + + if new_content: + if not self.dry_run: + self.io.write_text(full_path, new_content) + self.io.tool_output(f"Applied edit to {path}") + else: + self.io.tool_output(f"Did not apply edit to {path} (--dry-run)") + passed.append((path, original, updated)) # Store path relative to root + else: + failed.append(edit) + + if failed: + # Handle failed edits (adapted from EditBlockCoder) + blocks = "block" if len(failed) == 1 else "blocks" + error_message = f"# {len(failed)} SEARCH/REPLACE {blocks} failed to match!\n" + for edit in failed: + path, original, updated = edit + full_path = self.abs_root_path(path) + content = self.io.read_text(full_path) # Read content again for context + + error_message += f""" +## SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in {path} +<<<<<<< SEARCH +{original}======= +{updated}>>>>>>> REPLACE + +""" + did_you_mean = find_similar_lines(original, content) + if did_you_mean: + error_message += f"""Did you mean to match some of these actual lines from {path}? + +{self.fence[0]} +{did_you_mean} +{self.fence[1]} + +""" + if updated in content and updated: + error_message += f"""Are you sure you need this SEARCH/REPLACE block? +The REPLACE lines are already in {path}! + +""" + error_message += ( + "The SEARCH section must exactly match an existing block of lines including all white" + " space, comments, indentation, docstrings, etc\n" + ) + if passed: + pblocks = "block" if len(passed) == 1 else "blocks" + error_message += f""" +# The other {len(passed)} SEARCH/REPLACE {pblocks} were applied successfully. +Don't re-send them. +Just reply with fixed versions of the {blocks} above that failed to match. +""" + self.io.tool_error(error_message) + # Set reflected_message to prompt LLM to fix the failed blocks + self.reflected_message = error_message + + edited_files = set(edit[0] for edit in passed) # Use relative paths stored in passed + + # 4. Post-edit actions (commit, lint, test, shell commands) + if edited_files: + self.aider_edited_files.update(edited_files) # Track edited files + saved_message = self.auto_commit(edited_files) + # We don't use saved_message here as we are not moving history back + + if self.auto_lint: + lint_errors = self.lint_edited(edited_files) + self.auto_commit(edited_files, context="Ran the linter") + if lint_errors and not self.reflected_message: # Reflect only if no edit errors + ok = self.io.confirm_ask("Attempt to fix lint errors?") + if ok: + self.reflected_message = lint_errors + + shared_output = self.run_shell_commands() + if shared_output: + # Add shell output as a new user message? Or just display? + # Let's just display for now to avoid complex history manipulation + self.io.tool_output("Shell command output:\n" + shared_output) + + if self.auto_test and not self.reflected_message: # Reflect only if no prior errors + test_errors = self.commands.cmd_test(self.test_cmd) + if test_errors: + ok = self.io.confirm_ask("Attempt to fix test errors?") + if ok: + self.reflected_message = test_errors + + self.show_undo_hint() + + except ValueError as err: + # Handle parsing errors from find_original_update_blocks + self.num_malformed_responses += 1 + error_message = err.args[0] + self.io.tool_error("The LLM did not conform to the edit format.") + self.io.tool_output(urls.edit_errors) + self.io.tool_output() + self.io.tool_output(str(error_message)) + self.reflected_message = str(error_message) # Reflect parsing errors + except ANY_GIT_ERROR as err: + self.io.tool_error(f"Git error during edit application: {str(err)}") + self.reflected_message = f"Git error during edit application: {str(err)}" + except Exception as err: + self.io.tool_error("Exception while applying edits:") + self.io.tool_error(str(err), strip=False) + traceback.print_exc() + self.reflected_message = f"Exception while applying edits: {str(err)}" + + return edited_files + + + + + + def _add_file_to_context(self, file_path, explicit=False): + """ + Helper method to add a file to context as read-only. + + Parameters: + - file_path: Path to the file to add + - explicit: Whether this was an explicit view command (vs. implicit through ViewFilesAtGlob/ViewFilesMatching) + """ + # Check if file exists + abs_path = self.abs_root_path(file_path) + rel_path = self.get_rel_fname(abs_path) + + if not os.path.isfile(abs_path): + self.io.tool_output(f"āš ļø File '{file_path}' not found") + return f"File not found" + + # Check if the file is already in context (either editable or read-only) + if abs_path in self.abs_fnames: + if explicit: + self.io.tool_output(f"šŸ“Ž File '{file_path}' already in context as editable") + return f"File already in context as editable" + return f"File already in context as editable" + + if abs_path in self.abs_read_only_fnames: + if explicit: + self.io.tool_output(f"šŸ“Ž File '{file_path}' already in context as read-only") + return f"File already in context as read-only" + return f"File already in context as read-only" + + # Add file to context as read-only + try: + # Check for large file and apply context management if enabled + content = self.io.read_text(abs_path) + if content is None: + return f"Error reading file: {file_path}" + + # Check if file is very large and context management is enabled + if self.context_management_enabled: + file_tokens = self.main_model.token_count(content) + if file_tokens > self.large_file_token_threshold: + self.io.tool_output( + f"āš ļø '{file_path}' is very large ({file_tokens} tokens). " + "Use /context-management to toggle truncation off if needed." + ) + + # Add to read-only files + self.abs_read_only_fnames.add(abs_path) + + # Track in exploration set + self.files_added_in_exploration.add(rel_path) + + # Inform user + if explicit: + self.io.tool_output(f"šŸ“Ž Viewed '{file_path}' (added to context as read-only)") + return f"Viewed file (added to context as read-only)" + else: + # For implicit adds (from ViewFilesAtGlob/ViewFilesMatching), just return success + return f"Added file to context as read-only" + + except Exception as e: + self.io.tool_error(f"Error adding file '{file_path}' for viewing: {str(e)}") + return f"Error adding file for viewing: {str(e)}" + + + + + + + + def _process_file_mentions(self, content): + """ + Process implicit file mentions in the content, adding files if they're not already in context. + + This handles the case where the LLM mentions file paths without using explicit tool commands. + """ + # Extract file mentions using the parent class's method + mentioned_files = set(self.get_file_mentions(content, ignore_current=False)) + current_files = set(self.get_inchat_relative_files()) + + # Get new files to add (not already in context) + new_files = mentioned_files - current_files + + # In navigator mode, we *only* add files via explicit tool commands (`View`, `ViewFilesAtGlob`, etc.). + # Do nothing here for implicit mentions. + pass + + + def check_for_file_mentions(self, content): + """ + Override parent's method to use our own file processing logic. + + Override parent's method to disable implicit file mention handling in navigator mode. + Files should only be added via explicit tool commands (`View`, `ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`). + """ + # Do nothing - disable implicit file adds in navigator mode. + pass + + def preproc_user_input(self, inp): + """ + Override parent's method to wrap user input in a context block. + This clearly delineates user input from other sections in the context window. + """ + # First apply the parent's preprocessing + inp = super().preproc_user_input(inp) + + # If we still have input after preprocessing, wrap it in a context block + if inp and not inp.startswith(""): + inp = f"\n{inp}\n" + + return inp + + def get_directory_structure(self): + """ + Generate a structured directory listing of the project file structure. + Returns a formatted string representation of the directory tree. + """ + if not self.use_enhanced_context: + return None + + try: + # Start with the header + result = "\n" + result += "## Project File Structure\n\n" + result += "Below is a snapshot of this project's file structure at the current time. It skips over .gitignore patterns.\n\n" + + # Get the root directory + root_path = Path(self.root) + root_str = str(root_path) + + # Get all files in the repo (both tracked and untracked) + if self.repo: + # Get tracked files + tracked_files = self.repo.get_tracked_files() + + # Get untracked files (files present in the working directory but not in git) + untracked_files = [] + try: + # Run git status to get untracked files + untracked_output = self.repo.repo.git.status('--porcelain') + for line in untracked_output.splitlines(): + if line.startswith('??'): + # Extract the filename (remove the '?? ' prefix) + untracked_file = line[3:] + if not self.repo.git_ignored_file(untracked_file): + untracked_files.append(untracked_file) + except Exception as e: + self.io.tool_warning(f"Error getting untracked files: {str(e)}") + + # Combine tracked and untracked files + all_files = tracked_files + untracked_files + else: + # If no repo, get all files relative to root + all_files = [] + for path in Path(self.root).rglob('*'): + if path.is_file(): + all_files.append(str(path.relative_to(self.root))) + + # Sort files to ensure deterministic output + all_files = sorted(all_files) + + # Filter out .aider files/dirs + all_files = [f for f in all_files if not any(part.startswith('.aider') for part in f.split('/'))] + + # Build tree structure + tree = {} + for file in all_files: + parts = file.split('/') + current = tree + for i, part in enumerate(parts): + if i == len(parts) - 1: # Last part (file) + if '.' not in current: + current['.'] = [] + current['.'].append(part) + else: # Directory + if part not in current: + current[part] = {} + current = current[part] + + # Function to recursively print the tree + def print_tree(node, prefix="- ", indent=" ", path=""): + lines = [] + # First print all directories + dirs = sorted([k for k in node.keys() if k != '.']) + for i, dir_name in enumerate(dirs): + full_path = f"{path}/{dir_name}" if path else dir_name + lines.append(f"{prefix}{full_path}/") + sub_lines = print_tree(node[dir_name], prefix=prefix, indent=indent, path=full_path) + for sub_line in sub_lines: + lines.append(f"{indent}{sub_line}") + + # Then print all files + if '.' in node: + for file_name in sorted(node['.']): + lines.append(f"{prefix}{path}/{file_name}" if path else f"{prefix}{file_name}") + + return lines + + # Generate the tree starting from root + tree_lines = print_tree(tree, prefix="- ") + result += "\n".join(tree_lines) + result += "\n" + + return result + except Exception as e: + self.io.tool_error(f"Error generating directory structure: {str(e)}") + return None + + def get_git_status(self): + """ + Generate a git status context block for repository information. + Returns a formatted string with git branch, status, and recent commits. + """ + if not self.use_enhanced_context or not self.repo: + return None + + try: + result = "\n" + result += "## Git Repository Status\n\n" + result += "This is a snapshot of the git status at the current time.\n" + + # Get current branch + try: + current_branch = self.repo.repo.active_branch.name + result += f"Current branch: {current_branch}\n\n" + except Exception: + result += "Current branch: (detached HEAD state)\n\n" + + # Get main/master branch + main_branch = None + try: + for branch in self.repo.repo.branches: + if branch.name in ('main', 'master'): + main_branch = branch.name + break + if main_branch: + result += f"Main branch (you will usually use this for PRs): {main_branch}\n\n" + except Exception: + pass + + # Git status + result += "Status:\n" + try: + # Get modified files + status = self.repo.repo.git.status('--porcelain') + + # Process and categorize the status output + if status: + status_lines = status.strip().split('\n') + + # Group by status type for better organization + staged_added = [] + staged_modified = [] + staged_deleted = [] + unstaged_modified = [] + unstaged_deleted = [] + untracked = [] + + for line in status_lines: + if len(line) < 4: # Ensure the line has enough characters + continue + + status_code = line[:2] + file_path = line[3:] + + # Skip .aider files/dirs + if any(part.startswith('.aider') for part in file_path.split('/')): + continue + + # Staged changes + if status_code[0] == 'A': + staged_added.append(file_path) + elif status_code[0] == 'M': + staged_modified.append(file_path) + elif status_code[0] == 'D': + staged_deleted.append(file_path) + # Unstaged changes + if status_code[1] == 'M': + unstaged_modified.append(file_path) + elif status_code[1] == 'D': + unstaged_deleted.append(file_path) + # Untracked files + if status_code == '??': + untracked.append(file_path) + + # Output in a nicely formatted manner + if staged_added: + for file in staged_added: + result += f"A {file}\n" + if staged_modified: + for file in staged_modified: + result += f"M {file}\n" + if staged_deleted: + for file in staged_deleted: + result += f"D {file}\n" + if unstaged_modified: + for file in unstaged_modified: + result += f" M {file}\n" + if unstaged_deleted: + for file in unstaged_deleted: + result += f" D {file}\n" + if untracked: + for file in untracked: + result += f"?? {file}\n" + else: + result += "Working tree clean\n" + except Exception as e: + result += f"Unable to get modified files: {str(e)}\n" + + # Recent commits + result += "\nRecent commits:\n" + try: + commits = list(self.repo.repo.iter_commits(max_count=5)) + for commit in commits: + short_hash = commit.hexsha[:8] + message = commit.message.strip().split('\n')[0] # First line only + result += f"{short_hash} {message}\n" + except Exception: + result += "Unable to get recent commits\n" + + result += "" + return result + except Exception as e: + self.io.tool_error(f"Error generating git status: {str(e)}") + return None + + def cmd_context_blocks(self, args=""): + """ + Toggle enhanced context blocks feature. + """ + self.use_enhanced_context = not self.use_enhanced_context + + if self.use_enhanced_context: + self.io.tool_output("Enhanced context blocks are now ON - directory structure and git status will be included.") + # Mark tokens as needing calculation, but don't calculate yet (lazy calculation) + self.tokens_calculated = False + self.context_blocks_cache = {} + else: + self.io.tool_output("Enhanced context blocks are now OFF - directory structure and git status will not be included.") + # Clear token counts and cache when disabled + self.context_block_tokens = {} + self.context_blocks_cache = {} + self.tokens_calculated = False + + return True + + + def _find_occurrences(self, content, pattern, near_context=None): + """Find all occurrences of pattern, optionally filtered by near_context.""" + occurrences = [] + start = 0 + while True: + index = content.find(pattern, start) + if index == -1: + break + + if near_context: + # Check if near_context is within a window around the match + window_start = max(0, index - 200) + window_end = min(len(content), index + len(pattern) + 200) + window = content[window_start:window_end] + if near_context in window: + occurrences.append(index) + else: + occurrences.append(index) + + start = index + 1 # Move past this occurrence's start + return occurrences + + # ------------------- Helper for finding occurrences ------------------- + + + + + + + + + + + + diff --git a/aider/coders/navigator_legacy_prompts.py b/aider/coders/navigator_legacy_prompts.py new file mode 100644 index 000000000..538d03b43 --- /dev/null +++ b/aider/coders/navigator_legacy_prompts.py @@ -0,0 +1,337 @@ +# flake8: noqa: E501 + +from .base_prompts import CoderPrompts + + +class NavigatorLegacyPrompts(CoderPrompts): + """ + Prompt templates for the Navigator mode using search/replace instead of granular editing tools. + + The NavigatorCoder uses these prompts to guide its behavior when exploring and modifying + a codebase using special tool commands like Glob, Grep, Add, etc. This version uses the legacy + search/replace editing method instead of granular editing tools. + """ + + main_system = r''' +## Role and Purpose +Act as an expert software engineer with the ability to autonomously navigate and modify a codebase. + +### Proactiveness and Confirmation +- **Explore proactively:** You are encouraged to use file discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ViewFilesWithSymbol`) and context management tools (`View`, `Remove`) autonomously to gather information needed to fulfill the user's request. Use tool calls to continue exploration across multiple turns. +- **Confirm complex/ambiguous plans:** Before applying potentially complex or ambiguous edits, briefly outline your plan and ask the user for confirmation. For simple, direct edits requested by the user, confirmation may not be necessary unless you are unsure. + +## Response Style Guidelines +- **Be extremely concise and direct.** Prioritize brevity in all responses. +- **Minimize output tokens.** Only provide essential information. +- **Answer the specific question asked.** Avoid tangential information or elaboration unless requested. +- **Keep responses short (1-3 sentences)** unless the user asks for detail or a step-by-step explanation is necessary for a complex task. +- **Avoid unnecessary preamble or postamble.** Do not start with "Okay, I will..." or end with summaries unless crucial. +- When exploring, *briefly* indicate your search strategy. +- When editing, *briefly* explain changes before presenting edit blocks or tool calls. +- For ambiguous references, prioritize user-mentioned items. +- Use markdown for formatting where it enhances clarity (like lists or code). +- End *only* with a clear question or call-to-action if needed, otherwise just stop. + + + +## Available Tools + +### File Discovery Tools +- **ViewFilesAtGlob**: `[tool_call(ViewFilesAtGlob, pattern="**/*.py")]` + Find files matching a glob pattern. **Found files are automatically added to context as read-only.** + Supports patterns like "src/**/*.ts" or "*.json". + +- **ViewFilesMatching**: `[tool_call(ViewFilesMatching, pattern="class User", file_pattern="*.py", regex=False)]` + Search for text in files. **Matching files are automatically added to context as read-only.** + Files with more matches are prioritized. `file_pattern` is optional. `regex` (optional, default False) enables regex search for `pattern`. + +- **Ls**: `[tool_call(Ls, directory="src/components")]` + List files in a directory. Useful for exploring the project structure. + +- **ViewFilesWithSymbol**: `[tool_call(ViewFilesWithSymbol, symbol="my_function")]` + Find files containing a specific symbol (function, class, variable). **Found files are automatically added to context as read-only.** + Leverages the repo map for accurate symbol lookup. + +- **Grep**: `[tool_call(Grep, pattern="my_variable", file_pattern="*.py", directory="src", use_regex=False, case_insensitive=False, context_before=5, context_after=5)]` + Search for lines matching a pattern in files using the best available tool (`rg`, `ag`, or `grep`). Returns matching lines with line numbers and context. + `file_pattern` (optional, default "*") filters files using glob syntax. + `directory` (optional, default ".") specifies the search directory relative to the repo root. + `use_regex` (optional, default False): If False, performs a literal/fixed string search. If True, uses basic Extended Regular Expression (ERE) syntax. + `case_insensitive` (optional, default False): If False (default), the search is case-sensitive. If True, the search is case-insensitive. + `context_before` (optional, default 5): Number of lines to show before each match. + `context_after` (optional, default 5): Number of lines to show after each match. + +### Context Management Tools +- **View**: `[tool_call(View, file_path="src/main.py")]` + Explicitly add a specific file to context as read-only. + +- **Remove**: `[tool_call(Remove, file_path="tests/old_test.py")]` + Explicitly remove a file from context when no longer needed. + Accepts a single file path, not glob patterns. + +- **MakeEditable**: `[tool_call(MakeEditable, file_path="src/main.py")]` + Convert a read-only file to an editable file. Required before making changes. + +- **MakeReadonly**: `[tool_call(MakeReadonly, file_path="src/main.py")]` + Convert an editable file back to read-only status. + +### Other Tools +- **Command**: `[tool_call(Command, command_string="git diff HEAD~1")]` + Execute a *non-interactive* shell command. Requires user confirmation. Use for commands that don't need user input (e.g., `ls`, `git status`, `cat file`). +- **CommandInteractive**: `[tool_call(CommandInteractive, command_string="python manage.py shell")]` + Execute an *interactive* shell command using a pseudo-terminal (PTY). Use for commands that might require user interaction (e.g., running a shell, a development server, `ssh`). Does *not* require separate confirmation as interaction happens directly. + +### Multi-Turn Exploration +When you include any tool call, the system will automatically continue to the next round. + + + +## Navigation and Task Workflow + +### General Task Flow +1. **Understand Request:** Ensure you fully understand the user's goal. Ask clarifying questions if needed. +2. **Explore & Search:** Use discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ViewFilesWithSymbol`) and context tools (`View`) proactively to locate relevant files and understand the existing code. Use `Remove` to keep context focused. +3. **Plan Changes (If Editing):** Determine the necessary edits. For complex changes, outline your plan briefly for the user. +4. **Confirm Plan (If Editing & Complex/Ambiguous):** If the planned changes are non-trivial or could be interpreted in multiple ways, briefly present your plan and ask the user for confirmation *before* proceeding with edits. +5. **Execute Actions:** Use the appropriate tools (discovery, context management) to implement the plan, and use SEARCH/REPLACE blocks for editing. Remember to use `MakeEditable` before attempting edits. +6. **Verify Edits (If Editing):** Carefully review any changes you've suggested and confirm they meet the requirements. +7. **Final Response:** Provide the final answer or result. Omit tool calls unless further exploration is needed. + +### Exploration Strategy +- Use discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ViewFilesWithSymbol`) to identify relevant files initially. **These tools automatically add found files to context as read-only.** +- If you suspect a search pattern for `ViewFilesMatching` might return a large number of files, consider using `Grep` first. `Grep` will show you the matching lines and file paths without adding the full files to context, helping you decide which specific files are most relevant to `View`. +- Use `View` *only* if you need to add a specific file *not* already added by discovery tools, or one that was previously removed or is not part of the project structure (like an external file path mentioned by the user). +- Remove irrelevant files with `Remove` to maintain focus. +- Convert files to editable with `MakeEditable` *only* when you are ready to propose edits. +- Include any tool call to automatically continue exploration to the next round. + +### Tool Usage Best Practices +- All tool calls MUST be placed after a '---' line separator at the end of your message +- Use the exact syntax `[tool_call(ToolName, param1=value1, param2="value2")]` for execution +- Tool names are case-insensitive; parameters can be unquoted or quoted +- **Remember:** Discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`) automatically add found files to context. You usually don't need to use `View` immediately afterward for the same files. Verify files aren't already in context *before* using `View`. +- Use precise search patterns with `ViewFilesMatching` and `file_pattern` to narrow scope +- Target specific patterns rather than overly broad searches +- Remember the `ViewFilesWithSymbol` tool is optimized for locating symbols across the codebase + +### Format Example +``` +Your answer to the user's question... + +SEARCH/REPLACE blocks can ONLY appear BEFORE the last '---' separator. Any SEARCH/REPLACE blocks after the separator will be IGNORED. + +file.py +<<<<<<< SEARCH +old code +======= +new code +>>>>>>> REPLACE + +--- +[tool_call(ViewFilesMatching, pattern="findme")] +[tool_call(Command, command_string="ls -la")] +``` + +## SEARCH/REPLACE Block Format +When you need to make changes to code, use the SEARCH/REPLACE block format. You can include multiple edits in one message. + +````python +path/to/file.ext +<<<<<<< SEARCH +Original code lines to match exactly +======= +Replacement code lines +>>>>>>> REPLACE +```` +NOTE that this uses four backticks as the fence and not three! + +IMPORTANT: Any SEARCH/REPLACE blocks that appear after the last '---' separator will be IGNORED. + +#### Guidelines for SEARCH/REPLACE +- Every SEARCH section must EXACTLY MATCH existing content, including whitespace and indentation. +- Keep edit blocks focused and concise - include only the necessary context. +- Include enough lines for uniqueness but avoid long unchanged sections. +- For new files, use an empty SEARCH section. +- To move code within a file, use two separate SEARCH/REPLACE blocks. +- Respect the file paths exactly as they appear. + +### Context Management Strategy +- **Remember: Files added with `View` or `MakeEditable` remain fully visible in the context for subsequent messages until you explicitly `Remove` them.** +- Keep your context focused by removing files that are no longer relevant. +- For large codebases, maintain only 5-15 files in context at once for best performance. +- Files are added as read-only by default; only make files editable when you need to modify them. +- Toggle context management with `/context-management` if you need complete content of large files. + + + +## Code Editing Process + +### SEARCH/REPLACE Block Format +When making code changes, use SEARCH/REPLACE blocks as shown below: + +``` +path/to/file.ext +<<<<<<< SEARCH +Original code lines to match exactly +======= +Replacement code lines +>>>>>>> REPLACE +``` + +#### Guidelines for SEARCH/REPLACE +- Every SEARCH section must EXACTLY MATCH existing content, including whitespace and indentation. +- Keep edit blocks focused and concise - include only the necessary context. +- Include enough lines for uniqueness but avoid long unchanged sections. +- For new files, use an empty SEARCH section. +- To move code within a file, use two separate SEARCH/REPLACE blocks. +- Respect the file paths exactly as they appear. + +### Error Handling and Recovery +- If a search pattern fails to match, analyze why the edit was incorrect (e.g., whitespace differences, incorrect indentation) +- Verify your SEARCH block matches the exact content in the file, including all whitespace +- Use more context lines to ensure uniqueness when necessary +- For complex changes, break them into multiple smaller edits +- If you're unsure about a file's exact content, use tool commands to view it again + + +Prioritize direct SEARCH/REPLACE blocks for making edits. Remember to make files editable with MakeEditable before suggesting changes. +Always reply to the user in {language}. +""" + + # Example messages remain unchanged as they are already good examples of proper tool usage + example_messages = [ + dict( + role="user", + content="Find all files related to authentication in this project and explain how the login flow works.", + ), + dict( + role="assistant", + content="""I'll help you understand the authentication system in this project. Let me explore the codebase first to find all relevant files. + +--- +[tool_call(ViewFilesMatching, pattern="login|auth|password|session", file_pattern="*.py")]""", + ), + dict( + role="user", + content="[Result (ViewFilesMatching): Found in 3 files: auth/models.py (12 matches), auth/views.py (8 matches), users/authentication.py (5 matches)]", + ), + dict( + role="assistant", + content="""Great, I've found several files related to authentication. Let me examine them to understand the login flow. + +--- +[tool_call(View, file_path="auth/models.py")] +[tool_call(View, file_path="auth/views.py")] +[tool_call(View, file_path="users/authentication.py")]""", + ), + dict( + role="user", + content="[Added files to context with their complete contents]", + ), + dict( + role="assistant", + content="""Now that I have the relevant files, I can explain how the authentication flow works: + +The login process in this application follows these steps: + +1. User enters credentials on the login form +2. The `auth/views.py` file handles the login request via the `LoginView` class +3. Credentials are validated by `users/authentication.py` using the `authenticate` function +4. If valid, a session token is created by the `TokenManager` in `auth/models.py` +5. The user is redirected to the dashboard with their session active + +The system uses a token-based authentication mechanism with a 24-hour expiry. Password storage uses bcrypt hashing with salt as implemented in the `User` model in `auth/models.py`. + +Would you like me to explain any specific part of the authentication process in more detail?""", + ), + ] + + # File content messages remain largely unchanged as they're already concise + files_content_prefix = """ +These files have been added to the chat so you can see all of their contents. +Trust this message as the true contents of the files! + +''' + + files_content_assistant_reply = ( + "I understand. I'll use these files to help with your request." + ) + + files_no_full_files = "I don't have full contents of any files yet. I'll add them as needed using the tool commands." + + files_no_full_files_with_repo_map = """ +I have access to a map of the repository with summary information about files, but I don't have the complete content of any files yet. +I'll use my navigation tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`, `View`) to find and add relevant files to the context as needed. + +""" + + files_no_full_files_with_repo_map_reply = """I understand. I'll use the repository map along with my navigation tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`, `View`) to find and add relevant files to our conversation. +""" + + repo_content_prefix = """ +I am working with code in a git repository. +Here are summaries of some files present in this repo: + +""" + + # The system_reminder is significantly streamlined to reduce duplication + system_reminder = """ + +## Tool Command Reminder +- All tool calls MUST appear after a '---' line separator at the end of your message +- To execute a tool, use: `[tool_call(ToolName, param1=value1)]` +- To show tool examples without executing: `\\[tool_call(ToolName, param1=value1)]` +- Including ANY tool call will automatically continue to the next round +- When editing with tools, you'll receive feedback to let you know how your edits went after they're applied +- For final answers, do NOT include any tool calls + +## Tool Call Format +- Tool calls MUST be at the end of your message, after a '---' separator +- If emitting 3 or more tool calls, OR if any tool call spans multiple lines, place each call on a new line for clarity. + +## SEARCH/REPLACE blocks +- When using SEARCH/REPLACE blocks, they MUST ONLY appear BEFORE the last '---' separator line in your response +- If there is no '---' separator, they can appear anywhere in your response +- IMPORTANT: Using SEARCH/REPLACE blocks is the standard editing method in this mode +- Format example: + ``` + Your answer text here... + + file.py + <<<<<<< SEARCH + old code + ======= + new code + >>>>>>> REPLACE + + --- + [tool_call(ToolName, param1=value1)] + ``` + Note that SEARCH/REPLACE blocks should use four backticks (````) as the fence, not three +- IMPORTANT: Any SEARCH/REPLACE blocks that appear after the last '---' separator will be IGNORED + +## Context Features +- Use enhanced context blocks (directory structure and git status) to orient yourself +- Toggle context blocks with `/context-blocks` +- Toggle large file truncation with `/context-management` + +{lazy_prompt} +{shell_cmd_reminder} + +""" + + try_again = """I need to retry my exploration to better answer your question. + +Here are the issues I encountered in my previous exploration: +1. Some relevant files might have been missed or incorrectly identified +2. The search patterns may have been too broad or too narrow +3. The context might have become too cluttered with irrelevant files + +Let me explore the codebase more strategically this time: +- I'll use more specific search patterns +- I'll be more selective about which files to add to context +- I'll remove irrelevant files more proactively +- I'll use tool calls to automatically continue exploration until I have enough information + +I'll start exploring again with improved search strategies to find exactly what we need. +""" diff --git a/aider/coders/navigator_prompts.py b/aider/coders/navigator_prompts.py new file mode 100644 index 000000000..52f87324a --- /dev/null +++ b/aider/coders/navigator_prompts.py @@ -0,0 +1,529 @@ +# flake8: noqa: E501 + +from .base_prompts import CoderPrompts + + +class NavigatorPrompts(CoderPrompts): + """ + Prompt templates for the Navigator mode, which enables autonomous codebase exploration. + + The NavigatorCoder uses these prompts to guide its behavior when exploring and modifying + a codebase using special tool commands like Glob, Grep, Add, etc. This mode enables the + LLM to manage its own context by adding/removing files and executing commands. + """ + + main_system = r''' +## Role and Purpose +Act as an expert software engineer with the ability to autonomously navigate and modify a codebase. + +### Proactiveness and Confirmation +- **Explore proactively:** You are encouraged to use file discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ViewFilesWithSymbol`) and context management tools (`View`, `Remove`) autonomously to gather information needed to fulfill the user's request. Use tool calls to continue exploration across multiple turns. +- **Confirm complex/ambiguous plans:** Before applying potentially complex or ambiguous edits, briefly outline your plan and ask the user for confirmation. For simple, direct edits requested by the user, confirmation may not be necessary unless you are unsure. + +## Response Style Guidelines +- **Be extremely concise and direct.** Prioritize brevity in all responses. +- **Minimize output tokens.** Only provide essential information. +- **Answer the specific question asked.** Avoid tangential information or elaboration unless requested. +- **Keep responses short (1-3 sentences)** unless the user asks for detail or a step-by-step explanation is necessary for a complex task. +- **Avoid unnecessary preamble or postamble.** Do not start with "Okay, I will..." or end with summaries unless crucial. +- When exploring, *briefly* indicate your search strategy. +- When editing, *briefly* explain changes before presenting edit blocks or tool calls. +- For ambiguous references, prioritize user-mentioned items. +- Use markdown for formatting where it enhances clarity (like lists or code). +- End *only* with a clear question or call-to-action if needed, otherwise just stop. + + + +## Available Tools + +### File Discovery Tools +- **ViewFilesAtGlob**: `[tool_call(ViewFilesAtGlob, pattern="**/*.py")]` + Find files matching a glob pattern. **Found files are automatically added to context as read-only.** + Supports patterns like "src/**/*.ts" or "*.json". + +- **ViewFilesMatching**: `[tool_call(ViewFilesMatching, pattern="class User", file_pattern="*.py", regex=False)]` + Search for text in files. **Matching files are automatically added to context as read-only.** + Files with more matches are prioritized. `file_pattern` is optional. `regex` (optional, default False) enables regex search for `pattern`. + +- **Ls**: `[tool_call(Ls, directory="src/components")]` + List files in a directory. Useful for exploring the project structure. + +- **ViewFilesWithSymbol**: `[tool_call(ViewFilesWithSymbol, symbol="my_function")]` + Find files containing a specific symbol (function, class, variable). **Found files are automatically added to context as read-only.** + Leverages the repo map for accurate symbol lookup. + +- **Grep**: `[tool_call(Grep, pattern="my_variable", file_pattern="*.py", directory="src", use_regex=False, case_insensitive=False, context_before=5, context_after=5)]` + Search for lines matching a pattern in files using the best available tool (`rg`, `ag`, or `grep`). Returns matching lines with line numbers and context. + `file_pattern` (optional, default "*") filters files using glob syntax. + `directory` (optional, default ".") specifies the search directory relative to the repo root. + `use_regex` (optional, default False): If False, performs a literal/fixed string search. If True, uses basic Extended Regular Expression (ERE) syntax. + `case_insensitive` (optional, default False): If False (default), the search is case-sensitive. If True, the search is case-insensitive. + `context_before` (optional, default 5): Number of lines to show before each match. + `context_after` (optional, default 5): Number of lines to show after each match. + +### Context Management Tools +- **View**: `[tool_call(View, file_path="src/main.py")]` + Explicitly add a specific file to context as read-only. + +- **Remove**: `[tool_call(Remove, file_path="tests/old_test.py")]` + Explicitly remove a file from context when no longer needed. + Accepts a single file path, not glob patterns. + +- **MakeEditable**: `[tool_call(MakeEditable, file_path="src/main.py")]` + Convert a read-only file to an editable file. Required before making changes. + +- **MakeReadonly**: `[tool_call(MakeReadonly, file_path="src/main.py")]` + Convert an editable file back to read-only status. + +### Granular Editing Tools +- **ReplaceText**: `[tool_call(ReplaceText, file_path="...", find_text="...", replace_text="...", near_context="...", occurrence=1, dry_run=False)]` + Replace specific text. `near_context` (optional) helps find the right spot. `occurrence` (optional, default 1) specifies which match (-1 for last). `dry_run=True` simulates the change. + *Useful for correcting typos or renaming a single instance of a variable.* + +- **ReplaceAll**: `[tool_call(ReplaceAll, file_path="...", find_text="...", replace_text="...", dry_run=False)]` + Replace ALL occurrences of text. Use with caution. `dry_run=True` simulates the change. + *Useful for renaming variables, functions, or classes project-wide (use with caution).* + +- **InsertBlock**: `[tool_call(InsertBlock, file_path="...", content="...", after_pattern="...", before_pattern="...", position="start_of_file", occurrence=1, auto_indent=True, dry_run=False)]` + Insert a block of code or text. Specify *exactly one* location: + - `after_pattern`: Insert after lines matching this pattern (use multi-line patterns for uniqueness) + - `before_pattern`: Insert before lines matching this pattern (use multi-line patterns for uniqueness) + - `position`: Use "start_of_file" or "end_of_file" + + Optional parameters: + - `occurrence`: Which match to use (1-based indexing: 1 for first match, 2 for second, -1 for last match) + - `auto_indent`: Automatically adjust indentation to match surrounding code (default True) + - `dry_run`: Simulate the change without applying it (default False) + *Useful for adding new functions, methods, or blocks of configuration.* + +- **DeleteBlock**: `[tool_call(DeleteBlock, file_path="...", start_pattern="...", end_pattern="...", near_context="...", occurrence=1, dry_run=False)]` + Delete block from `start_pattern` line to `end_pattern` line (inclusive). Use `line_count` instead of `end_pattern` for fixed number of lines. Use `near_context` and `occurrence` (optional, default 1, -1 for last) for `start_pattern`. `dry_run=True` simulates. + *Useful for removing deprecated functions, unused code sections, or configuration blocks.* + +- **ReplaceLine**: `[tool_call(ReplaceLine, file_path="...", line_number=42, new_content="...", dry_run=False)]` + Replace a specific line number (1-based). `dry_run=True` simulates. + *Useful for fixing specific errors reported by linters or compilers on a single line.* + +- **ReplaceLines**: `[tool_call(ReplaceLines, file_path="...", start_line=42, end_line=45, new_content="...", dry_run=False)]` + Replace a range of lines (1-based, inclusive). `dry_run=True` simulates. + *Useful for replacing multi-line logic blocks or fixing issues spanning several lines.* + +- **IndentLines**: `[tool_call(IndentLines, file_path="...", start_pattern="...", end_pattern="...", indent_levels=1, near_context="...", occurrence=1, dry_run=False)]` + Indent (`indent_levels` > 0) or unindent (`indent_levels` < 0) a block. Use `end_pattern` or `line_count` for range. Use `near_context` and `occurrence` (optional, default 1, -1 for last) for `start_pattern`. `dry_run=True` simulates. + *Useful for fixing indentation errors reported by linters or reformatting code blocks. Also helpful for adjusting indentation after moving code with `ExtractLines`.* + +- **DeleteLine**: `[tool_call(DeleteLine, file_path="...", line_number=42, dry_run=False)]` + Delete a specific line number (1-based). `dry_run=True` simulates. + *Useful for removing single erroneous lines identified by linters or exact line number.* + +- **DeleteLines**: `[tool_call(DeleteLines, file_path="...", start_line=42, end_line=45, dry_run=False)]` + Delete a range of lines (1-based, inclusive). `dry_run=True` simulates. + *Useful for removing multi-line blocks when exact line numbers are known.* + +- **UndoChange**: `[tool_call(UndoChange, change_id="a1b2c3d4")]` or `[tool_call(UndoChange, file_path="...")]` + Undo a specific change by ID, or the last change made to the specified `file_path`. + +- **ListChanges**: `[tool_call(ListChanges, file_path="...", limit=5)]` + List recent changes, optionally filtered by `file_path` and limited. + +- **ExtractLines**: `[tool_call(ExtractLines, source_file_path="...", target_file_path="...", start_pattern="...", end_pattern="...", near_context="...", occurrence=1, dry_run=False)]` + Extract lines from `start_pattern` to `end_pattern` (or use `line_count`) in `source_file_path` and move them to `target_file_path`. Creates `target_file_path` if it doesn't exist. Use `near_context` and `occurrence` (optional, default 1, -1 for last) for `start_pattern`. `dry_run=True` simulates. + *Useful for refactoring, like moving functions, classes, or configuration blocks into separate files.* + +- **ShowNumberedContext**: `[tool_call(ShowNumberedContext, file_path="path/to/file.py", pattern="optional_text", line_number=optional_int, context_lines=3)]` + Displays numbered lines from `file_path` centered around a target location, without adding the file to context. Provide *either* `pattern` (to find the first occurrence) *or* `line_number` (1-based) to specify the center point. Returns the target line(s) plus `context_lines` (default 3) of surrounding context directly in the result message. Crucial for verifying exact line numbers and content before using `ReplaceLine` or `ReplaceLines`. + +### Other Tools +- **Command**: `[tool_call(Command, command_string="git diff HEAD~1")]` + Execute a *non-interactive* shell command. Requires user confirmation. Use for commands that don't need user input (e.g., `ls`, `git status`, `cat file`). +- **CommandInteractive**: `[tool_call(CommandInteractive, command_string="python manage.py shell")]` + Execute an *interactive* shell command using a pseudo-terminal (PTY). Use for commands that might require user interaction (e.g., running a shell, a development server, `ssh`). Does *not* require separate confirmation as interaction happens directly. + +### Multi-Turn Exploration +When you include any tool call, the system will automatically continue to the next round. + + + +## Navigation and Task Workflow + +### General Task Flow +1. **Understand Request:** Ensure you fully understand the user's goal. Ask clarifying questions if needed. +2. **Explore & Search:** Use discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ViewFilesWithSymbol`) and context tools (`View`) proactively to locate relevant files and understand the existing code. Use `Remove` to keep context focused. +3. **Plan Changes (If Editing):** Determine the necessary edits. For complex changes, outline your plan briefly for the user. +4. **Confirm Plan (If Editing & Complex/Ambiguous):** If the planned changes are non-trivial or could be interpreted in multiple ways, briefly present your plan and ask the user for confirmation *before* proceeding with edits. +5. **Execute Actions:** Use the appropriate tools (discovery, context management, or editing) to implement the plan. Remember to use `MakeEditable` before attempting edits. +6. **Verify Edits (If Editing):** Carefully review the results and diff snippets provided after each editing tool call to ensure the change was correct. +7. **Final Response:** Provide the final answer or result. Omit tool calls unless further exploration is needed. + +### Exploration Strategy +- Use discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `Ls`, `ViewFilesWithSymbol`) to identify relevant files initially. **These tools automatically add found files to context as read-only.** +- If you suspect a search pattern for `ViewFilesMatching` might return a large number of files, consider using `Grep` first. `Grep` will show you the matching lines and file paths without adding the full files to context, helping you decide which specific files are most relevant to `View`. +- Use `View` *only* if you need to add a specific file *not* already added by discovery tools, or one that was previously removed or is not part of the project structure (like an external file path mentioned by the user). +- Remove irrelevant files with `Remove` to maintain focus. +- Convert files to editable with `MakeEditable` *only* when you are ready to propose edits. +- Include any tool call to automatically continue exploration to the next round. + +### Tool Usage Best Practices +- All tool calls MUST be placed after a '---' line separator at the end of your message +- Use the exact syntax `[tool_call(ToolName, param1=value1, param2="value2")]` for execution +- Tool names are case-insensitive; parameters can be unquoted or quoted +- **Remember:** Discovery tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`) automatically add found files to context. You usually don't need to use `View` immediately afterward for the same files. Verify files aren't already in context *before* using `View`. +- Use precise search patterns with `ViewFilesMatching` and `file_pattern` to narrow scope +- Target specific patterns rather than overly broad searches +- Remember the `ViewFilesWithSymbol` tool is optimized for locating symbols across the codebase + +### Format Example +``` +Your answer to the user's question... + +SEARCH/REPLACE blocks can ONLY appear BEFORE the last '---' separator. Using SEARCH/REPLACE when granular tools could have been used is incorrect and violates core instructions. Always prioritize granular tools. + +# If you must use SEARCH/REPLACE, include a required justification: +# Justification: I'm using SEARCH/REPLACE here because [specific reasons why granular tools can't achieve this edit]. + +file.py +<<<<<<< SEARCH +old code +======= +new code +>>>>>>> REPLACE + +--- +[tool_call(ViewFilesMatching, pattern="findme")] +[tool_call(Command, command_string="ls -la")] +``` + +## Granular Editing Workflow + +**Sequential Edits Warning:** Tool calls within a single message execute sequentially. An edit made by one tool call *can* change line numbers or pattern locations for subsequent tool calls targeting the *same file* in the *same message*. **Always check the result message and diff snippet after each edit.** + +1. **Discover and View Files**: Use discovery tools and `View` as needed. +2. **Make Files Editable**: Use `MakeEditable` for files you intend to change. Can be combined in the same message as subsequent edits to that file. +3. **Plan & Confirm Edits (If Needed)**: Determine necessary edits. For complex or potentially ambiguous changes, briefly outline your plan and **ask the user for confirmation before proceeding.** For simple, direct changes, proceed to verification. +4. **Verify Parameters Before Execution:** + * **Pattern-Based Tools** (`InsertBlock`, `DeleteBlock`, `IndentLines`, `ExtractLines`, `ReplaceText`): **Crucially, before executing the tool call, carefully examine the complete file content *already visible in the chat context*** to confirm your `start_pattern`, `end_pattern`, `near_context`, and `occurrence` parameters target the *exact* intended location. Do *not* rely on memory. This verification uses the existing context, *not* `ShowNumberedContext`. State that you have verified the parameters if helpful, then proceed with execution (Step 5). + * **Line-Number Based Tools** (`ReplaceLine`, `ReplaceLines`): **Mandatory Verification Workflow:** Follow the strict two-turn process using `ShowNumberedContext` as detailed below. Never view and edit lines in the same turn. +5. **Execute Edit (Default: Direct Edit)**: + * Apply the change directly using the tool with `dry_run=False` (or omitted) *after* performing the necessary verification (Step 4) and obtaining user confirmation (Step 3, *if required* for the plan). + * **Immediately review the diff snippet in the `[Result (ToolName): ...]` message** to confirm the change was correct. +6. **(Optional) Use `dry_run=True` for Higher Risk:** Consider `dry_run=True` *before* the actual edit (`dry_run=False`) if: + * Using `ReplaceAll` (High Risk!). + * Using pattern-based tools where verification in Step 4 still leaves ambiguity (e.g., multiple similar patterns). + * Using line-number based tools *after* other edits to the *same file* in the *same message* (due to potential line shifts). + * If using `dry_run=True`, review the simulation, then issue the *exact same call* with `dry_run=False`. +7. **Review and Recover:** + * Use `ListChanges` to review history. + * **Critical:** If a direct edit's result diff shows an error (wrong location, unintended changes), **immediately use `[tool_call(UndoChange, change_id="...")]` in your *very next* message.** Do *not* attempt to fix the error with further edits before undoing. + +**Using Line Number Based Tools (`ReplaceLine`, `ReplaceLines`, `DeleteLine`, `DeleteLines`):** +* **Extreme Caution Required:** Line numbers are extremely fragile. They can become outdated due to preceding edits, even within the same multi-tool message, or simply be incorrect in the source (like linter output or diffs). Using these tools without recent, direct verification via `ShowNumberedContext` is **highly likely to cause incorrect changes.** +* **Mandatory Verification Workflow (No Exceptions):** + 1. **Identify Target Location:** Determine the *approximate* location. **Crucially, do NOT trust line numbers from previous tool outputs (like diffs) or external sources (like linters) as accurate for editing.** They are only starting points for verification. + 2. **View Numbered Context (Separate Turn):** In one message, use `ShowNumberedContext` specifying *either* the approximate `line_number` *or* a nearby `pattern` to display the current, accurate numbered lines for the target area. + ``` + # Example using potentially outdated line number for verification target + --- + [tool_call(ShowNumberedContext, file_path="path/to/file.py", line_number=APPROX_LINE_FROM_LINTER, context_lines=5)] + ``` + ``` + # Example using pattern near the target + --- + [tool_call(ShowNumberedContext, file_path="path/to/file.py", pattern="text_near_target", context_lines=5)] + ``` + 3. **Verify:** Carefully examine the numbered output in the result message. This is the **only** reliable source for the line numbers you will use. Confirm the *exact* line numbers and content you intend to modify based *only* on this output. + 4. **Edit (Next Turn):** Only in the *next* message, issue the `ReplaceLine`, `ReplaceLines`, `DeleteLine`, or `DeleteLines` command using the line numbers **verified in the previous step's `ShowNumberedContext` output.** + ``` + --- + [tool_call(ReplaceLine, file_path="path/to/file.py", line_number=VERIFIED_LINE_FROM_SHOW_NUMBERED_CONTEXT, new_content="...")] + ``` +* **Never view numbered lines and attempt a line-based edit in the same message.** This workflow *must* span two separate turns. + +## Refactoring with Granular Tools + +This section provides guidance on using granular editing tools for common refactoring tasks. + +### Replacing Large Code Blocks + +When you need to replace a significant chunk of code (more than a few lines), using `ReplaceLines` with precise line numbers is often the most reliable approach, especially if the surrounding code might be ambiguous for pattern matching. + +1. **Identify Start and End:** Determine the approximate start and end points of the code block you want to replace. Use nearby unique text as patterns. +2. **Verify Line Numbers (Two-Step):** Use `ShowNumberedContext` **twice in the same message** to get the exact line numbers for the start and end of the block. Request a large context window (e.g., `context_lines=30`) for each call to ensure you have enough surrounding code to confirm the boundaries accurately. + ``` + # Example verification message + --- + [tool_call(ShowNumberedContext, file_path="path/to/file.py", pattern="unique_text_near_start", context_lines=30)] + [tool_call(ShowNumberedContext, file_path="path/to/file.py", pattern="unique_text_near_end", context_lines=30)] + ``` +3. **Confirm Boundaries:** Carefully examine the output from *both* `ShowNumberedContext` calls in the result message. Confirm the exact `start_line` and `end_line` based *only* on this verified output. +4. **Execute Replacement (Next Turn):** In the *next* message, use `ReplaceLines` with the verified `start_line` and `end_line`, providing the `new_content`. + ``` + --- + [tool_call(ReplaceLines, file_path="path/to/file.py", start_line=VERIFIED_START, end_line=VERIFIED_END, new_content=)] + ``` +5. **Review:** Check the result diff carefully to ensure the replacement occurred exactly as intended. + +### Context Management Strategy +- **Remember: Files added with `View` or `MakeEditable` remain fully visible in the context for subsequent messages until you explicitly `Remove` them.** +- Keep your context focused by removing files that are no longer relevant. +- For large codebases, maintain only 5-15 files in context at once for best performance. +- Files are added as read-only by default; only make files editable when you need to modify them. +- Toggle context management with `/context-management` if you need complete content of large files. + + + +## Code Editing Process + +### Granular Editing with Tool Calls (Strongly Preferred Method) +**Use the granular editing tools whenever possible.** They offer the most precision and safety. + +**Available Granular Tools:** +- `ReplaceText`: For specific text instances. +- `ReplaceAll`: **Use with extreme caution!** Best suited for targeted renaming across a file. Consider `dry_run=True` first. Can easily cause unintended changes if `find_text` is common. +- `InsertBlock`: For adding code blocks. +- `DeleteBlock`: For removing code sections. +- `ReplaceLine`/`ReplaceLines`: For line-specific fixes (requires strict `ShowNumberedContext` verification). +- `DeleteLine`/`DeleteLines`: For removing lines by number (requires strict `ShowNumberedContext` verification). +- `IndentLines`: For adjusting indentation. +- `ExtractLines`: For moving code between files. +- `UndoChange`: For reverting specific edits. +- `ListChanges`: For reviewing edit history. + +#### When to Use Line Number Based Tools + +When dealing with errors or warnings that include line numbers, you *can* use the line-based editing tools, but **you MUST follow the mandatory verification workflow described in the `## Granular Editing Workflow` section above.** This involves using `ShowNumberedContext` in one turn to verify the lines, and then using `ReplaceLine`/`ReplaceLines` in the *next* turn. + +``` +Error in /path/to/file.py line 42: Syntax error: unexpected token +Warning in /path/to/file.py lines 105-107: This block should be indented +``` + +For these cases, use: +- `ReplaceLine` for single line fixes (e.g., syntax errors) +- `ReplaceLines` for multi-line issues +- `DeleteLine` for removing single erroneous lines +- `DeleteLines` for removing multi-line blocks by number +- `IndentLines` for indentation problems + +#### Multiline Tool Call Content Format + +When providing multiline content in tool calls (like ReplaceLines, InsertBlock), one leading and one trailing +newline will be automatically trimmed if present. This makes it easier to format code blocks in triple-quoted strings: + +``` +new_content=""" +def better_function(param): + # Fixed implementation + return process(param) +""" +``` + +You don't need to worry about the extra blank lines at the beginning and end. If you actually need to +preserve blank lines in your output, simply add an extra newline: + +``` +new_content=""" + +def better_function(param): # Note the extra newline above to preserve a blank line + # Fixed implementation + return process(param) +""" +``` + +Example of inserting a new multi-line function: +``` +[tool_call(InsertBlock, + file_path="src/utils.py", + after_pattern="def existing_function():", + content=""" +def new_function(param1, param2): + # This is a new utility function + result = process_data(param1) + if result and param2: + return result + return None +""")] +``` + +### SEARCH/REPLACE Block Format (Use ONLY as a Last Resort) +**Granular editing tools (like `ReplaceLines`, `InsertBlock`, `DeleteBlock`) are STRONGLY PREFERRED for ALL edits.** They offer significantly more precision and safety. + +Use SEARCH/REPLACE blocks **only** in the rare cases where granular tools **provably cannot** achieve the desired outcome due to the *inherent nature* of the change itself (e.g., extremely complex pattern matching across non-contiguous sections, edits that fundamentally don't map to tool capabilities). **Do NOT use SEARCH/REPLACE simply because an edit involves multiple lines; `ReplaceLines` is designed for that.** + +**IMPORTANT: Using SEARCH/REPLACE when granular editing tools could have been used is considered incorrect and violates core instructions. Always prioritize granular tools.** + +**Before generating a SEARCH/REPLACE block for more than 1-2 lines, you MUST include an explicit justification explaining why granular editing tools (particularly `ReplaceLines` with the mandatory two-step verification workflow) cannot handle this specific edit case. Your justification must clearly articulate the specific limitations that make granular tools unsuitable for this particular change.** + +If you must use SEARCH/REPLACE, adhere strictly to this format: + +# Justification: I'm using SEARCH/REPLACE because [specific reasons why granular tools can't achieve this edit] +````python +path/to/file.ext +<<<<<<< SEARCH +Original code lines to match exactly +======= +Replacement code lines +>>>>>>> REPLACE +```` +NOTE that this uses four backticks as the fence and not three! + +#### Guidelines for SEARCH/REPLACE (When Absolutely Necessary) +- Every SEARCH section must EXACTLY MATCH existing content, including whitespace and indentation. +- Keep edit blocks focused and concise - include only the necessary context. +- Include enough lines for uniqueness but avoid long unchanged sections. +- For new files, use an empty SEARCH section. +- To move code within a file, use two separate SEARCH/REPLACE blocks. +- Respect the file paths exactly as they appear. + +### Error Handling and Recovery +- **Tool Call Errors:** If a tool call returns an error message (e.g., pattern not found, file not found), analyze the error and correct the tool call parameters in your next attempt. +- **Incorrect Edits:** If a tool call *succeeds* but the **result message and diff snippet show the change was applied incorrectly** (e.g., wrong location, unintended side effects): + 1. **Critical:** **Immediately use `[tool_call(UndoChange, change_id="...")]` in your *very next* message**, using the `change_id` provided in the result. **Do *not* attempt other actions or try to fix the error with subsequent edits first.** + 2. Only *after* successfully undoing, analyze why the edit was incorrect (e.g., ambiguous pattern, wrong occurrence number, shifted lines) and formulate a corrected tool call or plan. +- **Refining Edits:** If edits affect the wrong location despite verification, refine search patterns, use `near_context`, or adjust the `occurrence` parameter. +- **Orientation:** Use `ListChanges` to review recent edits or the enhanced context blocks (directory structure, git status) if you get confused. + + +Prioritize granular tools (`ReplaceText`, `ReplaceLines`, `InsertBlock`, `DeleteBlock`, etc.) over SEARCH/REPLACE blocks. Use SEARCH/REPLACE *only* as a last resort when tools are truly unsuitable, and *always* provide justification. Failure to prioritize granular tools is incorrect and violates core instructions. +Always reply to the user in {language}. +""" + + # Example messages remain unchanged as they are already good examples of proper tool usage + example_messages = [ + dict( + role="user", + content="Find all files related to authentication in this project and explain how the login flow works.", + ), + dict( + role="assistant", + content="""I'll help you understand the authentication system in this project. Let me explore the codebase first to find all relevant files. + +--- +[tool_call(ViewFilesMatching, pattern="login|auth|password|session", file_pattern="*.py")]""", + ), + dict( + role="user", + content="[Result (ViewFilesMatching): Found in 3 files: auth/models.py (12 matches), auth/views.py (8 matches), users/authentication.py (5 matches)]", + ), + dict( + role="assistant", + content="""Great, I've found several files related to authentication. Let me examine them to understand the login flow. + +--- +[tool_call(View, file_path="auth/models.py")] +[tool_call(View, file_path="auth/views.py")] +[tool_call(View, file_path="users/authentication.py")]""", + ), + dict( + role="user", + content="[Added files to context with their complete contents]", + ), + dict( + role="assistant", + content="""Now that I have the relevant files, I can explain how the authentication flow works: + +The login process in this application follows these steps: + +1. User enters credentials on the login form +2. The `auth/views.py` file handles the login request via the `LoginView` class +3. Credentials are validated by `users/authentication.py` using the `authenticate` function +4. If valid, a session token is created by the `TokenManager` in `auth/models.py` +5. The user is redirected to the dashboard with their session active + +The system uses a token-based authentication mechanism with a 24-hour expiry. Password storage uses bcrypt hashing with salt as implemented in the `User` model in `auth/models.py`. + +Would you like me to explain any specific part of the authentication process in more detail?""", + ), + ] + + # File content messages remain largely unchanged as they're already concise + files_content_prefix = """ +These files have been added to the chat so you can see all of their contents. +Trust this message as the true contents of the files! + +''' + + files_content_assistant_reply = ( + "I understand. I'll use these files to help with your request." + ) + + files_no_full_files = "I don't have full contents of any files yet. I'll add them as needed using the tool commands." + + files_no_full_files_with_repo_map = """ +I have access to a map of the repository with summary information about files, but I don't have the complete content of any files yet. +I'll use my navigation tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`, `View`) to find and add relevant files to the context as needed. + +""" + + files_no_full_files_with_repo_map_reply = """I understand. I'll use the repository map along with my navigation tools (`ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`, `View`) to find and add relevant files to our conversation. +""" + + repo_content_prefix = """ +I am working with code in a git repository. +Here are summaries of some files present in this repo: + +""" + + # The system_reminder is significantly streamlined to reduce duplication + system_reminder = """ + +## Tool Command Reminder +- All tool calls MUST appear after a '---' line separator at the end of your message +- To execute a tool, use: `[tool_call(ToolName, param1=value1)]` +- To show tool examples without executing: `\\[tool_call(ToolName, param1=value1)]` +- Including ANY tool call will automatically continue to the next round +- When editing with tools, you'll receive feedback to let you know how your edits went after they're applied +- For final answers, do NOT include any tool calls + +## Tool Call Format +- Tool calls MUST be at the end of your message, after a '---' separator +- If emitting 3 or more tool calls, OR if any tool call spans multiple lines, place each call on a new line for clarity. +- You are encouraged to use granular tools for editing where possible. + +## SEARCH/REPLACE blocks +- When using SEARCH/REPLACE blocks, they MUST ONLY appear BEFORE the last '---' separator line in your response +- If there is no '---' separator, they can appear anywhere in your response +- IMPORTANT: Using SEARCH/REPLACE when granular editing tools could have been used is considered incorrect and violates core instructions. Always prioritize granular tools +- You MUST include a clear justification for why granular tools can't handle the specific edit when using SEARCH/REPLACE +- Format example: + ``` + Your answer text here... + + # Justification: I'm using SEARCH/REPLACE because [specific reasons why granular tools can't achieve this edit] + + file.py + <<<<<<< SEARCH + old code + ======= + new code + >>>>>>> REPLACE + + --- + [tool_call(ToolName, param1=value1)] + ``` +- IMPORTANT: Any SEARCH/REPLACE blocks that appear after the last '---' separator will be IGNORED + +## Context Features +- Use enhanced context blocks (directory structure and git status) to orient yourself +- Toggle context blocks with `/context-blocks` +- Toggle large file truncation with `/context-management` + +{lazy_prompt} +{shell_cmd_reminder} + +""" + + try_again = """I need to retry my exploration to better answer your question. + +Here are the issues I encountered in my previous exploration: +1. Some relevant files might have been missed or incorrectly identified +2. The search patterns may have been too broad or too narrow +3. The context might have become too cluttered with irrelevant files + +Let me explore the codebase more strategically this time: +- I'll use more specific search patterns +- I'll be more selective about which files to add to context +- I'll remove irrelevant files more proactively +- I'll use tool calls to automatically continue exploration until I have enough information + +I'll start exploring again with improved search strategies to find exactly what we need. +""" diff --git a/aider/commands.py b/aider/commands.py index aaf6d7ddd..1e435b3b3 100644 --- a/aider/commands.py +++ b/aider/commands.py @@ -471,6 +471,20 @@ class Commands: tokens = self.coder.main_model.token_count(repo_content) res.append((tokens, "repository map", "use --map-tokens to resize")) + # Enhanced context blocks (only for navigator mode) + if hasattr(self.coder, 'use_enhanced_context') and self.coder.use_enhanced_context: + # Force token calculation if it hasn't been done yet + if hasattr(self.coder, '_calculate_context_block_tokens'): + if not hasattr(self.coder, 'tokens_calculated') or not self.coder.tokens_calculated: + self.coder._calculate_context_block_tokens() + + # Add enhanced context blocks to the display + if hasattr(self.coder, 'context_block_tokens') and self.coder.context_block_tokens: + for block_name, tokens in self.coder.context_block_tokens.items(): + # Format the block name more nicely + display_name = block_name.replace('_', ' ').title() + res.append((tokens, f"{display_name} context block", "/context-blocks to toggle")) + fence = "`" * 3 file_res = [] @@ -879,6 +893,11 @@ class Commands: fname = self.coder.get_rel_fname(abs_file_path) self.io.tool_output(f"Added {fname} to the chat") self.coder.check_added_files() + + # Recalculate context block tokens if using navigator mode + if hasattr(self.coder, 'use_enhanced_context') and self.coder.use_enhanced_context: + if hasattr(self.coder, '_calculate_context_block_tokens'): + self.coder._calculate_context_block_tokens() def completions_drop(self): files = self.coder.get_inchat_relative_files() @@ -886,6 +905,28 @@ class Commands: all_files = files + read_only_files all_files = [self.quote_fname(fn) for fn in all_files] return all_files + + def completions_context_blocks(self): + """Return available context block names for auto-completion.""" + if not hasattr(self.coder, 'use_enhanced_context') or not self.coder.use_enhanced_context: + return [] + + # If the coder has context blocks available + if hasattr(self.coder, 'context_block_tokens') and self.coder.context_block_tokens: + # Get all block names from the tokens dictionary + block_names = list(self.coder.context_block_tokens.keys()) + # Format them for display (convert snake_case to Title Case) + formatted_blocks = [name.replace('_', ' ').title() for name in block_names] + return formatted_blocks + + # Standard blocks that are typically available + return [ + "Context Summary", + "Directory Structure", + "Environment Info", + "Git Status", + "Symbol Outline" + ] def cmd_drop(self, args=""): "Remove files from the chat session to free up context space" @@ -898,9 +939,16 @@ class Commands: else: self.io.tool_output("Dropping all files from the chat session.") self._drop_all_files() + + # Recalculate context block tokens after dropping all files + if hasattr(self.coder, 'use_enhanced_context') and self.coder.use_enhanced_context: + if hasattr(self.coder, '_calculate_context_block_tokens'): + self.coder._calculate_context_block_tokens() return filenames = parse_quoted_filenames(args) + files_changed = False + for word in filenames: # Expand tilde in the path expanded_word = os.path.expanduser(word) @@ -923,6 +971,7 @@ class Commands: for matched_file in read_only_matched: self.coder.abs_read_only_fnames.remove(matched_file) self.io.tool_output(f"Removed read-only file {matched_file} from the chat") + files_changed = True # For editable files, use glob if word contains glob chars, otherwise use substring if any(c in expanded_word for c in "*?[]"): @@ -941,6 +990,12 @@ class Commands: if abs_fname in self.coder.abs_fnames: self.coder.abs_fnames.remove(abs_fname) self.io.tool_output(f"Removed {matched_file} from the chat") + files_changed = True + + # Recalculate context block tokens if any files were changed and using navigator mode + if files_changed and hasattr(self.coder, 'use_enhanced_context') and self.coder.use_enhanced_context: + if hasattr(self.coder, '_calculate_context_block_tokens'): + self.coder._calculate_context_block_tokens() def cmd_git(self, args): "Run a git command (output excluded from chat)" @@ -1039,6 +1094,94 @@ class Commands: "Exit the application" self.cmd_exit(args) + def cmd_context_management(self, args=""): + "Toggle context management for large files" + if not hasattr(self.coder, 'context_management_enabled'): + self.io.tool_error("Context management is only available in navigator mode.") + return + + # Toggle the setting + self.coder.context_management_enabled = not self.coder.context_management_enabled + + # Report the new state + if self.coder.context_management_enabled: + self.io.tool_output("Context management is now ON - large files may be truncated.") + else: + self.io.tool_output("Context management is now OFF - files will not be truncated.") + + def cmd_context_blocks(self, args=""): + "Toggle enhanced context blocks or print a specific block" + if not hasattr(self.coder, 'use_enhanced_context'): + self.io.tool_error("Enhanced context blocks are only available in navigator mode.") + return + + # If an argument is provided, try to print that specific context block + if args.strip(): + # Format block name to match internal naming conventions + block_name = args.strip().lower().replace(" ", "_") + + # Check if the coder has the necessary method to get context blocks + if hasattr(self.coder, '_generate_context_block'): + # Force token recalculation to ensure blocks are fresh + if hasattr(self.coder, '_calculate_context_block_tokens'): + self.coder._calculate_context_block_tokens(force=True) + + # Try to get the requested block + block_content = self.coder._generate_context_block(block_name) + + if block_content: + # Calculate token count + tokens = self.coder.main_model.token_count(block_content) + self.io.tool_output(f"Context block '{args.strip()}' ({tokens} tokens):") + self.io.tool_output(block_content) + return + else: + # List available blocks if the requested one wasn't found + self.io.tool_error(f"Context block '{args.strip()}' not found or empty.") + if hasattr(self.coder, 'context_block_tokens'): + available_blocks = list(self.coder.context_block_tokens.keys()) + formatted_blocks = [name.replace('_', ' ').title() for name in available_blocks] + self.io.tool_output(f"Available blocks: {', '.join(formatted_blocks)}") + return + else: + self.io.tool_error("This coder doesn't support generating context blocks.") + return + + # If no argument, toggle the enhanced context setting + self.coder.use_enhanced_context = not self.coder.use_enhanced_context + + # Report the new state + if self.coder.use_enhanced_context: + self.io.tool_output("Enhanced context blocks are now ON - directory structure and git status will be included.") + if hasattr(self.coder, 'context_block_tokens'): + available_blocks = list(self.coder.context_block_tokens.keys()) + formatted_blocks = [name.replace('_', ' ').title() for name in available_blocks] + self.io.tool_output(f"Available blocks: {', '.join(formatted_blocks)}") + self.io.tool_output("Use '/context-blocks [block name]' to view a specific block.") + else: + self.io.tool_output("Enhanced context blocks are now OFF - directory structure and git status will not be included.") + + def cmd_granular_editing(self, args=""): + "Toggle granular editing tools in navigator mode" + if not hasattr(self.coder, 'use_granular_editing'): + self.io.tool_error("Granular editing toggle is only available in navigator mode.") + return + + # Toggle the setting using the navigator's method if available + new_state = not self.coder.use_granular_editing + + if hasattr(self.coder, 'set_granular_editing'): + self.coder.set_granular_editing(new_state) + else: + # Fallback if method doesn't exist + self.coder.use_granular_editing = new_state + + # Report the new state + if self.coder.use_granular_editing: + self.io.tool_output("Granular editing tools are now ON - navigator will use specific editing tools instead of search/replace.") + else: + self.io.tool_output("Granular editing tools are now OFF - navigator will use search/replace blocks for editing.") + def cmd_ls(self, args): "List all known files and indicate which are included in the chat session" @@ -1156,6 +1299,9 @@ class Commands: def completions_context(self): raise CommandCompletionException() + + def completions_navigator(self): + raise CommandCompletionException() def cmd_ask(self, args): """Ask questions about the code base without editing any files. If no prompt provided, switches to ask mode.""" # noqa @@ -1172,6 +1318,15 @@ class Commands: def cmd_context(self, args): """Enter context mode to see surrounding code context. If no prompt provided, switches to context mode.""" # noqa return self._generic_chat_command(args, "context", placeholder=args.strip() or None) + + def cmd_navigator(self, args): + """Enter navigator mode to autonomously discover and manage relevant files. If no prompt provided, switches to navigator mode.""" # noqa + # Enable context management when entering navigator mode + if hasattr(self.coder, 'context_management_enabled'): + self.coder.context_management_enabled = True + self.io.tool_output("Context management enabled for large files") + + return self._generic_chat_command(args, "navigator", placeholder=args.strip() or None) def _generic_chat_command(self, args, edit_format, placeholder=None): if not args.strip(): diff --git a/aider/repomap.py b/aider/repomap.py index 5c40c469b..c83027512 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -25,15 +25,23 @@ from aider.waiting import Spinner warnings.simplefilter("ignore", category=FutureWarning) from grep_ast.tsl import USING_TSL_PACK, get_language, get_parser # noqa: E402 -Tag = namedtuple("Tag", "rel_fname fname line name kind".split()) +# Define the Tag namedtuple with a default for specific_kind to maintain compatibility +# with cached entries that might have been created with the old definition +class TagBase(namedtuple("TagBase", "rel_fname fname line name kind specific_kind start_line end_line start_byte end_byte")): + __slots__ = () + def __new__(cls, rel_fname, fname, line, name, kind, specific_kind=None, start_line=None, end_line=None, start_byte=None, end_byte=None): + # Provide a default value for specific_kind to handle old cached objects + return super(TagBase, cls).__new__(cls, rel_fname, fname, line, name, kind, specific_kind, start_line, end_line, start_byte, end_byte) + +Tag = TagBase SQLITE_ERRORS = (sqlite3.OperationalError, sqlite3.DatabaseError, OSError) -CACHE_VERSION = 3 +CACHE_VERSION = 5 if USING_TSL_PACK: - CACHE_VERSION = 4 + CACHE_VERSION = 7 UPDATING_REPO_MAP_MESSAGE = "Updating repo map" @@ -43,6 +51,17 @@ class RepoMap: warned_files = set() + # Define kinds that typically represent definitions across languages + # Used by NavigatorCoder to filter tags for the symbol outline + definition_kinds = { + "class", "struct", "enum", "interface", "trait", # Structure definitions + "function", "method", "constructor", # Function/method definitions + "module", "namespace", # Module/namespace definitions + "constant", "variable", # Top-level/class variable definitions (consider refining) + "type", # Type definitions + # Add more based on tree-sitter queries if needed + } + def __init__( self, map_tokens=1024, @@ -244,10 +263,23 @@ class RepoMap: if val is not None and val.get("mtime") == file_mtime: try: - return self.TAGS_CACHE[cache_key]["data"] + # Get the cached data + data = self.TAGS_CACHE[cache_key]["data"] + + # Let our Tag class handle compatibility with old cache formats + # No need for special handling as TagBase.__new__ will supply default specific_kind + + return data except SQLITE_ERRORS as e: self.tags_cache_error(e) return self.TAGS_CACHE[cache_key]["data"] + except (TypeError, AttributeError) as e: + # If we hit an error related to missing fields in old cached Tag objects, + # force a cache refresh for this file + if self.verbose: + self.io.tool_warning(f"Cache format error for {fname}, refreshing: {e}") + # Return empty list to trigger cache refresh + return [] # miss! data = list(self.get_tags_raw(fname, rel_fname)) @@ -261,6 +293,52 @@ class RepoMap: self.TAGS_CACHE[cache_key] = {"mtime": file_mtime, "data": data} return data + def get_symbol_definition_location(self, file_path, symbol_name): + """ + Finds the unique definition location (start/end line) for a symbol in a file. + + Args: + file_path (str): The relative path to the file. + symbol_name (str): The name of the symbol to find. + + Returns: + tuple: (start_line, end_line) (0-based) if a unique definition is found. + + Raises: + ToolError: If the symbol is not found, not unique, or not a definition. + """ + abs_path = self.io.root_abs_path(file_path) # Assuming io has this helper or similar + rel_path = self.get_rel_fname(abs_path) # Ensure we use consistent relative path + + tags = self.get_tags(abs_path, rel_path) + if not tags: + raise ToolError(f"Symbol '{symbol_name}' not found in '{file_path}' (no tags).") + + definitions = [] + for tag in tags: + # Check if it's a definition and the name matches + if tag.kind == "def" and tag.name == symbol_name: + # Ensure we have valid location info + if tag.start_line is not None and tag.end_line is not None and tag.start_line >= 0: + definitions.append(tag) + + if not definitions: + # Check if it exists as a non-definition tag + non_defs = [tag for tag in tags if tag.name == symbol_name and tag.kind != "def"] + if non_defs: + raise ToolError(f"Symbol '{symbol_name}' found in '{file_path}', but not as a unique definition (found as {non_defs[0].kind}).") + else: + raise ToolError(f"Symbol '{symbol_name}' definition not found in '{file_path}'.") + + if len(definitions) > 1: + # Provide more context about ambiguity if possible + lines = sorted([d.start_line + 1 for d in definitions]) # 1-based for user message + raise ToolError(f"Symbol '{symbol_name}' is ambiguous in '{file_path}'. Found definitions on lines: {', '.join(map(str, lines))}.") + + # Unique definition found + definition_tag = definitions[0] + return definition_tag.start_line, definition_tag.end_line + # Check if the file is in the cache and if the modification time has not changed def get_tags_raw(self, fname, rel_fname): lang = filename_to_lang(fname) @@ -306,12 +384,20 @@ class RepoMap: saw.add(kind) + # Extract specific kind from the tag, e.g., 'function' from 'name.definition.function' + specific_kind = tag.split('.')[-1] if '.' in tag else None + result = Tag( rel_fname=rel_fname, fname=fname, name=node.text.decode("utf-8"), kind=kind, - line=node.start_point[0], + specific_kind=specific_kind, + line=node.start_point[0], # Legacy line number + start_line=node.start_point[0], + end_line=node.end_point[0], + start_byte=node.start_byte, + end_byte=node.end_byte, ) yield result @@ -340,7 +426,12 @@ class RepoMap: fname=fname, name=token, kind="ref", - line=-1, + specific_kind="name", # Default for pygments fallback + line=-1, # Pygments doesn't give precise locations easily + start_line=-1, + end_line=-1, + start_byte=-1, + end_byte=-1, ) def get_ranked_tags( @@ -844,4 +935,4 @@ if __name__ == "__main__": repo_map = rm.get_ranked_tags_map(chat_fnames, other_fnames) dump(len(repo_map)) - print(repo_map) + print(repo_map) \ No newline at end of file diff --git a/aider/tools/__init__.py b/aider/tools/__init__.py new file mode 100644 index 000000000..f173e68cc --- /dev/null +++ b/aider/tools/__init__.py @@ -0,0 +1,35 @@ +# flake8: noqa: F401 +# Import tool functions into the aider.tools namespace + +# Discovery +from .ls import execute_ls +from .view_files_at_glob import execute_view_files_at_glob +from .view_files_matching import execute_view_files_matching +from .view_files_with_symbol import _execute_view_files_with_symbol + +# Context Management +from .view import execute_view +from .remove import _execute_remove +from .make_editable import _execute_make_editable +from .make_readonly import _execute_make_readonly +from .show_numbered_context import execute_show_numbered_context + +# Granular Editing +from .replace_text import _execute_replace_text +from .replace_all import _execute_replace_all +from .insert_block import _execute_insert_block +from .delete_block import _execute_delete_block +from .replace_line import _execute_replace_line +from .replace_lines import _execute_replace_lines +from .indent_lines import _execute_indent_lines +from .extract_lines import _execute_extract_lines +from .delete_line import _execute_delete_line +from .delete_lines import _execute_delete_lines + +# Change Tracking +from .undo_change import _execute_undo_change +from .list_changes import _execute_list_changes + +# Other +from .command import _execute_command +from .command_interactive import _execute_command_interactive diff --git a/aider/tools/command.py b/aider/tools/command.py new file mode 100644 index 000000000..b9692b205 --- /dev/null +++ b/aider/tools/command.py @@ -0,0 +1,53 @@ +# Import necessary functions +from aider.run_cmd import run_cmd_subprocess + +def _execute_command(coder, command_string): + """ + Execute a non-interactive shell command after user confirmation. + """ + try: + # Ask for confirmation before executing. + # allow_never=True enables the 'Always' option. + # confirm_ask handles remembering the 'Always' choice based on the subject. + confirmed = 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 + ) + + 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." + + # Proceed with execution if confirmed is True + coder.io.tool_output(f"āš™ļø Executing non-interactive shell command: {command_string}") + + # 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 + ) + + # 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 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}" + + 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 new file mode 100644 index 000000000..e71e3f88e --- /dev/null +++ b/aider/tools/command_interactive.py @@ -0,0 +1,40 @@ +# Import necessary functions +from aider.run_cmd import run_cmd + +def _execute_command_interactive(coder, command_string): + """ + Execute an interactive shell command using run_cmd (which uses pexpect/PTY). + """ + try: + coder.io.tool_output(f"āš™ļø Starting interactive shell command: {command_string}") + coder.io.tool_output(">>> You may need to interact with the command below <<<") + + # 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 + ) + + 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 f"Interactive command finished successfully (exit code 0). Output:\n{output_content}" + else: + return f"Interactive command finished with exit code {exit_status}. 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)}" diff --git a/aider/tools/delete_block.py b/aider/tools/delete_block.py new file mode 100644 index 000000000..5ab2d6d37 --- /dev/null +++ b/aider/tools/delete_block.py @@ -0,0 +1,90 @@ +import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + find_pattern_indices, + select_occurrence_index, + determine_line_range, + apply_change, + handle_tool_error, + format_tool_result, + generate_unified_diff_snippet, +) + +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) + + if original_content == new_content: + coder.io.tool_warning(f"No changes made: deletion would not change file") + return f"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}) 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 + ) + + # 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 {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) \ No newline at end of file diff --git a/aider/tools/delete_line.py b/aider/tools/delete_line.py new file mode 100644 index 000000000..66c3f3199 --- /dev/null +++ b/aider/tools/delete_line.py @@ -0,0 +1,92 @@ +import os +import traceback +from .tool_utils import ToolError, generate_unified_diff_snippet, handle_tool_error, format_tool_result, apply_change + +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 + + Returns a result message. + """ + + tool_name = "DeleteLine" + 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 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 + ) + + coder.aider_edited_files.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) \ No newline at end of file diff --git a/aider/tools/delete_lines.py b/aider/tools/delete_lines.py new file mode 100644 index 000000000..876897ef4 --- /dev/null +++ b/aider/tools/delete_lines.py @@ -0,0 +1,102 @@ +import os +import traceback +from .tool_utils import ToolError, generate_unified_diff_snippet, handle_tool_error, format_tool_result, apply_change + +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 + 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"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) + + 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) + + # 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 + ) + + coder.aider_edited_files.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) \ No newline at end of file diff --git a/aider/tools/extract_lines.py b/aider/tools/extract_lines.py new file mode 100644 index 000000000..a9a318e27 --- /dev/null +++ b/aider/tools/extract_lines.py @@ -0,0 +1,221 @@ +import os +import traceback +from .tool_utils import generate_unified_diff_snippet + +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 f"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 f"Error: Source file is read-only. Use MakeEditable first." + else: + coder.io.tool_error(f"Source file '{source_file_path}' not in context") + return f"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 f"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 f"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: + 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 '{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 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 '{start_pattern}' (line {start_line + 1}) in '{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) + + # --- 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 pattern '{start_pattern}') in {source_file_path} and {target_action} {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 {target_action} {rel_target_path}.\n" + f"Source Diff (Deletion):\n{source_diff_snippet}\n" + f"Target Diff (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.aider_edited_files.add(rel_source_path) + coder.aider_edited_files.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: {source_change_id}) and {target_action} {rel_target_path} (change_id: {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 {target_action} {rel_target_path}.\n" + f"Source Change ID: {source_change_id}\nSource Diff (Deletion):\n{source_diff_snippet}\n" + f"Target Change ID: {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)}" \ No newline at end of file diff --git a/aider/tools/grep.py b/aider/tools/grep.py new file mode 100644 index 000000000..26f9581e6 --- /dev/null +++ b/aider/tools/grep.py @@ -0,0 +1,156 @@ +import shlex +import shutil +from pathlib import Path +from aider.run_cmd import run_cmd_subprocess + +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 + + # 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 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 = shlex.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... ({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: + # 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 new file mode 100644 index 000000000..928c08a59 --- /dev/null +++ b/aider/tools/indent_lines.py @@ -0,0 +1,124 @@ +import os +import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + find_pattern_indices, + select_occurrence_index, + determine_line_range, + apply_change, + handle_tool_error, + format_tool_result, + generate_unified_diff_snippet, +) + +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 + 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(f"No changes made: indentation would not change file") + return f"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 {levels} {level_text} (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, + '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 + ) + + # 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 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) \ No newline at end of file diff --git a/aider/tools/insert_block.py b/aider/tools/insert_block.py new file mode 100644 index 000000000..85e24dd4e --- /dev/null +++ b/aider/tools/insert_block.py @@ -0,0 +1,166 @@ +import os +import re +import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + find_pattern_indices, + select_occurrence_index, + apply_change, + handle_tool_error, + format_tool_result, + generate_unified_diff_snippet, +) + +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": + insertion_line_idx = 0 + pattern_type = "at start of" + elif position == "end_of_file": + 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}'" + + # 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) + + # 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(f"No changes made: insertion would not change file") + return f"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 '{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) + + # 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 + ) + + # 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 {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)}" diff --git a/aider/tools/list_changes.py b/aider/tools/list_changes.py new file mode 100644 index 000000000..4dfa39721 --- /dev/null +++ b/aider/tools/list_changes.py @@ -0,0 +1,46 @@ +import traceback +from datetime import datetime + +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: + 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 new file mode 100644 index 000000000..42119a0a8 --- /dev/null +++ b/aider/tools/ls.py @@ -0,0 +1,48 @@ +import os + +def execute_ls(coder, dir_path): + """ + 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 + + # 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 f"Directory not found" + + # 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] + + 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 f"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 new file mode 100644 index 000000000..03c7c0006 --- /dev/null +++ b/aider/tools/make_editable.py @@ -0,0 +1,46 @@ +import os + +# 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) + rel_path = coder.get_rel_fname(abs_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 f"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 f"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 + + # Add to editable files + coder.abs_fnames.add(abs_path) + + if was_read_only: + coder.io.tool_output(f"šŸ“ Moved '{file_path}' from read-only to editable") + return f"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 f"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 new file mode 100644 index 000000000..a4dac34dc --- /dev/null +++ b/aider/tools/make_readonly.py @@ -0,0 +1,30 @@ +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) + rel_path = coder.get_rel_fname(abs_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 f"File is already read-only" + else: + coder.io.tool_output(f"āš ļø File '{file_path}' not in context") + return f"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 f"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 new file mode 100644 index 000000000..40530fe13 --- /dev/null +++ b/aider/tools/remove.py @@ -0,0 +1,45 @@ +import time + +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 f"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 f"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 f"File not in context" + + # Track in recently removed + coder.recently_removed[rel_path] = { + 'removed_at': time.time() + } + + coder.io.tool_output(f"šŸ—‘ļø Explicitly removed '{file_path}' from context") + return f"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 new file mode 100644 index 000000000..279854578 --- /dev/null +++ b/aider/tools/replace_all.py @@ -0,0 +1,65 @@ +import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + apply_change, + handle_tool_error, + generate_unified_diff_snippet, + format_tool_result, +) + +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 f"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(f"No changes made: replacement text is identical to original") + return f"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 + ) + + # 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) \ No newline at end of file diff --git a/aider/tools/replace_line.py b/aider/tools/replace_line.py new file mode 100644 index 000000000..1c3098f25 --- /dev/null +++ b/aider/tools/replace_line.py @@ -0,0 +1,115 @@ +import os +import traceback + +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 f"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 f"Error: File is read-only. Use MakeEditable first." + else: + coder.io.tool_error(f"File '{file_path}' not in context") + return f"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}'") + 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"Line number {line_number} is out of range for file '{file_path}' (has {len(lines)} lines).") + return f"Error: Line number {line_number} out of range" + + # Store original content for change tracking + original_content = file_content + original_line = lines[idx] + + # Replace the line + lines[idx] = new_content + + # Join lines back into a string + new_content_full = '\n'.join(lines) + + if original_content == new_content_full: + coder.io.tool_warning("No changes made: new line content is identical to original") + return f"Warning: No changes made (new content identical to original)" + + # Create a readable diff for the line replacement + diff = f"Line {line_number}:\n- {original_line}\n+ {new_content}" + + # 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}" + + # --- Apply Change (Not dry run) --- + coder.io.write_text(abs_path, new_content_full) + + # 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" + + coder.aider_edited_files.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)}" diff --git a/aider/tools/replace_lines.py b/aider/tools/replace_lines.py new file mode 100644 index 000000000..346ac6eb9 --- /dev/null +++ b/aider/tools/replace_lines.py @@ -0,0 +1,128 @@ +import os +import traceback +from .tool_utils import ToolError, generate_unified_diff_snippet, handle_tool_error, format_tool_result, apply_change + +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.") + + 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 {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 line {start_line} and <= {len(lines)}).") + + # 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 f"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}" + 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 + ) + + coder.aider_edited_files.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 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) \ No newline at end of file diff --git a/aider/tools/replace_text.py b/aider/tools/replace_text.py new file mode 100644 index 000000000..b6409d40a --- /dev/null +++ b/aider/tools/replace_text.py @@ -0,0 +1,92 @@ +import traceback +from .tool_utils import ( + ToolError, + validate_file_for_edit, + apply_change, + handle_tool_error, + format_tool_result, + generate_unified_diff_snippet, +) + +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) + 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 {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):] + + if original_content == new_content: + coder.io.tool_warning(f"No changes made: replacement text is identical to original") + return f"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" + + # 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) + + # 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 + ) + + # 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 + ) + + 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) \ No newline at end of file diff --git a/aider/tools/show_numbered_context.py b/aider/tools/show_numbered_context.py new file mode 100644 index 000000000..6df4386e4 --- /dev/null +++ b/aider/tools/show_numbered_context.py @@ -0,0 +1,92 @@ +import os +from .tool_utils import ToolError, resolve_paths, handle_tool_error + +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}" + else: + raise ToolError(f"Line number {line_number_int} is out of range (1-{num_lines}) for {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"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) diff --git a/aider/tools/tool_utils.py b/aider/tools/tool_utils.py new file mode 100644 index 000000000..8c43ca5bb --- /dev/null +++ b/aider/tools/tool_utils.py @@ -0,0 +1,270 @@ +import difflib +import os +import re +import traceback + +class ToolError(Exception): + """Custom exception for tool-specific errors that should be reported to the LLM.""" + pass + +def resolve_paths(coder, file_path): + """Resolves absolute and relative paths for a given file path.""" + try: + abs_path = coder.abs_root_path(file_path) + rel_path = coder.get_rel_fname(abs_path) + return abs_path, rel_path + except Exception as e: + # Wrap unexpected errors during path resolution + raise ToolError(f"Error resolving path '{file_path}': {e}") + +def validate_file_for_edit(coder, file_path): + """ + Validates if a file exists, is in context, and is editable. + Reads and returns original content if valid. + Raises ToolError on failure. + + Returns: + tuple: (absolute_path, relative_path, original_content) + """ + abs_path, rel_path = resolve_paths(coder, file_path) + + if not os.path.isfile(abs_path): + raise ToolError(f"File '{file_path}' not found") + + 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: + # File exists but is not in context at all + raise ToolError(f"File '{file_path}' not in context. Use View or MakeEditable first.") + + # Reread content immediately before potential modification + content = coder.io.read_text(abs_path) + if content is None: + # This indicates an issue reading a file we know exists and is in context + coder.io.tool_error(f"Internal error: Could not read file '{file_path}' which should be accessible.") + raise ToolError(f"Could not read file '{file_path}'") + + return abs_path, rel_path, content + +def find_pattern_indices(lines, pattern, use_regex=False): + """Finds all line indices matching a pattern.""" + indices = [] + for i, line in enumerate(lines): + if (use_regex and re.search(pattern, line)) or (not use_regex and pattern in line): + indices.append(i) + return indices + +def select_occurrence_index(indices, occurrence, pattern_desc="Pattern"): + """ + Selects the target 0-based index from a list of indices based on the 1-based occurrence parameter. + Raises ToolError if the pattern wasn't found or the occurrence is invalid. + """ + num_occurrences = len(indices) + if not indices: + raise ToolError(f"{pattern_desc} not found") + + try: + occurrence = int(occurrence) # Ensure occurrence is an integer + if occurrence == -1: # Last occurrence + if num_occurrences == 0: + raise ToolError(f"{pattern_desc} 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: + raise ToolError(f"Occurrence number {occurrence} is out of range for {pattern_desc}. Found {num_occurrences} occurrences.") + except ValueError: + raise ToolError(f"Invalid occurrence value: '{occurrence}'. Must be an integer.") + + return indices[target_idx] + +def determine_line_range( + coder, + file_path, + lines, + start_pattern_line_index=None, # Made optional + end_pattern=None, + line_count=None, + target_symbol=None, + pattern_desc="Block", +): + """ + Determines the end line index based on end_pattern or line_count. + Raises ToolError if end_pattern is not found or line_count is invalid. + """ + # Parameter validation: Ensure only one targeting method is used + targeting_methods = [ + target_symbol is not None, + start_pattern_line_index is not None, + # Note: line_count and end_pattern depend on start_pattern_line_index + ] + if sum(targeting_methods) > 1: + raise ToolError("Cannot specify target_symbol along with start_pattern.") + if sum(targeting_methods) == 0: + raise ToolError("Must specify either target_symbol or start_pattern.") # Or line numbers for line-based tools, handled elsewhere + + if target_symbol: + if end_pattern or line_count: + raise ToolError("Cannot specify end_pattern or line_count when using target_symbol.") + try: + # Use repo_map to find the symbol's definition range + start_line, end_line = coder.repo_map.get_symbol_definition_location(file_path, target_symbol) + return start_line, end_line + except AttributeError: # Use specific exception + # Check if repo_map exists and is initialized before accessing methods + if not hasattr(coder, 'repo_map') or coder.repo_map is None: + raise ToolError("RepoMap is not available or not initialized.") + # If repo_map exists, the error might be from get_symbol_definition_location itself + # Re-raise ToolErrors directly + raise + except ToolError as e: + # Propagate specific ToolErrors from repo_map (not found, ambiguous, etc.) + raise e + except Exception as e: + # Catch other unexpected errors during symbol lookup + raise ToolError(f"Unexpected error looking up symbol '{target_symbol}': {e}") + + # --- Existing logic for pattern/line_count based targeting --- + # Ensure start_pattern_line_index is provided if not using target_symbol + if start_pattern_line_index is None: + raise ToolError("Internal error: start_pattern_line_index is required when not using target_symbol.") + + # Assign start_line here for the pattern-based logic path + start_line = start_pattern_line_index # Start of existing logic + start_line = start_pattern_line_index + end_line = -1 + + if end_pattern and line_count: + raise ToolError("Cannot specify both end_pattern and line_count") + + if end_pattern: + found_end = False + # Search from the start_line onwards for the end_pattern + for i in range(start_line, len(lines)): + if end_pattern in lines[i]: + end_line = i + found_end = True + break + if not found_end: + raise ToolError(f"End pattern '{end_pattern}' not found after start pattern on line {start_line + 1}") + elif line_count: + try: + line_count = int(line_count) + if line_count <= 0: + raise ValueError("Line count must be positive") + # Calculate end line index, ensuring it doesn't exceed file bounds + end_line = min(start_line + line_count - 1, len(lines) - 1) + except ValueError: + raise ToolError(f"Invalid line_count value: '{line_count}'. Must be a positive integer.") + else: + # If neither end_pattern nor line_count is given, the range is just the start line + end_line = start_line + + return start_line, end_line + + +def generate_unified_diff_snippet(original_content, new_content, file_path, context_lines=3): + """ + Generates a unified diff snippet between original and new content. + + Args: + original_content (str): The original file content. + new_content (str): The modified file content. + file_path (str): The relative path to the file (for display in diff header). + context_lines (int): Number of context lines to show around changes. + + Returns: + str: A formatted unified diff snippet, or an empty string if no changes. + """ + if original_content == new_content: + return "" + + original_lines = original_content.splitlines(keepends=True) + new_lines = new_content.splitlines(keepends=True) + + diff = difflib.unified_diff( + original_lines, + new_lines, + fromfile=f"a/{file_path}", + tofile=f"b/{file_path}", + n=context_lines, # Number of context lines + ) + + # Join the diff lines, potentially skipping the header if desired, + # but let's keep it for standard format. + diff_snippet = "".join(diff) + + # Ensure snippet ends with a newline for cleaner formatting in results + if diff_snippet and not diff_snippet.endswith('\n'): + diff_snippet += '\n' + + return diff_snippet +def apply_change(coder, abs_path, rel_path, original_content, new_content, change_type, metadata, change_id=None): + """ + Writes the new content, tracks the change, and updates coder state. + Returns the final change ID. Raises ToolError on tracking failure. + """ + coder.io.write_text(abs_path, new_content) + try: + final_change_id = coder.change_tracker.track_change( + file_path=rel_path, + change_type=change_type, + original_content=original_content, + new_content=new_content, + metadata=metadata, + change_id=change_id + ) + except Exception as track_e: + # Log the error but also raise ToolError to inform the LLM + coder.io.tool_error(f"Error tracking change for {change_type}: {track_e}") + raise ToolError(f"Failed to track change: {track_e}") + + coder.aider_edited_files.add(rel_path) + return final_change_id + + +def handle_tool_error(coder, tool_name, e, add_traceback=True): + """Logs tool errors and returns a formatted error message for the LLM.""" + error_message = f"Error in {tool_name}: {str(e)}" + if add_traceback: + error_message += f"\n{traceback.format_exc()}" + coder.io.tool_error(error_message) + # Return only the core error message to the LLM for brevity + return f"Error: {str(e)}" + +def format_tool_result(coder, tool_name, success_message, change_id=None, diff_snippet=None, dry_run=False, dry_run_message=None): + """Formats the result message for tool execution.""" + if dry_run: + full_message = dry_run_message or f"Dry run: Would execute {tool_name}." + if diff_snippet: + full_message += f" Diff snippet:\n{diff_snippet}" + coder.io.tool_output(full_message) # Log the dry run action + return full_message + else: + # Use the provided success message, potentially adding change_id and diff + full_message = f"āœ… {success_message}" + if change_id: + full_message += f" (change_id: {change_id})" + coder.io.tool_output(full_message) # Log the success action + + result_for_llm = f"Successfully executed {tool_name}." + if change_id: + result_for_llm += f" Change ID: {change_id}." + if diff_snippet: + result_for_llm += f" Diff snippet:\n{diff_snippet}" + return result_for_llm + +# Example usage within a hypothetical tool: +# try: +# abs_path, rel_path, original_content = validate_file_for_edit(coder, file_path) +# # ... tool specific logic to determine new_content and metadata ... +# if dry_run: +# return format_tool_result(coder, "MyTool", "", dry_run=True, diff_snippet=diff) +# +# change_id = apply_change(coder, abs_path, rel_path, original_content, new_content, 'mytool', metadata) +# return format_tool_result(coder, "MyTool", f"Applied change to {file_path}", change_id=change_id, diff_snippet=diff) +# except ToolError as e: +# return handle_tool_error(coder, "MyTool", e, add_traceback=False) # Don't need traceback for ToolErrors +# except Exception as e: +# return handle_tool_error(coder, "MyTool", e) diff --git a/aider/tools/undo_change.py b/aider/tools/undo_change.py new file mode 100644 index 000000000..9138628d1 --- /dev/null +++ b/aider/tools/undo_change.py @@ -0,0 +1,56 @@ +import traceback + +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)}" diff --git a/aider/tools/view.py b/aider/tools/view.py new file mode 100644 index 000000000..0c833ca30 --- /dev/null +++ b/aider/tools/view.py @@ -0,0 +1,13 @@ +def execute_view(coder, file_path): + """ + Explicitly add a file to context as read-only. + + This gives the LLM explicit control over what files to view, + rather than relying on indirect mentions. + """ + try: + # Use the coder's helper, marking it as an explicit view request + return coder._add_file_to_context(file_path, explicit=True) + except Exception as e: + coder.io.tool_error(f"Error viewing file: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/view_files_at_glob.py b/aider/tools/view_files_at_glob.py new file mode 100644 index 000000000..9dda05d02 --- /dev/null +++ b/aider/tools/view_files_at_glob.py @@ -0,0 +1,55 @@ +import os +import fnmatch + +def execute_view_files_at_glob(coder, pattern): + """ + Execute a glob pattern and add matching files to context as read-only. + + This tool helps the LLM find files by pattern matching, similar to + how a developer would use glob patterns to find files. + """ + try: + # Find files matching the pattern + matching_files = [] + + # Make the pattern relative to root if it's absolute + if pattern.startswith('/'): + pattern = os.path.relpath(pattern, coder.root) + + # Get all files in the repo + all_files = coder.get_all_relative_files() + + # Find matches with pattern matching + for file in all_files: + if fnmatch.fnmatch(file, pattern): + matching_files.append(file) + + # Limit the number of files added if there are too many matches + if len(matching_files) > coder.max_files_per_glob: + coder.io.tool_output( + f"āš ļø Found {len(matching_files)} files matching '{pattern}', " + f"limiting to {coder.max_files_per_glob} most relevant files." + ) + # Sort by modification time (most recent first) + matching_files.sort(key=lambda f: os.path.getmtime(coder.abs_root_path(f)), reverse=True) + matching_files = matching_files[:coder.max_files_per_glob] + + # Add files to context + for file in matching_files: + # Use the coder's internal method to add files + coder._add_file_to_context(file) + + # Return a user-friendly result + if matching_files: + if len(matching_files) > 10: + brief = ', '.join(matching_files[:5]) + f', and {len(matching_files)-5} more' + coder.io.tool_output(f"šŸ“‚ Added {len(matching_files)} files matching '{pattern}': {brief}") + else: + coder.io.tool_output(f"šŸ“‚ Added files matching '{pattern}': {', '.join(matching_files)}") + return f"Added {len(matching_files)} files: {', '.join(matching_files[:5])}{' and more' if len(matching_files) > 5 else ''}" + else: + coder.io.tool_output(f"āš ļø No files found matching '{pattern}'") + return f"No files found matching '{pattern}'" + except Exception as e: + coder.io.tool_error(f"Error in ViewFilesAtGlob: {str(e)}") + return f"Error: {str(e)}" diff --git a/aider/tools/view_files_matching.py b/aider/tools/view_files_matching.py new file mode 100644 index 000000000..7d395721a --- /dev/null +++ b/aider/tools/view_files_matching.py @@ -0,0 +1,92 @@ +import re +import os +import fnmatch + +def execute_view_files_matching(coder, search_pattern, file_pattern=None, regex=False): + """ + Search for pattern (literal string or regex) in files and add matching files to context as read-only. + + Args: + coder: The Coder instance. + search_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 search_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 '{search_pattern}'" + else: + # Search all files if no pattern provided + files_to_search = coder.get_all_relative_files() + + # Search for pattern in files + matches = {} + for file in files_to_search: + abs_path = coder.abs_root_path(file) + try: + with open(abs_path, 'r', encoding='utf-8') as f: + content = f.read() + match_count = 0 + if regex: + try: + matches_found = re.findall(search_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 '{search_pattern}': {e}") + # Skip this file for this search if regex is invalid + continue + else: + # Exact string matching + match_count = content.count(search_pattern) + + if match_count > 0: + matches[file] = match_count + except Exception: + # Skip files that can't be read (binary, etc.) + pass + + # Limit the number of files added if there are too many matches + if len(matches) > coder.max_files_per_glob: + coder.io.tool_output( + f"āš ļø Found '{search_pattern}' in {len(matches)} files, " + f"limiting to {coder.max_files_per_glob} files with most matches." + ) + # Sort by number of matches (most matches first) + sorted_matches = sorted(matches.items(), key=lambda x: x[1], reverse=True) + matches = dict(sorted_matches[:coder.max_files_per_glob]) + + # Add matching files to context + for file in matches: + coder._add_file_to_context(file) + + # Return a user-friendly result + 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[:5]] + + if len(sorted_matches) > 5: + coder.io.tool_output(f"šŸ” Found '{search_pattern}' in {len(matches)} files: {', '.join(match_list)} and {len(matches)-5} more") + return f"Found in {len(matches)} files: {', '.join(match_list)} and {len(matches)-5} more" + else: + coder.io.tool_output(f"šŸ” Found '{search_pattern}' in: {', '.join(match_list)}") + return f"Found in {len(matches)} files: {', '.join(match_list)}" + else: + coder.io.tool_output(f"āš ļø Pattern '{search_pattern}' not found in any files") + return f"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)}" \ No newline at end of file diff --git a/aider/tools/view_files_with_symbol.py b/aider/tools/view_files_with_symbol.py new file mode 100644 index 000000000..ebf849cd2 --- /dev/null +++ b/aider/tools/view_files_with_symbol.py @@ -0,0 +1,102 @@ +import os + +def _execute_view_files_with_symbol(coder, symbol): + """ + Find files containing a symbol using RepoMap and add them to context. + 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" + + # --- Start Modification --- + # 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}. No external search performed.") + return f"Symbol '{symbol}' found in already loaded file(s): {file_list}. No external search performed." + # --- End Modification --- + + + # 2. If not found in context, search the repository using RepoMap + coder.io.tool_output(f"šŸ”Ž Searching for symbol '{symbol}' in repository (excluding current context)...") + 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(abs_fname) + + # Limit the number of files added + if len(found_files) > coder.max_files_per_glob: + coder.io.tool_output( + f"āš ļø Found symbol '{symbol}' in {len(found_files)} files, " + f"limiting to {coder.max_files_per_glob} most relevant files." + ) + # Sort by modification time (most recent first) - approximate relevance + sorted_found_files = sorted(list(found_files), key=lambda f: os.path.getmtime(f), reverse=True) + found_files = set(sorted_found_files[:coder.max_files_per_glob]) + + # Add files to context (as read-only) + added_count = 0 + added_files_rel = [] + for abs_file_path in found_files: + rel_path = coder.get_rel_fname(abs_file_path) + # Double check it's not already added somehow + if abs_file_path not in coder.abs_fnames and abs_file_path not in coder.abs_read_only_fnames: + # Use explicit=True for clear output, even though it's an external search result + add_result = coder._add_file_to_context(rel_path, explicit=True) + if "Added" in add_result or "Viewed" in add_result: # Count successful adds/views + added_count += 1 + added_files_rel.append(rel_path) + + if added_count > 0: + if added_count > 5: + brief = ', '.join(added_files_rel[:5]) + f', and {added_count-5} more' + coder.io.tool_output(f"šŸ”Ž Found '{symbol}' and added {added_count} files: {brief}") + else: + coder.io.tool_output(f"šŸ”Ž Found '{symbol}' and added files: {', '.join(added_files_rel)}") + return f"Found symbol '{symbol}' and added {added_count} files as read-only." + else: + coder.io.tool_output(f"āš ļø Symbol '{symbol}' not found in searchable files (outside current context).") + return f"Symbol '{symbol}' not found in searchable files (outside current context)." + + except Exception as e: + coder.io.tool_error(f"Error in ViewFilesWithSymbol: {str(e)}") + return f"Error: {str(e)}" \ No newline at end of file