mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 16:15:29 +00:00
Merge branch 'main' into prepare-v1
Conflicts: src/serena/resources/serena_config.template.yml
This commit is contained in:
commit
d8d1cb113f
16 files changed
+510
-67
No files matched your search
+2
-1
@@ -6,7 +6,8 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
* Add monorepo/multi-language support
|
||||
* Project configuration files (`project.yml`) can now define multiple languages.
|
||||
Auto-detection adds only the most prominent language by default.
|
||||
* Additional languages can be conveniently added via the Dashboard while a project is already activated.
|
||||
* Additional languages can be conveniently added via the Dashboard while a project is already activated.
|
||||
* The current project can be activated automatically even when the MCP configuration is global (through the --project-from-cwd flag)
|
||||
* Support overloaded symbols in `FindSymbolTool` and related tools
|
||||
* Name paths of overloaded symbols now include an index (e.g., `myOverloadedFunction[2]`)
|
||||
* Responses of the Java language server, which handled this in its own way, are now adapted accordingly,
|
||||
|
||||
@@ -157,6 +157,7 @@ to get a list of all available options.
|
||||
Some useful options include:
|
||||
|
||||
* `--project <path|name>`: specify the project to work on by name or path.
|
||||
* `--project-from-cwd`: auto-detect project from current working directory (searches up for `.serena/project.yml` or `.git`, falls back to CWD). Intended only for CLI-based agents like Claude Code, Gemini and Codex.
|
||||
* `--context <context>`: specify the operation [context](contexts) in which Serena shall operate
|
||||
* `--mode <mode>`: specify one or more [modes](modes) to enable (can be passed several times)
|
||||
* `--enable-web-dashboard <true|false>`: enable or disable the web dashboard (enabled by default)
|
||||
@@ -234,4 +235,4 @@ Here are some examples of commands you might find useful:
|
||||
<serena> prompts edit-override prompt-name
|
||||
```
|
||||
|
||||
Explore the full set of commands and options using the CLI itself!
|
||||
Explore the full set of commands and options using the CLI itself!
|
||||
@@ -88,6 +88,18 @@ Note:
|
||||
that Serena is configured to work on the current project from the get-go, following
|
||||
Claude Code's mode of operation.
|
||||
|
||||
Alternatively, use `--project-from-cwd` for user-level configuration that works across all projects:
|
||||
|
||||
```shell
|
||||
claude mcp add --scope user serena -- <serena> start-mcp-server --context=claude-code --project-from-cwd
|
||||
```
|
||||
|
||||
This searches up from the current directory for `.serena/project.yml` or `.git` markers,
|
||||
falling back to the current directory if neither is found. This makes it suitable for a
|
||||
single global MCP configuration. The `--project-from-cwd` option is intended for CLI-based agents
|
||||
(like Claude Code, Gemina or Codex) that invoke Serena from within project directories.
|
||||
|
||||
Be sure to use at least `v1.0.52` of Claude Code (as earlier versions do not read MCP server system prompts upon startup).
|
||||
|
||||
### VSCode
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ if __name__ == "__main__":
|
||||
|
||||
serena_config = SerenaConfig.from_config_file()
|
||||
serena_config.log_level = logging.INFO
|
||||
serena_config.jetbrains = False
|
||||
serena_config.gui_log_window_enabled = False
|
||||
serena_config.web_dashboard = False
|
||||
|
||||
|
||||
+16
-9
@@ -18,13 +18,14 @@ 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 SerenaConfig, ToolInclusionDefinition
|
||||
from serena.config.serena_config import LanguageBackend, SerenaConfig, ToolInclusionDefinition
|
||||
from serena.dashboard import SerenaDashboardAPI
|
||||
from serena.ls_manager import LanguageServerManager
|
||||
from serena.project import Project
|
||||
from serena.prompt_factory import SerenaPromptFactory
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.tools import ActivateProjectTool, GetCurrentConfigTool, ReplaceContentTool, Tool, ToolMarker, ToolRegistry
|
||||
from serena.util.gui import system_has_usable_display
|
||||
from serena.util.inspection import iter_subclasses
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
from solidlsp.ls_config import Language
|
||||
@@ -226,7 +227,10 @@ class SerenaAgent:
|
||||
self._tool_usage_stats = ToolUsageStats(token_count_estimator)
|
||||
|
||||
# log fundamental information
|
||||
log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})")
|
||||
log.info(
|
||||
f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()}; "
|
||||
f"language backend={self.serena_config.language_backend.name})"
|
||||
)
|
||||
log.info("Configuration file: %s", self.serena_config.config_file_path)
|
||||
log.info("Available projects: {}".format(", ".join(self.serena_config.project_names)))
|
||||
log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}")
|
||||
@@ -238,7 +242,7 @@ class SerenaAgent:
|
||||
tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context]
|
||||
if self._context.single_project:
|
||||
tool_inclusion_definitions.extend(self._single_project_context_tool_inclusion_definitions(project))
|
||||
if self.serena_config.jetbrains:
|
||||
if self.serena_config.language_backend == LanguageBackend.JETBRAINS:
|
||||
tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains"))
|
||||
|
||||
self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions)
|
||||
@@ -281,11 +285,14 @@ class SerenaAgent:
|
||||
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
|
||||
log.info("Serena web dashboard started at %s", dashboard_url)
|
||||
if self.serena_config.web_dashboard_open_on_launch:
|
||||
# open the dashboard URL in the default web browser (using a separate process to control
|
||||
# output redirection)
|
||||
process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,))
|
||||
process.start()
|
||||
process.join(timeout=1)
|
||||
if not system_has_usable_display():
|
||||
log.warning("Not opening the Serena web dashboard automatically because no usable display was detected.")
|
||||
else:
|
||||
# open the dashboard URL in the default web browser (using a separate process to control
|
||||
# output redirection)
|
||||
process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,))
|
||||
process.start()
|
||||
process.join(timeout=1)
|
||||
# inform the GUI window (if any)
|
||||
if self._gui_log_viewer is not None:
|
||||
self._gui_log_viewer.set_dashboard_url(dashboard_url)
|
||||
@@ -514,7 +521,7 @@ class SerenaAgent:
|
||||
"""
|
||||
:return: whether this agent uses language server-based code analysis
|
||||
"""
|
||||
return not self.serena_config.jetbrains
|
||||
return self.serena_config.language_backend == LanguageBackend.LSP
|
||||
|
||||
def _activate_project(self, project: Project) -> None:
|
||||
log.info(f"Activating {project.project_name} at {project.project_root}")
|
||||
|
||||
+71
-3
@@ -5,6 +5,7 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
@@ -17,7 +18,7 @@ from tqdm import tqdm
|
||||
|
||||
from serena.agent import SerenaAgent
|
||||
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths
|
||||
from serena.config.serena_config import LanguageBackend, ProjectConfig, SerenaConfig, SerenaPaths
|
||||
from serena.constants import (
|
||||
DEFAULT_CONTEXT,
|
||||
DEFAULT_MODES,
|
||||
@@ -35,6 +36,42 @@ from solidlsp.util.subprocess_util import subprocess_kwargs
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_project_root(root: str | Path | None = None) -> str:
|
||||
"""Find project root by walking up from CWD.
|
||||
|
||||
Checks for .serena/project.yml first (explicit Serena project), then .git (git root).
|
||||
Falls back to CWD if no marker is found.
|
||||
|
||||
:param root: If provided, constrains the search to this directory and below
|
||||
(acts as a virtual filesystem root). Search stops at this boundary.
|
||||
:return: absolute path to project root (falls back to CWD if no marker found)
|
||||
"""
|
||||
current = Path.cwd().resolve()
|
||||
boundary = Path(root).resolve() if root is not None else None
|
||||
|
||||
def ancestors() -> Iterator[Path]:
|
||||
"""Yield current directory and ancestors up to boundary."""
|
||||
yield current
|
||||
for parent in current.parents:
|
||||
yield parent
|
||||
if boundary is not None and parent == boundary:
|
||||
return
|
||||
|
||||
# First pass: look for .serena
|
||||
for directory in ancestors():
|
||||
if (directory / ".serena" / "project.yml").is_file():
|
||||
return str(directory)
|
||||
|
||||
# Second pass: look for .git
|
||||
for directory in ancestors():
|
||||
if (directory / ".git").exists(): # .git can be file (worktree) or dir
|
||||
return str(directory)
|
||||
|
||||
# Fall back to CWD
|
||||
return str(current)
|
||||
|
||||
|
||||
# --------------------- Utilities -------------------------------------
|
||||
|
||||
|
||||
@@ -117,6 +154,12 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
show_default=True,
|
||||
help="Built-in mode names or paths to custom mode YAMLs.",
|
||||
)
|
||||
@click.option(
|
||||
"--language-backend",
|
||||
type=click.Choice([lb.value for lb in LanguageBackend]),
|
||||
default=None,
|
||||
help="Override the configured language backend.",
|
||||
)
|
||||
@click.option(
|
||||
"--transport",
|
||||
type=click.Choice(["stdio", "sse", "streamable-http"]),
|
||||
@@ -124,8 +167,16 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
show_default=True,
|
||||
help="Transport protocol.",
|
||||
)
|
||||
@click.option("--host", type=str, default="0.0.0.0", show_default=True)
|
||||
@click.option("--port", type=int, default=8000, show_default=True)
|
||||
@click.option(
|
||||
"--host",
|
||||
type=str,
|
||||
default="0.0.0.0",
|
||||
show_default=True,
|
||||
help="Listen address for the MCP server (when using corresponding transport).",
|
||||
)
|
||||
@click.option(
|
||||
"--port", type=int, default=8000, show_default=True, help="Listen port for the MCP server (when using corresponding transport)."
|
||||
)
|
||||
@click.option("--enable-web-dashboard", type=bool, is_flag=False, default=None, help="Override dashboard setting in config.")
|
||||
@click.option("--enable-gui-log-window", type=bool, is_flag=False, default=None, help="Override GUI log window setting in config.")
|
||||
@click.option(
|
||||
@@ -136,11 +187,19 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
)
|
||||
@click.option("--trace-lsp-communication", type=bool, is_flag=False, default=None, help="Whether to trace LSP communication.")
|
||||
@click.option("--tool-timeout", type=float, default=None, help="Override tool execution timeout in config.")
|
||||
@click.option(
|
||||
"--project-from-cwd",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Auto-detect project from current working directory (searches for .serena/project.yml or .git, falls back to CWD). Intended for CLI-based agents like Claude Code, Gemini and Codex.",
|
||||
)
|
||||
def start_mcp_server(
|
||||
project: str | None,
|
||||
project_file_arg: str | None,
|
||||
project_from_cwd: bool | None,
|
||||
context: str,
|
||||
modes: tuple[str, ...],
|
||||
language_backend: str | None,
|
||||
transport: Literal["stdio", "sse", "streamable-http"],
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -169,12 +228,21 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
|
||||
log.info("Initializing Serena MCP server")
|
||||
log.info("Storing logs in %s", log_path)
|
||||
|
||||
# Handle --project-from-cwd flag
|
||||
if project_from_cwd:
|
||||
if project is not None or project_file_arg is not None:
|
||||
raise click.UsageError("--project-from-cwd cannot be used with --project or positional project argument")
|
||||
project = find_project_root()
|
||||
log.info("Auto-detected project root: %s", project)
|
||||
|
||||
project_file = project_file_arg or project
|
||||
factory = SerenaMCPFactorySingleProcess(context=context, project=project_file, memory_log_handler=memory_log_handler)
|
||||
server = factory.create_mcp_server(
|
||||
host=host,
|
||||
port=port,
|
||||
modes=modes,
|
||||
language_backend=LanguageBackend.from_str(language_backend) if language_backend else None,
|
||||
enable_web_dashboard=enable_web_dashboard,
|
||||
enable_gui_log_window=enable_gui_log_window,
|
||||
log_level=log_level,
|
||||
|
||||
@@ -70,7 +70,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
|
||||
own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname)
|
||||
if not os.path.exists(own_yaml_path):
|
||||
raise FileNotFoundError(
|
||||
f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}. "
|
||||
f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}."
|
||||
f"Available modes:\n{cls.list_registered_mode_names()}"
|
||||
)
|
||||
return own_yaml_path
|
||||
@@ -78,16 +78,8 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin):
|
||||
@classmethod
|
||||
def from_name(cls, name: str) -> Self:
|
||||
"""Load a registered Serena mode."""
|
||||
try:
|
||||
mode_path = cls.get_path(name)
|
||||
return cls.from_yaml(mode_path)
|
||||
except:
|
||||
try:
|
||||
# Allow loading an internal mode if it is explicitly requested
|
||||
return cls.from_name_internal(name)
|
||||
except:
|
||||
pass
|
||||
raise
|
||||
mode_path = cls.get_path(name)
|
||||
return cls.from_yaml(mode_path)
|
||||
|
||||
@classmethod
|
||||
def from_name_internal(cls, name: str) -> Self:
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections.abc import Iterable
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Optional, Self, TypeVar
|
||||
@@ -106,6 +107,26 @@ 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"
|
||||
"""
|
||||
Use the language server protocol (LSP), spawning freely available language servers
|
||||
via the SolidLSP library that is part of Serena
|
||||
"""
|
||||
JETBRAINS = "JetBrains"
|
||||
"""
|
||||
Use the Serena plugin in your JetBrains IDE.
|
||||
(requires the plugin to be installed and the project being worked on to be open in your IDE)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def from_str(backend_str: str) -> "LanguageBackend":
|
||||
for backend in LanguageBackend:
|
||||
if backend.value.lower() == backend_str.lower():
|
||||
return backend
|
||||
raise ValueError(f"Unknown language backend '{backend_str}': valid values are {[b.value for b in LanguageBackend]}")
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class ProjectConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
project_name: str
|
||||
@@ -362,9 +383,10 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
the path to the configuration file to which updates of the configuration shall be saved;
|
||||
if None, the configuration is not saved to disk
|
||||
"""
|
||||
jetbrains: bool = False
|
||||
|
||||
language_backend: LanguageBackend = LanguageBackend.LSP
|
||||
"""
|
||||
whether to apply JetBrains mode
|
||||
the language backend to use for code understanding features
|
||||
"""
|
||||
|
||||
token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name
|
||||
@@ -446,7 +468,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
|
||||
# load list of known projects
|
||||
instance.projects = []
|
||||
num_project_migrations = 0
|
||||
num_migrations = 0
|
||||
for path in loaded_commented_yaml["projects"]:
|
||||
path = Path(path).resolve()
|
||||
if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()):
|
||||
@@ -456,7 +478,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
path = cls._migrate_out_of_project_config_file(path)
|
||||
if path is None:
|
||||
continue
|
||||
num_project_migrations += 1
|
||||
num_migrations += 1
|
||||
project_config = ProjectConfig.load(path)
|
||||
project = RegisteredProject(
|
||||
project_root=str(path),
|
||||
@@ -469,7 +491,21 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
field_name = key
|
||||
return loaded_commented_yaml.get(key, get_dataclass_default(SerenaConfig, field_name))
|
||||
|
||||
# set other configuration parameters
|
||||
# determine language backend
|
||||
language_backend = get_dataclass_default(SerenaConfig, "language_backend")
|
||||
if "language_backend" in loaded_commented_yaml:
|
||||
backend_str = loaded_commented_yaml["language_backend"]
|
||||
language_backend = LanguageBackend.from_str(backend_str)
|
||||
else:
|
||||
# backward compatibility (migrate Boolean field "jetbrains")
|
||||
if "jetbrains" in loaded_commented_yaml:
|
||||
num_migrations += 1
|
||||
if loaded_commented_yaml["jetbrains"]:
|
||||
language_backend = LanguageBackend.JETBRAINS
|
||||
del loaded_commented_yaml["jetbrains"]
|
||||
instance.language_backend = language_backend
|
||||
|
||||
# set other configuration parameters (primitive types)
|
||||
instance.gui_log_window_enabled = get_value_or_default("gui_log_window", "gui_log_window_enabled")
|
||||
instance.web_dashboard_listen_address = get_value_or_default("web_dashboard_listen_address")
|
||||
instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO))
|
||||
@@ -479,16 +515,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
instance.trace_lsp_communication = get_value_or_default("trace_lsp_communication")
|
||||
instance.excluded_tools = get_value_or_default("excluded_tools")
|
||||
instance.included_optional_tools = get_value_or_default("included_optional_tools")
|
||||
instance.jetbrains = get_value_or_default("jetbrains")
|
||||
instance.token_count_estimator = get_value_or_default("token_count_estimator")
|
||||
instance.default_max_tool_answer_chars = get_value_or_default("default_max_tool_answer_chars")
|
||||
instance.ls_specific_settings = get_value_or_default("ls_specific_settings")
|
||||
|
||||
# re-save the configuration file if any migrations were performed
|
||||
if num_project_migrations > 0:
|
||||
log.info(
|
||||
f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration"
|
||||
)
|
||||
if num_migrations > 0:
|
||||
log.info("Legacy configuration was migrated; re-saving configuration file")
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
@@ -591,9 +624,15 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin):
|
||||
"""
|
||||
if self.config_file_path is None:
|
||||
return
|
||||
|
||||
assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML"
|
||||
|
||||
loaded_original_yaml = deepcopy(self.loaded_commented_yaml)
|
||||
# projects are unique absolute paths
|
||||
# we also canonicalize them before saving
|
||||
|
||||
# convert project objects into list of paths
|
||||
loaded_original_yaml["projects"] = sorted({str(project.project_root) for project in self.projects})
|
||||
|
||||
# convert language backend to string
|
||||
loaded_original_yaml["language_backend"] = self.language_backend.value
|
||||
|
||||
save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True)
|
||||
@@ -9,6 +9,7 @@ from pydantic import BaseModel
|
||||
from sensai.util import logging
|
||||
|
||||
from serena.analytics import ToolUsageStats
|
||||
from serena.config.serena_config import LanguageBackend
|
||||
from serena.constants import SERENA_DASHBOARD_DIR
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
@@ -443,7 +444,7 @@ class SerenaDashboardAPI:
|
||||
available_modes=available_modes,
|
||||
available_contexts=available_contexts,
|
||||
available_memories=available_memories,
|
||||
jetbrains_mode=self._agent.serena_config.jetbrains,
|
||||
jetbrains_mode=self._agent.serena_config.language_backend == LanguageBackend.JETBRAINS,
|
||||
languages=languages,
|
||||
encoding=encoding,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ from serena.agent import (
|
||||
SerenaConfig,
|
||||
)
|
||||
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.config.serena_config import LanguageBackend
|
||||
from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, SERENA_LOG_FORMAT
|
||||
from serena.tools import Tool
|
||||
from serena.util.exception import show_fatal_exception_safe
|
||||
@@ -251,6 +252,7 @@ class SerenaMCPFactory:
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8000,
|
||||
modes: Sequence[str] = DEFAULT_MODES,
|
||||
language_backend: LanguageBackend | None = None,
|
||||
enable_web_dashboard: bool | None = None,
|
||||
enable_gui_log_window: bool | None = None,
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
||||
@@ -263,6 +265,7 @@ class SerenaMCPFactory:
|
||||
:param host: The host to bind to
|
||||
:param port: The port to bind to
|
||||
:param modes: List of mode names or paths to mode files
|
||||
:param language_backend: the language backend to use, overriding the configuration setting.
|
||||
:param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration.
|
||||
:param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then.
|
||||
If not specified, will take the value from the serena configuration.
|
||||
@@ -286,6 +289,8 @@ class SerenaMCPFactory:
|
||||
config.trace_lsp_communication = trace_lsp_communication
|
||||
if tool_timeout is not None:
|
||||
config.tool_timeout = tool_timeout
|
||||
if language_backend is not None:
|
||||
config.language_backend = language_backend
|
||||
|
||||
modes_instances = [SerenaAgentMode.load(mode) for mode in modes]
|
||||
self._instantiate_agent(config, modes_instances)
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
language_backend: LSP
|
||||
# the language backend to use for code understanding and manipulation.
|
||||
# Possible values are:
|
||||
# * LSP: Use the language server protocol (LSP), spawning freely available language servers
|
||||
# via the SolidLSP library that is part of Serena.
|
||||
# * JetBrains: Use the Serena plugin in your JetBrains IDE.
|
||||
# (requires the plugin to be installed and the project being worked on to be open
|
||||
# in your IDE).
|
||||
|
||||
gui_log_window: False
|
||||
# whether to open a graphical window with Serena's logs.
|
||||
# This is mainly supported on Windows and (partly) on Linux; not available on macOS.
|
||||
@@ -42,7 +51,6 @@ ls_specific_settings: {}
|
||||
# (same entry as in project.yml) to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
#
|
||||
|
||||
tool_timeout: 240
|
||||
# timeout, in seconds, after which tool executions are terminated
|
||||
@@ -53,12 +61,6 @@ excluded_tools: []
|
||||
included_optional_tools: []
|
||||
# list of optional tools (which are disabled by default) to be included
|
||||
|
||||
jetbrains: False
|
||||
# whether to enable JetBrains mode and use tools based on the Serena JetBrains IDE plugin
|
||||
# instead of language-server-based tools. Requires the Serena JetBrains plugin to be installed and
|
||||
# a JetBrains IDE instance to be running with an open project.
|
||||
|
||||
|
||||
default_max_tool_answer_chars: 150000
|
||||
# Used as default for tools where the apply method has a default maximal answer length.
|
||||
# Even though the value of the max_answer_chars can be changed when calling the tool, it may make sense to adjust this default
|
||||
@@ -73,14 +75,5 @@ token_count_estimator: CHAR_COUNT
|
||||
# data files on first run), or ANTHROPIC_CLAUDE_SONNET_4 which will use the (free of cost) Anthropic API to
|
||||
# estimate the token count using the Claude Sonnet 4 tokenizer.
|
||||
|
||||
|
||||
# MANAGED BY SERENA, KEEP AT THE BOTTOM OF THE YAML AND DON'T EDIT WITHOUT NEED
|
||||
# The list of registered projects.
|
||||
# To add a project, within a chat, simply ask Serena to "activate the project /path/to/project" or,
|
||||
# if the project was previously added, "activate the project <project name>".
|
||||
# By default, the project's name will be the name of the directory containing the project, but you may change it
|
||||
# by editing the (auto-generated) project configuration file `/path/project/project/.serena/project.yml` file.
|
||||
# If you want to maintain full control of the project configuration, create the project.yml file manually and then
|
||||
# instruct Serena to activate the project by its path for first-time activation.
|
||||
# NOTE: Make sure there are no name collisions in the names of registered projects.
|
||||
# The list of registered project paths (updated automatically).
|
||||
projects: []
|
||||
@@ -9,6 +9,7 @@ File and file system-related tools, specifically for
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
@@ -202,6 +203,45 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
|
||||
relative_path, needle, repl, mode=mode, allow_multiple_occurrences=allow_multiple_occurrences, require_not_ignored=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _create_replacement_function(regex_pattern: str, repl_template: str, regex_flags: int) -> Callable[[re.Match], str]:
|
||||
"""
|
||||
Creates a replacement function that validates for ambiguity and handles backreferences.
|
||||
|
||||
:param regex_pattern: The regex pattern being used for matching
|
||||
:param repl_template: The replacement template with $!1, $!2, etc. for backreferences
|
||||
:param regex_flags: The flags to use when searching (e.g., re.DOTALL | re.MULTILINE)
|
||||
:return: A function suitable for use with re.sub() or re.subn()
|
||||
"""
|
||||
|
||||
def validate_and_replace(match: re.Match) -> str:
|
||||
matched_text = match.group(0)
|
||||
|
||||
# For multi-line match, check if the same pattern matches again within the already-matched text,
|
||||
# rendering the match ambiguous. Typical pattern in the code:
|
||||
# <start><other-stuff><start><stuff><end>
|
||||
# When matching
|
||||
# <start>.*?<end>
|
||||
# this will match the entire span above, while only the suffix may have been intended.
|
||||
# (See test case for a practical example.)
|
||||
# To detect this, we check if the same pattern matches again within the matched text,
|
||||
if "\n" in matched_text and re.search(regex_pattern, matched_text[1:], flags=regex_flags):
|
||||
raise ValueError(
|
||||
"Match is ambiguous: the search pattern matches multiple overlapping occurrences. "
|
||||
"Please revise the search pattern to be more specific to avoid ambiguity."
|
||||
)
|
||||
|
||||
# Handle backreferences: replace $!1, $!2, etc. with actual matched groups
|
||||
def expand_backreference(m: re.Match) -> str:
|
||||
group_num = int(m.group(1))
|
||||
group_value = match.group(group_num)
|
||||
return group_value if group_value is not None else m.group(0)
|
||||
|
||||
result = re.sub(r"\$!(\d+)", expand_backreference, repl_template)
|
||||
return result
|
||||
|
||||
return validate_and_replace
|
||||
|
||||
def replace_content(
|
||||
self,
|
||||
relative_path: str,
|
||||
@@ -226,19 +266,19 @@ class ReplaceContentTool(Tool, ToolMarkerCanEdit):
|
||||
else:
|
||||
raise ValueError(f"Invalid mode: '{mode}', expected 'literal' or 'regex'.")
|
||||
|
||||
# escape backslashes in repl
|
||||
repl = repl.replace("\\", "\\\\")
|
||||
# convert $!1, $!2, etc. to \1, \2, etc. for backreferences
|
||||
repl = re.sub(r"\$!(\d+)", "\\\\1", repl)
|
||||
regex_flags = re.DOTALL | re.MULTILINE
|
||||
|
||||
# create replacement function with validation and backreference handling
|
||||
repl_fn = self._create_replacement_function(regex, repl, regex_flags=regex_flags)
|
||||
|
||||
# perform replacement
|
||||
updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE)
|
||||
updated_content, n = re.subn(regex, repl_fn, original_content, flags=regex_flags)
|
||||
|
||||
if n == 0:
|
||||
return f"Error: No matches of search expression found in file '{relative_path}'."
|
||||
raise ValueError(f"Error: No matches of search expression found in file '{relative_path}'.")
|
||||
if not allow_multiple_occurrences and n > 1:
|
||||
return (
|
||||
f"Error: Expression matches {n} occurrences in file '{relative_path}'. "
|
||||
raise ValueError(
|
||||
f"Expression matches {n} occurrences in file '{relative_path}'. "
|
||||
"Please revise the expression to be more specific or enable allow_multiple_occurrences if this is expected."
|
||||
)
|
||||
context.set_updated_content(updated_content)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
import platform
|
||||
|
||||
|
||||
def system_has_usable_display() -> bool:
|
||||
system = platform.system()
|
||||
|
||||
# macOS and native Windows: assume display is available for desktop usage
|
||||
if system == "Darwin" or system == "Windows":
|
||||
return True
|
||||
|
||||
# Other systems, assumed to be Unix-like (Linux, FreeBSD, Cygwin/MSYS, etc.):
|
||||
# detect display availability since users may operate in CLI contexts
|
||||
else:
|
||||
# Check X11 or Wayland - if environment variables are set to non-empty values, assume display is usable
|
||||
display = os.environ.get("DISPLAY", "")
|
||||
wayland_display = os.environ.get("WAYLAND_DISPLAY", "")
|
||||
|
||||
if display or wayland_display:
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Dummy WebSocket manager class for testing ambiguous regex matching.
|
||||
*/
|
||||
class WebSocketManager {
|
||||
constructor() {
|
||||
console.log("WebSocketManager initializing\nStatus OK");
|
||||
this.ws = null;
|
||||
this.statusElement = document.getElementById("status");
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the WebSocket server.
|
||||
*/
|
||||
connectToServer() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.updateConnectionStatus("Already connected", true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket("ws://localhost:4402");
|
||||
this.updateConnectionStatus("Connecting...", false);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log("Connected to server");
|
||||
this.updateConnectionStatus("Connected", true);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log("Received message:", event.data);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.handleMessage(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to parse message:", error);
|
||||
this.updateConnectionStatus("Parse error", false);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log("Connection closed");
|
||||
const message = event.reason || undefined;
|
||||
this.updateConnectionStatus("Disconnected", false, message);
|
||||
this.ws = null;
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error("WebSocket error:", error);
|
||||
this.updateConnectionStatus("Connection error", false);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to server:", error);
|
||||
this.updateConnectionStatus("Connection failed", false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the connection status display.
|
||||
*/
|
||||
updateConnectionStatus(status, isConnected, message) {
|
||||
if (this.statusElement) {
|
||||
const text = message ? `${status}: ${message}` : status;
|
||||
this.statusElement.textContent = text;
|
||||
this.statusElement.style.color = isConnected ? "green" : "red";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming messages.
|
||||
*/
|
||||
handleMessage(data) {
|
||||
console.log("Handling:", data);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from serena.cli import ProjectCommands
|
||||
from serena.cli import ProjectCommands, TopLevelCommands, find_project_root
|
||||
from serena.config.serena_config import ProjectConfig
|
||||
|
||||
pytestmark = pytest.mark.filterwarnings("ignore::UserWarning")
|
||||
@@ -262,6 +263,85 @@ class TestProjectCreateHelper:
|
||||
ProjectCommands._create_project(temp_project_dir, None, ("python",))
|
||||
|
||||
|
||||
class TestFindProjectRoot:
|
||||
"""Tests for find_project_root helper with virtual chroot boundary."""
|
||||
|
||||
def test_finds_serena_from_subdirectory(self, temp_project_dir):
|
||||
"""Test that .serena/project.yml is found when searching from a subdirectory."""
|
||||
serena_dir = os.path.join(temp_project_dir, ".serena")
|
||||
os.makedirs(serena_dir)
|
||||
Path(os.path.join(serena_dir, "project.yml")).touch()
|
||||
subdir = os.path.join(temp_project_dir, "src", "nested")
|
||||
os.makedirs(subdir)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(subdir)
|
||||
result = find_project_root(root=temp_project_dir)
|
||||
assert result is not None
|
||||
assert os.path.samefile(result, temp_project_dir)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_serena_preferred_over_git(self, temp_project_dir):
|
||||
"""Test that .serena/project.yml takes priority over .git at the same level."""
|
||||
serena_dir = os.path.join(temp_project_dir, ".serena")
|
||||
os.makedirs(serena_dir)
|
||||
Path(os.path.join(serena_dir, "project.yml")).touch()
|
||||
os.makedirs(os.path.join(temp_project_dir, ".git"))
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(temp_project_dir)
|
||||
result = find_project_root(root=temp_project_dir)
|
||||
assert result is not None
|
||||
assert os.path.isdir(os.path.join(result, ".serena"))
|
||||
assert os.path.samefile(result, temp_project_dir)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_git_used_as_fallback(self, temp_project_dir):
|
||||
"""Test that .git is found when no .serena exists."""
|
||||
os.makedirs(os.path.join(temp_project_dir, ".git"))
|
||||
subdir = os.path.join(temp_project_dir, "src")
|
||||
os.makedirs(subdir)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(subdir)
|
||||
result = find_project_root(root=temp_project_dir)
|
||||
assert result is not None
|
||||
assert os.path.samefile(result, temp_project_dir)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
def test_falls_back_to_cwd_when_no_markers(self, temp_project_dir):
|
||||
"""Test falls back to CWD when no markers exist within boundary."""
|
||||
subdir = os.path.join(temp_project_dir, "src")
|
||||
os.makedirs(subdir)
|
||||
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(subdir)
|
||||
result = find_project_root(root=temp_project_dir)
|
||||
assert os.path.samefile(result, subdir)
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
|
||||
class TestProjectFromCwdMutualExclusivity:
|
||||
"""Tests for --project-from-cwd mutual exclusivity."""
|
||||
|
||||
def test_project_from_cwd_with_project_flag_fails(self, cli_runner):
|
||||
"""Test that --project-from-cwd with --project raises error."""
|
||||
result = cli_runner.invoke(
|
||||
TopLevelCommands.start_mcp_server,
|
||||
["--project-from-cwd", "--project", "/some/path"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "cannot be used with" in result.output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# For manual testing, you can run this file directly:
|
||||
# uv run pytest test/serena/test_cli_project_commands.py -v
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
|
||||
from serena.agent import SerenaAgent
|
||||
from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig
|
||||
from serena.project import Project
|
||||
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, ReplaceSymbolBodyTool
|
||||
from serena.tools import SUCCESS_RESULT, FindReferencingSymbolsTool, FindSymbolTool, ReplaceContentTool, ReplaceSymbolBodyTool
|
||||
from solidlsp.ls_config import Language
|
||||
from test.conftest import get_repo_path, language_tests_enabled
|
||||
from test.solidlsp import clojure as clj
|
||||
@@ -56,6 +59,30 @@ def serena_config():
|
||||
return config
|
||||
|
||||
|
||||
def read_project_file(project: Project, relative_path: str) -> str:
|
||||
"""Utility function to read a file from the project."""
|
||||
file_path = os.path.join(project.project_root, relative_path)
|
||||
with open(file_path, encoding=project.project_config.encoding) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def project_file_modification_context(serena_agent: SerenaAgent, relative_path: str) -> Iterator[None]:
|
||||
"""Context manager to modify a project file and revert the changes after use."""
|
||||
project = serena_agent.get_active_project()
|
||||
file_path = os.path.join(project.project_root, relative_path)
|
||||
|
||||
# Read the original content
|
||||
original_content = read_project_file(project, relative_path)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Revert to the original content
|
||||
with open(file_path, "w", encoding=project.project_config.encoding) as f:
|
||||
f.write(original_content)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[SerenaAgent]:
|
||||
language = Language(request.param)
|
||||
@@ -70,7 +97,8 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere
|
||||
agent.execute_task(lambda: None)
|
||||
|
||||
yield agent
|
||||
# explicitly delete to free resources
|
||||
|
||||
# explicitly shut down to free resources
|
||||
agent.shutdown(timeout=5)
|
||||
|
||||
|
||||
@@ -355,3 +383,84 @@ class TestSerenaAgent:
|
||||
replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool)
|
||||
with pytest.raises(ValueError, match=match_text):
|
||||
replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent",
|
||||
[
|
||||
pytest.param(
|
||||
Language.TYPESCRIPT,
|
||||
marks=pytest.mark.typescript,
|
||||
),
|
||||
],
|
||||
indirect=["serena_agent"],
|
||||
)
|
||||
def test_replace_content_regex_with_wildcard_ok(self, serena_agent: SerenaAgent):
|
||||
"""
|
||||
Tests a regex-based content replacement that has a unique match
|
||||
"""
|
||||
relative_path = "ws_manager.js"
|
||||
with project_file_modification_context(serena_agent, relative_path):
|
||||
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
|
||||
result = replace_content_tool.apply(
|
||||
needle=r'catch \(error\) \{\s*console.error\("Failed to connect.*?\}',
|
||||
repl='catch(error) { console.log("Never mind"); }',
|
||||
relative_path=relative_path,
|
||||
mode="regex",
|
||||
)
|
||||
assert result == SUCCESS_RESULT
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent",
|
||||
[
|
||||
pytest.param(
|
||||
Language.TYPESCRIPT,
|
||||
marks=pytest.mark.typescript,
|
||||
),
|
||||
],
|
||||
indirect=["serena_agent"],
|
||||
)
|
||||
@pytest.mark.parametrize("mode", ["literal", "regex"])
|
||||
def test_replace_content_with_backslashes(self, serena_agent: SerenaAgent, mode: Literal["literal", "regex"]):
|
||||
"""
|
||||
Tests a content replacement where the needle and replacement strings contain backslashes.
|
||||
This is a regression test for escaping issues.
|
||||
"""
|
||||
relative_path = "ws_manager.js"
|
||||
needle = r'console.log("WebSocketManager initializing\nStatus OK");'
|
||||
repl = r'console.log("WebSocketManager initialized\nAll systems go!");'
|
||||
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
|
||||
mode: Literal["literal", "regex"]
|
||||
with project_file_modification_context(serena_agent, relative_path):
|
||||
result = replace_content_tool.apply(
|
||||
needle=re.escape(needle) if mode == "regex" else needle,
|
||||
repl=repl,
|
||||
relative_path=relative_path,
|
||||
mode=mode,
|
||||
)
|
||||
assert result == SUCCESS_RESULT
|
||||
new_content = read_project_file(serena_agent.get_active_project(), relative_path)
|
||||
assert repl in new_content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent",
|
||||
[
|
||||
pytest.param(
|
||||
Language.TYPESCRIPT,
|
||||
marks=pytest.mark.typescript,
|
||||
),
|
||||
],
|
||||
indirect=["serena_agent"],
|
||||
)
|
||||
def test_replace_content_regex_with_wildcard_ambiguous(self, serena_agent: SerenaAgent):
|
||||
"""
|
||||
Tests that an ambiguous replacement where there is a larger match that internally contains
|
||||
a smaller match triggers an exception
|
||||
"""
|
||||
replace_content_tool = serena_agent.get_tool(ReplaceContentTool)
|
||||
with pytest.raises(ValueError, match="ambiguous"):
|
||||
replace_content_tool.apply(
|
||||
needle=r'catch \(error\) \{.*?this\.updateConnectionStatus\("Connection failed", false\);.*?\}',
|
||||
repl='catch(error) { console.log("Never mind"); }',
|
||||
relative_path="ws_manager.js",
|
||||
mode="regex",
|
||||
)
|
||||
Reference in new issue
Block a user