Skip to content

Commit dae8661

Browse files
authored
Global find/replace tool (#882)
1 parent a9a2791 commit dae8661

File tree

6 files changed

+299
-32
lines changed

6 files changed

+299
-32
lines changed

src/codegen/extensions/langchain/agent.py

+11-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Demo implementation of an agent with Codegen tools."""
22

3-
from typing import TYPE_CHECKING, Any, Optional
3+
from typing import TYPE_CHECKING, Any
44

55
from langchain.tools import BaseTool
66
from langchain_core.messages import SystemMessage
@@ -13,13 +13,15 @@
1313
from codegen.extensions.langchain.tools import (
1414
CreateFileTool,
1515
DeleteFileTool,
16+
GlobalReplacementEditTool,
1617
ListDirectoryTool,
1718
MoveSymbolTool,
1819
ReflectionTool,
1920
RelaceEditTool,
2021
RenameFileTool,
2122
ReplacementEditTool,
2223
RevealSymbolTool,
24+
SearchFilesByNameTool,
2325
SearchTool,
2426
# SemanticEditTool,
2527
ViewFileTool,
@@ -38,8 +40,8 @@ def create_codebase_agent(
3840
system_message: SystemMessage = SystemMessage(REASONER_SYSTEM_MESSAGE),
3941
memory: bool = True,
4042
debug: bool = False,
41-
additional_tools: Optional[list[BaseTool]] = None,
42-
config: Optional[AgentConfig] = None,
43+
additional_tools: list[BaseTool] | None = None,
44+
config: AgentConfig | None = None,
4345
**kwargs,
4446
) -> CompiledGraph:
4547
"""Create an agent with all codebase tools.
@@ -76,6 +78,8 @@ def create_codebase_agent(
7678
ReplacementEditTool(codebase),
7779
RelaceEditTool(codebase),
7880
ReflectionTool(codebase),
81+
SearchFilesByNameTool(codebase),
82+
GlobalReplacementEditTool(codebase),
7983
# SemanticSearchTool(codebase),
8084
# =====[ Github Integration ]=====
8185
# Enable Github integration
@@ -101,8 +105,8 @@ def create_chat_agent(
101105
system_message: SystemMessage = SystemMessage(REASONER_SYSTEM_MESSAGE),
102106
memory: bool = True,
103107
debug: bool = False,
104-
additional_tools: Optional[list[BaseTool]] = None,
105-
config: Optional[dict[str, Any]] = None, # over here you can pass in the max length of the number of messages
108+
additional_tools: list[BaseTool] | None = None,
109+
config: dict[str, Any] | None = None, # over here you can pass in the max length of the number of messages
106110
**kwargs,
107111
) -> CompiledGraph:
108112
"""Create an agent with all codebase tools.
@@ -151,7 +155,7 @@ def create_codebase_inspector_agent(
151155
system_message: SystemMessage = SystemMessage(REASONER_SYSTEM_MESSAGE),
152156
memory: bool = True,
153157
debug: bool = True,
154-
config: Optional[dict[str, Any]] = None,
158+
config: dict[str, Any] | None = None,
155159
**kwargs,
156160
) -> CompiledGraph:
157161
"""Create an inspector agent with read-only codebase tools.
@@ -189,7 +193,7 @@ def create_agent_with_tools(
189193
system_message: SystemMessage = SystemMessage(REASONER_SYSTEM_MESSAGE),
190194
memory: bool = True,
191195
debug: bool = True,
192-
config: Optional[dict[str, Any]] = None,
196+
config: dict[str, Any] | None = None,
193197
**kwargs,
194198
) -> CompiledGraph:
195199
"""Create an agent with a specific set of tools.

src/codegen/extensions/langchain/tools.py

+81-19
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Langchain tools for workspace operations."""
22

3-
from typing import Callable, ClassVar, Literal, Optional
3+
from collections.abc import Callable
4+
from typing import ClassVar, Literal
45

56
from langchain_core.tools.base import BaseTool
67
from pydantic import BaseModel, Field
@@ -9,6 +10,7 @@
910
from codegen.extensions.tools.bash import run_bash_command
1011
from codegen.extensions.tools.github.checkout_pr import checkout_pr
1112
from codegen.extensions.tools.github.view_pr_checks import view_pr_checks
13+
from codegen.extensions.tools.global_replacement_edit import replacement_edit_global
1214
from codegen.extensions.tools.linear.linear import (
1315
linear_comment_on_issue_tool,
1416
linear_create_issue_tool,
@@ -50,10 +52,10 @@ class ViewFileInput(BaseModel):
5052
"""Input for viewing a file."""
5153

5254
filepath: str = Field(..., description="Path to the file relative to workspace root")
53-
start_line: Optional[int] = Field(None, description="Starting line number to view (1-indexed, inclusive)")
54-
end_line: Optional[int] = Field(None, description="Ending line number to view (1-indexed, inclusive)")
55-
max_lines: Optional[int] = Field(None, description="Maximum number of lines to view at once, defaults to 250")
56-
line_numbers: Optional[bool] = Field(True, description="If True, add line numbers to the content (1-indexed)")
55+
start_line: int | None = Field(None, description="Starting line number to view (1-indexed, inclusive)")
56+
end_line: int | None = Field(None, description="Ending line number to view (1-indexed, inclusive)")
57+
max_lines: int | None = Field(None, description="Maximum number of lines to view at once, defaults to 250")
58+
line_numbers: bool | None = Field(True, description="If True, add line numbers to the content (1-indexed)")
5759

5860

5961
class ViewFileTool(BaseTool):
@@ -72,10 +74,10 @@ def __init__(self, codebase: Codebase) -> None:
7274
def _run(
7375
self,
7476
filepath: str,
75-
start_line: Optional[int] = None,
76-
end_line: Optional[int] = None,
77-
max_lines: Optional[int] = None,
78-
line_numbers: Optional[bool] = True,
77+
start_line: int | None = None,
78+
end_line: int | None = None,
79+
max_lines: int | None = None,
80+
line_numbers: bool | None = True,
7981
) -> str:
8082
result = view_file(
8183
self.codebase,
@@ -120,7 +122,7 @@ class SearchInput(BaseModel):
120122
description="""The search query to find in the codebase. When ripgrep is available, this will be passed as a ripgrep pattern. For regex searches, set use_regex=True.
121123
Ripgrep is the preferred method.""",
122124
)
123-
file_extensions: Optional[list[str]] = Field(default=None, description="Optional list of file extensions to search (e.g. ['.py', '.ts'])")
125+
file_extensions: list[str] | None = Field(default=None, description="Optional list of file extensions to search (e.g. ['.py', '.ts'])")
124126
page: int = Field(default=1, description="Page number to return (1-based, default: 1)")
125127
files_per_page: int = Field(default=10, description="Number of files to return per page (default: 10)")
126128
use_regex: bool = Field(default=False, description="Whether to treat query as a regex pattern (default: False)")
@@ -137,7 +139,7 @@ class SearchTool(BaseTool):
137139
def __init__(self, codebase: Codebase) -> None:
138140
super().__init__(codebase=codebase)
139141

140-
def _run(self, query: str, file_extensions: Optional[list[str]] = None, page: int = 1, files_per_page: int = 10, use_regex: bool = False) -> str:
142+
def _run(self, query: str, file_extensions: list[str] | None = None, page: int = 1, files_per_page: int = 10, use_regex: bool = False) -> str:
141143
result = search(self.codebase, query, file_extensions=file_extensions, page=page, files_per_page=files_per_page, use_regex=use_regex)
142144
return result.render()
143145

@@ -273,7 +275,7 @@ class RevealSymbolInput(BaseModel):
273275

274276
symbol_name: str = Field(..., description="Name of the symbol to analyze")
275277
degree: int = Field(default=1, description="How many degrees of separation to traverse")
276-
max_tokens: Optional[int] = Field(
278+
max_tokens: int | None = Field(
277279
default=None,
278280
description="Optional maximum number of tokens for all source code combined",
279281
)
@@ -296,7 +298,7 @@ def _run(
296298
self,
297299
symbol_name: str,
298300
degree: int = 1,
299-
max_tokens: Optional[int] = None,
301+
max_tokens: int | None = None,
300302
collect_dependencies: bool = True,
301303
collect_usages: bool = True,
302304
) -> str:
@@ -849,8 +851,10 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]:
849851
RenameFileTool(codebase),
850852
ReplacementEditTool(codebase),
851853
RevealSymbolTool(codebase),
854+
GlobalReplacementEditTool(codebase),
852855
RunBashCommandTool(), # Note: This tool doesn't need the codebase
853856
SearchTool(codebase),
857+
SearchFilesByNameTool(codebase),
854858
# SemanticEditTool(codebase),
855859
# SemanticSearchTool(codebase),
856860
ViewFileTool(codebase),
@@ -872,6 +876,62 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]:
872876
]
873877

874878

879+
class GlobalReplacementEditInput(BaseModel):
880+
"""Input for replacement editing across the entire codebase."""
881+
882+
file_pattern: str = Field(
883+
default="*",
884+
description=("Glob pattern to match files that should be edited. Supports all Python glob syntax including wildcards (*, ?, **)"),
885+
)
886+
pattern: str = Field(
887+
...,
888+
description=(
889+
"Regular expression pattern to match text that should be replaced. "
890+
"Supports all Python regex syntax including capture groups (\\1, \\2, etc). "
891+
"The pattern is compiled with re.MULTILINE flag by default."
892+
),
893+
)
894+
replacement: str = Field(
895+
...,
896+
description=(
897+
"Text to replace matched patterns with. Can reference regex capture groups using \\1, \\2, etc. If using regex groups in pattern, make sure to preserve them in replacement if needed."
898+
),
899+
)
900+
count: int | None = Field(
901+
default=None,
902+
description=(
903+
"Maximum number of replacements to make. "
904+
"Use None to replace all occurrences (default), or specify a number to limit replacements. "
905+
"Useful when you only want to replace the first N occurrences."
906+
),
907+
)
908+
909+
910+
class GlobalReplacementEditTool(BaseTool):
911+
"""Tool for regex-based replacement editing of files across the entire codebase.
912+
913+
Use this to make a change across an entire codebase if you have a regex pattern that matches the text you want to replace and are trying to edit a large number of files.
914+
"""
915+
916+
name: ClassVar[str] = "global_replace"
917+
description: ClassVar[str] = "Replace text in the entire codebase using regex pattern matching."
918+
args_schema: ClassVar[type[BaseModel]] = GlobalReplacementEditInput
919+
codebase: Codebase = Field(exclude=True)
920+
921+
def __init__(self, codebase: Codebase) -> None:
922+
super().__init__(codebase=codebase)
923+
924+
def _run(
925+
self,
926+
file_pattern: str,
927+
pattern: str,
928+
replacement: str,
929+
count: int | None = None,
930+
) -> str:
931+
result = replacement_edit_global(self.codebase, file_pattern, pattern, replacement, count)
932+
return result.render()
933+
934+
875935
class ReplacementEditInput(BaseModel):
876936
"""Input for replacement editing."""
877937

@@ -905,7 +965,7 @@ class ReplacementEditInput(BaseModel):
905965
"Default is -1 (end of file)."
906966
),
907967
)
908-
count: Optional[int] = Field(
968+
count: int | None = Field(
909969
default=None,
910970
description=(
911971
"Maximum number of replacements to make. "
@@ -933,7 +993,7 @@ def _run(
933993
replacement: str,
934994
start: int = 1,
935995
end: int = -1,
936-
count: Optional[int] = None,
996+
count: int | None = None,
937997
) -> str:
938998
result = replacement_edit(
939999
self.codebase,
@@ -997,7 +1057,7 @@ class ReflectionInput(BaseModel):
9971057
context_summary: str = Field(..., description="Summary of the current context and problem being solved")
9981058
findings_so_far: str = Field(..., description="Key information and insights gathered so far")
9991059
current_challenges: str = Field(default="", description="Current obstacles or questions that need to be addressed")
1000-
reflection_focus: Optional[str] = Field(default=None, description="Optional specific aspect to focus reflection on (e.g., 'architecture', 'performance', 'next steps')")
1060+
reflection_focus: str | None = Field(default=None, description="Optional specific aspect to focus reflection on (e.g., 'architecture', 'performance', 'next steps')")
10011061

10021062

10031063
class ReflectionTool(BaseTool):
@@ -1020,7 +1080,7 @@ def _run(
10201080
context_summary: str,
10211081
findings_so_far: str,
10221082
current_challenges: str = "",
1023-
reflection_focus: Optional[str] = None,
1083+
reflection_focus: str | None = None,
10241084
) -> str:
10251085
result = perform_reflection(context_summary=context_summary, findings_so_far=findings_so_far, current_challenges=current_challenges, reflection_focus=reflection_focus, codebase=self.codebase)
10261086

@@ -1042,13 +1102,15 @@ class SearchFilesByNameTool(BaseTool):
10421102
- Find specific file types (e.g., '*.py', '*.tsx')
10431103
- Locate configuration files (e.g., 'package.json', 'requirements.txt')
10441104
- Find files with specific names (e.g., 'README.md', 'Dockerfile')
1105+
1106+
Uses fd under the hood
10451107
"""
10461108
args_schema: ClassVar[type[BaseModel]] = SearchFilesByNameInput
10471109
codebase: Codebase = Field(exclude=True)
10481110

10491111
def __init__(self, codebase: Codebase):
10501112
super().__init__(codebase=codebase)
10511113

1052-
def _run(self, pattern: str) -> str:
1114+
def _run(self, pattern: str, full_path: bool = False) -> str:
10531115
"""Execute the glob pattern search using fd."""
1054-
return search_files_by_name(self.codebase, pattern).render()
1116+
return search_files_by_name(self.codebase, pattern, full_path).render()

src/codegen/extensions/tools/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .github.create_pr_comment import create_pr_comment
99
from .github.create_pr_review_comment import create_pr_review_comment
1010
from .github.view_pr import view_pr
11+
from .global_replacement_edit import replacement_edit_global
1112
from .linear import (
1213
linear_comment_on_issue_tool,
1314
linear_get_issue_comments_tool,
@@ -49,6 +50,7 @@
4950
"perform_reflection",
5051
"rename_file",
5152
"replacement_edit",
53+
"replacement_edit_global",
5254
"reveal_symbol",
5355
"run_codemod",
5456
# Search operations

0 commit comments

Comments
 (0)