Skip to content

Commit 68bd888

Browse files
committed
feat: Add /iterate command
1 parent 2873f6c commit 68bd888

File tree

4 files changed

+166
-2
lines changed

4 files changed

+166
-2
lines changed

aider/coders/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from .help_coder import HelpCoder
99
from .udiff_coder import UnifiedDiffCoder
1010
from .wholefile_coder import WholeFileCoder
11-
11+
from .iterate_coder import IterateCoder
1212
# from .single_wholefile_func_coder import SingleWholeFileFunctionCoder
1313

1414
__all__ = [
@@ -17,6 +17,7 @@
1717
Coder,
1818
EditBlockCoder,
1919
EditBlockFencedCoder,
20+
IterateCoder,
2021
WholeFileCoder,
2122
UnifiedDiffCoder,
2223
# SingleWholeFileFunctionCoder,

aider/coders/iterate_coder.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from typing import Tuple
2+
import copy
3+
4+
from aider.coders.base_coder import Coder
5+
"""Perform a coding task on multiple files in batches that fit the context and outpot token limits, without sending them all at once."""
6+
class IterateCoder(Coder):
7+
coder : Coder = None
8+
original_kwargs: dict = None
9+
edit_format = "iterate"
10+
11+
def __init__(self, main_model, io, **kwargs):
12+
super().__init__(main_model, io,**kwargs)
13+
14+
def run_one(self, user_message, preproc):
15+
if self.coder is None:
16+
self.coder = Coder.create(main_model=self.main_model, edit_format=self.main_model.edit_format,from_coder=self,**self.original_kwargs)
17+
remaining_files_with_type_length : list[Tuple[str,bool,int]]=[]
18+
for f in self.abs_fnames:
19+
remaining_files_with_type_length.append((f, True, self.main_model.token_count(self.io.read_text(f))))
20+
for f in self.abs_read_only_fnames:
21+
remaining_files_with_type_length.append((f,False,self.main_model.token_count(self.io.read_text(f))))
22+
max_tokens = self.main_model.info.get('max_tokens')
23+
max_context = self.main_model.info['max_input_tokens']
24+
max_output = self.main_model.info['max_output_tokens']
25+
repo_token_count = self.main_model.get_repo_map_tokens()
26+
history_token_count = sum([tup[0] for tup in self.summarizer.tokenize( [msg["content"] for msg in self.done_messages])])
27+
"""fitting input files + chat history + repo_map + files_to_send to context limit and
28+
files_to_send to the output limit.
29+
output files are assumed to be greater in size than the input files"""
30+
prev_io = self.io.yes
31+
self.io.yes = True
32+
for files_to_send_with_types in self.file_cruncher( max_context=max_context,
33+
max_output= max_tokens if max_tokens is not None else max_output,
34+
context_tokens=repo_token_count + history_token_count,remaining_files=remaining_files_with_type_length):
35+
self.coder.done_messages=copy.deepcopy(self.done_messages) #reset history of the coder to the start of the /iterate command
36+
self.coder.cur_messages=[]
37+
self.coder.abs_fnames=set([f[0] for f in files_to_send_with_types if f[1]])
38+
self.coder.abs_read_only_fnames=set(f[0] for f in files_to_send_with_types if not f[1])
39+
self.coder.run_one(user_message,preproc)
40+
self.io.yes = prev_io
41+
class file_cruncher:
42+
context_tokens: int
43+
max_context:int
44+
max_output:int
45+
remaining_files : list[Tuple[str,bool,int]]
46+
PADDING:int = 50
47+
def __init__(self,max_context:int,max_output:int,context_tokens,remaining_files : list[Tuple[str,bool,int]] ):
48+
self.context_tokens = context_tokens
49+
self.max_context = max_context
50+
self.max_output = max_output
51+
self.remaining_files = sorted(remaining_files, key = lambda x: x[2])
52+
def __iter__(self):
53+
return self
54+
def __next__(self):
55+
if len(self.remaining_files) == 0:
56+
raise StopIteration
57+
files_to_send : list[Tuple[str,bool]]= []
58+
i:int =0
59+
total_context= 0
60+
total_output= 0
61+
for file_name, type_, length in self.remaining_files:
62+
if length + (length + self.PADDING) + self.context_tokens + total_context>= self.max_context or length + self.PADDING + total_output >= self.max_output:
63+
break
64+
total_context+=length + length + self.PADDING
65+
total_output+=length + self.PADDING
66+
files_to_send.append((file_name,type_))
67+
i+=1
68+
if i == 0: #no file fits the limits, roll the dice and let the user deal with it
69+
f,t,_ = self.remaining_files[i]
70+
files_to_send.append((f,t))
71+
i=1
72+
self.remaining_files = self.remaining_files[i:]
73+
return files_to_send
74+

aider/commands.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1059,7 +1059,9 @@ def cmd_help(self, args):
10591059
map_mul_no_files=map_mul_no_files,
10601060
show_announcements=False,
10611061
)
1062-
1062+
def cmd_iterate(self, args):
1063+
"""Iteratively perform the change on files in batches that fit to context and output limits"""
1064+
return self._generic_chat_command(args, "iterate")
10631065
def cmd_ask(self, args):
10641066
"""Ask questions about the code base without editing any files. If no prompt provided, switches to ask mode.""" # noqa
10651067
return self._generic_chat_command(args, "ask")

tests/basic/test_iterate.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import os
2+
import unittest
3+
from pathlib import Path
4+
from unittest.mock import MagicMock, patch
5+
from aider.coders import Coder
6+
from aider.io import InputOutput
7+
from aider.models import Model
8+
from aider.utils import GitTemporaryDirectory
9+
10+
11+
class TestIterateCoder(unittest.TestCase):
12+
def setUp(self):
13+
self.GPT35 = Model("gpt-3.5-turbo")
14+
self.io = InputOutput(yes=True)
15+
# self.webbrowser_patcher = patch("aider.io.webbrowser.open")
16+
# self.mock_webbrowser = self.webbrowser_patcher.start()
17+
18+
# Get all Python files in aider/coders directory
19+
coders_dir = Path(__file__).parent.parent.parent / "aider" / "coders"
20+
self.files = [str(f) for f in coders_dir.glob("*.py") if f.is_file()]
21+
22+
# Create coder with all files
23+
self.coder = Coder.create(
24+
main_model=self.GPT35,
25+
io=self.io,
26+
fnames=self.files,
27+
edit_format='iterate'
28+
)
29+
30+
def tearDown(self):
31+
# self.webbrowser_patcher.stop()
32+
return
33+
"""Tests that:
34+
- Every request retains the chat history until the /iterate command but not the history of other iterations.
35+
- Added files and history until the /iterate is unmodified.
36+
- Every file is processed(even if a single file that'll be sent with the request exceeds the limits.) and no duplicate processing
37+
"""
38+
def test_iterate_resets_history_and_processes_all_files(self):
39+
processed_files :list[str]= []
40+
original_context:list[dict[str,str]]
41+
prev_file_names : list[str] = None
42+
# Track messages sent to LLM and files processed
43+
def mock_send(self,messages, model=None, functions=None):
44+
nonlocal original_context
45+
nonlocal processed_files
46+
nonlocal prev_file_names
47+
for original_message in original_context:
48+
assert original_message in messages, f"Chat history before start of the command is not retained."
49+
# Simulate response mentioning filename
50+
a : str=""
51+
files_message = [msg['content'] for msg in messages if "*added these files to the chat*" in msg['content']][0]
52+
from re import findall
53+
file_names = findall(r'.*\n(\S+\.py)\n```.*',files_message)
54+
for f_name in file_names:
55+
assert prev_file_names == None or f_name not in prev_file_names, "files from previous iterations hasn't been cleaned up."
56+
prev_file_names = file_names
57+
processed_files.extend(file_names)
58+
# Return minimal response
59+
self.partial_response_content = "Done."
60+
self.partial_response_function_call=dict()
61+
62+
with GitTemporaryDirectory():
63+
# Mock the send method
64+
with patch.object(Coder, 'send',new_callable=lambda: mock_send):
65+
self.coder.coder = Coder.create(main_model=self.coder.main_model, edit_format=self.coder.main_model.edit_format,from_coder=self.coder,**self.coder.original_kwargs)
66+
67+
# Add initial conversation history
68+
original_context = self.coder.done_messages = [
69+
{"role": "user", "content": "Initial conversation"},
70+
{"role": "assistant", "content": "OK"}
71+
]
72+
73+
# Run iterate command
74+
self.coder.run(with_message="Process all files")
75+
# Verify all files were processed
76+
input_basenames = {Path(f).name for f in self.files}
77+
processed_basenames = {Path(f).name for f in processed_files}
78+
missing = input_basenames - processed_basenames
79+
assert not missing, f"Files not processed: {missing}"
80+
81+
# Verify history preservation and structure
82+
assert len(self.coder.done_messages) == 2, "Original chat history was modified"
83+
# Verify final file state
84+
assert len(self.coder.abs_fnames) == len(self.files), "Not all files remained in chat"
85+
86+
if __name__ == "__main__":
87+
unittest.main()

0 commit comments

Comments
 (0)