From 1cd05b4a2d27bd13671d39e0f57fe7249af5f804 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Fri, 27 Feb 2026 21:06:12 +0100 Subject: [PATCH 01/83] Add QueryProjectTool and ListQueryableProjectsTool These tools allow to query information from other projects (without activation) by allowing tools to be executed in the respective project context. LSP tools are not supported, as this would require language servers to be spawned and managed, but when using the JetBrains backend, symbolic access is fully supported for all projects, for which an IDE is open. This thus implements #1086 for users of the JetBrains language backend. The tools are optional. Mode 'query-projects' enables them. --- src/serena/agent.py | 11 +--- src/serena/code_editor.py | 27 +++------ src/serena/config/serena_config.py | 6 ++ src/serena/dashboard.py | 2 +- .../jetbrains/jetbrains_plugin_client.py | 50 ++++++++++++++++ .../resources/config/modes/query-projects.yml | 8 +++ src/serena/tools/__init__.py | 1 + src/serena/tools/query_project_tools.py | 60 +++++++++++++++++++ src/serena/tools/tools_base.py | 52 +++++++++++++--- test/serena/config/test_serena_config.py | 6 +- 10 files changed, 181 insertions(+), 42 deletions(-) create mode 100644 src/serena/resources/config/modes/query-projects.yml create mode 100644 src/serena/tools/query_project_tools.py diff --git a/src/serena/agent.py b/src/serena/agent.py index d48edbdf..7017518f 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -536,15 +536,6 @@ class SerenaAgent: ) return True - def get_project_root(self) -> str: - """ - :return: the root directory of the active project (if any); raises a ValueError if there is no active project - """ - project = self.get_active_project() - if project is None: - raise ValueError("Cannot get project root if no project is active.") - return project.project_root - def get_exposed_tool_instances(self) -> list["Tool"]: """ :return: the tool instances which are exposed (e.g. to the MCP client). @@ -711,7 +702,7 @@ class SerenaAgent: self.reset_language_server_manager() # initialize the language server in the background (if in language server mode) - if self.is_using_language_server(): + if self.get_language_backend().is_lsp(): self.issue_task(init_language_server_manager) if self._project_activation_callback is not None: diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index b5075c38..ea83da25 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -4,8 +4,9 @@ import os from abc import ABC, abstractmethod from collections.abc import Iterable, Iterator, Reversible from contextlib import contextmanager -from typing import TYPE_CHECKING, Generic, Optional, TypeVar, cast +from typing import Generic, TypeVar, cast +from serena.config.serena_config import ProjectConfig from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient from serena.symbol import JetBrainsSymbol, LanguageServerSymbol, LanguageServerSymbolRetriever, PositionInFile, Symbol from solidlsp import SolidLanguageServer, ls_types @@ -15,26 +16,16 @@ from solidlsp.ls_utils import PathUtils, TextUtils from .constants import DEFAULT_SOURCE_FILE_ENCODING from .project import Project -if TYPE_CHECKING: - from .agent import SerenaAgent - - log = logging.getLogger(__name__) TSymbol = TypeVar("TSymbol", bound=Symbol) class CodeEditor(Generic[TSymbol], ABC): - def __init__(self, project_root: str, agent: Optional["SerenaAgent"] = None) -> None: + def __init__(self, project_root: str, project_config: ProjectConfig | None = None) -> None: self.project_root = project_root - self.agent = agent - # set encoding based on active project, if available - encoding = DEFAULT_SOURCE_FILE_ENCODING - if agent is not None: - project = agent.get_active_project() - if project is not None: - encoding = project.project_config.encoding - self.encoding = encoding + # set encoding based on project configuration, if available + self.encoding = project_config.encoding if project_config is not None else DEFAULT_SOURCE_FILE_ENCODING class EditedFile(ABC): def __init__(self, relative_path: str) -> None: @@ -244,8 +235,8 @@ 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_root_path(), agent=agent) + def __init__(self, symbol_retriever: LanguageServerSymbolRetriever, project_config: ProjectConfig | None = None): + super().__init__(project_root=symbol_retriever.get_root_path(), project_config=project_config) self._symbol_retriever = symbol_retriever def _get_language_server(self, relative_path: str) -> SolidLanguageServer: @@ -379,9 +370,9 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): class JetBrainsCodeEditor(CodeEditor[JetBrainsSymbol]): - def __init__(self, project: Project, agent: Optional["SerenaAgent"] = None) -> None: + def __init__(self, project: Project) -> None: self._project = project - super().__init__(project_root=project.project_root, agent=agent) + super().__init__(project_root=project.project_root, project_config=project.project_config) class EditedFile(CodeEditor.EditedFile): def __init__(self, relative_path: str, project: Project): diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index b286c63b..a8679525 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -161,6 +161,12 @@ class LanguageBackend(Enum): return backend raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") + def is_lsp(self) -> bool: + return self == LanguageBackend.LSP + + def is_jetbrains(self) -> bool: + return self == LanguageBackend.JETBRAINS + @dataclass class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index a5c91a04..d835861f 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -507,7 +507,7 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, - jetbrains_mode=not self._agent.is_using_language_server(), + jetbrains_mode=self._agent.get_language_backend().is_jetbrains(), languages=languages, encoding=encoding, current_client=Tool.get_last_tool_call_client_str(), diff --git a/src/serena/jetbrains/jetbrains_plugin_client.py b/src/serena/jetbrains/jetbrains_plugin_client.py index f11dc080..f237a079 100644 --- a/src/serena/jetbrains/jetbrains_plugin_client.py +++ b/src/serena/jetbrains/jetbrains_plugin_client.py @@ -8,6 +8,7 @@ import logging import re import threading from concurrent.futures.thread import ThreadPoolExecutor +from dataclasses import dataclass from pathlib import Path from typing import Any, Literal, Optional, Self, TypeVar, cast @@ -16,6 +17,7 @@ from requests import Response from sensai.util.string import ToStringMixin import serena.jetbrains.jetbrains_types as jb +from serena.config.serena_config import RegisteredProject from serena.jetbrains.jetbrains_types import PluginStatusDTO from serena.project import Project from serena.util.class_decorators import singleton @@ -63,6 +65,12 @@ class ServerNotFoundError(Exception): """Raised when the plugin's service is not found.""" +@dataclass +class MatchedClient: + client: "JetBrainsPluginClient" + registered_project: RegisteredProject + + @singleton class JetBrainsPluginClientManager: """ @@ -73,6 +81,7 @@ class JetBrainsPluginClientManager: def __init__(self) -> None: self._clients: dict[int, "JetBrainsPluginClient"] = {} + self._matched_clients: list[MatchedClient] = [] self._lock = threading.Lock() def _submit_scan(self) -> list[concurrent.futures.Future["JetBrainsPluginClient"]]: @@ -116,6 +125,47 @@ class JetBrainsPluginClientManager: "with the Serena plugin installed and running!" ) + def match_clients(self, registered_projects: list[RegisteredProject]) -> list[MatchedClient]: + """ + Scans for plugin instances and matches them against the given registered projects. + + :param registered_projects: the list of registered projects to match plugin instances against + :return: the list of matched clients with their corresponding registered project + """ + matched_clients = [] + for future in self._submit_scan(): + client = future.result() + if client.project_root is not None: + for rp in registered_projects: + if client.matches(Path(rp.project_root)): + matched_clients.append(MatchedClient(client, rp)) + break + self._matched_clients = matched_clients + return matched_clients + + def get_matched_client( + self, registered_project: RegisteredProject, registered_projects: list[RegisteredProject] + ) -> Optional["JetBrainsPluginClient"]: + """ + Gets the matched client for a given registered project, if any. + + :param registered_project: the registered project to get the matched client for + :param registered_projects: the list of all registered projects (used to perform matching of all clients + if no match is found for the given project) + :return: the matched client or None if no match is found + """ + + def find_match() -> Optional["JetBrainsPluginClient"]: + for matched_client in self._matched_clients: + if matched_client.registered_project.project_root == registered_project.project_root: + return matched_client.client + return None + + match = find_match() + if match is None: + self.match_clients(registered_projects) + return find_match() + class JetBrainsPluginClient(ToStringMixin): """ diff --git a/src/serena/resources/config/modes/query-projects.yml b/src/serena/resources/config/modes/query-projects.yml new file mode 100644 index 00000000..ed140de9 --- /dev/null +++ b/src/serena/resources/config/modes/query-projects.yml @@ -0,0 +1,8 @@ +description: Enables tools that allow inactive projects to be queried +prompt: | + You can use the 'query_project' tool to query Serena projects without activating them. + Use this when a project is related to the active project and you need to query it for information. +excluded_tools: [] +included_optional_tools: + - list_queryable_projects + - query_project diff --git a/src/serena/tools/__init__.py b/src/serena/tools/__init__.py index e6fb4b08..08bb4338 100644 --- a/src/serena/tools/__init__.py +++ b/src/serena/tools/__init__.py @@ -7,3 +7,4 @@ from .cmd_tools import * from .config_tools import * from .workflow_tools import * from .jetbrains_tools import * +from .query_project_tools import * diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py new file mode 100644 index 00000000..c59cd420 --- /dev/null +++ b/src/serena/tools/query_project_tools.py @@ -0,0 +1,60 @@ +import json + +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager +from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional + + +class ListQueryableProjectsTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): + """ + Tool for listing all projects that can be queried by the QueryProjectTool. + """ + + def apply(self, symbol_access: bool = True) -> str: + """ + Lists available projects that can be queried with `query_project_tool`. + + :param symbol_access: whether to return only projects for which symbol access is available. Default: true + """ + # determine relevant projects + registered_projects = self.agent.serena_config.projects + if symbol_access: + backend = self.agent.get_language_backend() + if backend.is_jetbrains(): + matched_clients = JetBrainsPluginClientManager().match_clients(registered_projects) + relevant_projects = [mc.registered_project for mc in matched_clients] + else: + raise NotImplementedError(f"External symbol access is generally unavailable for language backend {backend.value}") + else: + relevant_projects = registered_projects + + # return project names, excluding the active project (if any) + project_names = [p.project_name for p in relevant_projects] + active_project = self.agent.get_active_project() + if active_project is not None: + project_names = [n for n in project_names if n != active_project.project_name] + return self._to_json(project_names) + + +class QueryProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): + """ + Tool for querying external project information (i.e. information from projects other than the current one), + by executing a read-only tool. + """ + + def apply(self, project_name: str, tool_name: str, tool_params_json: str) -> str: + """ + Queries a project by executing a read-only Serena tool. The tool will be executed in the context of the project. + Use this to query information from projects other than the activated project. + + :param project_name: the name of the project to query + :param tool_name: the name of the tool to execute in the other project. The tool must be read-only. + :param tool_params_json: the parameters to pass to the tool, encoded as a JSON string + """ + tool = self.agent.get_tool_by_name(tool_name) + assert tool.is_active(), f"Tool {tool_name} is not active." + assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project." + registered_project = self.agent.serena_config.get_registered_project(project_name) + assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried." + project = registered_project.get_project_instance(self.agent.serena_config) + with tool.project_override_context(project): + return tool.apply(**json.loads(tool_params_json)) # type: ignore diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 9fb1a393..426529aa 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -1,7 +1,8 @@ import inspect import json from abc import ABC -from collections.abc import Iterable +from collections.abc import Iterable, Iterator +from contextlib import contextmanager from dataclasses import dataclass from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast @@ -12,6 +13,7 @@ from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metada from sensai.util import logging from sensai.util.string import dict_string +from serena.config.serena_config import LanguageBackend from serena.project import MemoriesManager, Project from serena.prompt_factory import PromptFactory from serena.util.class_decorators import singleton @@ -31,12 +33,33 @@ SUCCESS_RESULT = "OK" class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent + self._project_override: Project | None = None + + @contextmanager + def project_override_context(self, project: Project) -> Iterator[None]: + """ + Context manager for temporarily overriding the active project in a component, + allowing tool executions to use the overridden project instead of the active project. + + :param project: the project to override with + """ + if self._project_override is not None: + raise RuntimeError("Nested project overrides are not supported.") + self._project_override = project + try: + yield + finally: + self._project_override = None + + def _assert_is_not_project_override(self) -> None: + if self._project_override is not None: + raise RuntimeError("This method is only supported for the active project.") def get_project_root(self) -> str: """ :return: the root directory of the active project, raises a ValueError if no active project configuration is set """ - return self.agent.get_project_root() + return self.project.project_root @property def prompt_factory(self) -> PromptFactory: @@ -49,22 +72,28 @@ class Component(ABC): def create_language_server_symbol_retriever(self) -> "LanguageServerSymbolRetriever": from serena.symbol import LanguageServerSymbolRetriever - if not self.agent.is_using_language_server(): - raise Exception("Cannot create LanguageServerSymbolRetriever; agent is not in language server mode.") + self._assert_is_not_project_override() + assert self.agent.get_language_backend().is_lsp(), "Language server symbol retriever can only be created for LSP language backend" language_server_manager = self.agent.get_language_server_manager_or_raise() return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent) @property def project(self) -> Project: - return self.agent.get_active_project_or_raise() + if self._project_override is not None: + return self._project_override + else: + return self.agent.get_active_project_or_raise() def create_code_editor(self) -> "CodeEditor": from ..code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor - if self.agent.is_using_language_server(): - return LanguageServerCodeEditor(self.create_language_server_symbol_retriever(), agent=self.agent) - else: - return JetBrainsCodeEditor(project=self.project, agent=self.agent) + match self.agent.get_language_backend(): + case LanguageBackend.LSP: + return LanguageServerCodeEditor(self.create_language_server_symbol_retriever(), project_config=self.project.project_config) + case LanguageBackend.JETBRAINS: + return JetBrainsCodeEditor(project=self.project) + case _: + raise ValueError class ToolMarker: @@ -234,6 +263,9 @@ class Tool(Component): def is_active(self) -> bool: return self.agent.tool_is_active(self.get_name()) + def is_readonly(self) -> bool: + return not self.can_edit() + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments @@ -392,6 +424,8 @@ class ToolRegistry: self._tool_dict[name] = RegisteredTool(tool_class=cls, is_optional=is_optional, tool_name=name) def get_tool_class_by_name(self, tool_name: str) -> type[Tool]: + if tool_name not in self._tool_dict: + raise ValueError(f"Tool named '{tool_name}' not found.") return self._tool_dict[tool_name].tool_class def get_all_tool_classes(self) -> list[type[Tool]]: diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index e4515a0d..87e295b7 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -217,8 +217,7 @@ class TestEffectiveLanguageBackend: config, name = _make_config_with_project("test_proj", language_backend=None, global_backend=LanguageBackend.LSP) agent = SerenaAgent(project=name, serena_config=config) try: - assert agent.get_language_backend() == LanguageBackend.LSP - assert agent.is_using_language_server() is True + assert agent.get_language_backend().is_lsp() finally: agent.shutdown(timeout=5) @@ -229,8 +228,7 @@ class TestEffectiveLanguageBackend: ) agent = SerenaAgent(project=name, serena_config=config) try: - assert agent.get_language_backend() == LanguageBackend.JETBRAINS - assert agent.is_using_language_server() is False + assert agent.get_language_backend().is_jetbrains() finally: agent.shutdown(timeout=5) From 59f344cc00461bf2f3627496e3b5d963ab5a4401 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Feb 2026 12:59:05 +0100 Subject: [PATCH 02/83] Add project system prompt --- .serena/project.yml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.serena/project.yml b/.serena/project.yml index f416c01c..a9218e45 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -90,7 +90,22 @@ included_optional_tools: [] # This cannot be combined with non-empty excluded_tools or included_optional_tools. # initial prompt for the project. It will always be given to the LLM upon activating the project # (contrary to the memories, which are loaded on demand). -initial_prompt: "" +initial_prompt: | + IMPORTANT: You use an idiomatic, object-oriented style. + In particular, this implies that, for any non-trivial interfaces, you use interfaces that expect explicitly typed abstractions + rather than mere functions (i.e. use the strategy pattern, for example). + You avoid the use of low-level data structures in all cases where an object-oriented abstraction would be more appropriate. + For simple data storage, you use dataclasses instead of dictionaries or tuples. + + You structure function implementations into functional blocks that are separated by blank lines. + Atop each functional block, you write an elliptical phrase (starting with lower-case letter) that describes the purpose of the + block in a concise manner. + + Docstrings: You consistently use reStructuredText. + + Comments: + When describing parameters, methods/functions and classes, you use a precise style, where the initial (elliptical) phrase + clearly defines *what* it is. Any details then follow in subsequent sentences. # the encoding used by text files in the project # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings From 1af824c025c47c398105eb6f31aa6a4c43c5496f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Feb 2026 13:36:17 +0100 Subject: [PATCH 03/83] Project: Make SerenaConfig compulsory, simplifying LanguageServerManager creation --- src/serena/agent.py | 16 +------- src/serena/cli.py | 4 +- src/serena/config/serena_config.py | 2 +- src/serena/project.py | 37 +++++++++---------- test/conftest.py | 5 ++- .../config/test_global_ignored_paths.py | 22 ++--------- test/serena/config/test_serena_config.py | 16 +++++--- test/serena/test_serena_agent.py | 7 ++-- 8 files changed, 41 insertions(+), 68 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 7017518f..36ac3fc9 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -799,21 +799,7 @@ class SerenaAgent: """ Starts/resets the language server manager for the current project """ - tool_timeout = self.serena_config.tool_timeout - if tool_timeout is None or tool_timeout < 0: - ls_timeout = None - else: - if tool_timeout < 10: - raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds") - ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout - - # instantiate and start the necessary language servers - self.get_active_project_or_raise().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, - ) + self.get_active_project_or_raise().create_language_server_manager() def add_language(self, language: Language) -> None: """ diff --git a/src/serena/cli.py b/src/serena/cli.py index 711e8e9a..0c77310b 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -640,9 +640,7 @@ class ProjectCommands(AutoRegisteringGroup): serena_config = SerenaConfig.from_config_file() proj = registered_project.get_project_instance(serena_config=serena_config) click.echo(f"Indexing symbols in {proj} …") - ls_mgr = proj.create_language_server_manager( - log_level=lvl, ls_timeout=timeout, ls_specific_settings=serena_config.ls_specific_settings - ) + ls_mgr = proj.create_language_server_manager() try: log_file = os.path.join(proj.project_root, ".serena", "logs", "indexing.txt") diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index a8679525..30a47c30 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -488,7 +488,7 @@ class RegisteredProject(ToStringMixin): """ return self.project_root.samefile(Path(path).resolve()) - def get_project_instance(self, serena_config: "SerenaConfig | None") -> "Project": + def get_project_instance(self, serena_config: "SerenaConfig") -> "Project": """ Returns the project instance for this registered project, loading it if necessary. """ diff --git a/src/serena/project.py b/src/serena/project.py index 59b89ec4..59cb8098 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -10,7 +10,6 @@ from sensai.util.logging import LogTime from sensai.util.string import ToStringMixin from serena.config.serena_config import ( - DEFAULT_TOOL_TIMEOUT, ProjectConfig, get_serena_managed_in_project_dir, ) @@ -117,13 +116,16 @@ class MemoriesManager: class Project(ToStringMixin): def __init__( self, + *, project_root: str, project_config: ProjectConfig, + serena_config: "SerenaConfig", is_newly_created: bool = False, - serena_config: "SerenaConfig | None" = None, ): + assert serena_config is not None self.project_root = project_root self.project_config = project_config + self._serena_config = serena_config self.memories_manager = MemoriesManager(project_root) self.language_server_manager: LanguageServerManager | None = None self._is_newly_created = is_newly_created @@ -137,7 +139,6 @@ class Project(ToStringMixin): f.write(f"/{SolidLanguageServer.CACHE_FOLDER_NAME}\n") # prepare ignore spec asynchronously, ensuring immediate project activation. - self._serena_config = serena_config self.__ignored_patterns: list[str] self.__ignore_spec: pathspec.PathSpec self._ignore_spec_available = threading.Event() @@ -147,7 +148,7 @@ class Project(ToStringMixin): with LogTime(f"Gathering ignore spec for project {self.project_config.project_name}", logger=log): # gather ignored paths from the global configuration, project configuration, and gitignore files - global_ignored_paths = self._serena_config.ignored_paths if self._serena_config else [] + global_ignored_paths = self._serena_config.ignored_paths ignored_patterns = list(global_ignored_paths) + list(self.project_config.ignored_paths) if len(global_ignored_paths) > 0: log.info(f"Using {len(global_ignored_paths)} ignored paths from the global configuration.") @@ -189,7 +190,7 @@ class Project(ToStringMixin): def load( cls, project_root: str | Path, - serena_config: "SerenaConfig | None", + serena_config: "SerenaConfig", autogenerate: bool = True, ) -> "Project": project_root = Path(project_root).resolve() @@ -464,23 +465,21 @@ class Project(ToStringMixin): source_file_path=relative_file_path, ) - 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, - ) -> LanguageServerManager: + def create_language_server_manager(self) -> LanguageServerManager: """ Creates the language server manager for the project, starting one language server per configured programming language. - :param log_level: the log level for the language server - :param ls_timeout: the timeout for the language server - :param trace_lsp_communication: whether to trace LSP communication - :param ls_specific_settings: optional LS specific configuration of the language server, - see docstrings in the inits of subclasses of SolidLanguageServer to see what values may be passed. :return: the language server manager, which is also stored in the project instance """ + # determine timeout to use for LS calls + tool_timeout = self._serena_config.tool_timeout + if tool_timeout is None or tool_timeout < 0: + ls_timeout = None + else: + if tool_timeout < 10: + raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds") + ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout + # if there is an existing instance, stop its language servers first if self.language_server_manager is not None: log.info("Stopping existing language server manager ...") @@ -493,8 +492,8 @@ class Project(ToStringMixin): encoding=self.project_config.encoding, ignored_patterns=self._ignored_patterns, ls_timeout=ls_timeout, - ls_specific_settings=ls_specific_settings, - trace_lsp_communication=trace_lsp_communication, + ls_specific_settings=self._serena_config.ls_specific_settings, + trace_lsp_communication=self._serena_config.trace_lsp_communication, ) self.language_server_manager = LanguageServerManager.from_languages(self.project_config.languages, factory) return self.language_server_manager diff --git a/test/conftest.py b/test/conftest.py index 565d40b7..6a93ff30 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -10,7 +10,7 @@ from typing import Any import pytest from sensai.util.logging import configure -from serena.config.serena_config import SerenaPaths +from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_MANAGED_DIR_NAME from serena.project import Project from serena.util.file_system import GitignoreParser @@ -117,7 +117,8 @@ def start_default_ls_context(language: Language) -> Iterator[SolidLanguageServer def _create_default_project(language: Language) -> Project: repo_path = str(get_repo_path(language)) - return Project.load(repo_path, serena_config=None) + serena_config = SerenaConfig() + return Project.load(repo_path, serena_config=serena_config) @pytest.fixture(scope="session") diff --git a/test/serena/config/test_global_ignored_paths.py b/test/serena/config/test_global_ignored_paths.py index 23481373..b804fe8b 100644 --- a/test/serena/config/test_global_ignored_paths.py +++ b/test/serena/config/test_global_ignored_paths.py @@ -20,9 +20,7 @@ def _create_test_project( ignored_paths=project_ignored_paths or [], ignore_all_files_in_gitignore=False, ) - serena_config: SerenaConfig | None = None - if global_ignored_paths: - serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=global_ignored_paths) return Project( project_root=str(project_root), project_config=config, @@ -84,21 +82,6 @@ class TestGlobalIgnoredPaths: # Non-ignored files still accessible assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) - def test_default_global_ignored_paths_backward_compatible(self) -> None: - """Project created without global_ignored_paths parameter works as before.""" - config = ProjectConfig( - project_name="test_project", - languages=[Language.PYTHON], - ignored_paths=["build"], - ignore_all_files_in_gitignore=False, - ) - project = Project( - project_root=str(self.project_path), - project_config=config, - ) - assert project.is_ignored_path(str(self.project_path / "build" / "output.js")) - assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg" / "index.js")) - def test_duplicate_patterns_across_global_and_project(self) -> None: """Duplicate patterns across global and project do not cause errors.""" project = _create_test_project( @@ -161,7 +144,8 @@ class TestRegisteredProjectGlobalIgnoredPaths: project_root=str(self.project_path), project_config=config, ) - project = registered.get_project_instance(serena_config=None) + serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False, ignored_paths=[]) + project = registered.get_project_instance(serena_config=serena_config) assert not project.is_ignored_path(str(self.project_path / "node_modules" / "pkg.js")) def test_from_project_root_passes_global_ignored_paths(self) -> None: diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 87e295b7..63f5b30f 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -191,6 +191,12 @@ def _make_config_with_project( global_backend: LanguageBackend = LanguageBackend.LSP, ) -> tuple[SerenaConfig, str]: """Create a SerenaConfig with a single registered project and return (config, project_name).""" + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + log_level=logging.ERROR, + language_backend=global_backend, + ) project = Project( project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), project_config=ProjectConfig( @@ -198,12 +204,7 @@ def _make_config_with_project( languages=[Language.PYTHON], language_backend=language_backend, ), - ) - config = SerenaConfig( - gui_log_window=False, - web_dashboard=False, - log_level=logging.ERROR, - language_backend=global_backend, + serena_config=config, ) config.projects = [RegisteredProject.from_project_instance(project)] return config, project_name @@ -259,6 +260,7 @@ class TestEffectiveLanguageBackend: languages=[Language.PYTHON], language_backend=LanguageBackend.JETBRAINS, ), + serena_config=config, ) config.projects.append(RegisteredProject.from_project_instance(jb_project)) @@ -281,6 +283,7 @@ class TestEffectiveLanguageBackend: languages=[Language.PYTHON], language_backend=LanguageBackend.LSP, ), + serena_config=config, ) config.projects.append(RegisteredProject.from_project_instance(lsp_project2)) @@ -303,6 +306,7 @@ class TestEffectiveLanguageBackend: languages=[Language.PYTHON], language_backend=None, ), + serena_config=config, ) config.projects.append(RegisteredProject.from_project_instance(proj2)) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 8d8a5193..a0f978a6 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -21,7 +21,8 @@ from test.solidlsp import clojure as clj @pytest.fixture def serena_config(): - """Create an in-memory configuration for tests with test repositories pre-registered.""" + config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) + # Create test projects for all supported languages test_projects = [] for language in [ @@ -47,16 +48,16 @@ def serena_config(): project_name=project_name, languages=[language], ignored_paths=[], - excluded_tools=set(), + excluded_tools=[], read_only=False, ignore_all_files_in_gitignore=True, initial_prompt="", encoding="utf-8", ), + serena_config=config, ) test_projects.append(RegisteredProject.from_project_instance(project)) - config = SerenaConfig(gui_log_window=False, web_dashboard=False, log_level=logging.ERROR) config.projects = test_projects return config From 0f6471d2a851c8b37e29ca34f44d47e3182fb13b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Feb 2026 13:38:38 +0100 Subject: [PATCH 04/83] Introduce ProjectServer to support QueryProjectTool for the LSP backend The server allows arbitrary tools to be called remotely, spawning language servers dynamically for each requested project. This implements #1086 for LSP users. --- src/serena/agent.py | 17 ++- src/serena/cli.py | 55 +++++++++ src/serena/project_server.py | 157 ++++++++++++++++++++++++ src/serena/tools/query_project_tools.py | 36 ++++-- src/serena/tools/tools_base.py | 33 +---- 5 files changed, 261 insertions(+), 37 deletions(-) create mode 100644 src/serena/project_server.py diff --git a/src/serena/agent.py b/src/serena/agent.py index 36ac3fc9..5b779ed4 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -6,7 +6,8 @@ import os import platform import subprocess import sys -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Sequence +from contextlib import contextmanager from logging import Logger from typing import TYPE_CHECKING, Optional, TypeVar @@ -853,3 +854,17 @@ class SerenaAgent: if ls_manager is None: return [] return ls_manager.get_active_languages() + + @contextmanager + def active_project_context(self, project: Project) -> Iterator[None]: + """ + Context manager for temporarily setting/overriding the active project + + :param project: the project to be active + """ + original_project = self._active_project + self._active_project = project + try: + yield + finally: + self._active_project = original_project diff --git a/src/serena/cli.py b/src/serena/cli.py index 0c77310b..9fdfc728 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -350,6 +350,61 @@ class TopLevelCommands(AutoRegisteringGroup): else: print(f"{prefix}\n{instr}\n{postfix}") + @staticmethod + @click.command( + "start-project-server", + help="Starts the Serena project server, which exposes project querying capabilities via HTTP.", + context_settings={"max_content_width": _MAX_CONTENT_WIDTH}, + ) + @click.option( + "--host", + type=str, + default="127.0.0.1", + show_default=True, + help="Listen address for the project server.", + ) + @click.option( + "--port", + type=int, + default=None, + help="Listen port for the project server (default: ProjectServer.PORT).", + ) + @click.option( + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default=None, + help="Override log level in config.", + ) + def start_project_server( + host: str, + port: int | None, + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, + ) -> None: + from serena.project_server import ProjectServer + + # initialize logging + Logger.root.setLevel(logging.INFO) + formatter = logging.Formatter(SERENA_LOG_FORMAT) + stderr_handler = logging.StreamHandler(stream=sys.stderr) + stderr_handler.formatter = formatter + Logger.root.addHandler(stderr_handler) + log_path = SerenaPaths().get_next_log_file_path("project-server") + file_handler = logging.FileHandler(log_path, mode="w") + file_handler.formatter = formatter + Logger.root.addHandler(file_handler) + + if log_level is not None: + Logger.root.setLevel(logging.getLevelNamesMapping()[log_level]) + + log.info("Starting Serena project server") + log.info("Storing logs in %s", log_path) + + server = ProjectServer() + run_kwargs: dict[str, Any] = {"host": host} + if port is not None: + run_kwargs["port"] = port + server.run(**run_kwargs) + class ModeCommands(AutoRegisteringGroup): """Group for 'mode' subcommands.""" diff --git a/src/serena/project_server.py b/src/serena/project_server.py new file mode 100644 index 00000000..ef88e883 --- /dev/null +++ b/src/serena/project_server.py @@ -0,0 +1,157 @@ +import json +import logging +from typing import TYPE_CHECKING + +import requests as requests_lib +from flask import Flask, request +from pydantic import BaseModel +from sensai.util.logging import LogTime + +from serena.config.serena_config import LanguageBackend, SerenaConfig +from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient + +if TYPE_CHECKING: + from serena.project import Project + +log = logging.getLogger(__name__) + +# disable Werkzeug's logging to avoid cluttering the output +logging.getLogger("werkzeug").setLevel(logging.WARNING) + + +class QueryProjectRequest(BaseModel): + """ + Request model for the /query_project endpoint, matching the interface of + :class:`~serena.tools.query_project_tools.QueryProjectTool`. + """ + + project_name: str + tool_name: str + tool_params_json: str + + +class ProjectServer: + """ + A lightweight Flask server that exposes a SerenaAgent's project querying + capabilities via HTTP, using the LSP language server backend for symbolic retrieval. + + Projects are loaded on demand when a query is made for them, and cached in memory for subsequent queries. + + The server instantiates a :class:`SerenaAgent` with default options and + provides a ``/query_project`` endpoint whose interface matches + :class:`~serena.tools.query_project_tools.QueryProjectTool`. + """ + + PORT = JetBrainsPluginClient.BASE_PORT - 1 + + def __init__(self) -> None: + from serena.agent import SerenaAgent + + serena_config = SerenaConfig.from_config_file() + serena_config.gui_log_window = False + serena_config.web_dashboard = False + serena_config.language_backend = LanguageBackend.LSP + + self._agent = SerenaAgent(serena_config=serena_config) + self._loaded_projects_by_name: dict[str, "Project"] = {} + + # create the Flask application + self._app = Flask(__name__) + self._setup_routes() + + def _setup_routes(self) -> None: + @self._app.route("/heartbeat", methods=["GET"]) + def heartbeat() -> dict[str, str]: + return {"status": "alive"} + + @self._app.route("/query_project", methods=["POST"]) + def query_project() -> str: + query_request = QueryProjectRequest.model_validate(request.get_json()) + return self._query_project(query_request) + + def _get_project(self, project_name: str) -> "Project": + """Gets the project with the given name, loading it if necessary.""" + if project_name in self._loaded_projects_by_name: + return self._loaded_projects_by_name[project_name] + else: + serena_config = self._agent.serena_config + registered_project = serena_config.get_registered_project(project_name) + if registered_project is None: + raise ValueError(f"Project '{project_name}' is not registered with Serena.") + with LogTime(f"Loading project '{project_name}'"): + project = registered_project.get_project_instance(serena_config) + project.create_language_server_manager() + self._loaded_projects_by_name[project_name] = project + return project + + def _query_project(self, req: QueryProjectRequest) -> str: + """Handle a /query_project request by invoking the agent on the specified project and tool.""" + project = self._get_project(req.project_name) + with self._agent.active_project_context(project): + tool = self._agent.get_tool_by_name(req.tool_name) + params = json.loads(req.tool_params_json) + return tool.apply_ex(**params) + + def run(self, host: str = "127.0.0.1", port: int = PORT) -> int: + """Run the server on the given host and port. + + :param host: the host address to listen on. + :param port: the port to listen on. + :return: the port number the server is running on. + """ + from flask import cli + + # suppress the default Flask startup banner + cli.show_server_banner = lambda *args, **kwargs: None + + self._app.run(host=host, port=port, debug=False, use_reloader=False, threaded=True) + return port + + +class ProjectServerClient: + """Client for interacting with a running :class:`ProjectServer`. + + Upon instantiation, the client verifies that the server is reachable + by sending a heartbeat request. If the server is not running, a + :class:`ConnectionError` is raised. + """ + + def __init__(self, host: str = "127.0.0.1", port: int = ProjectServer.PORT, timeout: int = 300) -> None: + """ + :param host: the host address of the project server. + :param port: the port of the project server. + :raises ConnectionError: if the project server is not reachable. + """ + self._base_url = f"http://{host}:{port}" + self._timeout = timeout + + # verify that the server is running + try: + response = requests_lib.get(f"{self._base_url}/heartbeat", timeout=5) + response.raise_for_status() + except requests_lib.ConnectionError: + raise ConnectionError(f"ProjectServer is not reachable at {self._base_url}. Make sure the server is running.") + except requests_lib.RequestException as e: + raise ConnectionError(f"ProjectServer health check failed: {e}") + + def query_project(self, project_name: str, tool_name: str, tool_params_json: str) -> str: + """ + Query a project by executing a Serena tool in its context. + + The interface matches :meth:`QueryProjectTool.apply + `. + + :param project_name: the name of the project to query. + :param tool_name: the name of the tool to execute. The tool must be read-only. + :param tool_params_json: the parameters to pass to the tool, encoded as a JSON string. + :return: the tool's result as a string. + """ + payload = QueryProjectRequest( + project_name=project_name, + tool_name=tool_name, + tool_params_json=tool_params_json, + ).model_dump() + + response = requests_lib.post(f"{self._base_url}/query_project", json=payload, timeout=self._timeout) + response.raise_for_status() + return response.text diff --git a/src/serena/tools/query_project_tools.py b/src/serena/tools/query_project_tools.py index c59cd420..b675a0aa 100644 --- a/src/serena/tools/query_project_tools.py +++ b/src/serena/tools/query_project_tools.py @@ -1,10 +1,12 @@ import json +from serena.config.serena_config import LanguageBackend from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClientManager +from serena.project_server import ProjectServerClient from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional -class ListQueryableProjectsTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): +class ListQueryableProjectsTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): """ Tool for listing all projects that can be queried by the QueryProjectTool. """ @@ -20,10 +22,12 @@ class ListQueryableProjectsTool(Tool, ToolMarkerDoesNotRequireActiveProject, Too if symbol_access: backend = self.agent.get_language_backend() if backend.is_jetbrains(): + # projects with open IDE instances can be queried matched_clients = JetBrainsPluginClientManager().match_clients(registered_projects) relevant_projects = [mc.registered_project for mc in matched_clients] else: - raise NotImplementedError(f"External symbol access is generally unavailable for language backend {backend.value}") + # all projects can be queried via ProjectServer (which instantiates projects dynamically) + relevant_projects = registered_projects else: relevant_projects = registered_projects @@ -35,7 +39,7 @@ class ListQueryableProjectsTool(Tool, ToolMarkerDoesNotRequireActiveProject, Too return self._to_json(project_names) -class QueryProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOptional): +class QueryProjectTool(Tool, ToolMarkerOptional, ToolMarkerDoesNotRequireActiveProject): """ Tool for querying external project information (i.e. information from projects other than the current one), by executing a read-only tool. @@ -53,8 +57,24 @@ class QueryProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject, ToolMarkerOp tool = self.agent.get_tool_by_name(tool_name) assert tool.is_active(), f"Tool {tool_name} is not active." assert tool.is_readonly(), f"Tool {tool_name} is not read-only and cannot be executed in another project." - registered_project = self.agent.serena_config.get_registered_project(project_name) - assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried." - project = registered_project.get_project_instance(self.agent.serena_config) - with tool.project_override_context(project): - return tool.apply(**json.loads(tool_params_json)) # type: ignore + if self._is_project_server_required(tool): + client = ProjectServerClient() + return client.query_project(project_name, tool_name, tool_params_json) + else: + registered_project = self.agent.serena_config.get_registered_project(project_name) + assert registered_project is not None, f"Project {project_name} is not registered and cannot be queried." + project = registered_project.get_project_instance(self.agent.serena_config) + with tool.agent.active_project_context(project): + return tool.apply(**json.loads(tool_params_json)) # type: ignore + + def _is_project_server_required(self, tool: Tool) -> bool: + match self.agent.get_language_backend(): + case LanguageBackend.JETBRAINS: + return False + case LanguageBackend.LSP: + # Note: As long as only read-only tools are considered, only symbolic tools require the project server. + # But if we were to allow non-read-only tools, then tools using a CodeEditor also indirectly require language servers. + assert tool.is_readonly() + return tool.is_symbolic() + case _: + raise NotImplementedError diff --git a/src/serena/tools/tools_base.py b/src/serena/tools/tools_base.py index 426529aa..556321d9 100644 --- a/src/serena/tools/tools_base.py +++ b/src/serena/tools/tools_base.py @@ -1,8 +1,7 @@ import inspect import json from abc import ABC -from collections.abc import Iterable, Iterator -from contextlib import contextmanager +from collections.abc import Iterable from dataclasses import dataclass from types import TracebackType from typing import TYPE_CHECKING, Any, Protocol, Self, TypeVar, cast @@ -33,27 +32,6 @@ SUCCESS_RESULT = "OK" class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent - self._project_override: Project | None = None - - @contextmanager - def project_override_context(self, project: Project) -> Iterator[None]: - """ - Context manager for temporarily overriding the active project in a component, - allowing tool executions to use the overridden project instead of the active project. - - :param project: the project to override with - """ - if self._project_override is not None: - raise RuntimeError("Nested project overrides are not supported.") - self._project_override = project - try: - yield - finally: - self._project_override = None - - def _assert_is_not_project_override(self) -> None: - if self._project_override is not None: - raise RuntimeError("This method is only supported for the active project.") def get_project_root(self) -> str: """ @@ -72,17 +50,13 @@ class Component(ABC): def create_language_server_symbol_retriever(self) -> "LanguageServerSymbolRetriever": from serena.symbol import LanguageServerSymbolRetriever - self._assert_is_not_project_override() assert self.agent.get_language_backend().is_lsp(), "Language server symbol retriever can only be created for LSP language backend" language_server_manager = self.agent.get_language_server_manager_or_raise() return LanguageServerSymbolRetriever(language_server_manager, agent=self.agent) @property def project(self) -> Project: - if self._project_override is not None: - return self._project_override - else: - return self.agent.get_active_project_or_raise() + return self.agent.get_active_project_or_raise() def create_code_editor(self) -> "CodeEditor": from ..code_editor import JetBrainsCodeEditor, LanguageServerCodeEditor @@ -266,6 +240,9 @@ class Tool(Component): def is_readonly(self) -> bool: return not self.can_edit() + def is_symbolic(self) -> bool: + return issubclass(self.__class__, ToolMarkerSymbolicRead) or issubclass(self.__class__, ToolMarkerSymbolicEdit) + def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, mcp_ctx: Context | None = None, **kwargs) -> str: # type: ignore """ Applies the tool with logging and exception handling, using the given keyword arguments From 918a90280e9fc2e4e915432d23589b1259a1c185 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 28 Feb 2026 21:12:37 +0100 Subject: [PATCH 05/83] Add information on project querying to the documentation & changelog --- CHANGELOG.md | 4 +++ docs/02-usage/040_workflow.md | 42 ++++++++++++++++++++++++++++-- docs/02-usage/050_configuration.md | 3 ++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d94210..dad0e4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ Status of the `main` branch. Changes prior to the next official version change w * Project configuration files (`project.yml`) can now define multiple languages. Auto-detection adds only the most prominent language by default. * Additional languages can be conveniently added via the Dashboard while a project is already activated. + * Add support for querying projects other than the currently active one via new tools `QueryProjectTool` and `ListQueryableProjectsTool`. + The `QueryProjectTool` allows Serena tools to be called on other projects. + * For the LSP backend, calling symbolic tools require a project server to be spawned that will launch the respective language servers + * For the JetBrains backend, all projects for which IDE instances are open can directly be queried * The current project can be activated automatically even when the MCP configuration is global (through the --project-from-cwd flag) * Support overloaded symbols in `FindSymbolTool` and related tools * Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`) diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index c5153401..e52560ea 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -36,7 +36,7 @@ For instance, when using `uvx`, run * For an existing project, the main programming language will be detected automatically, but you can choose to explicitly specify multiple languages by passing the `--language` parameter multiple times (e.g. `--language python --language typescript`). - * You can optionally specify a custom project name with `--name "My Project"`. + * You can optionally specify a custom project name with `--name my-name`. * You can immediately index the project after creation with `--index`. (project-config)= @@ -163,4 +163,42 @@ linting results, and test results to assess the correctness of its actions. Therefore, software that is designed to meaningful interpretable outputs (e.g. log messages) and that has a good test coverage is much easier to work with for Serena. -We generally recommend to start an editing task from a state where all linting checks and tests pass. \ No newline at end of file +We generally recommend to start an editing task from a state where all linting checks and tests pass. + +## Working with Multiple Projects Simultaneously + +There are several ways in which you might want to work with multiple projects simultaneously. + +### Simultaneously Editing in Multiple Projects + +If fulfilling a task requires the agent to edit code in multiple projects, the recommended approach is to create a **monorepo folder**, +i.e. a folder that contains all the projects as sub-folders, and open that monorepo folder as a project in Serena. +You may also use symbolic links to create a monorepo folder if the projects are located in different places on your filesystem. + +If several languages are used across the projects, specify all of them as needed when using the LSP backend; +For JetBrains mode, make sure that your IDE is configured to work with all the languages used across the projects (e.g. by installing the respective language plugins). + +(query-projects)= +### Reading from External Projects + +If, while working on a project, you want Serena to be able to read code or other information from another project (e.g. a library or otherwise related project), +this can be enabled via the `query_project` tool. +Provided that the project you want to query is known to Serena (i.e. you have created it as described above), +the `query_project` tool allows the agent to query files and symbolic information from that project. + +To enable this tool, [activate the mode](modes) `query-projects`. +This also enables a second tool for listing projects that can be queried. + +Depending on the language backend being used, the management of resources for the external projects varies: + +* When using the JetBrains backend, make sure that every project for which you want symbolic queries to work is open in an IDE instance. +* When using the LSP backend, executing symbolic tools via the query tool requires that Serena's **Project Server** be started, + which will automatically spawn the necessary language servers for the projects that are queried. + + To start the server, run + + start-project-server + + where `` is your way of running Serena. For example, when using `uvx`, run + + uvx --from git+https://github.com/oraios/serena serena start-project-server diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index aaf256af..dd0704ae 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -113,7 +113,8 @@ Examples of built-in modes include: * `one-shot`: Configures Serena for tasks that should be completed in a single response, often used with `planning` for generating reports or initial plans. * `no-onboarding`: Skips the initial onboarding process if it's not needed for a particular session but retains the memory tools (assuming initial memories were created externally). * `onboarding`: Focuses on the project onboarding process. -* `no-memories`: Disables all memory tools (and tools building on memories such as onboarding tools) +* `no-memories`: Disables all memory tools (and tools building on memories such as onboarding tools) +* `query-projects`: Enables tools for querying other Serena projects (without activating them); see section [Reading from External Projects](query-projects) Find the concrete definitions of these modes [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/modes). From 9800d4e98b5977a5d56c90963df65ea9a772799f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 15:43:01 +0100 Subject: [PATCH 06/83] Introduce DotNETUtil * Apply in FSharpLanguageServer and CSharpLanguageServer * CSharpLanguageServer: Disabled auto-install of .NET (does not work reliably) and remove corresponding test * Remove TestFSharpLanguageServerSetup --- src/serena/util/dotnet.py | 156 ++++++++++ src/serena/util/version.py | 2 +- .../csharp_language_server.py | 101 +----- .../fsharp_language_server.py | 17 +- .../csharp/test_csharp_dotnet_install.py | 290 ------------------ test/solidlsp/fsharp/test_fsharp_basic.py | 42 --- 6 files changed, 161 insertions(+), 447 deletions(-) create mode 100644 src/serena/util/dotnet.py delete mode 100644 test/solidlsp/csharp/test_csharp_dotnet_install.py diff --git a/src/serena/util/dotnet.py b/src/serena/util/dotnet.py new file mode 100644 index 00000000..99b34526 --- /dev/null +++ b/src/serena/util/dotnet.py @@ -0,0 +1,156 @@ +import logging +import platform +import re +import shutil +import subprocess +import urllib +from pathlib import Path + +from serena.util.version import Version +from solidlsp.ls_exceptions import SolidLSPException + +log = logging.getLogger(__name__) + + +class DotNETUtil: + def __init__(self, required_version: str, allow_higher_version: bool = True): + """ + :param required_version: the required .NET runtime version specified as a string (e.g. "10.0" for .NET 10.0) + :param allow_higher_version: whether to allow higher versions than the required version + """ + self._system_dotnet = shutil.which("dotnet") + self._required_version_str = required_version + self._required_version_components = [int(c) for c in required_version.split(".")] + self._allow_higher_version = allow_higher_version + self._installed_versions = self._determine_installed_versions() + + def _determine_installed_versions(self) -> list[Version]: + if self._system_dotnet: + try: + result = subprocess.run([self._system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) + version_strings = re.findall(r"Microsoft.NETCore.App\s+([^\s]+)", result.stdout) + log.info("Installed .NET runtime versions: %s", version_strings) + return [Version(v) for v in version_strings] + except: + log.warning("Failed to run 'dotnet --list-runtimes' to check .NET version; assuming no installed .NET versions") + return [] + else: + log.info("Found no `dotnet` on system PATH; assuming no installed .NET versions") + return [] + + def is_required_version_available(self) -> bool: + """ + Checks whether the required .NET runtime version is installed and raises an exception if not. + + :param required_version_components: the required .NET runtime version specified as a list of integers representing the version components (e.g., [6, 1] for .NET 6.1) + :param allow_higher_version: whether to allow higher versions than the required version (e.g., if True, .NET 7.0 would satisfy a requirement of .NET 6.1) + """ + required_version_str = ".".join(str(c) for c in self._required_version_components) + for v in self._installed_versions: + if self._allow_higher_version: + if v.is_at_least(*self._required_version_components): + log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str} or higher") + return True + else: + if v.is_equal(*self._required_version_components): + log.info(f"Found installed .NET runtime version {v} which satisfies requirement of {required_version_str}") + return True + return False + + def get_dotnet_path_or_raise(self) -> str: + """ + Returns the path to the dotnet executable if the required .NET runtime version is available, otherwise raises an exception. + """ + if not self.is_required_version_available(): + raise SolidLSPException( + f"Required .NET runtime version {self._required_version_str} not found " + f"(installed versions: {self._installed_versions}). " + "Please install the required .NET runtime version from https://dotnet.microsoft.com/en-us/download/dotnet " + "and ensure that `dotnet` is on the system PATH." + ) + assert self._system_dotnet is not None + return self._system_dotnet + + @staticmethod + def install_dotnet_with_script(version: str, base_path: str) -> str: + """ + Install .NET runtime using Microsoft's official installation script. + + NOTE: This method is unreliable and therefore currently unused. It is kept for reference. + + :version: the version to install as a string (e.g. "10.0") + :return: the path to the dotnet executable. + """ + dotnet_dir = Path(base_path) / f"dotnet-runtime-{version}" + + # Determine binary name based on platform + is_windows = platform.system().lower() == "windows" + dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") + + if dotnet_exe.exists(): + log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") + return str(dotnet_exe) + + # Download and run install script + log.info(f"Installing .NET {version} runtime using official Microsoft install script...") + dotnet_dir.mkdir(parents=True, exist_ok=True) + + try: + if is_windows: + # PowerShell script for Windows + script_url = "https://dot.net/v1/dotnet-install.ps1" + script_path = dotnet_dir / "dotnet-install.ps1" + urllib.request.urlretrieve(script_url, script_path) + + cmd = [ + "pwsh", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(script_path), + "-Version", + version, + "-InstallDir", + str(dotnet_dir), + "-Runtime", + "dotnet", + "-NoPath", + ] + else: + # Bash script for Linux/macOS + script_url = "https://dot.net/v1/dotnet-install.sh" + script_path = dotnet_dir / "dotnet-install.sh" + urllib.request.urlretrieve(script_url, script_path) + script_path.chmod(0o755) + + cmd = [ + "bash", + str(script_path), + "--version", + version, + "--install-dir", + str(dotnet_dir), + "--runtime", + "dotnet", + "--no-path", + ] + + # Run the install script + log.info("Running .NET install script: %s", cmd) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + log.debug(f"Install script output: {result.stdout}") + + if not dotnet_exe.exists(): + raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") + + log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") + return str(dotnet_exe) + + except subprocess.CalledProcessError as e: + raise SolidLSPException(f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}") from e + except Exception as e: + message = f"Failed to install .NET {version} runtime: {e}" + if is_windows and isinstance(e, FileNotFoundError): + message += "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." + raise SolidLSPException(message) from e diff --git a/src/serena/util/version.py b/src/serena/util/version.py index d8d8381f..142d4356 100644 --- a/src/serena/util/version.py +++ b/src/serena/util/version.py @@ -23,7 +23,7 @@ class Version: self.version_string = version_string self.components = self._get_version_components(version_string) - def __str__(self) -> str: + def __repr__(self) -> str: return self.version_string @staticmethod diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index ae9d9264..28621bb2 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -6,7 +6,6 @@ import logging import os import platform import shutil -import subprocess import threading import urllib.request from collections.abc import Iterable @@ -15,6 +14,7 @@ from typing import Any, cast from overrides import override +from serena.util.dotnet import DotNETUtil from solidlsp.ls import DocumentSymbols, LanguageServerDependencyProvider, LSPFileBuffer, SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_exceptions import SolidLSPException @@ -366,21 +366,7 @@ class CSharpLanguageServer(SolidLanguageServer): def _ensure_dotnet_runtime(self) -> str: """Ensure .NET runtime is available and return the dotnet executable path.""" - # Check if dotnet is already available on the system - system_dotnet = shutil.which("dotnet") - if system_dotnet: - # Check if it's .NET 10 or compatible - try: - result = subprocess.run([system_dotnet, "--list-runtimes"], capture_output=True, text=True, check=True) - # Accept .NET 10 or higher (10.x, 11.x, etc.) - if any(f"Microsoft.NETCore.App {v}." in result.stdout for v in range(10, 20)): - log.info("Found system .NET 10+ runtime") - return system_dotnet - except subprocess.CalledProcessError: - pass - - # Install .NET 10 runtime using Microsoft's install script - return self._install_dotnet_with_script() + return DotNETUtil("10.0", allow_higher_version=True).get_dotnet_path_or_raise() def _ensure_language_server(self, lang_server_dep: RuntimeDependency) -> str: """Ensure language server is available and return the DLL path.""" @@ -475,89 +461,6 @@ class CSharpLanguageServer(SolidLanguageServer): except Exception as e: raise SolidLSPException(f"Failed to download package {package_name} version {package_version} from NuGet.org: {e}") from e - def _install_dotnet_with_script(self, version: str = "10.0") -> str: - """ - Install .NET runtime using Microsoft's official install script. - Returns the path to the dotnet executable. - """ - dotnet_dir = Path(self._ls_resources_dir) / f"dotnet-runtime-{version}" - - # Determine binary name based on platform - is_windows = platform.system().lower() == "windows" - dotnet_exe = dotnet_dir / ("dotnet.exe" if is_windows else "dotnet") - - if dotnet_exe.exists(): - log.info(f"Using cached .NET {version} runtime from {dotnet_exe}") - return str(dotnet_exe) - - # Download and run install script - log.info(f"Installing .NET {version} runtime using official Microsoft install script...") - dotnet_dir.mkdir(parents=True, exist_ok=True) - - try: - if is_windows: - # PowerShell script for Windows - script_url = "https://dot.net/v1/dotnet-install.ps1" - script_path = dotnet_dir / "dotnet-install.ps1" - urllib.request.urlretrieve(script_url, script_path) - - cmd = [ - "pwsh", - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - str(script_path), - "-Version", - version, - "-InstallDir", - str(dotnet_dir), - "-Runtime", - "dotnet", - "-NoPath", - ] - else: - # Bash script for Linux/macOS - script_url = "https://dot.net/v1/dotnet-install.sh" - script_path = dotnet_dir / "dotnet-install.sh" - urllib.request.urlretrieve(script_url, script_path) - script_path.chmod(0o755) - - cmd = [ - "bash", - str(script_path), - "--version", - version, - "--install-dir", - str(dotnet_dir), - "--runtime", - "dotnet", - "--no-path", - ] - - # Run the install script - log.info("Running .NET install script: %s", cmd) - result = subprocess.run(cmd, capture_output=True, text=True, check=True) - log.debug(f"Install script output: {result.stdout}") - - if not dotnet_exe.exists(): - raise SolidLSPException(f"dotnet executable not found at {dotnet_exe} after installation") - - log.info(f"Successfully installed .NET {version} runtime to {dotnet_exe}") - return str(dotnet_exe) - - except subprocess.CalledProcessError as e: - raise SolidLSPException( - f"Failed to install .NET {version} runtime using install script: {e.stderr if e.stderr else e}" - ) from e - except Exception as e: - message = f"Failed to install .NET {version} runtime: {e}" - if is_windows and isinstance(e, FileNotFoundError): - message += ( - "; pwsh, i.e. PowerShell 7+, is required to install .NET runtime. Make sure pwsh is available on your system." - ) - raise SolidLSPException(message) from e - def _get_initialize_params(self) -> InitializeParams: """ Returns the initialize params for the Microsoft.CodeAnalysis.LanguageServer. diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py index e4ee3e2c..9d12cf5d 100644 --- a/src/solidlsp/language_servers/fsharp_language_server.py +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -11,6 +11,7 @@ from pathlib import Path from overrides import override +from serena.util.dotnet import DotNETUtil from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig @@ -61,21 +62,7 @@ class FSharpLanguageServer(SolidLanguageServer): """ Setup runtime dependencies for F# Language Server and return the command to start the server. """ - # First check if .NET SDK is installed - dotnet_exe = shutil.which("dotnet") - if not dotnet_exe: - raise RuntimeError( - ".NET SDK is not installed or not in PATH. Please install .NET SDK 8.0 or later and ensure 'dotnet' is in your PATH." - ) - - # Verify dotnet version - import subprocess - - try: - result = subprocess.run([dotnet_exe, "--version"], capture_output=True, text=True, check=True) - log.info(f"Found .NET SDK version: {result.stdout.strip()}") - except subprocess.CalledProcessError: - raise RuntimeError("Failed to get .NET SDK version. Please ensure .NET SDK is properly installed.") + dotnet_exe = DotNETUtil("8.0", allow_higher_version=True).get_dotnet_path_or_raise() RuntimeDependencyCollection( [ diff --git a/test/solidlsp/csharp/test_csharp_dotnet_install.py b/test/solidlsp/csharp/test_csharp_dotnet_install.py deleted file mode 100644 index f59db6e5..00000000 --- a/test/solidlsp/csharp/test_csharp_dotnet_install.py +++ /dev/null @@ -1,290 +0,0 @@ -"""Tests for C# language server .NET runtime installation using official install scripts.""" - -import tempfile -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest - -from solidlsp.language_servers.csharp_language_server import CSharpLanguageServer -from solidlsp.settings import SolidLSPSettings - - -@pytest.mark.csharp -class TestDotNetInstallScript: - """Test .NET runtime installation using Microsoft's official install scripts.""" - - @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") - @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") - @patch("solidlsp.language_servers.csharp_language_server.platform.system") - @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") - @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") - @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_install_dotnet_uses_bash_script_on_linux( - self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls - ): - """Test that Linux uses bash install script.""" - from solidlsp.ls_utils import PlatformId - - mock_platform.return_value = "Linux" - mock_platform_id.return_value = PlatformId.LINUX_x64 - mock_which.return_value = None # No system dotnet - mock_ensure_ls.return_value = "/fake/server.dll" - - with tempfile.TemporaryDirectory() as temp_dir: - - def mock_retrieve(url, path): - # Create the file that urlretrieve would create - Path(path).touch() - # Also create the dotnet executable after script download - if "dotnet-install.sh" in str(path): - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet" - dotnet_exe.touch() - dotnet_exe.chmod(0o755) - - mock_urlretrieve.side_effect = mock_retrieve - mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="") - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - _ = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Verify bash script was downloaded - assert mock_urlretrieve.called - script_url = mock_urlretrieve.call_args[0][0] - assert script_url == "https://dot.net/v1/dotnet-install.sh" - - @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") - @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") - @patch("solidlsp.language_servers.csharp_language_server.platform.system") - @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") - @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") - @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_install_dotnet_uses_powershell_script_on_windows( - self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls - ): - """Test that Windows uses PowerShell install script.""" - from solidlsp.ls_utils import PlatformId - - mock_platform.return_value = "Windows" - mock_platform_id.return_value = PlatformId.WIN_x64 - mock_which.return_value = None # No system dotnet - mock_ensure_ls.return_value = "/fake/server.dll" - - with tempfile.TemporaryDirectory() as temp_dir: - - def mock_retrieve(url, path): - # Create the file that urlretrieve would create - Path(path).touch() - # Also create the dotnet executable after script download - if "dotnet-install.ps1" in str(path): - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet.exe" - dotnet_exe.touch() - - mock_urlretrieve.side_effect = mock_retrieve - mock_subprocess.return_value = Mock(returncode=0, stdout="", stderr="") - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - _ = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Verify PowerShell script was downloaded - assert mock_urlretrieve.called - script_url = mock_urlretrieve.call_args[0][0] - assert script_url == "https://dot.net/v1/dotnet-install.ps1" - - def test_uses_system_dotnet_10_if_available(self): - """Test that system .NET 10 is used if available instead of downloading.""" - from solidlsp.ls_utils import PlatformId - - with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: - with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: - with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: - with patch( - "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" - ) as mock_ensure_ls: - mock_which.return_value = "/usr/bin/dotnet" - mock_ensure_ls.return_value = "/fake/server.dll" - mock_platform_id.return_value = PlatformId.LINUX_x64 - - # Mock dotnet --list-runtimes output showing .NET 10 - mock_result = Mock() - mock_result.stdout = "Microsoft.NETCore.App 10.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result - - with tempfile.TemporaryDirectory() as temp_dir: - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should have used system dotnet - assert dependency_provider._dotnet_path == "/usr/bin/dotnet" - - def test_runtime_dependencies_no_longer_include_dotnet_downloads(self): - """Test that _RUNTIME_DEPENDENCIES no longer includes manual .NET download entries.""" - from solidlsp.language_servers.csharp_language_server import _RUNTIME_DEPENDENCIES - - # Check that DotNetRuntime entries don't exist (we use install script instead) - dotnet_deps = [dep for dep in _RUNTIME_DEPENDENCIES if dep.id == "DotNetRuntime"] - - # After our changes, there should be no DotNetRuntime entries - # (we'll use the install script instead) - assert len(dotnet_deps) == 0, "DotNetRuntime dependencies should be removed in favor of install script" - - @patch("solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server") - @patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") - @patch("solidlsp.language_servers.csharp_language_server.platform.system") - @patch("solidlsp.language_servers.csharp_language_server.subprocess.run") - @patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") - @patch("solidlsp.language_servers.csharp_language_server.shutil.which") - def test_cached_dotnet_is_reused(self, mock_which, mock_urlretrieve, mock_subprocess, mock_platform, mock_platform_id, mock_ensure_ls): - """Test that cached .NET installation is reused without re-downloading.""" - from solidlsp.ls_utils import PlatformId - - mock_platform.return_value = "Linux" - mock_platform_id.return_value = PlatformId.LINUX_x64 - mock_which.return_value = None - mock_ensure_ls.return_value = "/fake/server.dll" - - with tempfile.TemporaryDirectory() as temp_dir: - # Pre-create the cached dotnet executable - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet" - dotnet_exe.touch() - dotnet_exe.chmod(0o755) - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should use cached dotnet without downloading install script - assert dependency_provider._dotnet_path == str(dotnet_exe) - assert not mock_urlretrieve.called, "Should not download install script when cached dotnet exists" - - def test_rejects_dotnet_9_and_installs_dotnet_10(self): - """Test that .NET 9 is rejected and .NET 10 install is triggered.""" - from solidlsp.ls_utils import PlatformId - - with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: - with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: - with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: - with patch("solidlsp.language_servers.csharp_language_server.urllib.request.urlretrieve") as mock_urlretrieve: - with patch("solidlsp.language_servers.csharp_language_server.platform.system") as mock_platform: - with patch( - "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" - ) as mock_ensure_ls: - mock_which.return_value = "/usr/bin/dotnet" - mock_ensure_ls.return_value = "/fake/server.dll" - mock_platform_id.return_value = PlatformId.LINUX_x64 - mock_platform.return_value = "Linux" - - # Mock dotnet --list-runtimes output showing only .NET 9 - mock_result = Mock() - mock_result.stdout = "Microsoft.NETCore.App 9.0.1 [/usr/share/dotnet/shared/Microsoft.NETCore.App]" - mock_result.returncode = 0 - - call_count = 0 - - def subprocess_side_effect(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - # First call: dotnet --list-runtimes returns .NET 9 - return mock_result - else: - # Second call: install script execution - return Mock(returncode=0, stdout="", stderr="") - - mock_subprocess.side_effect = subprocess_side_effect - - with tempfile.TemporaryDirectory() as temp_dir: - - def mock_retrieve(url, path): - Path(path).touch() - if "dotnet-install.sh" in str(path): - dotnet_dir = Path(temp_dir) / "dotnet-runtime-10.0" - dotnet_dir.mkdir(parents=True, exist_ok=True) - dotnet_exe = dotnet_dir / "dotnet" - dotnet_exe.touch() - dotnet_exe.chmod(0o755) - - mock_urlretrieve.side_effect = mock_retrieve - - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should have installed .NET 10 (not used system .NET 9) - assert dependency_provider._dotnet_path == str(Path(temp_dir) / "dotnet-runtime-10.0" / "dotnet") - assert mock_urlretrieve.called, "Should download install script when .NET 9 found" - - def test_accepts_dotnet_11_and_higher(self): - """Test that .NET 11+ versions are accepted for forward compatibility.""" - from solidlsp.ls_utils import PlatformId - - for version in ["11.0.1", "12.0.0", "15.3.2"]: - with patch("solidlsp.language_servers.csharp_language_server.shutil.which") as mock_which: - with patch("solidlsp.language_servers.csharp_language_server.subprocess.run") as mock_subprocess: - with patch("solidlsp.language_servers.common.PlatformUtils.get_platform_id") as mock_platform_id: - with patch( - "solidlsp.language_servers.csharp_language_server.CSharpLanguageServer.DependencyProvider._ensure_language_server" - ) as mock_ensure_ls: - mock_which.return_value = "/usr/bin/dotnet" - mock_ensure_ls.return_value = "/fake/server.dll" - mock_platform_id.return_value = PlatformId.LINUX_x64 - - # Mock dotnet --list-runtimes output showing .NET 11+ - mock_result = Mock() - mock_result.stdout = f"Microsoft.NETCore.App {version} [/usr/share/dotnet/shared/Microsoft.NETCore.App]" - mock_result.returncode = 0 - mock_subprocess.return_value = mock_result - - with tempfile.TemporaryDirectory() as temp_dir: - mock_settings = SolidLSPSettings() - custom_settings = SolidLSPSettings.CustomLSSettings({}) - - dependency_provider = CSharpLanguageServer.DependencyProvider( - custom_settings=custom_settings, - ls_resources_dir=temp_dir, - solidlsp_settings=mock_settings, - repository_root_path="/fake/repo", - ) - - # Should use system dotnet for .NET 11+ - assert dependency_provider._dotnet_path == "/usr/bin/dotnet", f"Should accept .NET {version}" diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 578ae704..4e6d7024 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -1,14 +1,10 @@ import os -import tempfile import threading -from pathlib import Path from typing import Any -from unittest.mock import Mock, patch import pytest from solidlsp import SolidLanguageServer -from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import SymbolUtils from test.conftest import is_ci @@ -183,41 +179,3 @@ class TestFSharpLanguageServer: # This is a successful test - FsAutoComplete is working with F# files assert True, "F# language server can handle files successfully" - - -@pytest.mark.fsharp -class TestFSharpLanguageServerSetup: - """Test F# language server setup and configuration.""" - - def test_runtime_dependency_setup_without_dotnet(self) -> None: - """Test that setup fails gracefully when .NET is not installed.""" - with patch("shutil.which", return_value=None): - with pytest.raises(RuntimeError, match=r"\.NET SDK is not installed"): - FSharpLanguageServer._setup_runtime_dependencies(Mock(), Mock()) - - def test_runtime_dependency_setup_with_dotnet(self) -> None: - """Test that setup works when .NET is available.""" - mock_config = Mock() - mock_settings = Mock() - - # Mock the ls_resources_dir to return a temp directory - with tempfile.TemporaryDirectory() as temp_dir: - with patch("shutil.which", return_value="/usr/bin/dotnet"): - with patch.object(FSharpLanguageServer, "ls_resources_dir", return_value=temp_dir): - with patch("subprocess.run") as mock_run: - # Mock successful dotnet version check - mock_run.return_value.stdout = "8.0.100" - mock_run.return_value.returncode = 0 - - # Create a fake fsautocomplete executable - fsharp_dir = os.path.join(temp_dir, "fsharp-lsp") - os.makedirs(fsharp_dir, exist_ok=True) - # Use .exe extension on Windows, matching production code - exe_name = "fsautocomplete.exe" if os.name == "nt" else "fsautocomplete" - fsautocomplete_path = os.path.join(fsharp_dir, exe_name) - Path(fsautocomplete_path).touch() - - result = FSharpLanguageServer._setup_runtime_dependencies(mock_config, mock_settings) - - assert fsautocomplete_path in result - assert "--adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" in result From c4844ec4a22ce606fd0504a3ef814e4c06257175 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 15:55:17 +0100 Subject: [PATCH 07/83] Raise .NET version in CI to 10 --- .github/workflows/pytest.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 058ecbea..51577d5c 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -139,7 +139,10 @@ jobs: - name: Setup .NET SDK (for F# and C# languages) uses: actions/setup-dotnet@v4 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' + - name: List .NET runtimes + shell: bash + run: dotnet --list-runtimes - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: From 9c336cc3390815aa4d14bef7c2b33316086cf586 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 19:24:07 +0100 Subject: [PATCH 08/83] Update language requirements for C# and F# [skip ci] --- docs/01-about/020_programming-languages.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 97c7fc40..728da19f 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -30,7 +30,7 @@ Some languages require additional installations or setup steps, as noted. * **AL** * **Bash** * **C#** - (by default, uses the Roslyn language server (language `csharp`), requires `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)) on Windows; + (by default, uses the Roslyn language server (language `csharp`), requiring [.NET v10+](https://dotnet.microsoft.com/en-us/download/dotnet) and, on Windows, `pwsh` ([PowerShell 7+](https://learn.microsoft.com/en-us/powershell/scripting/install/install-powershell-on-windows?view=powershell-7.5)); set language to `csharp_omnisharp` to use OmiSharp instead) * **C/C++** (by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`); @@ -45,7 +45,7 @@ Some languages require additional installations or setup steps, as noted. * **Erlang** (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang) * **F#** - (requires .NET SDK 8.0+; uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) + (requires [.NET v8.0+](https://dotnet.microsoft.com/en-us/download/dotnet); uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) * **Fortran** (requires installation of fortls: `pip install fortls`) * **Go** From eb78921c0899af26c6eb51508b7c96d5e49045cf Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 19:12:10 +0100 Subject: [PATCH 09/83] Split test_find_symbol_references and test_find_symbol into stable and unstable tests --- test/serena/test_serena_agent.py | 172 ++++++++++++++++++------------- 1 file changed, 103 insertions(+), 69 deletions(-) diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 3dbef5d8..b04dc37f 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -117,37 +117,7 @@ class TestSerenaAgent: serena_config = SerenaConfig(gui_log_window=False, web_dashboard=False) SerenaAgent(project=project, serena_config=serena_config) - @pytest.mark.parametrize( - "serena_agent,symbol_name,expected_kind,expected_file", - [ - pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), - pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), - pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), - pytest.param( - Language.KOTLIN, - "Model", - "Struct", - "Model.kt", - marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), - ), - pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), - pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), - pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), - pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), - pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), - ], - indirect=["serena_agent"], - ) - def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str): - # skip flaky tests in CI - # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1040 - flaky_languages = {Language.FSHARP, Language.RUST} - if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: - pytest.skip("Test is flaky and thus skipped in CI environment.") - + def _assert_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: agent = serena_agent find_symbol_tool = agent.get_tool(FindSymbolTool) result = find_symbol_tool.apply(name_path_pattern=symbol_name, include_info=True) @@ -175,54 +145,53 @@ class TestSerenaAgent: assert "A simple model class" in symbol_info, f"Java class docstring not found in symbol info: {s}" @pytest.mark.parametrize( - "serena_agent,symbol_name,def_file,ref_file", + "serena_agent,symbol_name,expected_kind,expected_file", [ - pytest.param( - Language.PYTHON, - "User", - os.path.join("test_repo", "models.py"), - os.path.join("test_repo", "services.py"), - marks=pytest.mark.python, - ), - pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), - pytest.param( - Language.JAVA, - "Model", - os.path.join("src", "main", "java", "test_repo", "Model.java"), - os.path.join("src", "main", "java", "test_repo", "Main.java"), - marks=pytest.mark.java, - ), + pytest.param(Language.PYTHON, "User", "Class", "models.py", marks=pytest.mark.python), + pytest.param(Language.GO, "Helper", "Function", "main.go", marks=pytest.mark.go), + pytest.param(Language.JAVA, "Model", "Class", "Model.java", marks=pytest.mark.java), pytest.param( Language.KOTLIN, "Model", - os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), - os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + "Struct", + "Model.kt", marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), ), - pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), - pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), - pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), - pytest.param( - Language.CLOJURE, - "multiply", - clj.CORE_PATH, - clj.UTILS_PATH, - marks=pytest.mark.clojure, - ), - pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), - pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), - pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), - pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), + pytest.param(Language.TYPESCRIPT, "DemoClass", "Class", "index.ts", marks=pytest.mark.typescript), + pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), + pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), + pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), + pytest.param(Language.POWERSHELL, "function Greet-User ()", "Function", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.CPP_CCLS, "add", "Function", "b.cpp", marks=pytest.mark.cpp), ], indirect=["serena_agent"], ) - def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: - # skip flaky tests in CI - # TODO: Revisit the flaky tests and re-enable once the LS issues are resolved #1040 - flaky_languages = {Language.TYPESCRIPT} - if set(serena_agent.get_active_lsp_languages()).intersection(flaky_languages) and is_ci: - pytest.skip("Test is flaky and thus skipped in CI environment.") + def test_find_symbol_stable(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: + self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) + @pytest.mark.parametrize( + "serena_agent,symbol_name,expected_kind,expected_file", + [ + pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 + def test_find_symbol_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: + self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) + + @pytest.mark.parametrize( + "serena_agent,symbol_name,expected_kind,expected_file", + [ + pytest.param(Language.RUST, "add", "Function", "lib.rs", marks=pytest.mark.rust), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="Rust language server is unreliable") # See issue #1040 + def test_find_symbol_rust(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str) -> None: + self._assert_find_symbol(serena_agent, symbol_name, expected_kind, expected_file) + + def _assert_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: agent = serena_agent # Find the symbol location first @@ -257,6 +226,71 @@ class TestSerenaAgent: refs = json.loads(result) assert contains_ref_with_relative_path(refs, ref_file), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}" + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,ref_file", + [ + pytest.param( + Language.PYTHON, + "User", + os.path.join("test_repo", "models.py"), + os.path.join("test_repo", "services.py"), + marks=pytest.mark.python, + ), + pytest.param(Language.GO, "Helper", "main.go", "main.go", marks=pytest.mark.go), + pytest.param( + Language.JAVA, + "Model", + os.path.join("src", "main", "java", "test_repo", "Model.java"), + os.path.join("src", "main", "java", "test_repo", "Main.java"), + marks=pytest.mark.java, + ), + pytest.param( + Language.KOTLIN, + "Model", + os.path.join("src", "main", "kotlin", "test_repo", "Model.kt"), + os.path.join("src", "main", "kotlin", "test_repo", "Main.kt"), + marks=[pytest.mark.kotlin] + ([pytest.mark.skip(reason="Kotlin LSP JVM crashes on restart in CI")] if is_ci else []), + ), + pytest.param(Language.RUST, "add", os.path.join("src", "lib.rs"), os.path.join("src", "main.rs"), marks=pytest.mark.rust), + pytest.param(Language.PHP, "helperFunction", "helper.php", "index.php", marks=pytest.mark.php), + pytest.param( + Language.CLOJURE, + "multiply", + clj.CORE_PATH, + clj.UTILS_PATH, + marks=pytest.mark.clojure, + ), + pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), + pytest.param(Language.POWERSHELL, "function Greet-User ()", "main.ps1", "main.ps1", marks=pytest.mark.powershell), + pytest.param(Language.CPP_CCLS, "add", "b.cpp", "a.cpp", marks=pytest.mark.cpp), + ], + indirect=["serena_agent"], + ) + def test_find_symbol_references_stable(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,ref_file", + [ + pytest.param(Language.TYPESCRIPT, "helperFunction", "index.ts", "use_helper.ts", marks=pytest.mark.typescript), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="TypeScript language server is unreliable") # See issue #1040 + def test_find_symbol_references_typescript(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + + @pytest.mark.parametrize( + "serena_agent,symbol_name,def_file,ref_file", + [ + pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), + ], + indirect=["serena_agent"], + ) + @pytest.mark.xfail(reason="F# language server is unreliable") # See issue #1040 + def test_find_symbol_references_fsharp(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None: + self._assert_find_symbol_references(serena_agent, symbol_name, def_file, ref_file) + @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", [ From 90967ad67870e0a6ebe9b93b8df0c7066baa25d4 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Mon, 2 Mar 2026 21:13:18 +0100 Subject: [PATCH 10/83] TestFSharpLanguageServer: Mark test_find_referencing_symbols with xfail --- test/solidlsp/fsharp/test_fsharp_basic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py index 4e6d7024..26a0e695 100644 --- a/test/solidlsp/fsharp/test_fsharp_basic.py +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -49,6 +49,7 @@ class TestFSharpLanguageServer: for expected in expected_symbols: assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols" + @pytest.mark.xfail(is_ci, reason="Test is flaky") # TODO: Re-enable if the LS can be made more reliable #1040 @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: """Test finding references using symbol selection range.""" From ccbd15082bec5f2112cd4f5949c6c324fd0a54ef Mon Sep 17 00:00:00 2001 From: Michael Panchenko <35432522+MischaPanch@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:18:41 +0100 Subject: [PATCH 11/83] Feature/global memories (#1091) Support for global (cross-project) memories, adds a news item --------- Co-authored-by: Ariadne[bot] (butterflysky-ai) Co-authored-by: Dominik Jain --- docs/02-usage/040_workflow.md | 18 +-- docs/02-usage/045_memories.md | 84 ++++++++++ src/serena/agent.py | 7 +- src/serena/config/serena_config.py | 10 +- src/serena/dashboard.py | 35 +++- .../generated/generated_prompt_factory.py | 8 +- src/serena/project.py | 149 ++++++++++++++---- .../resources/config/modes/no-memories.yml | 1 + .../config/prompt_templates/system_prompt.yml | 7 +- src/serena/resources/dashboard/dashboard.css | 36 +++++ src/serena/resources/dashboard/dashboard.js | 77 ++++++++- src/serena/resources/dashboard/index.html | 17 +- .../resources/dashboard/news/20260303.html | 12 ++ .../resources/serena_config.template.yml | 6 + src/serena/tools/file_tools.py | 6 +- src/serena/tools/memory_tools.py | 49 ++++-- src/serena/tools/tools_base.py | 3 +- src/serena/tools/workflow_tools.py | 27 ++-- src/serena/util/inspection.py | 18 ++- 19 files changed, 466 insertions(+), 104 deletions(-) create mode 100644 docs/02-usage/045_memories.md create mode 100644 src/serena/resources/dashboard/news/20260303.html diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index c5153401..098ef523 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -103,22 +103,12 @@ By default, Serena will perform an **onboarding process** when it is started for the first time for a project. The goal of the onboarding is for Serena to get familiar with the project and to store memories, which it can then draw upon in future interactions. -If an LLM should fail to complete the onboarding and does not actually write the -respective memories to disk, you may need to ask it to do so explicitly. -The onboarding will usually read a lot of content from the project, thus filling -up the context. It can therefore be advisable to switch to another conversation -once the onboarding is complete. -After the onboarding, we recommend that you have a quick look at the memories and, -if necessary, edit them or add additional ones. +In general, **memories** provide a way for Serena to store and retrieve +information about the project, relevant conventions, and other relevant aspects. -**Memories** are files stored in `.serena/memories/` in the project directory, -which the agent can choose to read in subsequent interactions. -Feel free to read and adjust them as needed; you can also add new ones manually. -Every file in the `.serena/memories/` directory is a memory file. -Whenever Serena starts working on a project, the list of memories is -provided, and the agent can decide to read them. -We found that memories can significantly improve the user experience with Serena. +For more information on this, including how to manage +or disable these features, see [Memories & Onboarding](045-memories). ## Preparing Your Project diff --git a/docs/02-usage/045_memories.md b/docs/02-usage/045_memories.md new file mode 100644 index 00000000..e564d3c2 --- /dev/null +++ b/docs/02-usage/045_memories.md @@ -0,0 +1,84 @@ +# Memories & Onboarding + +Serena provides the functionality of a fully featured agent, and a useful aspect of this is Serena's memory system. +Despite its simplicity, we received positive feedback from many users who tend to combine it with their +agent's internal memory management (e.g., `AGENTS.md` files). + +## Memories + +Memories are simple, human-readable Markdown files that both you and +your agent can create, read, and edit. + +Serena differentiates between + * **project-specific memories**, which are stored in the `.serena/memories/` directory within your project folder, and + * **global memories**, which are shared across all projects and, by default, are stored in `~/.serena/memories/global/` + +The LLM is informed about the existence of memories and instructed to read them when appropriate, +inferring appropriateness from the file name. +When the agent starts working on a project, it receives the list of available memories. +The agent should be instructed to update memories by the user when appropriate. + +### Organizing Memories + +Memories can be organized into **topics** by using `/` in the memory name (e.g. `modules/frontend`). +The structure is mapped to the file system, where topics correspond to subdirectories. +The `list_memories` tool can filter by topic, allowing the agent to explore even large numbers of memories in a structured way. + +(global-memories)= +### Global Memories + +Global memories use the top-level topic `global`, i.e. whenever a memory name starts with `global/`, +it is stored in the global memories directory and is shared across all projects. + +By default, deletion and editing of global memories is allowed. +If you want to primarily manage such memories yourself and protect them from accidental modification by the agent, +set `edit_global_memories: False` in Serena's [global configuration](050-configuration). + +Since global memories are not versioned alongside your project files, +it can be helpful to track global memories with git (i.e. to make `~/.serena/memories/` a git repository) +in order to have a history of changes and the possibility to revert them if needed. + +### Manually Editing Memories + +You may edit memories directly in the file system, using your preferred text editor or IDE. +Alternatively, access them via the [Serena Dashboard](060_dashboard), which provides a graphical interface for +viewing, creating, editing, and deleting memories while Serena is running. + +(onboarding)= +## Onboarding + +By default, Serena performs an **onboarding process** when it encounters a project +for the first time (i.e., when no project memories exist yet). +The goal of the onboarding is for Serena to get familiar with the project — +its structure, build system, testing setup, and other essential aspects — +and to store this knowledge as memories for future interactions. + +In further project activations, Serena will check whether onboarding was already +performed by looking for existing project memories and will skip the onboarding +process if memories are found. + +### How Onboarding Works + +1. When a project is activated, Serena checks whether onboarding was already + performed (by checking if any memories exist). +2. If no memories are found, Serena triggers the onboarding process, which + reads key files and directories to understand the project. +3. The gathered information is written into project-specific memory files (see above). + +### Tips for Onboarding + +- **Context usage**: The onboarding process will read a lot of content from the project, + filling up the context window. It is therefore advisable to **switch to a new conversation** + once the onboarding is complete. +- **LLM failures**: If an LLM fails to complete the onboarding and does not actually + write the respective memories to disk, you may need to ask it to do so explicitly. +- **Review the results**: After onboarding, we recommend having a quick look at the + generated memories and editing them or adding new ones as needed. + +## Disabling Memories and Onboarding + +If you do not require the functionality described in this section, you can selectively disable it. + + * To disable all memory related tools (including onboarding), adding `no-memories` to the `base_modes` + in Serena's [global configuration](050-configuration). + * Similarly, to disable only onboarding, add `no-onboarding` to the `base_modes`. diff --git a/src/serena/agent.py b/src/serena/agent.py index 83f9694b..daa87820 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime -from sensai.util.string import TextBuilder +from sensai.util.string import TextBuilder, list_string from interprompt.jinja_template import JinjaTemplate from serena import serena_version @@ -29,7 +29,7 @@ from serena.config.serena_config import ( ) from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager, LanguageServerManagerInitialisationError -from serena.project import Project +from serena.project import MemoriesManager, Project from serena.prompt_factory import SerenaPromptFactory from serena.task_executor import TaskExecutor from serena.tools import ActivateProjectTool, GetCurrentConfigTool, OpenDashboardTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry @@ -611,12 +611,15 @@ class SerenaAgent: def create_system_prompt(self) -> str: available_tools = self._active_tools available_markers = available_tools.tool_marker_names + global_memory_names = MemoriesManager.list_global_memories() + global_memories_list = list_string(global_memory_names) if global_memory_names else "" log.info("Generating system prompt with available_tools=(see active tools), available_markers=%s", available_markers) system_prompt = self.prompt_factory.create_system_prompt( context_system_prompt=self._format_prompt(self._context.prompt), mode_system_prompts=[self._format_prompt(mode.prompt) for mode in self.get_active_modes()], available_tools=available_tools.tool_names, available_markers=available_markers, + global_memories_list=global_memories_list, ) # If a project is active at startup, append its activation message diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 432e8a65..9273d31f 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -85,6 +85,12 @@ class SerenaPaths: """ file containing the ID of the last read news snippet """ + global_memories_path = Path(os.path.join(self.serena_user_home_dir, "memories", "global")) + global_memories_path.mkdir(parents=True, exist_ok=True) + self.global_memories_path = global_memories_path + """ + directory where global memories are stored, i.e. memories that are available across all projects + """ self.last_returned_log_file_path: str | None = None """ the path to the last log file returned by `get_next_log_file_path`. If this is not None, the logs @@ -549,6 +555,9 @@ class SerenaConfig(SharedConfig): """List of paths to ignore across all projects. Same syntax as gitignore, so you can use * and **. These patterns are merged additively with each project's own ignored_paths.""" + edit_global_memories: bool = True + """Whether global memories are allowed to be deleted or edited.""" + # settings with overridden defaults default_modes: Sequence[str] | None = ("interactive", "editing") symbol_info_budget: float = 10.0 @@ -559,7 +568,6 @@ class SerenaConfig(SharedConfig): If the budget is exceeded, Serena stops issuing further requests and returns partial info results. 0 disables the budget (no early stopping). Negative values are invalid. """ - # *** fields that are NOT mapped to/from the configuration file *** _loaded_commented_yaml: CommentedMap | None = None diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index a5c91a04..26ec8f56 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -89,6 +89,11 @@ class RequestDeleteMemory(BaseModel): memory_name: str +class RequestRenameMemory(BaseModel): + old_name: str + new_name: str + + class ResponseGetSerenaConfig(BaseModel): content: str @@ -269,6 +274,18 @@ class SerenaDashboardAPI: except Exception as e: return {"status": "error", "message": str(e)} + @self._app.route("/rename_memory", methods=["POST"]) + def rename_memory() -> dict[str, str]: + request_data = request.get_json() + if not request_data: + return {"status": "error", "message": "No data provided"} + request_rename_memory = RequestRenameMemory.model_validate(request_data) + try: + result_message = self._rename_memory(request_rename_memory) + return {"status": "success", "message": result_message} + except Exception as e: + return {"status": "error", "message": str(e)} + @self._app.route("/get_serena_config", methods=["GET"]) def get_serena_config() -> dict[str, Any]: try: @@ -556,8 +573,7 @@ class SerenaDashboardAPI: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") - - project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content) + project.memories_manager.save_memory(request_save_memory.memory_name, request_save_memory.content, is_tool_context=False) self._agent.execute_task(run, logged=True, name="SaveMemory") @@ -566,11 +582,22 @@ class SerenaDashboardAPI: project = self._agent.get_active_project() if project is None: raise ValueError("No active project") - - project.memories_manager.delete_memory(request_delete_memory.memory_name) + project.memories_manager.delete_memory(request_delete_memory.memory_name, is_tool_context=False) self._agent.execute_task(run, logged=True, name="DeleteMemory") + def _rename_memory(self, request_rename_memory: RequestRenameMemory) -> str: + def run() -> str: + project = self._agent.get_active_project() + if project is None: + raise ValueError("No active project") + + return project.memories_manager.move_memory( + request_rename_memory.old_name, request_rename_memory.new_name, is_tool_context=False + ) + + return self._agent.execute_task(run, logged=True, name="RenameMemory") + def _get_serena_config(self) -> ResponseGetSerenaConfig: config_path = self._agent.serena_config.config_file_path if config_path is None or not os.path.exists(config_path): diff --git a/src/serena/generated/generated_prompt_factory.py b/src/serena/generated/generated_prompt_factory.py index 3a5e1442..9129318c 100644 --- a/src/serena/generated/generated_prompt_factory.py +++ b/src/serena/generated/generated_prompt_factory.py @@ -33,6 +33,12 @@ class PromptFactory(PromptFactoryBase): return self._render_prompt("prepare_for_new_conversation", locals()) def create_system_prompt( - self, *, available_markers: Any, available_tools: Any, context_system_prompt: Any, mode_system_prompts: Any + self, + *, + available_markers: Any, + available_tools: Any, + context_system_prompt: Any, + global_memories_list: Any, + mode_system_prompts: Any, ) -> str: return self._render_prompt("system_prompt", locals()) diff --git a/src/serena/project.py b/src/serena/project.py index 59b89ec4..ed4821cb 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,9 +1,10 @@ import json import logging import os +import shutil import threading from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal import pathspec from sensai.util.logging import LogTime @@ -12,12 +13,13 @@ from sensai.util.string import ToStringMixin from serena.config.serena_config import ( DEFAULT_TOOL_TIMEOUT, ProjectConfig, + SerenaPaths, get_serena_managed_in_project_dir, ) from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.util.file_system import GitignoreParser, match_path -from serena.util.text_utils import MatchedConsecutiveLines, search_files +from serena.util.text_utils import ContentReplacer, MatchedConsecutiveLines, search_files from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils @@ -29,26 +31,59 @@ log = logging.getLogger(__name__) class MemoriesManager: - def __init__(self, project_root: str): - self._memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" - self._memory_dir.mkdir(parents=True, exist_ok=True) + GLOBAL_TOPIC = "global" + _global_memory_dir = SerenaPaths().global_memories_path + + def __init__(self, project_root: str, global_memory_tool_write_access: bool = False): + """ + :param project_root: the project's root directory + :param global_memory_tool_write_access: whether to allow writing global memories in tool execution contexts + """ + self._project_memory_dir = Path(get_serena_managed_in_project_dir(project_root)) / "memories" + self._project_memory_dir.mkdir(parents=True, exist_ok=True) + self._global_memory_tool_write_access = global_memory_tool_write_access self._encoding = SERENA_FILE_ENCODING + def _is_global(self, name: str) -> bool: + return name == self.GLOBAL_TOPIC or name.startswith(self.GLOBAL_TOPIC + "/") + def get_memory_file_path(self, name: str) -> Path: # Strip .md extension if present name = name.replace(".md", "") - # Split by "/" to handle subdirectories + if self._is_global(name): + if name == self.GLOBAL_TOPIC: + raise ValueError( + f'Bare "{self.GLOBAL_TOPIC}" is not a valid memory name. ' + f'Use "{self.GLOBAL_TOPIC}/" to address a global memory.' + ) + # Strip "global/" prefix and resolve against global dir + sub_name = name[len(self.GLOBAL_TOPIC) + 1 :] + parts = sub_name.split("/") + filename = f"{parts[-1]}.md" + if len(parts) > 1: + subdir = self._global_memory_dir / "/".join(parts[:-1]) + subdir.mkdir(parents=True, exist_ok=True) + return subdir / filename + return self._global_memory_dir / filename + + # Project-local memory parts = name.split("/") filename = f"{parts[-1]}.md" if len(parts) > 1: # Create subdirectory path - subdir = self._memory_dir / "/".join(parts[:-1]) + subdir = self._project_memory_dir / "/".join(parts[:-1]) subdir.mkdir(parents=True, exist_ok=True) return subdir / filename - return self._memory_dir / filename + return self._project_memory_dir / filename + + def _check_write_access(self, name: str, is_tool_context: bool) -> None: + # in tool context, global memory write access can be disabled + if is_tool_context: + if self._is_global(name) and not self._global_memory_tool_write_access: + raise PermissionError(f"Writing to global memories is not allowed (attempted to write to '{name}')") def load_memory(self, name: str) -> str: memory_file_path = self.get_memory_file_path(name) @@ -57,47 +92,70 @@ class MemoriesManager: with open(memory_file_path, encoding=self._encoding) as f: return f.read() - def save_memory(self, name: str, content: str) -> str: + def save_memory(self, name: str, content: str, is_tool_context: bool) -> str: + self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) with open(memory_file_path, "w", encoding=self._encoding) as f: f.write(content) return f"Memory {name} written." + @staticmethod + def _list_memories(search_dir: Path, base_dir: Path, prefix: str = "") -> list[str]: + if not search_dir.exists(): + return [] + results = [] + for md_file in search_dir.rglob("*.md"): + rel = str(md_file.relative_to(base_dir).with_suffix("")).replace(os.sep, "/") + results.append(prefix + rel) + return results + + @classmethod + def list_global_memories(cls, subtopic: str = "") -> list[str]: + dir_path = cls._global_memory_dir + if subtopic: + dir_path = dir_path / subtopic.replace("/", os.sep) + return cls._list_memories(dir_path, cls._global_memory_dir, cls.GLOBAL_TOPIC + "/") + + def list_project_memories(self, topic: str = "") -> list[str]: + dir_path = self._project_memory_dir + if topic: + dir_path = dir_path / topic.replace("/", os.sep) + return self._list_memories(dir_path, self._project_memory_dir) + def list_memories(self, topic: str = "") -> list[str]: """ - List memories, optionally filtered by topic. + Lists all memories, optionally filtered by topic. + If the topic is omitted, both global and project-specific memories are returned. """ - memories = [] + memories: list[str] if topic: - # Only list memories in specified subdirectory - search_dir = self._memory_dir / topic.replace("/", os.sep) - if not search_dir.exists(): - return [] + if self._is_global(topic): + topic_parts = topic.split("/") + subtopic = "/".join(topic_parts[1:]) + memories = self.list_global_memories(subtopic=subtopic) + else: + memories = self.list_project_memories(topic=topic) else: - search_dir = self._memory_dir + memories = self.list_project_memories() + self.list_global_memories() - # Recursively find all .md files - for md_file in search_dir.rglob("*.md"): - # Calculate relative path as memory name - rel_path = md_file.relative_to(self._memory_dir) - name = str(rel_path.with_suffix("")).replace(os.sep, "/") - memories.append(name) - - # Sort alphabetically by name return sorted(memories) - def delete_memory(self, name: str) -> str: + def delete_memory(self, name: str, is_tool_context: bool) -> str: + self._check_write_access(name, is_tool_context) memory_file_path = self.get_memory_file_path(name) if not memory_file_path.exists(): return f"Memory {name} not found." memory_file_path.unlink() return f"Memory {name} deleted." - def rename_memory(self, old_name: str, new_name: str) -> str: + def move_memory(self, old_name: str, new_name: str, is_tool_context: bool) -> str: """ Rename or move a memory file. + Moving between global and project scope (e.g. "global/foo" -> "bar") is supported. """ + self._check_write_access(new_name, is_tool_context) + old_path = self.get_memory_file_path(old_name) new_path = self.get_memory_file_path(new_name) @@ -106,13 +164,35 @@ class MemoriesManager: if new_path.exists(): raise FileExistsError(f"Memory {new_name} already exists.") - # Ensure target directory exists new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(old_path, new_path) - # Move/rename the file - old_path.rename(new_path) return f"Memory renamed from {old_name} to {new_name}." + def edit_memory( + self, name: str, needle: str, repl: str, mode: Literal["literal", "regex"], allow_multiple_occurrences: bool, is_tool_context: bool + ) -> str: + """ + Edit a memory by replacing content matching a pattern. + + :param name: the memory name + :param needle: the string or regex to search for + :param repl: the replacement string + :param mode: "literal" or "regex" + :param allow_multiple_occurrences: + """ + self._check_write_access(name, is_tool_context) + memory_file_path = self.get_memory_file_path(name) + if not memory_file_path.exists(): + raise FileNotFoundError(f"Memory {name} not found.") + with open(memory_file_path, encoding=self._encoding) as f: + original_content = f.read() + replacer = ContentReplacer(mode=mode, allow_multiple_occurrences=allow_multiple_occurrences) + updated_content = replacer.replace(original_content, needle, repl) + with open(memory_file_path, "w", encoding=self._encoding) as f: + f.write(updated_content) + return f"Memory {name} edited successfully." + class Project(ToStringMixin): def __init__( @@ -124,7 +204,10 @@ class Project(ToStringMixin): ): self.project_root = project_root self.project_config = project_config - self.memories_manager = MemoriesManager(project_root) + + global_memory_write_access = serena_config.edit_global_memories if serena_config else False + self.memories_manager = MemoriesManager(project_root, global_memory_write_access) + self.language_server_manager: LanguageServerManager | None = None self._is_newly_created = is_newly_created @@ -220,10 +303,10 @@ class Project(ToStringMixin): msg = f"The project with name '{self.project_name}' at {self.project_root} is activated." 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: + project_memories = self.memories_manager.list_project_memories() + if project_memories: msg += ( - f"\nAvailable project memories: {json.dumps(memories)}\n" + f"\nAvailable project memories: {json.dumps(project_memories)}\n" + "Use the `read_memory` tool to read these memories later if they are relevant to the task." ) if self.project_config.initial_prompt: diff --git a/src/serena/resources/config/modes/no-memories.yml b/src/serena/resources/config/modes/no-memories.yml index 7a522875..08602141 100644 --- a/src/serena/resources/config/modes/no-memories.yml +++ b/src/serena/resources/config/modes/no-memories.yml @@ -6,6 +6,7 @@ excluded_tools: - read_memory - delete_memory - edit_memory + - rename_memory - list_memories - onboarding - check_onboarding_performed diff --git a/src/serena/resources/config/prompt_templates/system_prompt.yml b/src/serena/resources/config/prompt_templates/system_prompt.yml index 13064c14..125a6818 100644 --- a/src/serena/resources/config/prompt_templates/system_prompt.yml +++ b/src/serena/resources/config/prompt_templates/system_prompt.yml @@ -35,10 +35,13 @@ prompts: You can understand relationships between symbols by using the `find_referencing_symbols` tool. {% endif %} - {% if 'read_memory' in available_tools %} + {% if 'read_memory' in available_tools -%} You generally have access to memories and it may be useful for you to read them. You infer whether memories are relevant based on their names. - {% endif %} + {% if global_memories_list -%} + The following global (not project-specific) memories are available to you: {{ global_memories_list }} + {%- endif -%} + {%- endif %} The context and modes of operation are described below. These determine how to interact with your user and which kinds of interactions are expected of you. diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 662fed57..ec22d61e 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -1308,6 +1308,42 @@ body { gap: 8px; } +/* Memory Rename Styles */ +.memory-rename-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + opacity: 0.5; + transition: opacity 0.2s ease, background-color 0.2s ease; +} + +.memory-rename-btn:hover { + opacity: 1; + background-color: var(--border-color); +} + +.memory-rename-input { + font-size: inherit; + font-weight: inherit; + font-family: inherit; + color: var(--text-primary); + background: transparent; + border: none; + border-bottom: 1px solid var(--btn-primary); + outline: none; + padding: 0; + flex: 1; + min-width: 200px; + max-width: 80%; +} + /* Log Action Buttons (Save, Copy, Clear) */ .log-action-buttons { position: absolute; diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index f59dbb6a..5819cec3 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -307,6 +307,8 @@ class Dashboard { this.$modalCloseRemove = $('.modal-close-remove'); this.$editMemoryModal = $('#edit-memory-modal'); this.$editMemoryName = $('#edit-memory-name'); + this.$editMemoryRenameBtn = $('#edit-memory-rename-btn'); + this.$editMemoryRenameInput = $('#edit-memory-rename-input'); this.$editMemoryContent = $('#edit-memory-content'); this.$editMemorySaveBtn = $('#edit-memory-save-btn'); this.$editMemoryCancelBtn = $('#edit-memory-cancel-btn'); @@ -365,6 +367,19 @@ class Dashboard { this.$editMemoryCancelBtn.click(this.closeEditMemoryModal.bind(this)); this.$modalCloseEditMemory.click(this.closeEditMemoryModal.bind(this)); this.$editMemoryContent.on('input', this.trackMemoryChanges.bind(this)); + this.$editMemoryRenameBtn.click(this.startMemoryRename.bind(this)); + this.$editMemoryRenameInput.keydown(function (e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + self.commitMemoryRename(); + } else if (e.which === 27) { // Escape key + e.preventDefault(); + self.cancelMemoryRename(); + } + }); + this.$editMemoryRenameInput.on('blur', function () { + self.cancelMemoryRename(); + }); this.$deleteMemoryOkBtn.click(this.confirmDeleteMemoryOk.bind(this)); this.$deleteMemoryCancelBtn.click(this.closeDeleteMemoryModal.bind(this)); this.$modalCloseDeleteMemory.click(this.closeDeleteMemoryModal.bind(this)); @@ -1887,6 +1902,62 @@ class Dashboard { }); } + startMemoryRename() { + this.$editMemoryName.hide(); + this.$editMemoryRenameBtn.hide(); + this.$editMemoryRenameInput.val(this.currentMemoryName).show().focus().select(); + } + + cancelMemoryRename() { + this.$editMemoryRenameInput.hide(); + this.$editMemoryName.show(); + this.$editMemoryRenameBtn.show(); + } + + commitMemoryRename() { + const newName = this.$editMemoryRenameInput.val().trim(); + const oldName = this.currentMemoryName; + + // If name unchanged, just cancel + if (!newName || newName === oldName) { + this.cancelMemoryRename(); + return; + } + + // Validate memory name (alphanumeric, underscores, and slashes for subdirectories) + if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(newName)) { + alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")'); + this.$editMemoryRenameInput.focus(); + return; + } + + const self = this; + this.$editMemoryRenameInput.prop('disabled', true); + + $.ajax({ + url: '/rename_memory', type: 'POST', contentType: 'application/json', data: JSON.stringify({ + old_name: oldName, new_name: newName + }), success: function (response) { + if (response.status === 'success') { + self.currentMemoryName = newName; + self.$editMemoryName.text(newName); + self.cancelMemoryRename(); + // Reload config to reflect the rename in the memory list + self.loadConfigOverview(); + } else { + alert('Error: ' + response.message); + self.$editMemoryRenameInput.focus(); + } + }, error: function (xhr, status, error) { + console.error('Error renaming memory:', error); + alert('Error renaming memory: ' + (xhr.responseJSON ? xhr.responseJSON.message : error)); + self.$editMemoryRenameInput.focus(); + }, complete: function () { + self.$editMemoryRenameInput.prop('disabled', false); + } + }); + } + confirmDeleteMemory(memoryName) { // Set memory name to delete this.memoryToDelete = memoryName; @@ -1960,9 +2031,9 @@ class Dashboard { return; } - // Validate memory name (alphanumeric and underscores only) - if (!/^[a-zA-Z0-9_]+$/.test(memoryName)) { - alert('Memory name can only contain letters, numbers, and underscores'); + // Validate memory name (alphanumeric, underscores, and slashes for subdirectories) + if (!/^[a-zA-Z0-9_]+(?:\/[a-zA-Z0-9_]+)*$/.test(memoryName)) { + alert('Memory name can only contain letters, numbers, underscores, and "/" for subdirectories (e.g., "topic/memory_name")'); return; } diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index c8bf75d7..874aecf9 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -275,7 +275,17 @@