diff --git a/.gitignore b/.gitignore index bcc1ecf0595..b34f19f0644 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,4 @@ # Ignore specific files aider/__version__.py aider/_version.py -*.pyc \ No newline at end of file +*.pyc diff --git a/aider/__init__.py b/aider/__init__.py index 5cb76252732..71b3e3f366b 100644 --- a/aider/__init__.py +++ b/aider/__init__.py @@ -1,6 +1,6 @@ from packaging import version -__version__ = "0.87.0.dev" +__version__ = "0.87.1.dev" safe_version = __version__ try: diff --git a/aider/args.py b/aider/args.py index 1834c59fc09..ddc8c132d35 100644 --- a/aider/args.py +++ b/aider/args.py @@ -234,6 +234,30 @@ def get_parser(default_config_files, git_root): ), ) + ########## + group = parser.add_argument_group("Context Compaction") + group.add_argument( + "--enable-context-compaction", + action=argparse.BooleanOptionalAction, + default=False, + help="Enable automatic compaction of chat history to conserve tokens (default: False)", + ) + group.add_argument( + "--context-compaction-max-tokens", + type=int, + default=None, + help=( + "The maximum number of tokens in the conversation before context compaction is" + " triggered. (default: 80%% of model's context window)" + ), + ) + group.add_argument( + "--context-compaction-summary-tokens", + type=int, + default=4096, + help="The target maximum number of tokens for the generated summary. (default: 4096)", + ) + ########## group = parser.add_argument_group("Cache settings") group.add_argument( @@ -272,6 +296,15 @@ def get_parser(default_config_files, git_root): default=2, help="Multiplier for map tokens when no files are specified (default: 2)", ) + group.add_argument( + "--map-max-line-length", + type=int, + default=100, + help=( + "Maximum line length for the repo map code. Prevents sending crazy long lines of" + " minified JS files etc. (default: 100)" + ), + ) ########## group = parser.add_argument_group("History Files") diff --git a/aider/coders/architect_prompts.py b/aider/coders/architect_prompts.py index 2ac23f5fc19..61e94758f33 100644 --- a/aider/coders/architect_prompts.py +++ b/aider/coders/architect_prompts.py @@ -4,14 +4,14 @@ class ArchitectPrompts(CoderPrompts): - main_system = """Act as an expert architect engineer and provide direction to your editor engineer. -Study the change request and the current code. -Describe how to modify the code to complete the request. -The editor engineer will rely solely on your instructions, so make them unambiguous and complete. -Explain all needed code changes clearly and completely, but concisely. -Just show the changes needed. - -DO NOT show the entire updated function/file/etc! + main_system = """Act as an expert architect engineer providing direction to an editor engineer. +Deeply understand the user's change request and the provided code context. +Think step-by-step to develop a clear plan for the required code modifications. +Consider potential edge cases and how the changes should be verified. +Describe the plan and the necessary modifications to the editor engineer. Your instructions must be unambiguous, complete, and concise as the editor will rely solely on them. +Focus on *what* needs to change and *why*. + +DO NOT show large blocks of code or the entire updated file content. Explain the changes conceptually. Always reply to the user in {language}. """ diff --git a/aider/coders/base_coder.py b/aider/coders/base_coder.py index 74c37915686..4e2d9d502e8 100755 --- a/aider/coders/base_coder.py +++ b/aider/coders/base_coder.py @@ -61,8 +61,7 @@ def __init__(self, edit_format, valid_formats): self.edit_format = edit_format self.valid_formats = valid_formats super().__init__( - f"Unknown edit format {edit_format}. Valid formats are: { - ', '.join(valid_formats)}" + f"Unknown edit format {edit_format}. Valid formats are: {', '.join(valid_formats)}" ) @@ -340,6 +339,7 @@ def __init__( test_cmd=None, aider_commit_hashes=None, map_mul_no_files=8, + map_max_line_length=100, commands=None, summarizer=None, total_cost=0.0, @@ -358,6 +358,9 @@ def __init__( auto_copy_context=False, auto_accept_architect=True, mcp_servers=None, + enable_context_compaction=False, + context_compaction_max_tokens=None, + context_compaction_summary_tokens=8192, ): # Fill in a dummy Analytics if needed, but it is never .enable()'d self.analytics = analytics if analytics is not None else Analytics() @@ -386,6 +389,11 @@ def __init__( self.num_cache_warming_pings = num_cache_warming_pings self.mcp_servers = mcp_servers + self.enable_context_compaction = enable_context_compaction + + + self.context_compaction_max_tokens = context_compaction_max_tokens + self.context_compaction_summary_tokens = context_compaction_summary_tokens if not fnames: fnames = [] @@ -531,6 +539,7 @@ def __init__( max_inp_tokens, map_mul_no_files=map_mul_no_files, refresh=map_refresh, + max_code_line_length=map_max_line_length, ) self.summarizer = summarizer or ChatSummary( @@ -979,6 +988,7 @@ def run(self, with_message=None, preproc=True): if not self.io.placeholder: self.copy_context() user_message = self.get_input() + self.compact_context_if_needed() self.run_one(user_message, preproc) self.show_undo_hint() except KeyboardInterrupt: @@ -1099,7 +1109,7 @@ def keyboard_interrupt(self): self.last_keyboard_interrupt = now def summarize_start(self): - if not self.summarizer.too_big(self.done_messages): + if not self.summarizer.check_max_tokens(self.done_messages): return self.summarize_end() @@ -1113,10 +1123,10 @@ def summarize_start(self): def summarize_worker(self): self.summarizing_messages = list(self.done_messages) try: - self.summarized_done_messages = self.summarizer.summarize( - self.summarizing_messages) + self.summarized_done_messages = self.summarizer.summarize(self.summarizing_messages) except ValueError as err: self.io.tool_warning(err.args[0]) + self.summarized_done_messages = self.summarizing_messages if self.verbose: self.io.tool_output("Finished summarizing chat history.") @@ -1133,9 +1143,49 @@ def summarize_end(self): self.summarizing_messages = None self.summarized_done_messages = [] + def compact_context_if_needed(self): + if not self.enable_context_compaction: + self.summarize_start() + return + + if not self.summarizer.check_max_tokens(self.done_messages, max_tokens=self.context_compaction_max_tokens): + return + + self.io.tool_output("Compacting chat history to make room for new messages...") + + try: + # Create a summary of the conversation + summary_text = self.summarizer.summarize_all_as_text( + self.done_messages, + self.gpt_prompts.compaction_prompt, + self.context_compaction_summary_tokens, + ) + if not summary_text: + raise ValueError("Summarization returned an empty result.") + + # Replace old messages with the summary + self.done_messages = [ + { + "role": "user", + "content": summary_text, + }, + { + "role": "assistant", + "content": ( + "Ok, I will use this summary as the context for our conversation going" + " forward." + ), + }, + ] + self.io.tool_output("...chat history compacted.") + except Exception as e: + self.io.tool_warning(f"Context compaction failed: {e}") + self.io.tool_warning("Proceeding with full history for now.") + self.summarize_start() + return + def move_back_cur_messages(self, message): self.done_messages += self.cur_messages - self.summarize_start() # TODO check for impact on image messages if message: @@ -2341,59 +2391,63 @@ def show_send_output_stream(self, completion): self.partial_response_tool_call = [] for chunk in completion: - if len(chunk.choices) == 0: - continue + if isinstance(chunk, str): + text = chunk + received_content = True + else: + if len(chunk.choices) == 0: + continue - if ( - hasattr(chunk.choices[0], "finish_reason") - and chunk.choices[0].finish_reason == "length" - ): - raise FinishReasonLength() + if ( + hasattr(chunk.choices[0], "finish_reason") + and chunk.choices[0].finish_reason == "length" + ): + raise FinishReasonLength() - if chunk.choices[0].delta.tool_calls: - self.partial_response_tool_call.append(chunk) + if chunk.choices[0].delta.tool_calls: + self.partial_response_tool_call.append(chunk) - try: - func = chunk.choices[0].delta.function_call - # dump(func) - for k, v in func.items(): - if k in self.partial_response_function_call: - self.partial_response_function_call[k] += v - else: - self.partial_response_function_call[k] = v + try: + func = chunk.choices[0].delta.function_call + # dump(func) + for k, v in func.items(): + if k in self.partial_response_function_call: + self.partial_response_function_call[k] += v + else: + self.partial_response_function_call[k] = v - received_content = True - except AttributeError: - pass + received_content = True + except AttributeError: + pass - text = "" + text = "" - try: - reasoning_content = chunk.choices[0].delta.reasoning_content - except AttributeError: try: - reasoning_content = chunk.choices[0].delta.reasoning + reasoning_content = chunk.choices[0].delta.reasoning_content except AttributeError: - reasoning_content = None - - if reasoning_content: - if not self.got_reasoning_content: - text += f"<{REASONING_TAG}>\n\n" - text += reasoning_content - self.got_reasoning_content = True - received_content = True - - try: - content = chunk.choices[0].delta.content - if content: - if self.got_reasoning_content and not self.ended_reasoning_content: - text += f"\n\n\n\n" - self.ended_reasoning_content = True - - text += content + try: + reasoning_content = chunk.choices[0].delta.reasoning + except AttributeError: + reasoning_content = None + + if reasoning_content: + if not self.got_reasoning_content: + text += f"<{REASONING_TAG}>\n\n" + text += reasoning_content + self.got_reasoning_content = True received_content = True - except AttributeError: - pass + + try: + content = chunk.choices[0].delta.content + if content: + if self.got_reasoning_content and not self.ended_reasoning_content: + text += f"\n\n\n\n" + self.ended_reasoning_content = True + + text += content + received_content = True + except AttributeError: + pass if received_content: self._stop_waiting_spinner() @@ -2806,6 +2860,28 @@ def parse_partial_args(self): except JSONDecodeError: pass + 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 + # commits... def get_context_from_history(self, history): diff --git a/aider/coders/base_prompts.py b/aider/coders/base_prompts.py index cd93b9ef807..07f19381365 100644 --- a/aider/coders/base_prompts.py +++ b/aider/coders/base_prompts.py @@ -71,3 +71,15 @@ class CoderPrompts: rename_with_shell = "" go_ahead_tip = "" + + compaction_prompt = """You are an expert at summarizing conversations. +The user is going to provide you with a conversation that is getting too long to fit in the context window of a language model. +You need to summarize the conversation to reduce its length, while retaining all the important information. + +The summary should contain three parts: +- Overall Goal: What is the user trying to achieve with this conversation? +- Next Steps: What are the next steps for the language model to take to help the user? Create a checklist of what has been done and what is left to do. +- Active files: What files are currently in the context window? + +Here is the conversation so far: +""" diff --git a/aider/coders/editblock_prompts.py b/aider/coders/editblock_prompts.py index f6baaeb5fe8..df2734aae36 100644 --- a/aider/coders/editblock_prompts.py +++ b/aider/coders/editblock_prompts.py @@ -8,13 +8,16 @@ class EditBlockPrompts(CoderPrompts): main_system = """Act as an expert software developer. Always use best practices when coding. Respect and use existing conventions, libraries, etc that are already present in the code base. +Think step-by-step. Plan your changes carefully. Explain your plan first. {final_reminders} Take requests for changes to the supplied code. If the request is ambiguous, ask questions. +Consider potential edge cases. +Think about how to verify your changes are correct. Always reply to the user in {language}. -Once you understand the request you MUST: +Once you understand the request and have a plan, you MUST: 1. Decide if you need to propose *SEARCH/REPLACE* edits to any files that haven't been added to the chat. You can create new files without asking! diff --git a/aider/coders/editor_editblock_prompts.py b/aider/coders/editor_editblock_prompts.py index 0ec36b47f10..b749365cd6b 100644 --- a/aider/coders/editor_editblock_prompts.py +++ b/aider/coders/editor_editblock_prompts.py @@ -4,9 +4,12 @@ class EditorEditBlockPrompts(EditBlockPrompts): - main_system = """Act as an expert software developer who edits source code. + main_system = """Act as an expert software developer tasked with editing source code based on instructions from an architect. +Carefully implement the changes described in the request. {final_reminders} Describe each change with a *SEARCH/REPLACE block* per the examples below. +Ensure the SEARCH block exactly matches the original code. +Ensure the REPLACE block correctly implements the requested change. All changes to files must use this *SEARCH/REPLACE block* format. ONLY EVER RETURN CODE IN A *SEARCH/REPLACE BLOCK*! """ diff --git a/aider/coders/editor_whole_prompts.py b/aider/coders/editor_whole_prompts.py index 39bc38f6492..f52e1161953 100644 --- a/aider/coders/editor_whole_prompts.py +++ b/aider/coders/editor_whole_prompts.py @@ -4,7 +4,9 @@ class EditorWholeFilePrompts(WholeFilePrompts): - main_system = """Act as an expert software developer and make changes to source code. + main_system = """Act as an expert software developer tasked with editing source code based on instructions from an architect. +Carefully implement the changes described in the request. {final_reminders} -Output a copy of each file that needs changes. +Output a copy of each file that needs changes, containing the complete, updated content. +Ensure the entire file content is returned accurately. """ diff --git a/aider/coders/navigator_coder.py b/aider/coders/navigator_coder.py index f5f4cf7ed8a..535053cd88a 100644 --- a/aider/coders/navigator_coder.py +++ b/aider/coders/navigator_coder.py @@ -2445,40 +2445,4 @@ def cmd_context_blocks(self, args=""): 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/udiff_prompts.py b/aider/coders/udiff_prompts.py index 5201b8d893e..e3848042982 100644 --- a/aider/coders/udiff_prompts.py +++ b/aider/coders/udiff_prompts.py @@ -9,13 +9,15 @@ class UnifiedDiffPrompts(CoderPrompts): {final_reminders} Always use best practices when coding. Respect and use existing conventions, libraries, etc that are already present in the code base. +Consider potential edge cases. +Think about how to verify your changes are correct. Take requests for changes to the supplied code. If the request is ambiguous, ask questions. Always reply to the user in {language}. -For each file that needs to be changed, write out the changes similar to a unified diff like `diff -U0` would produce. +Once you have a plan, for each file that needs to be changed, write out the changes similar to a unified diff like `diff -U0` would produce. """ example_messages = [ diff --git a/aider/coders/wholefile_prompts.py b/aider/coders/wholefile_prompts.py index 6b679583351..3136cee2566 100644 --- a/aider/coders/wholefile_prompts.py +++ b/aider/coders/wholefile_prompts.py @@ -5,16 +5,19 @@ class WholeFilePrompts(CoderPrompts): main_system = """Act as an expert software developer. +Think step-by-step. Plan your changes carefully. Explain your plan first. Take requests for changes to the supplied code. If the request is ambiguous, ask questions. +Consider potential edge cases. +Think about how to verify your changes are correct. Always reply to the user in {language}. {final_reminders} -Once you understand the request you MUST: +Once you understand the request and have a plan, you MUST: 1. Determine if any code changes are needed. -2. Explain any needed changes. -3. If changes are needed, output a copy of each file that needs changes. +2. Explain the plan and any needed changes. +3. If changes are needed, output a copy of each file that needs changes, containing the complete, updated content. """ example_messages = [ diff --git a/aider/history.py b/aider/history.py index d35ca98b487..8f9d0d754d7 100644 --- a/aider/history.py +++ b/aider/history.py @@ -12,10 +12,16 @@ def __init__(self, models=None, max_tokens=1024): self.max_tokens = max_tokens self.token_count = self.models[0].token_count - def too_big(self, messages): + def check_max_tokens(self, messages, max_tokens=None): + if max_tokens is None: + max_tokens = self.max_tokens + + if not max_tokens: + return False + sized = self.tokenize(messages) total = sum(tokens for tokens, _msg in sized) - return total > self.max_tokens + return total > max_tokens def tokenize(self, messages): sized = [] @@ -36,11 +42,16 @@ def summarize_real(self, messages, depth=0): sized = self.tokenize(messages) total = sum(tokens for tokens, _msg in sized) - if total <= self.max_tokens and depth == 0: - return messages + + if total <= self.max_tokens: + if depth == 0: + # All fit, no summarization needed + return messages + # This is a chunk that's small enough to summarize in one go + return self.summarize_all(messages) min_split = 4 - if len(messages) <= min_split or depth > 3: + if len(messages) <= min_split or depth > 4: return self.summarize_all(messages) tail_tokens = 0 @@ -56,6 +67,12 @@ def summarize_real(self, messages, depth=0): else: break + # If we couldn't find a split point from the end, it's because the + # last message was too big. So just split off the last message and + # summarize the rest. This prevents infinite recursion. + if split_index == len(messages): + split_index = len(messages) - 1 + # Ensure the head ends with an assistant message while messages[split_index - 1]["role"] != "assistant" and split_index > 1: split_index -= 1 @@ -64,36 +81,22 @@ def summarize_real(self, messages, depth=0): return self.summarize_all(messages) # Split head and tail + head = messages[:split_index] tail = messages[split_index:] - # Only size the head once - sized_head = sized[:split_index] + summary = self.summarize_real(head, depth + 1) - # Precompute token limit (fallback to 4096 if undefined) - model_max_input_tokens = self.models[0].info.get("max_input_tokens") or 4096 - model_max_input_tokens -= 512 # reserve buffer for safety - - keep = [] - total = 0 + # If the combined summary and tail still fits, return directly + new_messages = summary + tail - # Iterate in original order, summing tokens until limit - for tokens, msg in sized_head: - total += tokens - if total > model_max_input_tokens: - break - keep.append(msg) - # No need to reverse lists back and forth + sized_new = self.tokenize(new_messages) + total_new = sum(tokens for tokens, _msg in sized_new) - summary = self.summarize_all(keep) - - # If the combined summary and tail still fits, return directly - summary_tokens = self.token_count(summary) - tail_tokens = sum(tokens for tokens, _ in sized[split_index:]) - if summary_tokens + tail_tokens < self.max_tokens: - return summary + tail + if total_new < self.max_tokens: + return new_messages # Otherwise recurse with increased depth - return self.summarize_real(summary + tail, depth + 1) + return self.summarize_real(new_messages, depth + 1) def summarize_all(self, messages): content = "" @@ -122,6 +125,38 @@ def summarize_all(self, messages): except Exception as e: print(f"Summarization failed for model {model.name}: {str(e)}") + err = "summarizer unexpectedly failed for all models" + print(err) + raise ValueError(err) + + def summarize_all_as_text(self, messages, prompt, max_tokens=None): + content = "" + for msg in messages: + role = msg["role"].upper() + if role not in ("USER", "ASSISTANT"): + continue + if not msg.get("content"): + continue + content += f"# {role}\n" + content += msg["content"] + if not content.endswith("\n"): + content += "\n" + + summarize_messages = [ + dict(role="system", content=prompt), + dict(role="user", content=content), + ] + + for model in self.models: + try: + summary = model.simple_send_with_retries( + summarize_messages, max_tokens=max_tokens + ) + if summary is not None: + return summary + except Exception as e: + print(f"Summarization failed for model {model.name}: {str(e)}") + raise ValueError("summarizer unexpectedly failed for all models") diff --git a/aider/main.py b/aider/main.py index e580b926dc5..9b7ffe7a728 100644 --- a/aider/main.py +++ b/aider/main.py @@ -967,6 +967,11 @@ def get_io(pretty): else: map_tokens = args.map_tokens + if args.enable_context_compaction and args.context_compaction_max_tokens is None: + max_input_tokens = main_model.info.get("max_input_tokens") + if max_input_tokens: + args.context_compaction_max_tokens = int(max_input_tokens * 0.8) + # Track auto-commits configuration analytics.event("auto_commits", enabled=bool(args.auto_commits)) @@ -1005,6 +1010,7 @@ def get_io(pretty): map_refresh=args.map_refresh, cache_prompts=args.cache_prompts, map_mul_no_files=args.map_multiplier_no_files, + map_max_line_length=args.map_max_line_length, num_cache_warming_pings=args.cache_keepalive_pings, suggest_shell_commands=args.suggest_shell_commands, chat_language=args.chat_language, @@ -1014,6 +1020,9 @@ def get_io(pretty): auto_accept_architect=args.auto_accept_architect, mcp_servers=mcp_servers, add_gitignore_files=args.add_gitignore_files, + enable_context_compaction=args.enable_context_compaction, + context_compaction_max_tokens=args.context_compaction_max_tokens, + context_compaction_summary_tokens=args.context_compaction_summary_tokens, ) except UnknownEditFormat as err: io.tool_error(str(err)) diff --git a/aider/models.py b/aider/models.py index 411608a5ad2..219ee3db4a0 100644 --- a/aider/models.py +++ b/aider/models.py @@ -618,17 +618,19 @@ def tokenizer(self, text): return litellm.encode(model=self.name, text=text) def token_count(self, messages): - if type(messages) is list: + if isinstance(messages, dict): + messages = [messages] + + if isinstance(messages, list): try: return litellm.token_counter(model=self.name, messages=messages) - except Exception as err: - print(f"Unable to count tokens: {err}") - return 0 + except Exception: + pass # fall back to raw tokenizer if not self.tokenizer: - return + return 0 - if type(messages) is str: + if isinstance(messages, str): msgs = messages else: msgs = json.dumps(messages) @@ -636,7 +638,7 @@ def token_count(self, messages): try: return len(self.tokenizer(msgs)) except Exception as err: - print(f"Unable to count tokens: {err}") + print(f"Unable to count tokens with tokenizer: {err}") return 0 def token_count_for_image(self, fname): @@ -952,7 +954,7 @@ class GitHubCopilotTokenError(Exception): os.environ[openai_api_key] = token - def send_completion(self, messages, functions, stream, temperature=None, tools=None): + def send_completion(self, messages, functions, stream, temperature=None, tools=None, max_tokens=None): if os.environ.get("AIDER_SANITY_CHECK_TURNS"): sanity_check_messages(messages) @@ -997,6 +999,12 @@ def send_completion(self, messages, functions, stream, temperature=None, tools=N if self.extra_params: kwargs.update(self.extra_params) + + if max_tokens: + kwargs["max_tokens"] = max_tokens + + if "max_tokens" in kwargs and kwargs["max_tokens"]: + kwargs["max_completion_tokens"] = kwargs.pop("max_tokens") if self.is_ollama() and "num_ctx" not in kwargs: num_ctx = int(self.token_count(messages) * 1.25) + 8192 kwargs["num_ctx"] = num_ctx @@ -1032,7 +1040,7 @@ def send_completion(self, messages, functions, stream, temperature=None, tools=N return hash_object, res - def simple_send_with_retries(self, messages): + def simple_send_with_retries(self, messages, max_tokens=None): from aider.exceptions import LiteLLMExceptions litellm_ex = LiteLLMExceptions() @@ -1045,13 +1053,12 @@ def simple_send_with_retries(self, messages): while True: try: - kwargs = { - "messages": messages, - "functions": None, - "stream": False, - } - - _hash, response = self.send_completion(**kwargs) + _hash, response = self.send_completion( + messages=messages, + functions=None, + stream=False, + max_tokens=max_tokens, + ) if not response or not hasattr(response, "choices") or not response.choices: return None res = response.choices[0].message.content diff --git a/aider/prompts.py b/aider/prompts.py index 912bc02c659..110b3dbd5ad 100644 --- a/aider/prompts.py +++ b/aider/prompts.py @@ -43,19 +43,14 @@ """ # CHAT HISTORY -summarize = """*Briefly* summarize this partial conversation about programming. -Include less detail about older parts and more detail about the most recent messages. -Start a new paragraph every time the topic changes! - -This is only part of a longer conversation so *DO NOT* conclude the summary with language like "Finally, ...". Because the conversation continues after the summary. -The summary *MUST* include the function names, libraries, packages that are being discussed. -The summary *MUST* include the filenames that are being referenced by the assistant inside the ```...``` fenced code blocks! -The summaries *MUST NOT* include ```...``` fenced code blocks! - -Phrase the summary with the USER in first person, telling the ASSISTANT about the conversation. -Write *as* the user. -The user should refer to the assistant as *you*. -Start the summary with "I asked you...". +summarize = """Summarize this conversation about programming from the user's perspective. +The user is 'I' and the AI assistant is 'you'. + +The summary should be brief, focusing on the most recent messages. +Start a new paragraph when the topic changes. +Mention any function names, libraries, packages, and filenames that were discussed or edited. +Do not use markdown ```...``` fenced code blocks. +This is a partial conversation, so do not use concluding phrases like "Finally...". """ -summary_prefix = "I spoke to you previously about a number of things.\n" +summary_prefix = "This is a summary of our recent conversation:\n" diff --git a/aider/repomap.py b/aider/repomap.py index 1301aaa3e91..e1961f7d756 100644 --- a/aider/repomap.py +++ b/aider/repomap.py @@ -1,7 +1,5 @@ -import colorsys import math import os -import random import shutil import sqlite3 import sys @@ -73,6 +71,7 @@ def __init__( max_context_window=None, map_mul_no_files=8, refresh="auto", + max_code_line_length=100, ): self.io = io self.verbose = verbose @@ -89,6 +88,8 @@ def __init__( self.map_mul_no_files = map_mul_no_files self.max_context_window = max_context_window + self.max_code_line_length = max_code_line_length + self.repo_content_prefix = repo_content_prefix self.main_model = main_model @@ -838,7 +839,12 @@ def to_tree(self, tags, chat_rel_fnames): if lois is not None: output += "\n" output += cur_fname + ":\n" - output += self.render_tree(cur_abs_fname, cur_fname, lois) + + # truncate long lines, in case we get minified js or something else crazy + output += truncate_long_lines( + self.render_tree(cur_abs_fname, cur_fname, lois), self.max_code_line_length + ) + lois = None elif cur_fname: output += "\n" + cur_fname + "\n" @@ -850,12 +856,13 @@ def to_tree(self, tags, chat_rel_fnames): if lois is not None: lois.append(tag.line) - # truncate long lines, in case we get minified js or something else crazy - output = "\n".join([line[:100] for line in output.splitlines()]) + "\n" - return output +def truncate_long_lines(text, max_length): + return "\n".join([line[:max_length] for line in text.splitlines()]) + "\n" + + def find_src_files(directory): if not os.path.isdir(directory): return [directory] @@ -867,13 +874,6 @@ def find_src_files(directory): return src_files -def get_random_color(): - hue = random.random() - r, g, b = [int(x * 255) for x in colorsys.hsv_to_rgb(hue, 1, 0.75)] - res = f"#{r:02x}{g:02x}{b:02x}" - return res - - def get_scm_fname(lang): # Load the tags queries if USING_TSL_PACK: diff --git a/aider/website/assets/sample.env b/aider/website/assets/sample.env index 29ab1a386f9..1749fe0ac05 100644 --- a/aider/website/assets/sample.env +++ b/aider/website/assets/sample.env @@ -129,6 +129,9 @@ ## Multiplier for map tokens when no files are specified (default: 2) #AIDER_MAP_MULTIPLIER_NO_FILES=true +## Maximum line length for the repo map code. Prevents sending crazy long lines of minified JS files etc. (default: 100) +#AIDER_MAP_MAX_LINE_LENGTH=100 + ################ # History Files: diff --git a/aider/website/docs/config/aider_conf.md b/aider/website/docs/config/aider_conf.md index bd5ea6246c2..74181a49fa6 100644 --- a/aider/website/docs/config/aider_conf.md +++ b/aider/website/docs/config/aider_conf.md @@ -173,6 +173,18 @@ cog.outl("```") ## Soft limit on tokens for chat history, after which summarization begins. If unspecified, defaults to the model's max_chat_history_tokens. #max-chat-history-tokens: xxx +###################### +# Context Compaction: + +## Enable automatic compaction of chat history to conserve tokens (default: False) +#enable-context-compaction: false + +## The maximum number of tokens in the conversation before context compaction is triggered. (default: 80% of model's context window) +#context-compaction-max-tokens: xxx + +## The target maximum number of tokens for the generated summary. (default: 4096) +#context-compaction-summary-tokens: 4096 + ################# # Cache settings: diff --git a/aider/website/docs/config/dotenv.md b/aider/website/docs/config/dotenv.md index 11681bf0722..5bd249f7c48 100644 --- a/aider/website/docs/config/dotenv.md +++ b/aider/website/docs/config/dotenv.md @@ -169,6 +169,9 @@ cog.outl("```") ## Multiplier for map tokens when no files are specified (default: 2) #AIDER_MAP_MULTIPLIER_NO_FILES=true +## Maximum line length for the repo map code. Prevents sending crazy long lines of minified JS files etc. (default: 100) +#AIDER_MAP_MAX_LINE_LENGTH=100 + ################ # History Files: diff --git a/aider/website/docs/config/options.md b/aider/website/docs/config/options.md index c974f671b72..974b2a5a007 100644 --- a/aider/website/docs/config/options.md +++ b/aider/website/docs/config/options.md @@ -35,6 +35,9 @@ usage: aider [-h] [--model] [--openai-api-key] [--anthropic-api-key] [--show-model-warnings | --no-show-model-warnings] [--check-model-accepts-settings | --no-check-model-accepts-settings] [--max-chat-history-tokens] + [--enable-context-compaction | --no-enable-context-compaction] + [--context-compaction-max-tokens] + [--context-compaction-summary-tokens] [--cache-prompts | --no-cache-prompts] [--cache-keepalive-pings] [--map-tokens] [--map-refresh] [--map-multiplier-no-files] @@ -241,6 +244,25 @@ Aliases: Soft limit on tokens for chat history, after which summarization begins. If unspecified, defaults to the model's max_chat_history_tokens. Environment variable: `AIDER_MAX_CHAT_HISTORY_TOKENS` +## Context Compaction: + +### `--enable-context-compaction` +Enable automatic compaction of chat history to conserve tokens (default: False) +Default: False +Environment variable: `AIDER_ENABLE_CONTEXT_COMPACTION` +Aliases: + - `--enable-context-compaction` + - `--no-enable-context-compaction` + +### `--context-compaction-max-tokens VALUE` +The maximum number of tokens in the conversation before context compaction is triggered. (default: 80% of model's context window) +Environment variable: `AIDER_CONTEXT_COMPACTION_MAX_TOKENS` + +### `--context-compaction-summary-tokens VALUE` +The target maximum number of tokens for the generated summary. (default: 4096) +Default: 4096 +Environment variable: `AIDER_CONTEXT_COMPACTION_SUMMARY_TOKENS` + ## Cache settings: ### `--cache-prompts` @@ -270,7 +292,12 @@ Environment variable: `AIDER_MAP_REFRESH` ### `--map-multiplier-no-files VALUE` Multiplier for map tokens when no files are specified (default: 2) Default: 2 -Environment variable: `AIDER_MAP_MULTIPLIER_NO_FILES` +Environment variable: `AIDER_MAP_MULTIPLIER_NO_FILES` + +### `--map-max-line-length VALUE` +Maximum line length for the repo map code. Prevents sending crazy long lines of minified JS files etc. (default: 100) +Default: 100 +Environment variable: `AIDER_MAP_MAX_LINE_LENGTH` ## History Files: diff --git a/tests/basic/test_repomap.py b/tests/basic/test_repomap.py index 185e6e62d5f..eeec52b43db 100644 --- a/tests/basic/test_repomap.py +++ b/tests/basic/test_repomap.py @@ -273,6 +273,67 @@ def test_get_repo_map_excludes_added_files(self): # close the open cache files, so Windows won't error del repo_map + def test_get_repo_map_follows_max_line_length(self): + hundred_chars = "0123456789" * 10 + test_file_name = "file1.py" + method_name = f"my_method_with_more_than_100_chars_{hundred_chars}" + + test_file_name_100_chars_content = f""" +class MyClass: + def {method_name}(self, arg1, arg2): + return arg1 + arg2 +""".lstrip() + + with IgnorantTemporaryDirectory() as temp_dir: + with open(os.path.join(temp_dir, test_file_name), "w") as f: + f.write(test_file_name_100_chars_content) + + io = InputOutput() + repo_map = RepoMap( + main_model=self.GPT35, root=temp_dir, io=io, max_code_line_length=200 + ) + + other_files = [ + os.path.join(temp_dir, test_file_name), + ] + + result = repo_map.get_repo_map([], other_files) + + self.assertIn(method_name, result) + + del repo_map + + def test_get_repo_map_dont_truncate_file_path(self): + hundred_chars = "0123456789" * 10 + test_file_name_100_chars = f"{hundred_chars}.py" + method_name = f"my_method_with_more_than_100_chars_{hundred_chars}" + + test_file_name_100_chars_content = f""" +class MyClass: + def {method_name}(self, arg1, arg2): + return arg1 + arg2 +""".lstrip() + + with IgnorantTemporaryDirectory() as temp_dir: + with open(os.path.join(temp_dir, test_file_name_100_chars), "w") as f: + f.write(test_file_name_100_chars_content) + + io = InputOutput() + repo_map = RepoMap( + main_model=self.GPT35, root=temp_dir, io=io, max_code_line_length=100 + ) + + other_files = [ + os.path.join(temp_dir, test_file_name_100_chars), + ] + + result = repo_map.get_repo_map([], other_files) + + self.assertIn(test_file_name_100_chars, result) + self.assertNotIn(method_name, result) + + del repo_map + class TestRepoMapTypescript(unittest.TestCase): def setUp(self):