diff --git a/docs/02-usage/025_jetbrains_plugin.md b/docs/02-usage/025_jetbrains_plugin.md index 4da61572..32c69591 100644 --- a/docs/02-usage/025_jetbrains_plugin.md +++ b/docs/02-usage/025_jetbrains_plugin.md @@ -48,10 +48,31 @@ language_backend: JetBrains *Note*: you can also use the button `Edit Global Serena Config` in the Serena MCP dashboard to open the config file in your default editor. **Per-Instance Configuration**. -The configuration setting in the global config file can be overridden on a -per-instance basis by providing the arguments `--language-backend JetBrains` when +The configuration setting in the global config file can be overridden on a +per-instance basis by providing the arguments `--language-backend JetBrains` when launching the Serena MCP server. +(per-project-language-backend)= +**Per-Project Configuration**. +You can also set the language backend on a per-project basis in the project's +`.serena/project.yml` file: + +```yaml +language_backend: JetBrains +``` + +If set, this overrides the global `language_backend` setting for the session when the project is +activated at startup (via the `--project` flag). + +:::{important} +The language backend is determined once at startup and cannot be changed during a running session. +If a project with a different backend is activated after startup, Serena will return an error. + +If you need to work with projects that use different backends, you can either: +1. Use the `--project` flag to activate the project at startup, which will use its configured backend. +2. Configure separate MCP server instances (one per backend) in your client. +::: + **Verifying the Setup**. You can verify that Serena is using the JetBrains plugin by either checking the dashboard, where you will see `Languages: diff --git a/docs/02-usage/040_workflow.md b/docs/02-usage/040_workflow.md index 8f90fe68..c5153401 100644 --- a/docs/02-usage/040_workflow.md +++ b/docs/02-usage/040_workflow.md @@ -46,12 +46,13 @@ After creation, you can adjust the project settings in the generated `.serena/pr within the project directory. The file allows you to configure ... - * the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin) + * the set of programming languages for which language servers are spawned (not relevant when using the JetBrains plugin) Note that you can dynamically add/remove language servers while Serena is running via the [Dashboard](060_dashboard). + * the [language backend](per-project-language-backend) to use for this project (overriding the global setting) * the encoding used in source files * ignore rules * write access - * an initial prompt that shall be passed to the LLM whenever the project is activated + * an initial prompt that shall be passed to the LLM whenever the project is activated * the name by which you want to refer to the project (relevant when telling the LLM to dynamically activate the project) * the set of tools and modes to use by default diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 85bad8d4..336e9679 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -23,7 +23,8 @@ The global configuration file allows you to change general settings and defaults ### Settings Some of the configurable settings include: - * the language backend to use by default (i.e., the JetBrains plugin or language servers) + * the language backend to use by default (i.e., the JetBrains plugin or language servers); + this can also be [overridden per project](per-project-language-backend) * UI settings affecting the [Serena Dashboard and GUI tool](060_dashboard.md) * the set of tools to enable/disable by default * the set of modes to use by default diff --git a/src/serena/agent.py b/src/serena/agent.py index 1997b7de..7f0bd652 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -17,7 +17,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 LanguageBackend, ModeSelectionDefinition, SerenaConfig, ToolInclusionDefinition +from serena.config.serena_config import LanguageBackend, ModeSelectionDefinition, ProjectConfig, SerenaConfig, ToolInclusionDefinition from serena.dashboard import SerenaDashboardAPI from serena.ls_manager import LanguageServerManager from serena.project import Project @@ -318,7 +318,23 @@ class SerenaAgent: tool_inclusion_definitions.append(self._context) if self._context.single_project: tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project)) - if self.serena_config.language_backend == LanguageBackend.JETBRAINS: + + # Determine the effective language backend for this session. + # If a startup project is provided and has a per-project override, use it; otherwise use the global config. + # Since we don't want to change the toolset after startup, the language backend cannot be changed within a running serena session + self._language_backend = self.serena_config.language_backend + _backend_from_project_config = False + if project is not None: + startup_project_config = self._find_project_config(project) + if startup_project_config is not None and startup_project_config.language_backend is not None: + self._language_backend = startup_project_config.language_backend + _backend_from_project_config = True + if _backend_from_project_config: + log.info(f"Using language backend as configured in project.yml: {self._language_backend.name}") + else: + log.info(f"Using language backend from global serena config: {self._language_backend.name}") + + if self._language_backend == LanguageBackend.JETBRAINS: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) self._exposed_tools = AvailableTools([t for t in self._all_tools.values() if self._base_tool_set.includes_name(t.get_name())]) @@ -365,6 +381,9 @@ class SerenaAgent: if self._gui_log_viewer is not None: self._gui_log_viewer.set_dashboard_url(dashboard_url) + def get_language_backend(self) -> LanguageBackend: + return self._language_backend + def get_current_tasks(self) -> list[TaskExecutor.TaskInfo]: """ Gets the list of tasks currently running or queued for execution. @@ -615,10 +634,28 @@ class SerenaAgent: """ :return: whether this agent uses language server-based code analysis """ - return self.serena_config.language_backend == LanguageBackend.LSP + return self._language_backend == LanguageBackend.LSP + + def _find_project_config(self, project_path_or_name: str) -> ProjectConfig | None: + """Find the ProjectConfig for the given project path or name, or None if not found.""" + registered = self.serena_config.get_registered_project(project_path_or_name) + if registered is not None: + return registered.project_config + return None def _activate_project(self, project: Project, update_modes_and_tools: bool = True) -> None: log.info(f"Activating {project.project_name} at {project.project_root}") + + # Check if the project requires a different language backend than the one initialized at startup + project_backend = project.project_config.language_backend + if project_backend is not None and project_backend != self._language_backend: + raise ValueError( + f"Cannot activate project '{project.project_name}': it requires the {project_backend.value} backend, " + f"but this session was initialized with {self._language_backend.value}. " + f"Workarounds: (1) Use project activation at startup via the --project flag, " + f"(2) Configure one MCP server per backend in your client." + ) + self._active_project = project if update_modes_and_tools: @@ -696,6 +733,10 @@ class SerenaAgent: result_str += f"Active project: {self._active_project.project_name}\n" else: result_str += "No active project\n" + result_str += f"Language backend: {self._language_backend.value}" + if self._active_project and self._active_project.project_config.language_backend is not None: + result_str += " (project override)" + result_str += f" (global default: {self.serena_config.language_backend.value})\n" result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) + "\n" result_str += f"Active context: {self._context.name}\n" diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 78dec2e3..0fab8fd4 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -134,24 +134,6 @@ class ModeSelectionDefinition: default_modes: Sequence[str] | None = None -@dataclass -class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): - """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form - (same as in ModeSelectionDefinition). - The defaults here shall be none and should be set to the global default values in SerenaConfig. - """ - - symbol_info_budget: float | None = None - - -class SerenaConfigError(Exception): - pass - - -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" """ @@ -172,6 +154,25 @@ class LanguageBackend(Enum): raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}") +@dataclass +class SharedConfig(ModeSelectionDefinition, ToolInclusionDefinition, ToStringMixin): + """Shared between SerenaConfig and ProjectConfig, the latter used to override values in the form + (same as in ModeSelectionDefinition). + The defaults here shall be none and should be set to the global default values in SerenaConfig. + """ + + symbol_info_budget: float | None = None + language_backend: LanguageBackend | None = None + + +class SerenaConfigError(Exception): + pass + + +def get_serena_managed_in_project_dir(project_root: str | Path) -> str: + return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) + + @dataclass(kw_only=True) class ProjectConfig(SharedConfig): project_name: str @@ -344,6 +345,9 @@ class ProjectConfig(SharedConfig): if symbol_info_budget < 0: raise ValueError(f"symbol_info_budget cannot be negative, got: {symbol_info_budget}") + language_backend_value = data.get("language_backend") + language_backend = LanguageBackend.from_str(language_backend_value) if language_backend_value else None + return cls( project_name=data["project_name"], languages=languages, @@ -355,6 +359,7 @@ class ProjectConfig(SharedConfig): ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], initial_prompt=data["initial_prompt"], encoding=data["encoding"], + language_backend=language_backend, base_modes=data["base_modes"], default_modes=data["default_modes"], symbol_info_budget=symbol_info_budget, @@ -366,6 +371,7 @@ class ProjectConfig(SharedConfig): """ d = dataclasses.asdict(self) d["languages"] = [lang.value for lang in self.languages] + d["language_backend"] = self.language_backend.value if self.language_backend is not None else None return d @classmethod diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index db7ab4a7..a5c91a04 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -10,7 +10,7 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats -from serena.config.serena_config import LanguageBackend, SerenaConfig, SerenaPaths +from serena.config.serena_config import SerenaConfig, SerenaPaths from serena.constants import SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -507,7 +507,7 @@ class SerenaDashboardAPI: available_modes=available_modes, available_contexts=available_contexts, available_memories=available_memories, - jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS, + jetbrains_mode=not self._agent.is_using_language_server(), languages=languages, encoding=encoding, current_client=Tool.get_last_tool_call_client_str(), diff --git a/src/serena/resources/project.template.yml b/src/serena/resources/project.template.yml index 88f1b7ea..afbb0aca 100644 --- a/src/serena/resources/project.template.yml +++ b/src/serena/resources/project.template.yml @@ -31,6 +31,13 @@ languages: ["python"] # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings encoding: "utf-8" +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + # whether to use project's .gitignore files to ignore files ignore_all_files_in_gitignore: true diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index 93abd5a6..e4515a0d 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -1,11 +1,14 @@ +import logging import shutil import tempfile from pathlib import Path import pytest -from serena.config.serena_config import ProjectConfig +from serena.agent import SerenaAgent +from serena.config.serena_config import LanguageBackend, ProjectConfig, RegisteredProject, SerenaConfig from serena.constants import PROJECT_TEMPLATE_FILE +from serena.project import Project from solidlsp.ls_config import Language @@ -124,3 +127,190 @@ class TestProjectConfig: def test_template_is_complete(self): _, is_complete = ProjectConfig._load_yaml(PROJECT_TEMPLATE_FILE) assert is_complete, "Project template YAML is incomplete; all fields must be present (with descriptions)." + + +class TestProjectConfigLanguageBackend: + """Tests for the per-project language_backend field.""" + + def test_language_backend_defaults_to_none(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + ) + assert config.language_backend is None + + def test_language_backend_can_be_set(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + language_backend=LanguageBackend.JETBRAINS, + ) + assert config.language_backend == LanguageBackend.JETBRAINS + + def test_language_backend_roundtrips_through_yaml(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + language_backend=LanguageBackend.JETBRAINS, + ) + d = config._to_yaml_dict() + assert d["language_backend"] == "JetBrains" + + def test_language_backend_none_roundtrips_through_yaml(self): + config = ProjectConfig( + project_name="test", + languages=[Language.PYTHON], + ) + d = config._to_yaml_dict() + assert d["language_backend"] is None + + def test_language_backend_parsed_from_dict(self): + """Test that _from_dict parses language_backend correctly.""" + template_path = PROJECT_TEMPLATE_FILE + data, _ = ProjectConfig._load_yaml(template_path) + data["project_name"] = "test" + data["languages"] = ["python"] + data["language_backend"] = "JetBrains" + config = ProjectConfig._from_dict(data) + assert config.language_backend == LanguageBackend.JETBRAINS + + def test_language_backend_none_when_missing_from_dict(self): + """Test that _from_dict handles missing language_backend gracefully.""" + template_path = PROJECT_TEMPLATE_FILE + data, _ = ProjectConfig._load_yaml(template_path) + data["project_name"] = "test" + data["languages"] = ["python"] + data.pop("language_backend", None) + config = ProjectConfig._from_dict(data) + assert config.language_backend is None + + +def _make_config_with_project( + project_name: str, + language_backend: LanguageBackend | None = None, + global_backend: LanguageBackend = LanguageBackend.LSP, +) -> tuple[SerenaConfig, str]: + """Create a SerenaConfig with a single registered project and return (config, project_name).""" + project = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name=project_name, + languages=[Language.PYTHON], + language_backend=language_backend, + ), + ) + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + log_level=logging.ERROR, + language_backend=global_backend, + ) + config.projects = [RegisteredProject.from_project_instance(project)] + return config, project_name + + +class TestEffectiveLanguageBackend: + """Tests for per-project language_backend override logic in SerenaAgent.""" + + def test_default_backend_is_global(self): + """When no project override, effective backend matches global config.""" + 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 + finally: + agent.shutdown(timeout=5) + + def test_project_overrides_global_backend(self): + """When startup project has language_backend set, it overrides the global.""" + config, name = _make_config_with_project( + "test_jetbrains", language_backend=LanguageBackend.JETBRAINS, global_backend=LanguageBackend.LSP + ) + agent = SerenaAgent(project=name, serena_config=config) + try: + assert agent.get_language_backend() == LanguageBackend.JETBRAINS + assert agent.is_using_language_server() is False + finally: + agent.shutdown(timeout=5) + + def test_no_project_uses_global_backend(self): + """When no startup project is provided, effective backend is the global one.""" + config = SerenaConfig( + gui_log_window=False, + web_dashboard=False, + log_level=logging.ERROR, + language_backend=LanguageBackend.LSP, + ) + agent = SerenaAgent(project=None, serena_config=config) + try: + assert agent.get_language_backend() == LanguageBackend.LSP + finally: + agent.shutdown(timeout=5) + + def test_activate_project_rejects_backend_mismatch(self): + """Post-init activation of a project with mismatched backend raises ValueError.""" + # Start with LSP backend + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + + # Add a second project that requires JetBrains + jb_project = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name="jb_proj", + languages=[Language.PYTHON], + language_backend=LanguageBackend.JETBRAINS, + ), + ) + config.projects.append(RegisteredProject.from_project_instance(jb_project)) + + agent = SerenaAgent(project=name, serena_config=config) + try: + with pytest.raises(ValueError, match="Cannot activate project"): + agent.activate_project_from_path_or_name("jb_proj") + finally: + agent.shutdown(timeout=5) + + def test_activate_project_allows_matching_backend(self): + """Post-init activation of a project with matching backend succeeds.""" + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + + # Add a second project that also uses LSP + lsp_project2 = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name="lsp_proj2", + languages=[Language.PYTHON], + language_backend=LanguageBackend.LSP, + ), + ) + config.projects.append(RegisteredProject.from_project_instance(lsp_project2)) + + agent = SerenaAgent(project=name, serena_config=config) + try: + # Should not raise + agent.activate_project_from_path_or_name("lsp_proj2") + finally: + agent.shutdown(timeout=5) + + def test_activate_project_allows_none_backend(self): + """Post-init activation of a project with no backend override succeeds.""" + config, name = _make_config_with_project("lsp_proj", language_backend=None, global_backend=LanguageBackend.LSP) + + # Add a second project with no backend override + proj2 = Project( + project_root=str(Path(__file__).parent.parent / "resources" / "repos" / "python" / "test_repo"), + project_config=ProjectConfig( + project_name="proj2", + languages=[Language.PYTHON], + language_backend=None, + ), + ) + config.projects.append(RegisteredProject.from_project_instance(proj2)) + + agent = SerenaAgent(project=name, serena_config=config) + try: + # Should not raise — None means "inherit session backend" + agent.activate_project_from_path_or_name("proj2") + finally: + agent.shutdown(timeout=5)