mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 12:15:10 +00:00
Merge pull request #1060 from mareurs/per-project-language-backend
feat: support per-project language_backend override
This commit is contained in:
8 files changed
+296
-29
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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)
|
||||
Reference in new issue
Block a user