mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 22:14:54 +00:00
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:
1 parent
5d417beb80
commit
e783bdbc1b
5 files changed
+244
-6
No files matched your search
+34
-3
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user