Add LanguageServerManager, adding multi-language support in a single project

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2025-10-28 18:52:23 +01:00
1 parent 340338f593
commit e0ab4db189
16 files changed
+277 -127

No files matched your search

+14 -28
View File
@@ -22,12 +22,12 @@ from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats
from serena.config.context_mode import RegisteredContext, SerenaAgentContext, SerenaAgentMode
from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition, ToolSet
from serena.dashboard import SerenaDashboardAPI
from serena.ls_manager import LanguageServerManager
from serena.project import Project
from serena.prompt_factory import SerenaPromptFactory
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, Tool, ToolMarker, ToolRegistry
from serena.util.inspection import iter_subclasses
from serena.util.logging import MemoryLogHandler
from solidlsp import SolidLanguageServer
if TYPE_CHECKING:
from serena.gui_log_viewer import GuiLogViewer
@@ -86,7 +86,7 @@ class SerenaAgent:
# project-specific instances, which will be initialized upon project activation
self._active_project: Project | None = None
self.language_server: SolidLanguageServer | None = None
self.language_server_manager: LanguageServerManager | None = None
# adjust log level
serena_log_level = self.serena_config.log_level
@@ -391,15 +391,14 @@ class SerenaAgent:
self._active_project = project
self._update_active_tools()
def init_language_server() -> None:
def init_language_server_manager() -> None:
# start the language server
with LogTime("Language server initialization", logger=log):
self.reset_language_server()
assert self.language_server is not None
self.reset_language_server_manager()
# initialize the language server in the background (if in language server mode)
if self.is_using_language_server():
self.issue_task(init_language_server)
self.issue_task(init_language_server_manager)
if self._project_activation_callback is not None:
self._project_activation_callback()
@@ -506,10 +505,7 @@ class SerenaAgent:
return result_str
def is_language_server_running(self) -> bool:
return self.language_server is not None and self.language_server.is_running()
def reset_language_server(self) -> None:
def reset_language_server_manager(self) -> None:
"""
Starts/resets the language server for the current project
"""
@@ -522,26 +518,18 @@ class SerenaAgent:
ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout
# stop the language server if it is running
if self.is_language_server_running():
assert self.language_server is not None
log.info(f"Stopping the current language server at {self.language_server.repository_root_path} ...")
self.language_server.stop()
self.language_server = None
if self.language_server_manager is not None:
self.language_server_manager.stop_all()
self.language_server_manager = None
# instantiate and start the language server
# instantiate and start the necessarys language servers
assert self._active_project is not None
self.language_server = self._active_project.create_language_server(
self.language_server_manager = self._active_project.create_language_server_manager(
log_level=self.serena_config.log_level,
ls_timeout=ls_timeout,
trace_lsp_communication=self.serena_config.trace_lsp_communication,
ls_specific_settings=self.serena_config.ls_specific_settings,
)
log.info(f"Starting the language server for {self._active_project.project_name}")
self.language_server.start()
if not self.language_server.is_running():
raise RuntimeError(
f"Failed to start the language server for {self._active_project.project_name} at {self._active_project.project_root}"
)
def get_tool(self, tool_class: type[TTool]) -> TTool:
return self._all_tools[tool_class] # type: ignore
@@ -556,11 +544,9 @@ class SerenaAgent:
if not hasattr(self, "_is_initialized"):
return
log.info("SerenaAgent is shutting down ...")
if self.is_language_server_running():
log.info("Stopping the language server ...")
assert self.language_server is not None
self.language_server.save_cache()
self.language_server.stop()
if self.language_server_manager is not None:
self.language_server_manager.stop_all(save_cache=True)
self.language_server_manager = None
if self._gui_log_viewer:
log.info("Stopping the GUI log window ...")
self._gui_log_viewer.stop()
+2 -1
View File
@@ -429,7 +429,7 @@ class ProjectCommands(AutoRegisteringGroup):
all_langs = [l.name.lower() for l in Language.iter_all(include_experimental=True)]
raise ValueError(f"Unknown language '{language}'. Supported: {all_langs}")
generated_conf = ProjectConfig.autogenerate(project_root=project_path, project_language=lang_inst)
print(f"Generated project.yml with language {generated_conf.language.value} at {yml_path}.")
print(f"Generated project.yml with languages {generated_conf.languages} at {yml_path}.")
@staticmethod
@click.command("index", help="Index a project by saving symbols to the LSP cache.")
@@ -460,6 +460,7 @@ class ProjectCommands(AutoRegisteringGroup):
serena_config = SerenaConfig.from_config_file()
proj = Project.load(os.path.abspath(project))
click.echo(f"Indexing symbols in project {project}…")
# TODO FIXME
ls = proj.create_language_server(log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings)
log_file = os.path.join(project, ".serena", "logs", "indexing.txt")
+12 -10
View File
@@ -231,12 +231,11 @@ class CodeEditor(Generic[TSymbol], ABC):
class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, agent: Optional["SerenaAgent"] = None):
super().__init__(project_root=symbol_retriever.get_language_server().repository_root_path, agent=agent)
super().__init__(project_root=symbol_retriever.get_root_path(), agent=agent)
self._symbol_retriever = symbol_retriever
@property
def _lang_server(self) -> SolidLanguageServer:
return self._symbol_retriever.get_language_server()
def _get_language_server(self, relative_path: str) -> SolidLanguageServer:
return self._symbol_retriever.get_language_server(relative_path)
class EditedFile(CodeEditor.EditedFile):
def __init__(self, lang_server: SolidLanguageServer, relative_path: str, file_buffer: LSPFileBuffer):
@@ -258,12 +257,14 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
@contextmanager
def _open_file_context(self, relative_path: str) -> Iterator["CodeEditor.EditedFile"]:
with self._lang_server.open_file(relative_path) as file_buffer:
yield self.EditedFile(self._lang_server, relative_path, file_buffer)
lang_server = self._get_language_server(relative_path)
with lang_server.open_file(relative_path) as file_buffer:
yield self.EditedFile(lang_server, relative_path, file_buffer)
def _get_code_file_content(self, relative_path: str) -> str:
"""Get the content of a file using the language server."""
return self._lang_server.language_server.retrieve_full_file_content(relative_path)
lang_server = self._get_language_server(relative_path)
return lang_server.language_server.retrieve_full_file_content(relative_path)
def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> LanguageServerSymbol:
symbol_candidates = self._symbol_retriever.find_by_name(name_path, within_relative_path=relative_file_path)
@@ -289,7 +290,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
# Handle the 'changes' format (URI -> list of TextEdits)
for uri, edits in uri_to_edits.items():
file_path = PathUtils.uri_to_path(uri)
relative_path = os.path.relpath(file_path, self._lang_server.repository_root_path)
relative_path = os.path.relpath(file_path, self._symbol_retriever.get_root_path())
modified_relative_paths.append(relative_path)
with self._edited_file_context(relative_path) as edited_file:
edited_file = cast(LanguageServerCodeEditor.EditedFile, edited_file)
@@ -305,12 +306,13 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]):
assert symbol.location.line is not None
assert symbol.location.column is not None
rename_result = self._lang_server.request_rename_symbol_edit(
lang_server = self._get_language_server(relative_file_path)
rename_result = lang_server.request_rename_symbol_edit(
relative_file_path=relative_file_path, line=symbol.location.line, column=symbol.location.column, new_name=new_name
)
if rename_result is None:
raise ValueError(
f"Language server for {self._lang_server.language_id} returned no rename edits for symbol '{name_path}'. "
f"Language server for {lang_server.language_id} returned no rename edits for symbol '{name_path}'. "
f"The symbol might not support renaming."
)
modified_files = self._apply_workspace_edit(rename_result)
+20 -11
View File
@@ -162,7 +162,7 @@ def is_running_in_docker() -> bool:
@dataclass(kw_only=True)
class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
project_name: str
language: Language
languages: list[Language]
ignored_paths: list[str] = field(default_factory=list)
read_only: bool = False
ignore_all_files_in_gitignore: bool = True
@@ -212,7 +212,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
dominant_language = project_language.value
config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True)
config_with_comments["project_name"] = project_name
config_with_comments["language"] = dominant_language
config_with_comments["languages"] = [dominant_language]
if save_to_disk:
save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True)
return cls._from_dict(config_with_comments)
@@ -226,19 +226,28 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
"""
Create a ProjectConfig instance from a configuration dictionary
"""
language_str = data["language"].lower()
project_name = data["project_name"]
language_strings = data.get("languages", [])
# backwards compatibility
if language_str == "javascript":
log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`")
language_str = "typescript"
try:
language = Language(language_str)
except ValueError as e:
raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e
lang_name_mapping = {"javascript": "typescript"}
if len(language_strings) == 0 and "language" in data:
language_strings = [data["language"]]
languages: list[Language] = []
for language_str in language_strings:
try:
language_str = language_str.lower()
if language_str in lang_name_mapping:
language_str = lang_name_mapping[language_str]
language = Language(language_str)
languages.append(language)
except ValueError as e:
raise ValueError(f"Invalid language: {data['language']}.\nValid language_strings are: {[l.value for l in Language]}") from e
return cls(
project_name=project_name,
language=language,
languages=languages,
ignored_paths=data.get("ignored_paths", []),
excluded_tools=data.get("excluded_tools", []),
included_optional_tools=data.get("included_optional_tools", []),
+1 -1
View File
@@ -154,7 +154,7 @@ class SerenaDashboardAPI:
active_project_name = project.project_name if project else None
project_info = {
"name": active_project_name,
"language": project.project_config.language.value if project else None,
"language": ", ".join([l.value for l in project.project_config.languages]) if project else None,
}
# Get context info
+88
View File
@@ -0,0 +1,88 @@
import logging
from collections.abc import Callable, Iterator
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
log = logging.getLogger(__name__)
class LanguageServerManager:
"""
Manages one or more language servers
"""
def __init__(
self,
language_servers: dict[Language, SolidLanguageServer],
language_server_factories: dict[Language, Callable[[], SolidLanguageServer]] | None = None,
) -> None:
"""
:param language_servers: a mapping from language to language server; the servers are assumed to be already started.
The first server in the iteration order is used as the default server.
All servers are assumed to serve the same project root.
:param language_server_factories: factories for the language servers, to be used for restarting them if needed
"""
self._language_servers = language_servers
self._language_server_factories = language_server_factories or {}
self._default_language_server = next(iter(language_servers.values()))
self._root_path = self._default_language_server.repository_root_path
def get_root_path(self) -> str:
return self._root_path
def _ensure_functional_ls(self, ls: SolidLanguageServer) -> SolidLanguageServer:
if not ls.is_running():
log.warning(f"Language server for language {ls.language} is not running; restarting ...")
ls = self.restart_language_server(ls.language)
return ls
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
ls: SolidLanguageServer | None = None
if len(self._language_servers) > 1:
for candidate in self._language_servers.values():
if not candidate.is_ignored_path(relative_path, ignore_unsupported_files=True):
ls = candidate
break
if ls is None:
ls = self._default_language_server
return self._ensure_functional_ls(ls)
def restart_language_server(self, language: Language) -> SolidLanguageServer:
"""
Forces recreation and restart of the language server for the given language.
:param language: the language
:return: the newly created language server
"""
factory = self._language_server_factories.get(language)
if factory is None:
raise ValueError(f"No language server factory found for language {language}; cannot recreate and restart")
self._language_servers[language] = factory()
self._language_servers[language].start()
return self._language_servers[language]
def iter_language_servers(self) -> Iterator[SolidLanguageServer]:
for ls in self._language_servers.values():
yield self._ensure_functional_ls(ls)
def stop_all(self, save_cache: bool = False) -> None:
"""
Stops all managed language servers.
:param save_cache: whether to save the cache before stopping
"""
for ls in self.iter_language_servers():
if ls.is_running():
if save_cache:
ls.save_cache()
log.info(f"Stopping language server for language {ls.language} ...")
ls.stop()
def save_all_caches(self) -> None:
"""
Saves the caches of all managed language servers.
"""
for ls in self.iter_language_servers():
if ls.is_running():
ls.save_cache()
+61 -29
View File
@@ -1,6 +1,7 @@
import json
import logging
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any
@@ -9,6 +10,7 @@ from sensai.util.string import ToStringMixin
from serena.config.serena_config import DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir
from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME
from serena.ls_manager import LanguageServerManager
from serena.text_utils import MatchedConsecutiveLines, search_files
from serena.util.file_system import GitignoreParser, match_path
from solidlsp import SolidLanguageServer
@@ -101,10 +103,6 @@ class Project(ToStringMixin):
def project_name(self) -> str:
return self.project_config.project_name
@property
def language(self) -> Language:
return self.project_config.language
@classmethod
def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project":
project_root = Path(project_root).resolve()
@@ -127,7 +125,8 @@ class Project(ToStringMixin):
msg = f"Created and activated a new project with name '{self.project_name}' at {self.project_root}. "
else:
msg = f"The project with name '{self.project_name}' at {self.project_root} is activated."
msg += f"\nProgramming language: {self.project_config.language.value}; file encoding: {self.project_config.encoding}"
languages_str = ", ".join([lang.value for lang in self.project_config.languages])
msg += f"\nProgramming languages: {languages_str}; file encoding: {self.project_config.encoding}"
memories = self.memories_manager.list_memories()
if memories:
msg += (
@@ -179,8 +178,13 @@ class Project(ToStringMixin):
# Check file extension if it's a file
is_file = os.path.isfile(abs_path)
if is_file and ignore_non_source_files:
fn_matcher = self.language.get_source_fn_matcher()
if not fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_language = False
for language in self.project_config.languages:
fn_matcher = language.get_source_fn_matcher()
if fn_matcher.is_relevant_filename(abs_path):
is_file_in_supported_language = True
break
if not is_file_in_supported_language:
return True
# Create normalized path for consistent handling
@@ -345,13 +349,46 @@ class Project(ToStringMixin):
source_file_path=relative_file_path,
)
def create_language_server(
def _create_language_server_factory(
self,
language: Language,
ls_timeout: float | None,
ls_specific_settings: dict | None,
log_level: int,
trace_lsp_communication: bool,
) -> Callable[[], SolidLanguageServer]:
def factory() -> SolidLanguageServer:
ls_config = LanguageServerConfig(
code_language=language,
ignored_paths=self._ignored_patterns,
trace_lsp_communication=trace_lsp_communication,
encoding=self.project_config.encoding,
)
ls_logger = LanguageServerLogger(log_level=log_level)
log.info(f"Creating language server instance for {self.project_root}, language={language}.")
return SolidLanguageServer.create(
ls_config,
ls_logger,
self.project_root,
timeout=ls_timeout,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
ls_specific_settings=ls_specific_settings or {},
),
)
return factory
def create_language_server_manager(
self,
log_level: int = logging.INFO,
ls_timeout: float | None = DEFAULT_TOOL_TIMEOUT - 5,
trace_lsp_communication: bool = False,
ls_specific_settings: dict[Language, Any] | None = None,
) -> SolidLanguageServer:
) -> LanguageServerManager:
"""
Create a language server for a project. Note that you will have to start it
before performing any LS operations.
@@ -365,23 +402,18 @@ class Project(ToStringMixin):
see docstrings in the inits of subclasses of SolidLanguageServer to see what values may be passed.
:return: the language server
"""
ls_config = LanguageServerConfig(
code_language=self.language,
ignored_paths=self._ignored_patterns,
trace_lsp_communication=trace_lsp_communication,
encoding=self.project_config.encoding,
)
ls_logger = LanguageServerLogger(log_level=log_level)
log.info(f"Creating language server instance for {self.project_root}.")
return SolidLanguageServer.create(
ls_config,
ls_logger,
self.project_root,
timeout=ls_timeout,
solidlsp_settings=SolidLSPSettings(
solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME,
project_data_relative_path=SERENA_MANAGED_DIR_NAME,
ls_specific_settings=ls_specific_settings or {},
),
)
log.info(f"Creating language server manager for {self.project_root}")
language_servers: dict[Language, SolidLanguageServer] = {}
language_server_factories: dict[Language, Callable[[], SolidLanguageServer]] = {}
for language in self.project_config.languages:
language_server_factory = self._create_language_server_factory(
language, ls_timeout, ls_specific_settings, log_level, trace_lsp_communication
)
language_server = language_server_factory()
log.info(f"Starting language server for {language} at root {self.project_root}")
language_server.start()
if not language_server.is_running():
raise RuntimeError(f"Failed to start the language server for {language} for root {self.project_root}")
language_servers[language] = language_server
language_server_factories[language] = language_server_factory
return LanguageServerManager(language_servers, language_server_factories)
+28 -21
View File
@@ -12,6 +12,7 @@ from solidlsp import SolidLanguageServer
from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol
from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation
from .ls_manager import LanguageServerManager
from .project import Project
if TYPE_CHECKING:
@@ -441,24 +442,25 @@ class ReferenceInLanguageServerSymbol(ToStringMixin):
class LanguageServerSymbolRetriever:
def __init__(self, lang_server: SolidLanguageServer, agent: Union["SerenaAgent", None] = None) -> None:
def __init__(self, ls: SolidLanguageServer | LanguageServerManager, agent: Union["SerenaAgent", None] = None) -> None:
"""
:param lang_server: the language server to use for symbol retrieval as well as editing operations.
:param ls: the language server or language server manager to use for symbol retrieval and editing operations.
:param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't
need an agent to be aware of file modifications performed by the symbol manager.
"""
self._lang_server = lang_server
if isinstance(ls, SolidLanguageServer):
ls_manager = LanguageServerManager({ls.language: ls})
else:
ls_manager = ls
assert isinstance(ls_manager, LanguageServerManager)
self._ls_manager: LanguageServerManager = ls_manager
self.agent = agent
def set_language_server(self, lang_server: SolidLanguageServer) -> None:
"""
Set the language server to use for symbol retrieval and editing operations.
This is useful if you want to change the language server after initializing the SymbolManager.
"""
self._lang_server = lang_server
def get_root_path(self) -> str:
return self._ls_manager.get_root_path()
def get_language_server(self) -> SolidLanguageServer:
return self._lang_server
def get_language_server(self, relative_path: str) -> SolidLanguageServer:
return self._ls_manager.get_language_server(relative_path)
def find_by_name(
self,
@@ -475,24 +477,27 @@ class LanguageServerSymbolRetriever:
to symbols within a specific file or directory.
"""
symbols: list[LanguageServerSymbol] = []
symbol_roots = self._lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body)
for root in symbol_roots:
symbols.extend(
LanguageServerSymbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
for lang_server in self._ls_manager.iter_language_servers():
symbol_roots = lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body)
for root in symbol_roots:
symbols.extend(
LanguageServerSymbol(root).find(
name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching
)
)
)
return symbols
def get_document_symbols(self, relative_path: str) -> list[LanguageServerSymbol]:
symbol_dicts, _roots = self._lang_server.request_document_symbols(relative_path, include_body=False)
lang_server = self.get_language_server(relative_path)
symbol_dicts, _roots = lang_server.request_document_symbols(relative_path, include_body=False)
symbols = [LanguageServerSymbol(s) for s in symbol_dicts]
return symbols
def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None:
if location.relative_path is None:
return None
symbol_dicts, _roots = self._lang_server.request_document_symbols(location.relative_path, include_body=False)
lang_server = self.get_language_server(location.relative_path)
symbol_dicts, _roots = lang_server.request_document_symbols(location.relative_path, include_body=False)
for symbol_dict in symbol_dicts:
symbol = LanguageServerSymbol(symbol_dict)
if symbol.location == location:
@@ -561,7 +566,8 @@ class LanguageServerSymbolRetriever:
assert symbol_location.relative_path is not None
assert symbol_location.line is not None
assert symbol_location.column is not None
references = self._lang_server.request_referencing_symbols(
lang_server = self.get_language_server(symbol_location.relative_path)
references = lang_server.request_referencing_symbols(
relative_file_path=symbol_location.relative_path,
line=symbol_location.line,
column=symbol_location.column,
@@ -589,7 +595,8 @@ class LanguageServerSymbolRetriever:
return cls(name_path=symbol.get_name_path(), kind=int(symbol.symbol_kind))
def get_symbol_overview(self, relative_path: str) -> dict[str, list[SymbolOverviewElement]]:
path_to_unified_symbols = self._lang_server.request_overview(relative_path)
lang_server = self.get_language_server(relative_path)
path_to_unified_symbols = lang_server.request_overview(relative_path)
result = {}
for file_path, unified_symbols in path_to_unified_symbols.items():
# TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables
+1 -1
View File
@@ -42,7 +42,7 @@ class RestartLanguageServerTool(Tool, ToolMarkerOptional):
"""Use this tool only on explicit user request or after confirmation.
It may be necessary to restart the language server if it hangs.
"""
self.agent.reset_language_server()
self.agent.reset_language_server_manager()
return SUCCESS_RESULT
+17 -11
View File
@@ -47,9 +47,9 @@ class Component(ABC):
def create_language_server_symbol_retriever(self) -> LanguageServerSymbolRetriever:
if not self.agent.is_using_language_server():
raise Exception("Cannot create LanguageServerSymbolRetriever; agent is not in language server mode.")
language_server = self.agent.language_server
assert language_server is not None
return LanguageServerSymbolRetriever(language_server, agent=self.agent)
language_server_manager = self.agent.language_server_manager
assert language_server_manager is not None
return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent)
@property
def project(self) -> Project:
@@ -244,18 +244,24 @@ class Tool(Component):
"Error: No active project. Ask the user to provide the project path or to select a project from this list of known projects: "
+ f"{self.agent.serena_config.project_names}"
)
if self.agent.is_using_language_server() and not self.agent.is_language_server_running():
log.info("Language server is not running. Starting it ...")
self.agent.reset_language_server()
# apply the actual tool
try:
result = apply_fn(**kwargs)
except SolidLSPException as e:
if e.is_language_server_terminated():
log.error(f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ...")
self.agent.reset_language_server()
result = apply_fn(**kwargs)
affected_language = e.get_affected_language()
if affected_language is not None and self.agent.language_server_manager is not None:
log.error(
f"Language server terminated while executing tool ({e}). Restarting the language server and retrying ..."
)
self.agent.language_server_manager.restart_language_server(affected_language)
result = apply_fn(**kwargs)
else:
log.error(
f"Language server terminated while executing tool ({e}), but affected language is unknown. Not retrying."
)
raise
else:
raise
@@ -273,8 +279,8 @@ class Tool(Component):
log.info(f"Result: {result}")
try:
if self.agent.language_server is not None:
self.agent.language_server.save_cache()
if self.agent.language_server_manager is not None:
self.agent.language_server_manager.save_all_caches()
except Exception as e:
log.error(f"Error saving language server cache: {e}")
+1
View File
@@ -214,6 +214,7 @@ class SolidLanguageServer(ABC):
)
self.server = SolidLanguageServerHandler(
process_launch_info,
language=self.language,
logger=logging_fn,
start_independent_lsp_process=config.start_independent_lsp_process,
)
+12
View File
@@ -2,6 +2,8 @@
This module contains the exceptions raised by the framework.
"""
from solidlsp.ls_config import Language
class SolidLSPException(Exception):
def __init__(self, message: str, cause: Exception | None = None):
@@ -26,6 +28,16 @@ class SolidLSPException(Exception):
return isinstance(self.cause, LanguageServerTerminatedException)
def get_affected_language(self) -> Language | None:
"""
:return: the affected language for the case where the exception is caused by the language server having terminated
"""
from .ls_handler import LanguageServerTerminatedException
if isinstance(self.cause, LanguageServerTerminatedException):
return self.cause.language
return None
def __str__(self) -> str:
"""
Returns a string representation of the exception.
+13 -7
View File
@@ -14,6 +14,7 @@ from typing import Any
import psutil
from sensai.util.string import ToStringMixin
from solidlsp.ls_config import Language
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.ls_request import LanguageServerRequest
from solidlsp.lsp_protocol_handler.lsp_requests import LspNotification
@@ -42,9 +43,10 @@ class LanguageServerTerminatedException(Exception):
Exception raised when the language server process has terminated unexpectedly.
"""
def __init__(self, message: str, cause: Exception | None = None) -> None:
def __init__(self, message: str, language: Language, cause: Exception | None = None) -> None:
super().__init__(message)
self.message = message
self.language = language
self.cause = cause
def __str__(self) -> str:
@@ -132,10 +134,12 @@ class SolidLanguageServerHandler:
def __init__(
self,
process_launch_info: ProcessLaunchInfo,
language: Language,
logger: Callable[[str, str, StringDict | str], None] | None = None,
start_independent_lsp_process=True,
request_timeout: float | None = None,
) -> None:
self.language = language
self.send = LanguageServerRequest(self)
self.notify = LspNotification(self.send_notification)
@@ -310,8 +314,7 @@ class SolidLanguageServerHandler:
if self.logger is not None:
self.logger("client", "logger", message)
@staticmethod
def _read_bytes_from_process(process, stream, num_bytes):
def _read_bytes_from_process(self, process, stream, num_bytes):
"""Read exactly num_bytes from process stdout"""
data = b""
while len(data) < num_bytes:
@@ -319,7 +322,8 @@ class SolidLanguageServerHandler:
if not chunk:
if process.poll() is not None:
raise LanguageServerTerminatedException(
f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)"
f"Process terminated while trying to read response (read {num_bytes} of {len(data)} bytes before termination)",
language=self.language,
)
# Process still running but no data available yet, retry after a short delay
time.sleep(0.01)
@@ -356,13 +360,15 @@ class SolidLanguageServerHandler:
except LanguageServerTerminatedException as e:
exception = e
except (BrokenPipeError, ConnectionResetError) as e:
exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", cause=e)
exception = LanguageServerTerminatedException("Language server process terminated while reading stdout", self.language, cause=e)
except Exception as e:
exception = LanguageServerTerminatedException("Unexpected error while reading stdout from language server process", cause=e)
exception = LanguageServerTerminatedException(
"Unexpected error while reading stdout from language server process", self.language, cause=e
)
log.info("Language server stdout reader thread has terminated")
if not self._is_shutting_down:
if exception is None:
exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly")
exception = LanguageServerTerminatedException("Language server stdout read process terminated unexpectedly", self.language)
log.error(str(exception))
self._cancel_pending_requests(exception)
+4 -4
View File
@@ -50,7 +50,7 @@ class TestProjectConfigAutogenerate:
# Verify the configuration
assert config.project_name == self.project_path.name
assert config.language == Language.PYTHON
assert config.languages == [Language.PYTHON]
def test_autogenerate_with_multiple_languages(self):
"""Test autogeneration picks dominant language when multiple are present."""
@@ -62,7 +62,7 @@ class TestProjectConfigAutogenerate:
# Run autogenerate - should pick Python as dominant
config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False)
assert config.language == Language.PYTHON
assert config.languages == [Language.PYTHON]
def test_autogenerate_saves_to_disk(self):
"""Test that autogenerate can save the configuration to disk."""
@@ -78,7 +78,7 @@ class TestProjectConfigAutogenerate:
assert config_path.exists()
# Verify the content
assert config.language == Language.GO
assert config.languages == [Language.GO]
def test_autogenerate_nonexistent_path(self):
"""Test that autogenerate raises FileNotFoundError for non-existent path."""
@@ -115,7 +115,7 @@ class TestProjectConfigAutogenerate:
config = ProjectConfig.autogenerate(self.project_path, project_name=custom_name, save_to_disk=False)
assert config.project_name == custom_name
assert config.language == Language.TYPESCRIPT
assert config.languages == [Language.TYPESCRIPT]
def test_autogenerate_error_message_format(self):
"""Test the specific format of the error message for better user experience."""
+2 -2
View File
@@ -37,7 +37,7 @@ def serena_config():
project_root=str(repo_path),
project_config=ProjectConfig(
project_name=project_name,
language=language,
languages=[language],
ignored_paths=[],
excluded_tools=set(),
read_only=False,
@@ -248,7 +248,7 @@ class TestSerenaAgent:
and expected_kind.lower() in s["kind"].lower()
and expected_file in s["relative_path"]
for s in symbols
), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project.language.name}. Symbols: {symbols}"
), f"Expected to find {name_path} ({expected_kind}) in {expected_file}. Symbols: {symbols}"
@pytest.mark.parametrize(
"serena_agent,name_path",
+1 -1
View File
@@ -205,7 +205,7 @@ class EditingTest(ABC):
log.info(f"Starting language server for {self.language} {self.rel_path}")
language_server.start()
log.info(f"Language server started for {self.language} {self.rel_path}")
yield LanguageServerSymbolRetriever(lang_server=language_server)
yield LanguageServerSymbolRetriever(ls=language_server)
finally:
if language_server is not None and language_server.is_running():
log.info(f"Stopping language server for {self.language} {self.rel_path}")