Merge pull request #1060 from mareurs/per-project-language-backend

feat: support per-project language_backend override
This commit is contained in:
Michael Panchenko authored and GitHub committed 2026-02-24 12:26:04 +01:00
commit e7a562aa4d
8 files changed
+296 -29

No files matched your search

+23 -2
View File
@@ -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:
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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
+44 -3
View File
@@ -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"
+24 -18
View File
@@ -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
+2 -2
View File
@@ -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(),
@@ -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
+191 -1
View File
@@ -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)