diff --git a/scripts/profile_tool_call.py b/scripts/profile_tool_call.py index e0e69981..8fe922a5 100644 --- a/scripts/profile_tool_call.py +++ b/scripts/profile_tool_call.py @@ -25,7 +25,6 @@ if __name__ == "__main__": serena_config = SerenaConfig.from_config_file() serena_config.log_level = logging.INFO - serena_config.jetbrains = False serena_config.gui_log_window_enabled = False serena_config.web_dashboard = False diff --git a/src/serena/agent.py b/src/serena/agent.py index e596dd1c..6f3c203b 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -18,7 +18,7 @@ from interprompt.jinja_template import JinjaTemplate from serena import serena_version from serena.analytics import RegisteredTokenCountEstimator, ToolUsageStats from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import SerenaConfig, ToolInclusionDefinition +from serena.config.serena_config import LanguageBackend, SerenaConfig, ToolInclusionDefinition from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager from serena.project import Project @@ -226,7 +226,10 @@ class SerenaAgent: self._tool_usage_stats = ToolUsageStats(token_count_estimator) # log fundamental information - log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") + log.info( + f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; " + f"language backend={self.serena_config.language_backend.name})" + ) log.info("Configuration file: %s", self.serena_config.config_file_path) log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}") @@ -238,7 +241,7 @@ class SerenaAgent: tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] if self._context.single_project: tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project)) - if self.serena_config.jetbrains: + if self.serena_config.language_backend == LanguageBackend.JETBRAINS: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) @@ -514,7 +517,7 @@ class SerenaAgent: """ :return: whether this agent uses language server-based code analysis """ - return not self.serena_config.jetbrains + return self.serena_config.language_backend == LanguageBackend.LSP def _activate_project(self, project: Project) -> None: log.info(f"Activating {project.project_name} at {project.project_root}") diff --git a/src/serena/cli.py b/src/serena/cli.py index 2a722ad3..bc4af3e6 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -18,7 +18,7 @@ from tqdm import tqdm from serena.agent import SerenaAgent from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode -from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths +from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, @@ -154,6 +154,12 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=True, help="Built-in mode names or paths to custom mode YAMLs.", ) + @click.option( + "--language-backend", + type=click.Choice([lb.value for lb in LanguageBackend]), + default=None, + help="Override the configured language backend.", + ) @click.option( "--transport", type=click.Choice(["stdio", "sse", "streamable-http"]), @@ -161,8 +167,16 @@ class TopLevelCommands(AutoRegisteringGroup): show_default=True, help="Transport protocol.", ) - @click.option("--host", type=str, default="0.0.0.0", show_default=True) - @click.option("--port", type=int, default=8000, show_default=True) + @click.option( + "--host", + type=str, + default="0.0.0.0", + show_default=True, + help="Listen address for the MCP server (when using corresponding transport).", + ) + @click.option( + "--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)." + ) @click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.") @click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.") @click.option( @@ -185,6 +199,7 @@ class TopLevelCommands(AutoRegisteringGroup): project_from_cwd: bool | None, context: str, modes: tuple[str, ...], + language_backend: str | None, transport: Literal["stdio", "sse", "streamable-http"], host: str, port: int, @@ -227,6 +242,7 @@ class TopLevelCommands(AutoRegisteringGroup): host=host, port=port, modes=modes, + language_backend=LanguageBackend.from_str(language_backend) if language_backend else None, enable_web_dashboard=enable_web_dashboard, enable_gui_log_window=enable_gui_log_window, log_level=log_level, diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index 6cc94350..f1f34fd9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -70,7 +70,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}. " + f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}." f"Available modes:\n{cls.list_registered_mode_names()}" ) return own_yaml_path @@ -78,16 +78,8 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def from_name(cls, name: str) -> Self: """Load a registered Serena mode.""" - try: - mode_path = cls.get_path(name) - return cls.from_yaml(mode_path) - except: - try: - # Allow loading an internal mode if it is explicitly requested - return cls.from_name_internal(name) - except: - pass - raise + mode_path = cls.get_path(name) + return cls.from_yaml(mode_path) @classmethod def from_name_internal(cls, name: str) -> Self: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 06e1c6d1..5c3b26fd 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -9,6 +9,7 @@ from collections.abc import Iterable from copy import deepcopy from dataclasses import dataclass, field from datetime import datetime +from enum import Enum from functools import cached_property from pathlib import Path from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar @@ -106,6 +107,26 @@ def get_serena_managed_in_project_dir(project_root: str | Path) -> str: return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) +class LanguageBackend(Enum): + LSP = "LSP" + """ + Use the language server protocol (LSP), spawning freely available language servers + via the SolidLSP library that is part of Serena + """ + JETBRAINS = "JetBrains" + """ + Use the Serena plugin in your JetBrains IDE. + (requires the plugin to be installed and the project being worked on to be open in your IDE) + """ + + @staticmethod + def from_str(backend_str: str) -> "LanguageBackend": + for backend in LanguageBackend: + if backend.value.lower() == backend_str.lower(): + return backend + raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") + + @dataclass(kw_only=True) class ProjectConfig(ToolInclusionDefinition, ToStringMixin): project_name: str @@ -362,9 +383,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): the path to the configuration file to which updates of the configuration shall be saved; if None, the configuration is not saved to disk """ - jetbrains: bool = False + + language_backend: LanguageBackend = LanguageBackend.LSP """ - whether to apply JetBrains mode + the language backend to use for code understanding features """ token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name @@ -446,7 +468,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): # load list of known projects instance.projects = [] - num_project_migrations = 0 + num_migrations = 0 for path in loaded_commented_yaml["projects"]: path = Path(path).resolve() if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()): @@ -456,7 +478,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): path = cls._migrate_out_of_project_config_file(path) if path is None: continue - num_project_migrations += 1 + num_migrations += 1 project_config = ProjectConfig.load(path) project = RegisteredProject( project_root=str(path), @@ -469,7 +491,21 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): field_name = key return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name)) - # set other configuration parameters + # determine language backend + language_backend = get_dataclass_default(SerenaConfig, "language_backend") + if "language_backend" in loaded_commented_yaml: + backend_str = loaded_commented_yaml["language_backend"] + language_backend = LanguageBackend.from_str(backend_str) + else: + # backward compatibility (migrate Boolean field "jetbrains") + if "jetbrains" in loaded_commented_yaml: + num_migrations += 1 + if loaded_commented_yaml["jetbrains"]: + language_backend = LanguageBackend.JETBRAINS + del loaded_commented_yaml["jetbrains"] + instance.language_backend = language_backend + + # set other configuration parameters (primitive types) instance.gui_log_window_enabled = get_value_or_default("gui_log_window", "gui_log_window_enabled") instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address") instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) @@ -479,16 +515,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication") instance.excluded_tools = get_value_or_default("excluded_tools") instance.included_optional_tools = get_value_or_default("included_optional_tools") - instance.jetbrains = get_value_or_default("jetbrains") instance.token_count_estimator = get_value_or_default("token_count_estimator") instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars") instance.ls_specific_settings = get_value_or_default("ls_specific_settings") # re-save the configuration file if any migrations were performed - if num_project_migrations > 0: - log.info( - f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration" - ) + if num_migrations > 0: + log.info("Legacy configuration was migrated; re-saving configuration file") instance.save() return instance @@ -591,9 +624,15 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): """ if self.config_file_path is None: return + assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML" + loaded_original_yaml = deepcopy(self.loaded_commented_yaml) - # projects are unique absolute paths - # we also canonicalize them before saving + + # convert project objects into list of paths loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects}) + + # convert language backend to string + loaded_original_yaml["language_backend"] = self.language_backend.value + save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 9002c3bf..55d8b87b 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -9,6 +9,7 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats +from serena.config.serena_config import LanguageBackend from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -443,7 +444,7 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, - jetbrains_mode=self._agent.serena_config.jetbrains, + jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS, languages=languages, encoding=encoding, ) diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 24c9bd38..f4e6646f 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -23,6 +23,7 @@ from serena.agent import ( SerenaConfig, ) from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode +from serena.config.serena_config import LanguageBackend from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, SERENA_LOG_FORMAT from serena.tools import Tool from serena.util.exception import show_fatal_exception_safe @@ -251,6 +252,7 @@ class SerenaMCPFactory: host: str = "0.0.0.0", port: int = 8000, modes: Sequence[str] = DEFAULT_MODES, + language_backend: LanguageBackend | None = None, enable_web_dashboard: bool | None = None, enable_gui_log_window: bool | None = None, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, @@ -263,6 +265,7 @@ class SerenaMCPFactory: :param host: The host to bind to :param port: The port to bind to :param modes: List of mode names or paths to mode files + :param language_backend: the language backend to use, overriding the configuration setting. :param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration. :param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then. If not specified, will take the value from the serena configuration. @@ -286,6 +289,8 @@ class SerenaMCPFactory: config.trace_lsp_communication = trace_lsp_communication if tool_timeout is not None: config.tool_timeout = tool_timeout + if language_backend is not None: + config.language_backend = language_backend modes_instances = [SerenaAgentMode.load(mode) for mode in modes] self._instantiate_agent(config, modes_instances) diff --git a/src/serena/resources/serena_config.template.yml b/src/serena/resources/serena_config.template.yml index 974fe7e5..d197991d 100644 --- a/src/serena/resources/serena_config.template.yml +++ b/src/serena/resources/serena_config.template.yml @@ -1,3 +1,12 @@ +language_backend: LSP +# the language backend to use for code understanding and manipulation. +# Possible values are: +# * LSP: Use the language server protocol (LSP), spawning freely available language servers +# via the SolidLSP library that is part of Serena. +# * JetBrains: Use the Serena plugin in your JetBrains IDE. +# (requires the plugin to be installed and the project being worked on to be open +# in your IDE). + gui_log_window: False # whether to open a graphical window with Serena's logs. # This is mainly supported on Windows and (partly) on Linux; not available on macOS. @@ -42,7 +51,6 @@ ls_specific_settings: {} # (same entry as in project.yml) to the options. # Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. # No documentation on options means no options are available. -# tool_timeout: 240 # timeout, in seconds, after which tool executions are terminated @@ -53,12 +61,6 @@ excluded_tools: [] included_optional_tools: [] # list of optional tools (which are disabled by default) to be included -jetbrains: False -# whether to enable JetBrains mode and use tools based on the Serena JetBrains IDE plugin -# instead of language server-based tools -# NOTE: The plugin is yet unreleased. This is for Serena developers only. - - default_max_tool_answer_chars: 150000 # Used as default for tools where the apply method has a default maximal answer length. # Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default @@ -73,14 +75,5 @@ token_count_estimator: CHAR_COUNT # data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to # estimate the token count using the Claude Sonnet 4 tokenizer. - -# MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED -# The list of registered projects. -# To add a project, within a chat, simply ask Serena to "activate the project /path/to/project" or, -# if the project was previously added, "activate the project ". -# By default, the project's name will be the name of the directory containing the project, but you may change it -# by editing the (auto-generated) project configuration file `/path/project/project/.serena/project.yml` file. -# If you want to maintain full control of the project configuration, create the project.yml file manually and then -# instruct Serena to activate the project by its path for first-time activation. -# NOTE: Make sure there are no name collisions in the names of registered projects. +# The list of registered project paths (updated automatically). projects: []