Merge branch 'main' of github.com:oraios/serena

This commit is contained in:
Michael Panchenko
2025-04-01 22:30:01 +02:00
2 changed files with 89 additions and 23 deletions
+82 -21
View File
@@ -35,6 +35,23 @@ from serena.util.shell import execute_shell_command
log = logging.getLogger(__name__)
LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"
TTool = TypeVar("TTool", bound="Tool")
SUCCESS_RESULT = "OK"
class LinesRead:
def __init__(self) -> None:
self.files = defaultdict(lambda: set())
def add_lines_read(self, relative_path: str, lines: tuple[int, int]) -> None:
self.files[relative_path].add(lines)
def were_lines_read(self, relative_path: str, lines: tuple[int, int]) -> bool:
lines_read_in_file = self.files[relative_path]
return lines in lines_read_in_file
def invalidate_lines_read(self, relative_path: str) -> None:
if relative_path in self.files:
del self.files[relative_path]
class SerenaAgent:
@@ -82,19 +99,20 @@ class SerenaAgent:
self.language_server = SyncLanguageServer.create(config, logger, self.project_root)
self.prompt_factory = PromptFactory()
self.symbol_manager = SymbolManager(self.language_server)
memories_dir = os.path.join(self.get_serena_managed_dir(), "memories")
self.memories_manager = MemoriesManager(memories_dir)
self.symbol_manager = SymbolManager(self.language_server, self)
self.memories_manager = MemoriesManager(os.path.join(self.get_serena_managed_dir(), "memories"))
self.lines_read = LinesRead()
# find all tool classes and instantiate them
excluded_tools = project_config.get("excluded_tools", [])
self._all_tools: dict[type[Tool], Tool] = {}
self.tools: dict[type[Tool], Tool] = {}
for tool_class in iter_tool_classes():
tool_instance = tool_class(self)
self._all_tools[tool_class] = tool_instance
if (tool_name := tool_class.get_name()) in excluded_tools:
log.info(f"Skipping tool {tool_name} because it is in the exclude list")
continue
tool_instance = tool_class(self)
self.tools[tool_class] = tool_instance
log.info(f"Loaded tools: {', '.join([tool.get_name() for tool in self.tools.values()])}")
@@ -104,7 +122,7 @@ class SerenaAgent:
self.language_server.start()
def get_tool(self, tool_class: type[TTool]) -> TTool:
return self.tools[tool_class] # type: ignore
return self._all_tools[tool_class] # type: ignore
def print_tool_overview(self) -> None:
_print_tool_overview(self.tools.values())
@@ -112,6 +130,9 @@ class SerenaAgent:
def get_serena_managed_dir(self) -> str:
return os.path.join(self.project_root, ".serena")
def mark_file_modified(self, relativ_path: str) -> None:
self.lines_read.invalidate_lines_read(relativ_path)
def __del__(self) -> None:
"""
Destructor to clean up the language server instance and GUI logger
@@ -265,13 +286,13 @@ class ReadFileTool(Tool):
self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = _DEFAULT_MAX_ANSWER_LENGTH
) -> str:
"""
Reads the given file or a chunk of it (between start_line and end_line). Generally, symbolic operations
Reads the given file or a chunk of it. Generally, symbolic operations
like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for.
Reading the entire file is only recommended if there is no other way to get the content required for the task.
:param relative_path: the relative path to the file to read
:param start_line: the start line of the range to read
:param end_line: the end line of the range to read. If None, the entire file will be read.
:param start_line: the 0-based index of the first line to be retrieved.
:param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file.
:param max_answer_chars: if the file (chunk) is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task.
@@ -282,7 +303,8 @@ class ReadFileTool(Tool):
if end_line is None:
result_lines = result_lines[start_line:]
else:
result_lines = result_lines[start_line:end_line]
self.agent.lines_read.add_lines_read(relative_path, (start_line, end_line))
result_lines = result_lines[start_line:end_line+1]
result = "\n".join(result_lines)
return self._limit_length(result, max_answer_chars)
@@ -524,7 +546,7 @@ class ReplaceSymbolBodyTool(Tool):
SymbolLocation(relative_path, line, column),
body=body,
)
return "OK"
return SUCCESS_RESULT
class InsertAfterSymbolTool(Tool):
@@ -548,11 +570,12 @@ class InsertAfterSymbolTool(Tool):
:param column: the column
:param body: the body/content to be inserted
"""
location = SymbolLocation(relative_path, line, column)
self.symbol_manager.insert_after(
SymbolLocation(relative_path, line, column),
location,
body=body,
)
return "OK"
return SUCCESS_RESULT
class InsertBeforeSymbolTool(Tool):
@@ -581,7 +604,7 @@ class InsertBeforeSymbolTool(Tool):
SymbolLocation(relative_path, line, column),
body=body,
)
return "OK"
return SUCCESS_RESULT
class DeleteLinesTool(Tool):
@@ -596,15 +619,50 @@ class DeleteLinesTool(Tool):
end_line: int,
) -> str:
"""
Deletes the given lines in the file. An editing operation, rarely used alone but can be useful in combination with
other tools.
Deletes the given lines in the file.
Requires that the same range of lines was previously read using the `read_file` tool to verify correctness
of the operation.
:param relative_path: the relative path to the file
:param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted
"""
if not self.agent.lines_read.were_lines_read(relative_path, (start_line, end_line)):
read_lines_tool = self.agent.get_tool(ReadFileTool)
return f"Error: Must call `{read_lines_tool.get_name()}` first to read exactly the affected lines."
self.symbol_manager.delete_lines(relative_path, start_line, end_line)
return "OK"
return SUCCESS_RESULT
class ReplaceLinesTool(Tool):
"""
Replaces a range of lines within a file with new content.
"""
def apply(
self,
relative_path: str,
start_line: int,
end_line: int,
content: str,
) -> str:
"""
Replaces the given range of lines in the given file.
Requires that the same range of lines was previously read using the `read_file` tool to verify correctness
of the operation.
:param relative_path: the relative path to the file
:param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted
:param content: the content to insert
"""
if not content.endswith("\n"):
content += "\n"
result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line)
if result != SUCCESS_RESULT:
return result
self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content)
return SUCCESS_RESULT
class InsertAtLineTool(Tool):
@@ -619,16 +677,19 @@ class InsertAtLineTool(Tool):
content: str,
) -> str:
"""
Inserts the given content at the given line in the file. In general, symbolic insert operations like
insert_after_symbol or insert_before_symbol should be preferred if you know which symbol you are looking for.
Inserts the given content at the given line in the file, pushing existing content of the line down.
In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which
symbol you are looking for.
However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body).
:param relative_path: the relative path to the file
:param line: the 0-based index of the line to insert content at
:param content: the body/content to be inserted
:param content: the content to be inserted
"""
if not content.endswith("\n"):
content += "\n"
self.symbol_manager.insert_at_line(relative_path, line, content)
return "OK"
return SUCCESS_RESULT
class CheckOnboardingPerformedTool(Tool):
+7 -2
View File
@@ -4,13 +4,16 @@ from collections.abc import Iterator, Sequence
from contextlib import contextmanager
from copy import copy
from dataclasses import asdict, dataclass
from typing import Any, Self
from typing import TYPE_CHECKING, Any, Self
from sensai.util.string import ToStringMixin
from multilspy import SyncLanguageServer
from multilspy.multilspy_types import Position, SymbolKind, UnifiedSymbolInformation
if TYPE_CHECKING:
from .agent import SerenaAgent
log = logging.getLogger(__name__)
@@ -187,8 +190,9 @@ class Symbol(ToStringMixin):
class SymbolManager:
def __init__(self, lang_server: SyncLanguageServer) -> None:
def __init__(self, lang_server: SyncLanguageServer, agent: "SerenaAgent") -> None:
self.lang_server = lang_server
self.agent = agent
def _to_symbols(self, items: list[UnifiedSymbolInformation]) -> list[Symbol]:
return [Symbol(s) for s in items]
@@ -289,6 +293,7 @@ class SymbolManager:
abs_path = os.path.join(root_path, relative_path)
with open(abs_path, "w") as f:
f.write(file_buffer.contents)
self.agent.mark_file_modified(relative_path)
@contextmanager
def _edited_symbol_location(self, location: SymbolLocation) -> Iterator[Symbol]: