feat: support per-project language_backend override

Determine the effective language backend at startup:
- If a startup project has a per-project override, use it
- Otherwise use the global config

Post-init activation of a project with a different backend returns
an error with workarounds (startup activation or separate MCP server
per backend). No dynamic tool swapping — the exposed toolset is fixed
for the session.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius AilincaandClaude Opus 4.6 committed 2026-02-23 01:10:29 +02:00
1 parent 5d417beb80
commit e783bdbc1b
5 files changed
+244 -6

No files matched your search

+34 -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,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"
+10
View File
@@ -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
+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._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)