From 85a9e47c5be00fbc47b831650d426fcdcad9715e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 15:15:29 +0200 Subject: [PATCH 1/7] Remove obsolete to_json_dict/from_json_dict methods from config classes --- src/serena/agent.py | 44 ++++---------------------------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 4f837378..9887c8cc 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -17,7 +17,7 @@ from collections import defaultdict from collections.abc import Callable, Generator, Iterable, Sequence from concurrent.futures import Future, ThreadPoolExecutor from copy import copy, deepcopy -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from fnmatch import fnmatch from functools import cached_property from logging import Logger @@ -168,14 +168,14 @@ class ProjectConfig(ToStringMixin): config_with_comments["language"] = dominant_language if save_to_disk: save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True) - return cls.from_json_dict(config_with_comments) + return cls._from_dict(config_with_comments) @classmethod def rel_path_to_project_yml(cls) -> str: return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) @classmethod - def from_json_dict(cls, data: dict[str, Any]) -> Self: + def _from_dict(cls, data: dict[str, Any]) -> Self: """ Create a ProjectConfig instance from a configuration dictionary """ @@ -200,12 +200,6 @@ class ProjectConfig(ToStringMixin): encoding=data.get("encoding", DEFAULT_ENCODING), ) - def to_json_dict(self) -> dict[str, Any]: - result = asdict(self) - result["language"] = result["language"].value - result["excluded_tools"] = list(result["excluded_tools"]) - return result - @classmethod def load(cls, project_root: Path | str, autogenerate: bool = True) -> Self: """ @@ -222,7 +216,7 @@ class ProjectConfig(ToStringMixin): yaml_data = yaml.safe_load(f) if "project_name" not in yaml_data: yaml_data["project_name"] = project_root.name - return cls.from_json_dict(yaml_data) + return cls._from_dict(yaml_data) def get_excluded_tool_classes(self) -> set[type["Tool"]]: return set(ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools) @@ -253,13 +247,6 @@ class Project: project_config = ProjectConfig.load(project_root, autogenerate=autogenerate) return cls(project_root=str(project_root), project_config=project_config) - @classmethod - def from_json_dict(cls, data: dict) -> Self: - return cls(project_root=data["project_root"], project_config=ProjectConfig.from_json_dict(data["project_config"])) - - def to_json_dict(self) -> dict: - return {"project_root": self.project_root, "project_config": self.project_config.to_json_dict()} - def path_to_project_yml(self) -> str: return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) @@ -355,19 +342,6 @@ class SerenaConfigBase(ABC): else: raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") - def to_json_dict(self) -> dict: - """Convert configuration to dictionary for serialization.""" - result = asdict(self) - result["projects"] = [project.to_json_dict() for project in self.projects] - return result - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - """Create configuration from dictionary.""" - data = copy(data) - data["projects"] = [Project.from_json_dict(project_data) for project_data in data["projects"]] - return cls(**data) - @dataclass(kw_only=True) class SerenaConfig(SerenaConfigBase): @@ -501,16 +475,6 @@ class SerenaConfig(SerenaConfigBase): super().remove_project(project_name) self.save() - def to_json_dict(self) -> dict: - result = super().to_json_dict() - result.pop("loaded_commented_yaml", None) - return result - - @classmethod - def from_json_dict(cls, data: dict) -> Self: - data["loaded_commented_yaml"] = cls._load_commented_yaml(cls.get_config_file_path()) - return super().from_json_dict(data) - class LinesRead: def __init__(self) -> None: From 8577616436ada1f8ff496f548b31f0ff4ccb2d73 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 16:07:07 +0200 Subject: [PATCH 2/7] Simplify configuration management, unifying SerenaConfigBase and SerenaConfig and removing other subclasses --- scripts/demo_run_tools.py | 13 +- src/serena/agent.py | 227 ++++++++++++++----------------- test/serena/test_serena_agent.py | 20 +-- 3 files changed, 102 insertions(+), 158 deletions(-) diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index e3c92861..64929b38 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -8,19 +8,8 @@ from pprint import pprint from serena.agent import * from serena.constants import REPO_ROOT - -@dataclass -class InMemorySerenaConfig(SerenaConfigBase): - """ - In-memory implementation of Serena configuration with the GUI disabled. - """ - - gui_log_window_enabled: bool = False - web_dashboard: bool = False - - if __name__ == "__main__": - agent = SerenaAgent(project=REPO_ROOT, serena_config=InMemorySerenaConfig()) + agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)) # apply a tool find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) diff --git a/src/serena/agent.py b/src/serena/agent.py index 9887c8cc..510cb532 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -28,7 +28,6 @@ from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar, Union, cast import click import yaml from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata -from overrides import override from pathspec import PathSpec from ruamel.yaml.comments import CommentedMap from sensai.util import logging @@ -252,9 +251,11 @@ class Project: @dataclass(kw_only=True) -class SerenaConfigBase(ABC): +class SerenaConfig: """ - Abstract base class for Serena configuration handling + Holds the Serena agent configuration, which is typically loaded from a YAML configuration file + (when instantiated via :method:`from_config_file`), which is updated when projects are added or removed. + For testing purposes, it can also be instantiated directly with the desired parameters. """ projects: list[Project] = field(default_factory=list) @@ -264,110 +265,28 @@ class SerenaConfigBase(ABC): web_dashboard: bool = True web_dashboard_open_on_launch: bool = True tool_timeout: float = DEFAULT_TOOL_TIMEOUT - - @cached_property - def project_paths(self) -> list[str]: - return sorted(project.project_root for project in self.projects) - - @cached_property - def project_names(self) -> list[str]: - return sorted(project.project_config.project_name for project in self.projects) - - def get_project(self, project_root_or_name: str) -> Project | None: - for project in self.projects: - if project.project_config.project_name == project_root_or_name: - return project - if os.path.isdir(project_root_or_name): - project_root = Path(project_root_or_name).resolve() - for project in self.projects: - if Path(project.project_root).resolve() == project_root: - return project - return None - - def add_project_from_path(self, project_root: Path | str, project_name: str | None = None) -> tuple[Project, bool]: - """ - Add a project to the Serena configuration from a given path. Will raise a FileExistsError if the - name or path is already registered. - - :param project_root: the path to the project to add - :param project_name: the name of the project to add; if None, the name of the project will be the name of the directory - containing the project - :return: the project that was added and a boolean indicating whether a new project configuration was generated and - saved to disk. It may be that no new project configuration was generated if the project configuration already - exists on disk but the project itself was not added yet to the Serena configuration. - """ - project_root = Path(project_root).resolve() - if not project_root.exists(): - raise FileNotFoundError(f"Error: Path does not exist: {project_root}") - if not project_root.is_dir(): - raise FileNotFoundError(f"Error: Path is not a directory: {project_root}") - - if project_name is None: - project_name = project_root.name - for already_registered_project in self.projects: - if already_registered_project.project_name == project_name: - raise FileExistsError( - f"Project name '{project_name}' already exists and points to {already_registered_project.project_root}." - ) - if str(already_registered_project.project_root) == str(project_root): - raise FileExistsError( - f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." - ) - - try: - project_config = ProjectConfig.load(project_root) - new_project_config_generated = False - except FileNotFoundError: - project_config = ProjectConfig.autogenerate(project_root, save_to_disk=True) - new_project_config_generated = True - - new_project = Project(project_root=str(project_root), project_config=project_config) - self._add_new_project(new_project) - - return new_project, new_project_config_generated - - def _add_new_project(self, project: Project) -> None: - """ - Adds a new project to the Serena configuration. No checks are performed here, - this method is intended to be overridden by subclasses. - """ - self.projects.append(project) - - def remove_project(self, project_name: str) -> None: - # find the index of the project with the desired name and remove it - for i, project in enumerate(self.projects): - if project.project_name == project_name: - del self.projects[i] - break - else: - raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") - - -@dataclass(kw_only=True) -class SerenaConfig(SerenaConfigBase): + loaded_commented_yaml: CommentedMap | None = None + config_file_path: str | None = None """ - Handles user-defined Serena configuration based on the (fixed) Serena configuration file. - Updates to the instance will be automatically saved to the configuration file. - Usually, there should be only one instance of this class in the application. + the path to the configuration file to which updates of the configuration shall be saved; + if None, the configuration is not saved to disk """ - loaded_commented_yaml: CommentedMap - CONFIG_FILE = "serena_config.yml" CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization @classmethod def autogenerate(cls) -> None: log.info("Autogenerating Serena configuration file") - if os.path.exists(cls.get_config_file_path()): + if os.path.exists(cls._determine_config_file_path()): raise FileExistsError( - f"Serena configuration file already exists at {cls.get_config_file_path()}. Please remove it if you want to autogenerate a new one." + f"Serena configuration file already exists at {cls._determine_config_file_path()}. Please remove it if you want to autogenerate a new one." ) loaded_commented_yaml = load_yaml(SELENA_CONFIG_TEMPLATE_FILE, preserve_comments=True) - save_yaml(cls.get_config_file_path(), loaded_commented_yaml, preserve_comments=True) + save_yaml(cls._determine_config_file_path(), loaded_commented_yaml, preserve_comments=True) @classmethod - def get_config_file_path(cls) -> str: + def _determine_config_file_path(cls) -> str: config_file = cls.CONFIG_FILE_DOCKER if is_running_in_docker() else cls.CONFIG_FILE return os.path.join(REPO_ROOT, config_file) @@ -388,11 +307,12 @@ class SerenaConfig(SerenaConfigBase): """ Static constructor to create SerenaConfig from the configuration file """ - config_file = cls.get_config_file_path() - log.info(f"Loading Serena configuration from {config_file}") - loaded_commented_yaml = cls._load_commented_yaml(config_file, generate_if_missing) + config_file_path = cls._determine_config_file_path() + log.info(f"Loading Serena configuration from {config_file_path}") + loaded_commented_yaml = cls._load_commented_yaml(config_file_path, generate_if_missing) + # Create instance - instance = cls(loaded_commented_yaml=loaded_commented_yaml) + instance = cls(loaded_commented_yaml=loaded_commented_yaml, config_file_path=config_file_path) # read projects if "projects" not in loaded_commented_yaml: @@ -458,22 +378,90 @@ class SerenaConfig(SerenaConfigBase): log.error(f"Error migrating configuration file: {e}") return None + @cached_property + def project_paths(self) -> list[str]: + return sorted(project.project_root for project in self.projects) + + @cached_property + def project_names(self) -> list[str]: + return sorted(project.project_config.project_name for project in self.projects) + + def get_project(self, project_root_or_name: str) -> Project | None: + for project in self.projects: + if project.project_config.project_name == project_root_or_name: + return project + if os.path.isdir(project_root_or_name): + project_root = Path(project_root_or_name).resolve() + for project in self.projects: + if Path(project.project_root).resolve() == project_root: + return project + return None + + def add_project_from_path(self, project_root: Path | str, project_name: str | None = None) -> tuple[Project, bool]: + """ + Add a project to the Serena configuration from a given path. Will raise a FileExistsError if the + name or path is already registered. + + :param project_root: the path to the project to add + :param project_name: the name of the project to add; if None, the name of the project will be the name of the directory + containing the project + :return: the project that was added and a boolean indicating whether a new project configuration was generated and + saved to disk. It may be that no new project configuration was generated if the project configuration already + exists on disk but the project itself was not added yet to the Serena configuration. + """ + project_root = Path(project_root).resolve() + if not project_root.exists(): + raise FileNotFoundError(f"Error: Path does not exist: {project_root}") + if not project_root.is_dir(): + raise FileNotFoundError(f"Error: Path is not a directory: {project_root}") + + if project_name is None: + project_name = project_root.name + for already_registered_project in self.projects: + if already_registered_project.project_name == project_name: + raise FileExistsError( + f"Project name '{project_name}' already exists and points to {already_registered_project.project_root}." + ) + if str(already_registered_project.project_root) == str(project_root): + raise FileExistsError( + f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'." + ) + + try: + project_config = ProjectConfig.load(project_root) + new_project_config_generated = False + except FileNotFoundError: + project_config = ProjectConfig.autogenerate(project_root, save_to_disk=True) + new_project_config_generated = True + + new_project = Project(project_root=str(project_root), project_config=project_config) + self.projects.append(new_project) + self.save() + + return new_project, new_project_config_generated + + def remove_project(self, project_name: str) -> None: + # find the index of the project with the desired name and remove it + for i, project in enumerate(self.projects): + if project.project_name == project_name: + del self.projects[i] + break + else: + raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") + self.save() + def save(self) -> None: + """ + Saves the configuration to the file from which it was loaded (if any) + """ + 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 loaded_original_yaml["projects"] = sorted({str(Path(project.project_root).resolve()) for project in self.projects}) - save_yaml(self.get_config_file_path(), loaded_original_yaml, preserve_comments=True) - - @override - def _add_new_project(self, project: Project) -> None: - super()._add_new_project(project) - self.save() - - @override - def remove_project(self, project_name: str) -> None: - super().remove_project(project_name) - self.save() + save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True) class LinesRead: @@ -544,7 +532,7 @@ class MemoriesManagerMDFilesInProject(MemoriesManager): def create_serena_config( - serena_config: SerenaConfigBase | None = None, + serena_config: SerenaConfig | 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, @@ -567,24 +555,7 @@ def create_serena_config( """ # obtain serena configuration if serena_config is not None: - # If a complete SerenaConfig is provided, use it directly - if isinstance(serena_config, SerenaConfig): - config = serena_config - else: - # For SerenaConfigBase instances (like test configs), create an in-memory SerenaConfig - # that preserves the base config attributes without loading from file - from ruamel.yaml.comments import CommentedMap - - config = SerenaConfig.__new__(SerenaConfig) # Create without calling __init__ - # Initialize basic attributes from base config - config.projects = getattr(serena_config, "projects", []) - config.gui_log_window_enabled = serena_config.gui_log_window_enabled - config.log_level = serena_config.log_level - config.trace_lsp_communication = serena_config.trace_lsp_communication - config.web_dashboard = serena_config.web_dashboard - config.tool_timeout = serena_config.tool_timeout - # Set empty yaml for in-memory config - config.loaded_commented_yaml = CommentedMap() + config = serena_config else: config = SerenaConfig.from_config_file() @@ -681,7 +652,7 @@ class SerenaAgent: self, project: str | None = None, project_activation_callback: Callable[[], None] | None = None, - serena_config: SerenaConfigBase | None = None, + serena_config: SerenaConfig | None = None, context: SerenaAgentContext | None = None, modes: list[SerenaAgentMode] | None = None, enable_web_dashboard: bool | None = None, diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index c1680523..40f29049 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -1,31 +1,15 @@ import json import os import time -from dataclasses import dataclass import pytest import test.solidlsp.clojure as clj -from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, Project, ProjectConfig, SerenaAgent, SerenaConfigBase +from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, Project, ProjectConfig, SerenaAgent, SerenaConfig from solidlsp.ls_config import Language from test.conftest import get_repo_path -@dataclass -class SerenaConfigForTests(SerenaConfigBase): - """ - In-memory implementation of Serena configuration with the GUI disabled. - """ - - gui_log_window_enabled: bool = False - web_dashboard: bool = False - - def __post_init__(self): - # Initialize with empty projects list if not already set - if not hasattr(self, "projects") or self.projects is None: - self.projects = [] - - @pytest.fixture def serena_config(): """Create an in-memory configuration for tests with test repositories pre-registered.""" @@ -59,7 +43,7 @@ def serena_config(): ) test_projects.append(project) - config = SerenaConfigForTests() + config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False) config.projects = test_projects return config From c771293a95525ea6cca95d28ad8d3b79c232081b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 16:20:03 +0200 Subject: [PATCH 3/7] Remove contrived method create_serena_config and remove unnecessary constructor args of SerenaAgent --- src/serena/agent.py | 70 ++------------------------------------------- src/serena/mcp.py | 27 ++++++++++------- 2 files changed, 20 insertions(+), 77 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 510cb532..2dec85d6 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -23,7 +23,7 @@ from functools import cached_property from logging import Logger from pathlib import Path from types import TracebackType -from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar, Union, cast +from typing import TYPE_CHECKING, Any, Self, TypeVar, Union import click import yaml @@ -531,54 +531,6 @@ class MemoriesManagerMDFilesInProject(MemoriesManager): return f"Memory {name} deleted." -def create_serena_config( - serena_config: SerenaConfig | 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, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, -) -> SerenaConfig: - """ - Create a SerenaConfig instance without instantiating a full SerenaAgent. - - This function extracts the configuration creation logic from SerenaAgent.__init__ - to allow creating configurations independently for process isolation and other use cases. - - :param serena_config: the base Serena configuration or None to read from default location - :param enable_web_dashboard: Whether to enable the web dashboard - :param enable_gui_log_window: Whether to enable the GUI log window - :param log_level: Log level - :param trace_lsp_communication: Whether to trace LSP communication - :param tool_timeout: Timeout in seconds for tool execution - :return: A fully configured SerenaConfig instance - """ - # obtain serena configuration - if serena_config is not None: - config = serena_config - else: - config = SerenaConfig.from_config_file() - - # Apply parameter overrides - if enable_web_dashboard is not None: - config.web_dashboard = enable_web_dashboard - if enable_gui_log_window is not None: - config.gui_log_window_enabled = enable_gui_log_window - if log_level is not None: - log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper()) - # transform to int - config.log_level = logging.getLevelNamesMapping()[log_level] - if trace_lsp_communication is not None: - config.trace_lsp_communication = trace_lsp_communication - if tool_timeout is not None: - config.tool_timeout = tool_timeout - - # Note: Project registration/activation is handled separately by the caller - # since it involves complex logic that may require the full agent context - - return config - - def create_ls_for_project( project: str | Project, log_level: int = logging.INFO, @@ -655,35 +607,19 @@ class SerenaAgent: serena_config: SerenaConfig | None = None, context: SerenaAgentContext | None = None, modes: list[SerenaAgentMode] | 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, - trace_lsp_communication: bool | None = None, - tool_timeout: float | None = None, ): """ :param project: the project to load immediately or None to not load any project; may be a path to the project or a name of an already registered project; :param project_activation_callback: a callback function to be called when a project is activated. + :param serena_config: the Serena configuration or None to read the configuration from the default location. :param context: the context in which the agent is operating, None for default context. The context may adjust prompts, tool availability, and tool descriptions. :param modes: list of modes in which the agent is operating (they will be combined), None for default modes. The modes may adjust prompts, tool availability, and tool descriptions. - :param serena_config: the Serena configuration or None to read the configuration from the default location. - :param enable_web_dashboard: whether to enable the web dashboard; If None, will take the value from the Serena configuration. - :param enable_gui_log_window: whether to enable the GUI log window; If None, will take the value from the Serena configuration. - :param log_level: the log level for the GUI log window; If None, will take the value from the serena configuration. - :param tool_timeout: the timeout in seconds for tool execution. If None, will take the value from the serena configuration. """ # obtain serena configuration using the decoupled factory function - self.serena_config = create_serena_config( - serena_config=serena_config, - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) + self.serena_config = serena_config or SerenaConfig.from_config_file() # adjust log level serena_log_level = self.serena_config.log_level diff --git a/src/serena/mcp.py b/src/serena/mcp.py index cab12c4b..bde199e2 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -9,7 +9,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass from logging import Formatter, Logger, StreamHandler from pathlib import Path -from typing import Any, Literal +from typing import Any, Literal, cast import click import docstring_parser @@ -23,7 +23,6 @@ from serena.agent import ( SerenaAgent, SerenaConfig, ToolInterface, - create_serena_config, show_fatal_exception_safe, ) from serena.config import SerenaAgentContext, SerenaAgentMode @@ -149,15 +148,23 @@ class SerenaMCPFactory: :param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration. """ try: - serena_config = create_serena_config( - enable_web_dashboard=enable_web_dashboard, - enable_gui_log_window=enable_gui_log_window, - log_level=log_level, - trace_lsp_communication=trace_lsp_communication, - tool_timeout=tool_timeout, - ) + config = SerenaConfig.from_config_file() + + # update configuration with the provided parameters + if enable_web_dashboard is not None: + config.web_dashboard = enable_web_dashboard + if enable_gui_log_window is not None: + config.gui_log_window_enabled = enable_gui_log_window + if log_level is not None: + log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper()) + config.log_level = logging.getLevelNamesMapping()[log_level] + if trace_lsp_communication is not None: + config.trace_lsp_communication = trace_lsp_communication + if tool_timeout is not None: + config.tool_timeout = tool_timeout + modes_instances = [SerenaAgentMode.load(mode) for mode in modes] - self._instantiate_agent(serena_config, modes_instances) + self._instantiate_agent(config, modes_instances) except Exception as e: show_fatal_exception_safe(e) From ea3daf6408a35b7995514d76ba28b6b5078611ac Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 17:04:04 +0200 Subject: [PATCH 4/7] Set new default config file location to ~/.serena/serena_config.yml, using the Serena installation directory as an alternative secondary location --- README.md | 8 +++++++- src/serena/agent.py | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5f5854c0..96220456 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,8 @@ This and other settings can be adjusted in the [configuration](#configuration) a cd serena ``` 2. Optionally create a config file from the template and adjust it according to your preferences. + You can either place it in your user directory (`~/.serena/serena_config.yml`) or into + the Serena installation directory: ```shell cp src/serena/resources/serena_config.template.yml serena_config.yml ``` @@ -279,7 +281,11 @@ Run with parameter `--help` to get a list of available options. Serena's behavior (active tools and prompts as well as logging configuration, etc.) is configured in four places: -1. The `serena_config.yml` for general settings that apply to all clients and projects +1. The `serena_config.yml` for general settings that apply to all clients and projects. + Serena will look for this file in your user directory under `.serena/serena_config.yml` + or within the Serena installation directory. + If you do not explicitly create the file, it will be auto-generated in the former location + when you first run Serena. 2. In the arguments passed to the `serena-mcp-server` in your client's config (see below), which will apply to all sessions started by the respective client. In particular, the [context](#contexts) parameter should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client. diff --git a/src/serena/agent.py b/src/serena/agent.py index 2dec85d6..8cfa79a6 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -287,8 +287,20 @@ class SerenaConfig: @classmethod def _determine_config_file_path(cls) -> str: - config_file = cls.CONFIG_FILE_DOCKER if is_running_in_docker() else cls.CONFIG_FILE - return os.path.join(REPO_ROOT, config_file) + """ + :return: the location where the Serena configuration file is stored/should be stored + """ + if is_running_in_docker(): + return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) + else: + candidates = [ + str(Path.home() / SERENA_MANAGED_DIR_NAME / cls.CONFIG_FILE), + os.path.join(REPO_ROOT, cls.CONFIG_FILE), + ] + for candidate in candidates: + if os.path.exists(candidate): + return candidate + return candidates[0] @classmethod def _load_commented_yaml(cls, config_file: str, generate_if_missing: bool = True) -> CommentedMap: From a50632b378125360a5c14bf908ea9f648aa30bfe Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 17:21:31 +0200 Subject: [PATCH 5/7] Simplify method structure for loading/creating config file --- src/serena/agent.py | 45 +++++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 8cfa79a6..91c6aa9c 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -276,14 +276,15 @@ class SerenaConfig: CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization @classmethod - def autogenerate(cls) -> None: - log.info("Autogenerating Serena configuration file") - if os.path.exists(cls._determine_config_file_path()): - raise FileExistsError( - f"Serena configuration file already exists at {cls._determine_config_file_path()}. Please remove it if you want to autogenerate a new one." - ) + def _generate_config_file(cls, config_file_path: str) -> None: + """ + Generates a Serena configuration file at the specified path from the template file. + + :param config_file_path: the path where the configuration file should be generated + """ + log.info(f"Auto-generating Serena configuration file in {config_file_path}") loaded_commented_yaml = load_yaml(SELENA_CONFIG_TEMPLATE_FILE, preserve_comments=True) - save_yaml(cls._determine_config_file_path(), loaded_commented_yaml, preserve_comments=True) + save_yaml(config_file_path, loaded_commented_yaml, preserve_comments=True) @classmethod def _determine_config_file_path(cls) -> str: @@ -302,28 +303,28 @@ class SerenaConfig: return candidate return candidates[0] - @classmethod - def _load_commented_yaml(cls, config_file: str, generate_if_missing: bool = True) -> CommentedMap: - if not os.path.exists(config_file): - if not generate_if_missing: - raise FileNotFoundError(f"Serena configuration file not found: {config_file}") - log.info(f"Serena configuration file not found at {config_file}, autogenerating...") - cls.autogenerate() - try: - return load_yaml(config_file, preserve_comments=True) - except Exception as e: - raise ValueError(f"Error loading Serena configuration from {config_file}: {e}") from e - @classmethod def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig": """ Static constructor to create SerenaConfig from the configuration file """ config_file_path = cls._determine_config_file_path() - log.info(f"Loading Serena configuration from {config_file_path}") - loaded_commented_yaml = cls._load_commented_yaml(config_file_path, generate_if_missing) - # Create instance + # create the configuration file from the template if necessary + if not os.path.exists(config_file_path): + if not generate_if_missing: + raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") + log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") + cls._generate_config_file(config_file_path) + + # load the configuration + log.info(f"Loading Serena configuration from {config_file_path}") + try: + loaded_commented_yaml = load_yaml(config_file_path, preserve_comments=True) + except Exception as e: + raise ValueError(f"Error loading Serena configuration from {config_file_path}: {e}") from e + + # create the configuration instance instance = cls(loaded_commented_yaml=loaded_commented_yaml, config_file_path=config_file_path) # read projects From 1969360a098c5e4fc45e1d75c8e8e678de152b7b Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 17:22:55 +0200 Subject: [PATCH 6/7] Improve comments --- src/serena/agent.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 91c6aa9c..71b09ca4 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -347,9 +347,9 @@ class SerenaConfig: project = Project.load(path) instance.projects.append(project) - # Force disable GUI in Docker environment + # set other configuration parameters if is_running_in_docker(): - instance.gui_log_window_enabled = False + instance.gui_log_window_enabled = False # not supported in Docker else: instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False) instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO)) From 061d565692ac2fd04bdb7c0b32c37e694c1c7b82 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Wed, 2 Jul 2025 17:26:06 +0200 Subject: [PATCH 7/7] Log location of configuration file --- src/serena/agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/serena/agent.py b/src/serena/agent.py index 71b09ca4..d0e11617 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -681,7 +681,9 @@ class SerenaAgent: if self.serena_config.web_dashboard_open_on_launch: webbrowser.open(f"http://localhost:{port}/dashboard/index.html") + # log fundamental information log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") + log.info("Configuration file: %s", self.serena_config.config_file_path) log.info("Available projects: {}".format(", ".join(self.serena_config.project_names))) # create executor for starting the language server and running tools in another thread