Added logic to split and batch the changes in architect mode. In this way editor models with small input context limits will be able to handle the code changes just as well as the more expensive models.

added option to use the new batch editing feature or to stick to the
original architect workflow that makes the changes in one go.

Added parameter `--use-batch-editing` and --no-use-batch-editing` as
command line argument.

Added option `use-batch-editing` to yaml configuration file.
This commit is contained in:
Emasoft 2025-05-17 04:53:53 +02:00
parent 3caab85931
commit 8f3cfe1985
7 changed files with 279 additions and 10 deletions

4
.gitignore vendored
View file

@ -15,4 +15,6 @@ aider/_version.py
.venv/
.#*
.gitattributes
tmp.benchmarks/
tmp.benchmarks/
uv.lock
CLAUDE.md

0
.ropeproject/.gitkeep Normal file
View file

View file

@ -178,6 +178,12 @@ def get_parser(default_config_files, git_root):
default=True,
help="Enable/disable automatic acceptance of architect changes (default: True)",
)
group.add_argument(
"--use-batch-editing",
action=argparse.BooleanOptionalAction,
default=False,
help="Enable/disable batch editing for architect mode (default: False)",
)
group.add_argument(
"--weak-model",
metavar="WEAK_MODEL",

View file

@ -7,6 +7,13 @@ class ArchitectCoder(AskCoder):
edit_format = "architect"
gpt_prompts = ArchitectPrompts()
auto_accept_architect = False
use_batch_editing = False
def __init__(self, main_model, io, use_batch_editing=False, auto_accept_architect=None, **kwargs):
super().__init__(main_model, io, **kwargs)
if auto_accept_architect is not None:
self.auto_accept_architect = auto_accept_architect
self.use_batch_editing = use_batch_editing
def reply_completed(self):
content = self.partial_response_content
@ -34,15 +41,164 @@ class ArchitectCoder(AskCoder):
new_kwargs = dict(io=self.io, from_coder=self)
new_kwargs.update(kwargs)
editor_coder = Coder.create(**new_kwargs)
editor_coder.cur_messages = []
editor_coder.done_messages = []
# Use the instance attribute for use_batch_editing
if self.verbose:
editor_coder.show_announcements()
if self.use_batch_editing:
# split the architect model response into chunks using natural delimiters (code blocka, newlines, separators, etc.)
chunks = []
chunks = self.split_response_by_natural_delimiters(content)
editor_coder.run(with_message=content, preproc=False)
for chunk in chunks:
if not chunk.strip():
continue
# Create a new chat session with the editor coder llm model for each chunk of the architect model response
editor_coder = Coder.create(**new_kwargs)
editor_coder.cur_messages = []
editor_coder.done_messages = []
if self.verbose:
editor_coder.show_announcements()
editor_coder.run(with_message=chunk, preproc=False)
self.move_back_cur_messages("I made those changes to the files.")
self.total_cost += editor_coder.total_cost
if self.aider_commit_hashes is None:
self.aider_commit_hashes = set()
self.aider_commit_hashes.update(editor_coder.aider_commit_hashes or set())
else:
# Create only one chat session with the editor coder llm model, not splitting the architect answer in chunks.
editor_coder = Coder.create(**new_kwargs)
editor_coder.cur_messages = []
editor_coder.done_messages = []
if self.verbose:
editor_coder.show_announcements()
# Run the editor coder with the entire architect model response
editor_coder.run(with_message=content, preproc=False)
self.move_back_cur_messages("I made those changes to the files.")
self.total_cost = editor_coder.total_cost
self.aider_commit_hashes = editor_coder.aider_commit_hashes
def split_response_by_natural_delimiters(self, content):
"""
Splits the content into chunks using natural delimiters, with heuristics:
- Never splits inside code blocks (even nested/mixed fences).
- Detects repeated block patterns (title/tag, blank lines, filename, code block) and splits accordingly.
- Lone comments between blocks are included in both adjacent chunks.
- Groups filename fences with their following code block.
- Groups delimiters/tags with their following block, including blank lines.
- Falls back to delimiter/tag splitting if no repeated pattern is found.
"""
import re
# Fence definitions
fence_openers = [
r"```[\w-]*", r"~~~~[\w-]*",
r"<code>", r"<pre>", r"<source>", r"<codeblock>", r"<sourcecode>", r"<diff>", r"<diff-fenced>"
]
fence_closers = [
r"```", r"~~~~",
r"</code>", r"</pre>", r"</source>", r"</codeblock>", r"</sourcecode>", r"</diff>", r"</diff-fenced>"
]
fence_opener_re = re.compile(rf"^({'|'.join(fence_openers)})\s*$", re.IGNORECASE)
fence_closer_re = re.compile(rf"^({'|'.join(fence_closers)})\s*$", re.IGNORECASE)
# Patterns for tags/titles, filenames, comments, and delimiters
tag_pattern = re.compile(
r"""(
^\[[A-Z0-9 _:\-./()]+\]$ | # [ALL CAPS/NUMERIC/UNDERSCORE/ETC]
^<[\w\s:\-./()|=\[\]!]+>$ | # <TAG ...>
^<<[\w\s:\-./()|=\[\]!]+>>$ | # <<TAG ...>>
^<\|[\w\s:\-./()|=\[\]!]+\|>$ | # <|TAG ...|>
^<=.*=>$ | # <=...=>
^<!.*!>$ | # <!....!>
^<==\|.*\|==>$ # <==| ... |==>
)""",
re.MULTILINE | re.VERBOSE
)
filename_pattern = re.compile(r"^[\w\./\\\-]+\.?\w*$")
comment_pattern = re.compile(r"^(#|<!--).*")
delimiter_pattern = re.compile(
r"""(
^----$ | ^={3,}$ | ^\s*#+\s.*?$ | \n{3,}
)""",
re.MULTILINE | re.VERBOSE
)
lines = content.splitlines(keepends=True)
n = len(lines)
# Step 1: Find all block start indices using the repeated pattern heuristic
block_starts = []
i = 0
while i < n:
# Look for 1-2 blank lines, then a tag/title, then 0-2 blank lines, then optional filename, then a fence opener
j = i
# Skip up to 2 blank lines
blanks = 0
while j < n and lines[j].strip() == "" and blanks < 2:
j += 1
blanks += 1
# Tag/title
if j < n and tag_pattern.match(lines[j]):
tag_idx = j
j += 1
# Up to 2 blank lines
blanks2 = 0
while j < n and lines[j].strip() == "" and blanks2 < 2:
j += 1
blanks2 += 1
# Optional filename
if j < n and filename_pattern.match(lines[j].strip()):
j += 1
# Fence opener
if j < n and fence_opener_re.match(lines[j]):
block_starts.append(i)
# Move to the end of the code block (handle nesting)
fence_stack = [fence_opener_re.match(lines[j]).group(1)]
j += 1
while j < n and fence_stack:
if fence_opener_re.match(lines[j]):
fence_stack.append(fence_opener_re.match(lines[j]).group(1))
elif fence_closer_re.match(lines[j]):
if fence_stack and fence_closer_re.match(lines[j]).group(1).lower().replace("-", "") == fence_stack[-1].lower().replace("-", ""):
fence_stack.pop()
j += 1
i = j
continue
i += 1
# If no repeated pattern found, fallback to delimiter/tag/code block splitting
if not block_starts:
block_starts = [0]
i = 0
while i < n:
# Find next delimiter/tag outside code blocks
if tag_pattern.match(lines[i]) or delimiter_pattern.match(lines[i]):
block_starts.append(i)
i += 1
# Step 2: Split into chunks, handling lone comments
chunks = []
for idx, start in enumerate(block_starts):
end = block_starts[idx + 1] if idx + 1 < len(block_starts) else n
chunk_lines = lines[start:end]
# If the last line(s) are lone comments, keep them for the next chunk too
comment_lines = []
while chunk_lines and comment_pattern.match(chunk_lines[-1]) and not fence_opener_re.match(chunk_lines[-1]):
comment_lines.insert(0, chunk_lines.pop())
chunk = "".join(chunk_lines)
if chunk.strip():
chunks.append(chunk)
# Add comment lines to the next chunk as well
if comment_lines and idx + 1 < len(block_starts):
lines[block_starts[idx + 1]:block_starts[idx + 1]] = comment_lines
return chunks
self.move_back_cur_messages("I made those changes to the files.")
self.total_cost = editor_coder.total_cost
self.aider_commit_hashes = editor_coder.aider_commit_hashes

View file

@ -335,6 +335,7 @@ class Coder:
file_watcher=None,
auto_copy_context=False,
auto_accept_architect=True,
use_batch_editing=False,
):
# Fill in a dummy Analytics if needed, but it is never .enable()'d
self.analytics = analytics if analytics is not None else Analytics()

View file

@ -996,6 +996,7 @@ def main(argv=None, input=None, output=None, force_git_root=None, return_coder=F
detect_urls=args.detect_urls,
auto_copy_context=args.copy_paste,
auto_accept_architect=args.auto_accept_architect,
use_batch_editing=args.use_batch_editing,
)
except UnknownEditFormat as err:
io.tool_error(str(err))

View file

@ -0,0 +1,103 @@
import unittest
from unittest.mock import MagicMock, patch
from aider.coders.architect_coder import ArchitectCoder
from aider.io import InputOutput
from aider.models import Model
class TestBatchEditing(unittest.TestCase):
def setUp(self):
self.GPT35 = Model("gpt-3.5-turbo")
self.webbrowser_patcher = patch("aider.io.webbrowser.open")
self.mock_webbrowser = self.webbrowser_patcher.start()
def tearDown(self):
self.webbrowser_patcher.stop()
def test_batch_editing_default_value(self):
"""Test that the default value for use_batch_editing is False"""
# Create an architect coder with default settings
io = InputOutput(yes=True)
with patch("aider.coders.architect_coder.AskCoder.__init__", return_value=None):
coder = ArchitectCoder(main_model=self.GPT35, io=io)
# Check that the default value is False
self.assertFalse(coder.use_batch_editing)
def test_batch_editing_parameter_passing(self):
"""Test that the use_batch_editing parameter is correctly passed to the ArchitectCoder"""
io = InputOutput(yes=True)
# Test with explicit True setting
with patch("aider.coders.architect_coder.AskCoder.__init__", return_value=None):
coder = ArchitectCoder(main_model=self.GPT35, io=io, use_batch_editing=True)
self.assertTrue(coder.use_batch_editing)
# Test with explicit False setting
with patch("aider.coders.architect_coder.AskCoder.__init__", return_value=None):
coder = ArchitectCoder(main_model=self.GPT35, io=io, use_batch_editing=False)
self.assertFalse(coder.use_batch_editing)
def test_batch_editing_usage_in_reply_completed(self):
"""Test that the use_batch_editing attribute controls the flow in reply_completed"""
io = InputOutput(yes=True)
io.confirm_ask = MagicMock(return_value=True)
# Create an ArchitectCoder with use_batch_editing=True
with patch("aider.coders.architect_coder.AskCoder.__init__", return_value=None):
coder = ArchitectCoder(main_model=self.GPT35, io=io)
# Set up the necessary attributes manually
coder.io = io # Need to set this explicitly since we're mocking __init__
coder.main_model = self.GPT35
coder.auto_accept_architect = True
coder.verbose = False
coder.total_cost = 0
coder.cur_messages = []
coder.done_messages = []
coder.aider_commit_hashes = None
coder.move_back_cur_messages = MagicMock()
# Mock the split_response_by_natural_delimiters method
coder.split_response_by_natural_delimiters = MagicMock()
coder.split_response_by_natural_delimiters.return_value = ["chunk1", "chunk2"]
# Mock editor_coder creation and execution
mock_editor = MagicMock()
mock_editor.total_cost = 0
mock_editor.aider_commit_hashes = set()
# Test with use_batch_editing=True
coder.use_batch_editing = True
with patch("aider.coders.architect_coder.Coder.create", return_value=mock_editor):
# Set partial response content
coder.partial_response_content = "Make these changes to the code"
# Call reply_completed
coder.reply_completed()
# Verify split_response_by_natural_delimiters was called
coder.split_response_by_natural_delimiters.assert_called_once_with("Make these changes to the code")
# Verify Coder.create was called twice (once for each chunk)
self.assertEqual(mock_editor.run.call_count, 2)
# Reset mocks
coder.split_response_by_natural_delimiters.reset_mock()
mock_editor.run.reset_mock()
# Test with use_batch_editing=False
coder.use_batch_editing = False
with patch("aider.coders.architect_coder.Coder.create", return_value=mock_editor):
# Call reply_completed
coder.reply_completed()
# Verify split_response_by_natural_delimiters was NOT called
coder.split_response_by_natural_delimiters.assert_not_called()
# Verify Coder.create was called once for the entire content
mock_editor.run.assert_called_once()
if __name__ == "__main__":
unittest.main()