Skip to content

Commit 72e9991

Browse files
authored
feat: adds in replace tool (#556)
1 parent a674dc0 commit 72e9991

File tree

7 files changed

+264
-6
lines changed

7 files changed

+264
-6
lines changed

src/codegen/extensions/langchain/agent.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
ListDirectoryTool,
2121
MoveSymbolTool,
2222
RenameFileTool,
23+
ReplacementEditTool,
2324
RevealSymbolTool,
2425
SearchTool,
2526
SemanticEditTool,
@@ -70,6 +71,7 @@ def create_codebase_agent(
7071
RevealSymbolTool(codebase),
7172
SemanticEditTool(codebase),
7273
SemanticSearchTool(codebase),
74+
ReplacementEditTool(codebase),
7375
# =====[ Github Integration ]=====
7476
# Enable Github integration
7577
# GithubCreatePRTool(codebase),

src/codegen/extensions/langchain/tools.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
linear_search_issues_tool,
1919
)
2020
from codegen.extensions.tools.link_annotation import add_links_to_message
21+
from codegen.extensions.tools.replacement_edit import replacement_edit
2122
from codegen.extensions.tools.reveal_symbol import reveal_symbol
2223
from codegen.extensions.tools.search import search
2324
from codegen.extensions.tools.semantic_edit import semantic_edit
@@ -37,7 +38,7 @@
3738
view_file,
3839
view_pr,
3940
)
40-
from ..tools.tool_prompts import _FILE_EDIT_DESCRIPTION
41+
from ..tools.semantic_edit_prompts import FILE_EDIT_PROMPT
4142

4243

4344
class ViewFileInput(BaseModel):
@@ -257,7 +258,7 @@ class SemanticEditInput(BaseModel):
257258
"""Input for semantic editing."""
258259

259260
filepath: str = Field(..., description="Path of the file relative to workspace root")
260-
edit_content: str = Field(..., description=_FILE_EDIT_DESCRIPTION)
261+
edit_content: str = Field(..., description=FILE_EDIT_PROMPT)
261262
start: int = Field(default=1, description="Starting line number (1-indexed, inclusive). Default is 1.")
262263
end: int = Field(default=-1, description="Ending line number (1-indexed, inclusive). Default is -1 (end of file).")
263264

@@ -706,6 +707,7 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]:
706707
ListDirectoryTool(codebase),
707708
MoveSymbolTool(codebase),
708709
RenameFileTool(codebase),
710+
ReplacementEditTool(codebase),
709711
RevealSymbolTool(codebase),
710712
RunBashCommandTool(), # Note: This tool doesn't need the codebase
711713
SearchTool(codebase),
@@ -725,3 +727,46 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]:
725727
LinearCreateIssueTool(codebase),
726728
LinearGetTeamsTool(codebase),
727729
]
730+
731+
732+
class ReplacementEditInput(BaseModel):
733+
"""Input for regex-based replacement editing."""
734+
735+
filepath: str = Field(..., description="Path to the file to edit")
736+
pattern: str = Field(..., description="Regex pattern to match")
737+
replacement: str = Field(..., description="Replacement text (can include regex groups)")
738+
start: int = Field(default=1, description="Starting line number (1-indexed, inclusive). Default is 1.")
739+
end: int = Field(default=-1, description="Ending line number (1-indexed, inclusive). Default is -1 (end of file).")
740+
count: Optional[int] = Field(default=None, description="Maximum number of replacements. Default is None (replace all).")
741+
742+
743+
class ReplacementEditTool(BaseTool):
744+
"""Tool for regex-based replacement editing of files."""
745+
746+
name: ClassVar[str] = "replace"
747+
description: ClassVar[str] = "Replace text in a file using regex pattern matching. For files over 300 lines, specify a line range."
748+
args_schema: ClassVar[type[BaseModel]] = ReplacementEditInput
749+
codebase: Codebase = Field(exclude=True)
750+
751+
def __init__(self, codebase: Codebase) -> None:
752+
super().__init__(codebase=codebase)
753+
754+
def _run(
755+
self,
756+
filepath: str,
757+
pattern: str,
758+
replacement: str,
759+
start: int = 1,
760+
end: int = -1,
761+
count: Optional[int] = None,
762+
) -> str:
763+
result = replacement_edit(
764+
self.codebase,
765+
filepath=filepath,
766+
pattern=pattern,
767+
replacement=replacement,
768+
start=start,
769+
end=end,
770+
count=count,
771+
)
772+
return json.dumps(result, indent=2)

src/codegen/extensions/tools/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from .list_directory import list_directory
1818
from .move_symbol import move_symbol
1919
from .rename_file import rename_file
20+
from .replacement_edit import replacement_edit
2021
from .reveal_symbol import reveal_symbol
2122
from .search import search
2223
from .semantic_edit import semantic_edit
@@ -42,6 +43,7 @@
4243
# Symbol operations
4344
"move_symbol",
4445
"rename_file",
46+
"replacement_edit",
4547
"reveal_symbol",
4648
# Search operations
4749
"search",
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Tool for making regex-based replacements in files."""
2+
3+
import difflib
4+
import re
5+
from typing import Optional
6+
7+
from codegen import Codebase
8+
9+
from .view_file import add_line_numbers
10+
11+
12+
def generate_diff(original: str, modified: str) -> str:
13+
"""Generate a unified diff between two strings.
14+
15+
Args:
16+
original: Original content
17+
modified: Modified content
18+
19+
Returns:
20+
Unified diff as a string
21+
"""
22+
original_lines = original.splitlines(keepends=True)
23+
modified_lines = modified.splitlines(keepends=True)
24+
25+
diff = difflib.unified_diff(
26+
original_lines,
27+
modified_lines,
28+
fromfile="original",
29+
tofile="modified",
30+
lineterm="",
31+
)
32+
33+
return "".join(diff)
34+
35+
36+
def _merge_content(original_content: str, edited_content: str, start: int, end: int) -> str:
37+
"""Merge edited content with original content, preserving content outside the edit range.
38+
39+
Args:
40+
original_content: Original file content
41+
edited_content: New content for the specified range
42+
start: Start line (1-indexed)
43+
end: End line (1-indexed or -1 for end of file)
44+
45+
Returns:
46+
Merged content
47+
"""
48+
original_lines = original_content.split("\n")
49+
edited_lines = edited_content.split("\n")
50+
51+
if start == -1 and end == -1: # Append mode
52+
return original_content + "\n" + edited_content
53+
54+
# Convert to 0-indexed
55+
start_idx = start - 1
56+
end_idx = end - 1 if end != -1 else len(original_lines)
57+
58+
# Merge the content
59+
result_lines = original_lines[:start_idx] + edited_lines + original_lines[end_idx + 1 :]
60+
61+
return "\n".join(result_lines)
62+
63+
64+
def replacement_edit(
65+
codebase: Codebase,
66+
filepath: str,
67+
pattern: str,
68+
replacement: str,
69+
start: int = 1,
70+
end: int = -1,
71+
count: Optional[int] = None,
72+
flags: re.RegexFlag = re.MULTILINE,
73+
) -> dict[str, str]:
74+
"""Replace text in a file using regex pattern matching.
75+
76+
Args:
77+
codebase: The codebase to operate on
78+
filepath: Path to the file to edit
79+
pattern: Regex pattern to match
80+
replacement: Replacement text (can include regex groups)
81+
start: Start line (1-indexed, default: 1)
82+
end: End line (1-indexed, -1 for end of file)
83+
count: Maximum number of replacements (None for all)
84+
flags: Regex flags (default: re.MULTILINE)
85+
86+
Returns:
87+
Dict containing edit results and status
88+
89+
Raises:
90+
FileNotFoundError: If file not found
91+
ValueError: If invalid line range or regex pattern
92+
"""
93+
try:
94+
file = codebase.get_file(filepath)
95+
except ValueError:
96+
msg = f"File not found: {filepath}"
97+
raise FileNotFoundError(msg)
98+
99+
# Get the original content
100+
original_content = file.content
101+
original_lines = original_content.split("\n")
102+
103+
# Get the section to edit
104+
total_lines = len(original_lines)
105+
start_idx = start - 1
106+
end_idx = end - 1 if end != -1 else total_lines
107+
108+
# Get the content to edit
109+
section_lines = original_lines[start_idx : end_idx + 1]
110+
section_content = "\n".join(section_lines)
111+
112+
try:
113+
# Compile pattern for better error messages
114+
regex = re.compile(pattern, flags)
115+
except re.error as e:
116+
msg = f"Invalid regex pattern: {e}"
117+
raise ValueError(msg)
118+
119+
# Perform the replacement
120+
if count is None:
121+
new_section = regex.sub(replacement, section_content)
122+
else:
123+
new_section = regex.sub(replacement, section_content, count=count)
124+
125+
# If no changes were made, return early
126+
if new_section == section_content:
127+
return {
128+
"filepath": filepath,
129+
"status": "unchanged",
130+
"message": "No matches found for the given pattern",
131+
}
132+
133+
# Merge the edited content with the original
134+
new_content = _merge_content(original_content, new_section, start, end)
135+
136+
# Generate diff
137+
diff = generate_diff(original_content, new_content)
138+
139+
# Apply the edit
140+
file.edit(new_content)
141+
codebase.commit()
142+
143+
return {
144+
"filepath": filepath,
145+
"diff": diff,
146+
"status": "success",
147+
"new_content": add_line_numbers(new_content),
148+
}

src/codegen/extensions/tools/semantic_edit.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from codegen import Codebase
1010

11-
from .tool_prompts import _HUMAN_PROMPT_DRAFT_EDITOR, _SYSTEM_PROMPT_DRAFT_EDITOR
11+
from .semantic_edit_prompts import _HUMAN_PROMPT_DRAFT_EDITOR, COMMANDER_SYSTEM_PROMPT
1212
from .view_file import add_line_numbers
1313

1414

@@ -152,7 +152,7 @@ def semantic_edit(codebase: Codebase, filepath: str, edit_content: str, start: i
152152
original_file_section = "\n".join(context_lines)
153153

154154
# =====[ Get the LLM ]=====
155-
system_message = _SYSTEM_PROMPT_DRAFT_EDITOR
155+
system_message = COMMANDER_SYSTEM_PROMPT
156156
human_message = _HUMAN_PROMPT_DRAFT_EDITOR
157157
prompt = ChatPromptTemplate.from_messages([system_message, human_message])
158158
llm = ChatAnthropic(

src/codegen/extensions/tools/tool_prompts.py renamed to src/codegen/extensions/tools/semantic_edit_prompts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
_FILE_EDIT_DESCRIPTION = (
1+
FILE_EDIT_PROMPT = (
22
"""Edit a file in plain-text format.
33
* The assistant can edit files by specifying the file path and providing a draft of the new file content.
44
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# unchanged` to indicate unchanged sections.
@@ -274,7 +274,7 @@ def helper():
274274
)
275275

276276

277-
_SYSTEM_PROMPT_DRAFT_EDITOR = """You are an expert code editor.
277+
COMMANDER_SYSTEM_PROMPT = """You are an expert code editor.
278278
279279
Another agent has determined an edit needs to be made to this file.
280280

tests/unit/codegen/extensions/test_tools.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
list_directory,
1313
move_symbol,
1414
rename_file,
15+
replacement_edit,
1516
reveal_symbol,
1617
search,
1718
semantic_edit,
@@ -179,3 +180,63 @@ def test_create_pr_review_comment(codebase):
179180
assert "error" not in result
180181
assert result["status"] == "success"
181182
assert result["message"] == "Review comment created successfully"
183+
184+
185+
def test_replacement_edit(codebase):
186+
"""Test regex-based replacement editing."""
187+
# Test basic replacement
188+
result = replacement_edit(
189+
codebase,
190+
filepath="src/main.py",
191+
pattern=r'print\("Hello, world!"\)',
192+
replacement='print("Goodbye, world!")',
193+
)
194+
assert "error" not in result
195+
assert result["status"] == "success"
196+
assert 'print("Goodbye, world!")' in result["new_content"]
197+
198+
# Test with line range
199+
result = replacement_edit(
200+
codebase,
201+
filepath="src/main.py",
202+
pattern=r"Greeter",
203+
replacement="Welcomer",
204+
start=5, # Class definition line
205+
end=7,
206+
)
207+
assert "error" not in result
208+
assert result["status"] == "success"
209+
assert "class Welcomer" in result["new_content"]
210+
211+
# Test with regex groups
212+
result = replacement_edit(
213+
codebase,
214+
filepath="src/main.py",
215+
pattern=r"def (\w+)\(\):",
216+
replacement=r"def \1_function():",
217+
)
218+
assert "error" not in result
219+
assert result["status"] == "success"
220+
assert "def hello_function():" in result["new_content"]
221+
222+
# Test with count limit
223+
result = replacement_edit(
224+
codebase,
225+
filepath="src/main.py",
226+
pattern=r"def",
227+
replacement="async def",
228+
count=1, # Only replace first occurrence
229+
)
230+
assert "error" not in result
231+
assert result["status"] == "success"
232+
assert result["new_content"].count("async def") == 1
233+
234+
# Test no matches
235+
result = replacement_edit(
236+
codebase,
237+
filepath="src/main.py",
238+
pattern=r"nonexistent_pattern",
239+
replacement="replacement",
240+
)
241+
assert result["status"] == "unchanged"
242+
assert "No matches found" in result["message"]

0 commit comments

Comments
 (0)