diff --git a/src/serena/agent.py b/src/serena/agent.py index 1997b7de..2876b751 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,16 @@ 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. + self._effective_language_backend = self.serena_config.language_backend + 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._effective_language_backend = startup_project_config.language_backend + log.info(f"Using per-project language backend override: {self._effective_language_backend.name}") + log.info(f"Effective language backend for this session: {self._effective_language_backend.name}") + if self._effective_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())]) @@ -615,10 +624,28 @@ class SerenaAgent: """ :return: whether this agent uses language server-based code analysis """ - return self.serena_config.language_backend == LanguageBackend.LSP + return self._effective_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._effective_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._effective_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 +723,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._effective_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..7d615a32 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -181,6 +181,11 @@ class ProjectConfig(SharedConfig): ignore_all_files_in_gitignore: bool = True initial_prompt: str = "" encoding: str = DEFAULT_SOURCE_FILE_ENCODING + language_backend: LanguageBackend | None = None + """ + The language backend to use for this project. + If None, the global setting from SerenaConfig is used. + """ SERENA_DEFAULT_PROJECT_FILE = "project.yml" FIELDS_WITHOUT_DEFAULTS = {"project_name", "languages"} @@ -344,6 +349,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 +363,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 +375,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 1f7ca87b..c95452b7 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..ea228815 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._effective_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._effective_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._effective_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)