From 22e72a5769fc075b99ded3d5ab4bd78b3d1c54a0 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 26 May 2025 21:47:19 +0200 Subject: [PATCH] Refactored project config management and activation Project activation is now always enabled Projects are lists to dirs Activation works based on name or path Activating a new project always registers it --- serena_config.template.yml | 35 +- src/serena/agent.py | 593 ++++++++++++++----------------- src/serena/agno.py | 2 +- src/serena/constants.py | 2 + src/serena/mcp.py | 4 +- test/serena/test_serena_agent.py | 13 +- 6 files changed, 281 insertions(+), 368 deletions(-) diff --git a/serena_config.template.yml b/serena_config.template.yml index bfa00e22..3c5500d1 100644 --- a/serena_config.template.yml +++ b/serena_config.template.yml @@ -1,25 +1,3 @@ -# whether to enable project activation/switching between the projects defined below using the -# "activate_project" command. If this is enabled, you can instruct the agent to switch to a project -# by its name (see "projects" below). -# Note that if this is enabled, then per-project tool activation will be handled within Serena, -# i.e. Serena will provide all tools but will disallow the execution of tools that are not enabled for -# the current project. This is because clients typically do not respond to a changed set of tools. -enable_project_activation: True - -# Add your list of projects here (which you can switch between using "activate_project"). -# Every list item must be a path to the project's root directory (e.g. "/path/to/myproject"), with the -# configuration file being located in "/path/to/myproject/.serena/project.yml", or a dict mapping -# project_name to project_path (to avoid name collisions). -# You can activate a project by its name using the "activate_project" command. -# If the name is not explicitly set, the name of the project will be the name of the directory -# ("myproject" in the example above). -# Note: comments in the block below are not preserved when saving the config file, -# so we recommend to not have any comments directly below the "projects" key. -projects: - - /path/to/myproject - - project_name: /path/to/myproject - - gui_log_window: False # Whether to open a graphical window with Serena's logs (not supported on macOS). # This is useful both for troubleshooting and for monitoring the tool calls, @@ -37,3 +15,16 @@ gui_log_window: False gui_log_level: 20 # minimum log level for the GUI log window (10 = debug, 20 = info, 30 = warning, 40 = error) + + +# Add your list of projects here (which you can switch between using "activate_project"). +# Every list item must be a path to the project's root directory (e.g. "/path/to/myproject"), with the +# configuration file being located in "/path/to/myproject/.serena/project.yml", or a dict mapping +# project_name to project_path (to avoid name collisions). +# You can activate a project by its name using the "activate_project" command. +# If the name is not explicitly set, the name of the project will be the name of the directory +# ("myproject" in the example above). +# Note: comments in the block below are not preserved when saving the config file, +# so we recommend to not have any comments directly below the "projects" key. +projects: + - /path/to/myproject diff --git a/src/serena/agent.py b/src/serena/agent.py index eab9c209..c462807c 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -8,11 +8,12 @@ import os import platform import sys import traceback -from abc import ABC +from abc import ABC, abstractmethod from collections import defaultdict from collections.abc import Callable, Generator, Iterable, Sequence from copy import copy, deepcopy from dataclasses import asdict, dataclass, field +from functools import cached_property from logging import Logger from pathlib import Path from typing import TYPE_CHECKING, Any, Self, TypeVar, Union @@ -29,6 +30,7 @@ from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_types import SymbolKind from serena import serena_root_path, serena_version from serena.config import SerenaAgentContext, SerenaAgentMode +from serena.constants import SERENA_MANAGED_DIR_NAME from serena.prompt_factory import PromptFactory, SerenaPromptFactory from serena.symbol import SymbolLocation, SymbolManager from serena.text_utils import search_files @@ -71,159 +73,139 @@ class SerenaConfigError(Exception): pass +def get_serena_managed_dir(project_root: str | Path) -> str: + return os.path.join(project_root, SERENA_MANAGED_DIR_NAME) + + @dataclass class ProjectConfig(ToStringMixin): project_name: str language: Language - project_root: str ignored_paths: list[str] = field(default_factory=list) excluded_tools: set[str] = field(default_factory=set) read_only: bool = False ignore_all_files_in_gitignore: bool = True - project_info_prompt: str = "" + initial_prompt: str = "" - SERENA_MANAGED_DIR = ".serena" SERENA_DEFAULT_PROJECT_FILE = "project.yml" @classmethod - def autogenerate(cls, project_root: str | Path, save_to_disk: bool = True) -> Self: + def autogenerate(cls, project_root: str | Path, project_name: str | None = None, save_to_disk: bool = True) -> Self: """ Autogenerate a project configuration for a given project root. + + :param project_root: the path to the project root + :param project_name: the name of the project; if None, the name of the project will be the name of the directory + containing the project + :param save_to_disk: whether to save the project configuration to disk + :return: the project configuration """ - project_root = os.path.abspath(project_root) - if not os.path.exists(project_root): + project_root = Path(project_root).resolve() + if not project_root.exists(): raise FileNotFoundError(f"Project root not found: {project_root}") - project_name = Path(project_root).name - language_composition = determine_programming_language_composition(project_root) + project_name = project_name or project_root.name + language_composition = determine_programming_language_composition(str(project_root)) # find the language with the highest percentage dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang]) result = cls( project_name=project_name, language=Language(dominant_language), - project_root=project_root, ) if save_to_disk: - result.save() + result.save(project_root) return result - def save(self) -> None: + def save(self, project_root: str | Path) -> None: """ Save the project configuration to the project root. """ - config_path = os.path.join(self.project_root, self.rel_path_to_project_yml()) - os.makedirs(os.path.dirname(config_path), exist_ok=True) - serialized_self = asdict(self) - serialized_self["language"] = self.language.value - serialized_self["excluded_tools"] = list(self.excluded_tools) - save_yaml(config_path, serialized_self) + project_root = Path(project_root).resolve() + config_path = project_root / self.rel_path_to_project_yml() + config_path.parent.mkdir(parents=True, exist_ok=True) + serializable_self = asdict(self) + serializable_self["language"] = self.language.value + serializable_self["excluded_tools"] = list(self.excluded_tools) + save_yaml(str(config_path), serializable_self) @classmethod def rel_path_to_project_yml(cls) -> str: - return os.path.join(cls.SERENA_MANAGED_DIR, cls.SERENA_DEFAULT_PROJECT_FILE) + return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) @classmethod - def from_config_dict(cls, config_dict: dict[str, Any], project_name: str, project_root: Path | None = None) -> Self: + def _from_yml_data(cls, yaml_data: dict[str, Any]) -> Self: """ Create a ProjectConfig instance from a configuration dictionary """ try: - language = Language(config_dict["language"].lower()) + yaml_data["language"] = Language(yaml_data["language"].lower()) except ValueError as e: - raise ValueError(f"Invalid language: {config_dict['language']}.\nValid languages are: {[l.value for l in Language]}") from e - - if project_root is None: - project_root = Path(config_dict["project_root"]) - project_root_str = str(project_root.resolve()) - - ignored_paths = config_dict.get("ignored_paths", []) - excluded_tools = set(config_dict.get("excluded_tools", [])) - read_only = config_dict.get("read_only", False) - project_info_prompt = config_dict.get("project_info_prompt", "") - - if "ignore_all_files_in_gitignore" not in config_dict: - raise SerenaConfigError( - f"`ignore_all_files_in_gitignore` key not found in configuration of project '{project_name}'. " - "Please update your `.yml` configuration file for this project. " - "It is recommended to set this to `True`." - ) - ignore_all_files_in_gitignore = config_dict["ignore_all_files_in_gitignore"] - - # Raise errors for deprecated keys - if "ignored_dirs" in config_dict: - raise SerenaConfigError( - f"Use of `ignored_dirs` key in configuration of project '{project_name}' deprecated. Please use `ignored_paths` instead. " - "Note that you can also set `ignore_all_files_in_gitignore` to `True`, which will be enough for most cases." - ) - - return cls( - project_name=project_name, - language=language, - project_root=project_root_str, - ignored_paths=ignored_paths, - excluded_tools=excluded_tools, - read_only=read_only, - ignore_all_files_in_gitignore=ignore_all_files_in_gitignore, - project_info_prompt=project_info_prompt, - ) + raise ValueError(f"Invalid language: {yaml_data['language']}.\nValid languages are: {[l.value for l in Language]}") from e + return cls(**yaml_data) @classmethod - def _from_yml(cls, yml_path: Path) -> Self: + def load(cls, project_root: Path | str) -> Self: """ - Create a ProjectConfig instance from a YAML file + Load a ProjectConfig instance from the path to the project root. """ - log.info(f"Loading project configuration from {yml_path}") - if not yml_path.exists(): - raise FileNotFoundError(f"Project file not found: {yml_path}") - try: - with open(yml_path, encoding="utf-8") as f: - config_dict = yaml.safe_load(f) - if yml_path.parent.name == cls.SERENA_MANAGED_DIR: - project_root = yml_path.parent.parent - project_name = project_root.name - else: - project_root = None - project_name = yml_path.stem - return cls.from_config_dict(config_dict, project_name=project_name, project_root=project_root) - except Exception as e: - raise ValueError(f"Error loading project configuration from {yml_path}: {e}") from e - - @classmethod - def load(cls, path: Path | str) -> Self: - """ - Load a ProjectConfig instance. Input can either be a path to a yaml file or a directory - containing `.serena/project.yml`. - """ - path = Path(path) - if not path.exists(): - raise FileNotFoundError(f"Path not found: {path}") - if path.is_dir(): - path = path / cls.SERENA_MANAGED_DIR / cls.SERENA_DEFAULT_PROJECT_FILE - return cls._from_yml(path) - - def get_serena_managed_dir(self) -> str: - return os.path.join(self.project_root, self.SERENA_MANAGED_DIR) + project_root = Path(project_root) + yaml_path = project_root / cls.rel_path_to_project_yml() + if not yaml_path.exists(): + raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") + with open(yaml_path, encoding="utf-8") as f: + yaml_data = yaml.safe_load(f) + if "project_name" not in yaml_data: + yaml_data["project_name"] = project_root.name + return cls._from_yml_data(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) +@dataclass +class Project: + project_root: str + project_config: ProjectConfig + + @property + def project_name(self) -> str: + return self.project_config.project_name + + @property + def language(self) -> Language: + return self.project_config.language + + @classmethod + def load(cls, project_root: str | Path) -> Self: + project_root = Path(project_root).resolve() + if not project_root.exists(): + raise FileNotFoundError(f"Project root not found: {project_root}") + project_config = ProjectConfig.load(project_root) + return cls(project_root=str(project_root), project_config=project_config) + + def path_to_project_yml(self) -> str: + return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) + + @dataclass class SerenaConfig: """ Handles user-defined Serena configuration based on the configuration file """ - projects: dict[str, ProjectConfig] = field(default_factory=dict) - project_names: list[str] = field(default_factory=list) + projects: list[Project] = field(default_factory=list) gui_log_window_enabled: bool = False gui_log_window_level: int = logging.INFO - enable_project_activation: bool = True loaded_original_yaml: dict | CommentedMap = field(default_factory=dict) CONFIG_FILE = "serena_config.yml" - def get_project_paths(self) -> list[str]: - return [project_config.project_root for project_config in self.projects.values()] + @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) @classmethod def get_config_file_path(cls) -> str: @@ -250,117 +232,94 @@ class SerenaConfig: # read projects if "projects" not in loaded_original_yaml: raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.") - projects = {} - for project_entry in loaded_original_yaml["projects"]: - if isinstance(project_entry, str): - project_path = Path(project_entry).resolve() - project_name = project_path.name - elif isinstance(project_entry, dict): - # should be a dict with a single entry mapping project name to project config path - if len(project_entry) != 1: - raise SerenaConfigError( - f"Invalid project configuration: expected a single entry mapping project name to project config path but got: {project_entry}." - ) - project_name, project_path = next(iter(project_entry.items())) - else: - raise SerenaConfigError(f"Invalid project configuration: expected a string or a dict, got {project_entry}.") - project_path = Path(project_path).resolve() - log.info(f"Loading project configuration from {project_path}") - project_config = ProjectConfig.load(project_path) - projects[project_name] = project_config - instance.projects = projects - instance.project_names = list(projects.keys()) + # projects entries are paths to the project root + instance.projects = [Project.load(project_root) for project_root in loaded_original_yaml["projects"]] + instance.gui_log_window_enabled = loaded_original_yaml.get("gui_log_window", False) instance.gui_log_window_level = loaded_original_yaml.get("gui_log_level", logging.INFO) - instance.enable_project_activation = loaded_original_yaml.get("enable_project_activation", True) instance.loaded_original_yaml = loaded_original_yaml return instance - def get_project_configuration(self, project_name: str) -> "ProjectConfig": - if project_name not in self.projects: - raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") - return self.projects[project_name] + def get_project(self, project_name: str) -> Project: + # currently, expects no duplicate project names + for project in self.projects: + if project.project_config.project_name == project_name: + return project + raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}") def save(self, preserve_comments: bool = True) -> None: loaded_original_yaml = deepcopy(self.loaded_original_yaml) - loaded_original_yaml["projects"] = [ - {project_name: str(project_config.project_root)} for project_name, project_config in self.projects.items() - ] + loaded_original_yaml["projects"] = [project.project_root for project in self.projects] save_yaml(self.get_config_file_path(), loaded_original_yaml, preserve_comments) def add_project_from_path( - self, path: Path | str, project_name: str | None = None, update_config_file: bool = True - ) -> tuple[str, ProjectConfig, bool]: + self, project_root: Path | str, project_name: str | None = None, update_config_file: bool = True + ) -> tuple[Project, bool]: """ - Add a project to the Serena configuration from a given path. + Add a project to the Serena configuration from a given path. Will raise a FileExistsError if the + name or path is already registered. + :param path: 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 :param update_config_file: whether to update the Serena configuration file on disk - :return: the name of the project that was added, the project configuration, and a boolean indicating whether a new - configuration was generated (True) or the existing one was used (False) + :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. """ - path = Path(path).resolve() - if not path.exists(): - raise FileNotFoundError(f"Error: Path does not exist: {path}") - if not path.is_dir(): - raise FileNotFoundError(f"Error: Path is not a directory: {path}") + 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 = path.name - if project_name in self.projects.keys(): - project_config = self.projects[project_name] - if os.path.abspath(project_config.project_root) == os.path.abspath(path): - raise FileExistsError(f"Project '{project_name}' was already added and points to the correct path, you can activate it.") - raise FileExistsError( - f"Name collision: '{project_name=}' was already added, but points to a different path: {project_config.project_root}." - + "You can either use a different name or remove the existing project first before adding this one again." - + "If you are an LLM assistant, ask the user explicitly how they want to proceed before executing any further actions!" - ) + 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(path) - new_config_was_generated = False + project_config = ProjectConfig.load(project_root) + new_project_config_generated = False except FileNotFoundError: - project_config = ProjectConfig.autogenerate(path) - project_config.save() - new_config_was_generated = True + project_config = ProjectConfig.autogenerate(project_root, save_to_disk=True) + new_project_config_generated = True - self.add_project(project_config, project_name, update_config_file) - return project_name, project_config, new_config_was_generated - - def add_project(self, project_config: ProjectConfig, project_name: str | None = None, update_config_file: bool = True) -> None: - if project_name is None: - project_name = project_config.project_name - existing_project_config = self.projects.get(project_name) - if existing_project_config is not None: - if existing_project_config.project_root == project_config.project_root: - raise FileExistsError(f"Project '{project_name}' was already added and points to the correct path, you can activate it.") - raise FileExistsError( - f"Name collision: '{project_name=}' was already added, but points to a different path: {existing_project_config.project_root}." - + "You can either use a different name or remove the existing project first before adding this one again." - ) - self.projects[project_name] = project_config - self.project_names.append(project_name) + new_project = Project(project_root=str(project_root), project_config=project_config) + self.projects.append(new_project) # Update the config file on disk and preserve comments if update_config_file: config_with_comments = SerenaConfig.from_config_file(preserve_comments=True) - config_with_comments.projects[project_name] = project_config + config_with_comments.projects.append(new_project) config_with_comments.save(preserve_comments=True) + return new_project, new_project_config_generated def remove_project(self, project_name: str, update_config_file: bool = True) -> None: - if project_name not in self.projects: + # 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}") - del self.projects[project_name] - self.project_names.remove(project_name) # Update the config file on disk and preserve comments if update_config_file: config_with_comments = SerenaConfig.from_config_file(preserve_comments=True) - config_with_comments.projects.pop(project_name, None) + for i, project in enumerate(config_with_comments.projects): + if project.project_name == project_name: + del config_with_comments.projects[i] + break config_with_comments.save(preserve_comments=True) @@ -380,20 +339,67 @@ class LinesRead: del self.files[relative_path] +class MemoriesManager(ABC): + @abstractmethod + def load_memory(self, name: str) -> str: + pass + + @abstractmethod + def save_memory(self, name: str, content: str) -> str: + pass + + @abstractmethod + def list_memories(self) -> list[str]: + pass + + @abstractmethod + def delete_memory(self, name: str) -> str: + pass + + +class MemoriesManagerMDFilesInProject(MemoriesManager): + def __init__(self, project_root: str): + self._memory_dir = Path(get_serena_managed_dir(project_root)) / "memories" + self._memory_dir.mkdir(parents=True, exist_ok=True) + + def _get_memory_file_path(self, name: str) -> Path: + filename = f"{name}.md" + return self._memory_dir / filename + + def load_memory(self, name: str) -> str: + memory_file_path = self._get_memory_file_path(name) + if not memory_file_path.exists(): + return f"Memory file {name} not found, consider creating it with the `write_memory` tool if you need it." + with open(memory_file_path, encoding="utf-8") as f: + return f.read() + + def save_memory(self, name: str, content: str) -> str: + memory_file_path = self._get_memory_file_path(name) + with open(memory_file_path, "w", encoding="utf-8") as f: + f.write(content) + return f"Memory {name} written." + + def list_memories(self) -> list[str]: + return [f.name for f in self._memory_dir.iterdir() if f.is_file()] + + def delete_memory(self, name: str) -> str: + memory_file_path = self._get_memory_file_path(name) + memory_file_path.unlink() + return f"Memory {name} deleted." + + class SerenaAgent: def __init__( self, - project_config: ProjectConfig | str | Path | None = None, + project: str | None = None, project_activation_callback: Callable[[], None] | None = None, serena_config: SerenaConfig | None = None, context: SerenaAgentContext | None = None, modes: list[SerenaAgentMode] | None = None, ): """ - :param project_config: the project to load immediately; may be a path to the project configuration file - or a configuration instance; - if None, do not load any project (must use project selection tool to activate a project). - If a project is provided, the corresponding language server will be started. + :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 context: the context in which the agent is operating, None for default context. The context may adjust prompts, tool availability, and tool descriptions. @@ -429,23 +435,15 @@ class SerenaAgent: self._project_activation_callback = project_activation_callback # project-specific instances, which will be initialized upon project activation - self._active_project_config: ProjectConfig | None = None + self._active_project: Project | None = None + self._active_project_root: str | None = None self.language_server: SyncLanguageServer | None = None self.symbol_manager: SymbolManager | None = None self.memories_manager: MemoriesManager | None = None self.lines_read: LinesRead | None = None - # find all tool classes and instantiate them - self._all_tools: dict[type[Tool], Tool] = {} - """maps tool classes to their instances (which are linked to the agent instance)""" - - for tool_class in ToolRegistry.get_all_tool_classes(): - tool_instance = tool_class(self) - if not self.serena_config.enable_project_activation: - if tool_class == ActivateProjectTool: - log.info(f"Excluding tool '{tool_instance.get_name()}' because project activation is disabled in configuration") - continue - self._all_tools[tool_class] = tool_instance + # instantiate all tool classes + self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry.get_all_tool_classes()} # Apply context and mode tool configurations if context is None: @@ -465,41 +463,22 @@ class SerenaAgent: self._gui_log_handler.log_viewer.set_tool_names(tool_names) # activate a project configuration (if provided or if there is only a single project available) - project_config_to_load: ProjectConfig | None = None - if project_config is not None: - if isinstance(project_config, (str, Path)): - project_config_to_load = ProjectConfig.load(project_config) - else: - project_config_to_load = project_config - else: - match len(self.serena_config.projects): - case 0: - raise RuntimeError(f"No projects found in {SerenaConfig.CONFIG_FILE} and no project file specified.") - case 1: - project_config_to_load = self.serena_config.get_project_configuration(self.serena_config.project_names[0]) - if project_config_to_load is not None: - self.activate_project(project_config_to_load) - else: - if not self.serena_config.enable_project_activation: - raise ValueError("Tool-based project activation is disabled in the configuration but no project file was provided.") + if project is not None: + self.activate_project_from_path_or_name(project) - def get_active_project_config(self) -> ProjectConfig | None: + def get_exposed_tool_instances(self) -> list["Tool"]: """ - :return: the active project configuration or None if no project is active + :return: all tool instances, including the non-active ones. For MCP clients, we need to expose them all since typical + clients don't react to changes in the set of tools. + An attempt to use a non-active tool will result in an error. """ - return self._active_project_config + return list(self._all_tools.values()) - def get_exposed_tools(self) -> list["Tool"]: + def get_active_project(self) -> Project | None: """ - :return: the list of tools that are to be exposed/registered in the client + :return: the active project or None if no project is active """ - if self.serena_config.enable_project_activation: - # With project activation, we must expose all tools and handle tool activation within Serena - # (because clients do not react to changed tools) - return list(self._all_tools.values()) - else: - # When project activation is not enabled, we only expose the active tools - return list(self._active_tools.values()) + return self._active_project def set_modes(self, modes: list[SerenaAgentMode]) -> None: """ @@ -536,9 +515,9 @@ class SerenaAgent: # context excluded_tool_classes.update(self._context.get_excluded_tool_classes()) # project config - if self._active_project_config is not None: - excluded_tool_classes.update(self._active_project_config.get_excluded_tool_classes()) - if self._active_project_config.read_only: + if self._active_project is not None: + excluded_tool_classes.update(self._active_project.project_config.get_excluded_tool_classes()) + if self._active_project.project_config.read_only: for tool_class in self._all_tools: if tool_class.can_edit(): excluded_tool_classes.add(tool_class) @@ -549,9 +528,9 @@ class SerenaAgent: log.info(f"Active tools after all exclusions ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") - def activate_project(self, project_config: ProjectConfig) -> None: - log.info(f"Activating {project_config}") - self._active_project_config = project_config + def _activate_project(self, project: Project) -> None: + log.info(f"Activating {project.project_name} at {project.project_root}") + self._active_project = project self._update_active_tools() # start the language server @@ -560,53 +539,43 @@ class SerenaAgent: # initialize project-specific instances self.symbol_manager = SymbolManager(self.language_server, self) - self.memories_manager = MemoriesManager(os.path.join(self._active_project_config.get_serena_managed_dir(), "memories")) + self.memories_manager = MemoriesManagerMDFilesInProject(project.project_root) self.lines_read = LinesRead() if self._project_activation_callback is not None: self._project_activation_callback() - def activate_project_from_path_or_name(self, project: str, register_project: bool = False) -> None: + def activate_project_from_path_or_name(self, project_root_or_name: str) -> tuple[Project, bool, bool]: """ Activate a project from a path or a name. - If the project was already registered, it will just be activated. If it was not registered and register_project is True, - the project will be registered and activated. - Finally, if it was not registered and register_project is False, the project will be - activated without being registered. In this case, either an existing `ProjectConfig` will be loaded, or a new one - will be autogenerated if the project directory exists but no `project.yml` was found. + If the project was already registered, it will just be activated. If it was not registered, + the project will be registered and activated. After that, the project can be activated again + by name (not just by path). + :return: a tuple of the project instance and two booleans indicating if a new project was added and if a new project configuration for the + added project was generated. """ - # Find or create the project config - # Either retrieve it from the registered projects, load it from an existing yaml in the .serena directory within the project, - # or autogenerate a new one if the project directory exists but no yaml is present. - project_config = None - if project in self.serena_config.project_names: - project_config = self.serena_config.get_project_configuration(project) - elif os.path.exists(project): - # maybe user passed a path instead of a name of a registered project - project_path = os.path.abspath(project) - for registered_project_config in self.serena_config.projects.values(): - if registered_project_config.project_root == project_path: - project_config = registered_project_config - break - if project_config is not None: - log.info(f"Activating registered project {project}") + new_project_generated = False + new_project_config_generated = False + project_instance: Project | None = None + for registered_project in self.serena_config.projects: + if project_root_or_name in (registered_project.project_name, registered_project.project_root): + project_instance = registered_project + break + if project_instance is not None: + log.info(f"Found registered project {project_instance.project_name} at path {project_instance.project_root}.") else: - if register_project: - project_name, project_config, was_autogenerated = self.serena_config.add_project_from_path(project, update_config_file=True) - log.info(f"Registered project {project_name} at path {project_config.project_root}.") - if was_autogenerated: - log.info( - f"Note: A new project configuration with language {project_config.language.value} was autogenerated since no project configuration was found." - ) - else: - log.info(f"Activating a non-registered project at path {project}") - try: - project_config = ProjectConfig.load(project) - except FileNotFoundError: - log.info(f"No project configuration found at path {project}, autogenerating one.") - project_config = ProjectConfig.autogenerate(project) - log.info(f"Autogenerated project configuration with language {project_config.language.value}.") - self.activate_project(project_config) + project_instance, new_project_config_generated = self.serena_config.add_project_from_path( + project_root_or_name, update_config_file=True + ) + new_project_generated = True + log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}.") + if new_project_config_generated: + log.info( + f"Note: A new project configuration with language {project_instance.project_config.language.value} " + f"was autogenerated since no project configuration was found in {project_root_or_name}." + ) + self._activate_project(project_instance) + return project_instance, new_project_generated, new_project_config_generated def get_active_tool_classes(self) -> list[type["Tool"]]: """ @@ -635,8 +604,8 @@ class SerenaAgent: :return: a string overview of the current configuration, including the active and available configuration options """ result_str = "Current configuration:\n" - if self._active_project_config is not None: - result_str += f"Active project: {self._active_project_config.project_name}\n" + if self._active_project is not None: + result_str += f"Active project: {self._active_project.project_name}\n" else: result_str += "No active project\n" result_str += "Available projects:\n" + "\n".join(list(self.serena_config.project_names)) @@ -687,20 +656,22 @@ class SerenaAgent: self.language_server = None # instantiate and start the language server - assert self._active_project_config is not None + assert self._active_project is not None multilspy_config = MultilspyConfig( - code_language=self._active_project_config.language, ignored_paths=self._active_project_config.ignored_paths + code_language=self._active_project.project_config.language, ignored_paths=self._active_project.project_config.ignored_paths ) ls_logger = MultilspyLogger() self.language_server = SyncLanguageServer.create( multilspy_config, ls_logger, - self._active_project_config.project_root, - add_gitignore_content_to_config=self._active_project_config.ignore_all_files_in_gitignore, + self._active_project.project_root, + add_gitignore_content_to_config=self._active_project.project_config.ignore_all_files_in_gitignore, ) self.language_server.start() if not self.language_server.is_running(): - raise RuntimeError(f"Failed to start the language server for {self._active_project_config}") + raise RuntimeError( + f"Failed to start the language server for {self._active_project.project_name} at {self._active_project.project_root}" + ) def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore @@ -729,36 +700,6 @@ class SerenaAgent: Logger.root.removeHandler(self._gui_log_handler) -class MemoriesManager: - def __init__(self, memory_dir: str): - self._memory_dir = Path(memory_dir) - self._memory_dir.mkdir(parents=True, exist_ok=True) - - def _get_memory_file_path(self, memory_file_name: str) -> Path: - return self._memory_dir / memory_file_name - - def load_memory(self, memory_file_name: str) -> str: - memory_file_path = self._get_memory_file_path(memory_file_name) - if not memory_file_path.exists(): - return f"Memory file {memory_file_name} not found, consider creating it with the `write_memory` tool if you need it." - with open(memory_file_path, encoding="utf-8") as f: - return f.read() - - def save_memory(self, memory_file_name: str, content: str) -> str: - memory_file_path = self._get_memory_file_path(memory_file_name) - with open(memory_file_path, "w", encoding="utf-8") as f: - f.write(content) - return f"Memory file {memory_file_name} written." - - def list_memories(self) -> list[str]: - return [f.name for f in self._memory_dir.iterdir() if f.is_file()] - - def delete_memory(self, memory_file_name: str) -> str: - memory_file_path = self._get_memory_file_path(memory_file_name) - memory_file_path.unlink() - return f"Memory file {memory_file_name} deleted." - - class Component(ABC): def __init__(self, agent: "SerenaAgent"): self.agent = agent @@ -772,7 +713,7 @@ class Component(ABC): """ :return: the root directory of the active project, raises a ValueError if no active project configuration is set """ - project_config = self.agent.get_active_project_config() + project_config = self.agent.get_active_project() if project_config is None: raise ValueError("Cannot get project root if no active project configuration is set.") return project_config.project_root @@ -900,7 +841,7 @@ class Tool(Component): try: # check whether the tool requires an active project and language server if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): - if self.agent._active_project_config is None: + if self.agent._active_project is None: return ( "Error: No active project. Ask to user to select a project from this list: " + f"{self.agent.serena_config.project_names}" @@ -1696,22 +1637,28 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): Activates a project by name. """ - def apply(self, project: str, register_project: bool = False) -> str: + def apply(self, project: str) -> str: """ - Activates the project with the given name, registering it if requested by the user. - Already registered projects will not be registered again. + Activates the project with the given name. :param project: the name of a registered project to activate or a path to a project directory - :param register_project: whether to register the project in the Serena configuration (if not already done before). - Registered projects will be saved in the `serena_config.yml` file and can be activated later by name. - Only use this option if explicitly requested by the user. """ - self.agent.activate_project_from_path_or_name(project, register_project) - project_config = self.agent._active_project_config - if project_config is None: - raise RuntimeError(f"Failed to activate project {project} without raising an error before, this should not happen.") - result_str = f"Activated project {project}, language: {project_config.language.value}" - result_str += f"\nAdditional project information:\n {project_config.project_info_prompt}" + active_project, new_project_generated, new_project_config_generated = self.agent.activate_project_from_path_or_name(project) + if new_project_generated: + result_str = ( + f"Created and activated a new project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}. " + + "You can activate this project later by name." + ) + else: + result_str = f"Activated existing project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}" + if new_project_config_generated: + result_str += ( + f"\nNote: A new project configuration was autogenerated because the given path did not contain a {ProjectConfig.SERENA_DEFAULT_PROJECT_FILE} file." + + f"You can now edit the project configuration in the file {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt." + ) + + if active_project.project_config.initial_prompt: + result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}" result_str += ( f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}" + "You should not read these memories directly, but rather use the `read_memory` tool to read them later if needed for the task." @@ -1720,32 +1667,6 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): return result_str -class AddProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): - """ - Adds a new project to the Serena configuration. - If the project is not already configured, this tool will autogenerate a project configuration. - """ - - def apply(self, project_path: str, project_name: str | None = None) -> str: - """ - Adds a new project to the Serena configuration. - - :param project_path: Path to the project directory - :param project_name: Name of the project to add. If not provided, the directory name will be used. - """ - project_name, project_config, was_autogenerated = self.agent.serena_config.add_project_from_path( - project_path, project_name, update_config_file=True - ) - result_msg = f"Successfully added project '{project_name}' to configuration." - if was_autogenerated: - result_msg += ( - f"\nNote: A new project configuration was autogenerated because the given path did not contain a {ProjectConfig.SERENA_DEFAULT_PROJECT_FILE} file." - + f"You can now edit the project configuration in the file {project_config.rel_path_to_project_yml()}." - + f"Identified project programming language: {project_config.language.value}" - ) - return result_msg - - class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): """ Removes a project from the Serena configuration. diff --git a/src/serena/agno.py b/src/serena/agno.py index 05c4ffd2..3e3fec49 100644 --- a/src/serena/agno.py +++ b/src/serena/agno.py @@ -24,7 +24,7 @@ log = logging.getLogger(__name__) class SerenaAgnoToolkit(Toolkit): def __init__(self, serena_agent: SerenaAgent): super().__init__("Serena") - for tool in serena_agent.get_exposed_tools(): + for tool in serena_agent.get_exposed_tool_instances(): self.functions[tool.get_name()] = self._create_agno_function(tool) log.info("Agno agent functions: %s", list(self.functions.keys())) diff --git a/src/serena/constants.py b/src/serena/constants.py index 5e3165e5..ea4a7493 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -7,5 +7,7 @@ PROMPT_TEMPLATES_DIR = str(_repo_root_path / "config" / "prompt_templates") CONTEXT_YAMLS_DIR = str(_repo_root_path / "config" / "contexts") MODE_YAMLS_DIR = str(_repo_root_path / "config" / "modes") +SERENA_MANAGED_DIR_NAME = ".serena" + DEFAULT_CONTEXT = "desktop-app" DEFAULT_MODES = ("interactive", "editing") diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 6be8c739..b99762f8 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -117,7 +117,7 @@ def create_mcp_server_and_agent( try: agent = SerenaAgent( - project_config=project, + project=project, # Callback disabled for the time being (see above) # project_activation_callback=update_tools context=context_instance, @@ -134,7 +134,7 @@ def create_mcp_server_and_agent( # unfortunately, query for changed tools. It only queries for changed resources and prompts regularly, # so we need to register all tools at startup, unfortunately. nonlocal mcp, agent - tools = agent.get_exposed_tools() + tools = agent.get_exposed_tool_instances() if mcp is not None: mcp._tool_manager._tools = {} for tool in tools: diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index 49b61f7c..604b9021 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -5,7 +5,7 @@ import time import pytest from multilspy.multilspy_config import Language -from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, ProjectConfig, SerenaAgent, SerenaConfig +from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, SerenaAgent, SerenaConfig from test.conftest import LanguageParamRequest, get_repo_path @@ -18,8 +18,7 @@ def serena_config(): def serena_agent(request: LanguageParamRequest, serena_config) -> SerenaAgent: language = Language(request.param) repo = get_repo_path(language) - project_config = ProjectConfig(project_name=os.path.basename(str(repo)), language=language, project_root=str(repo)) - return SerenaAgent(project_config=project_config, serena_config=serena_config) + return SerenaAgent(project=str(repo), serena_config=serena_config) class TestSerenaAgent: @@ -43,7 +42,7 @@ class TestSerenaAgent: assert any( symbol_name in s["name"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["location"]["relative_path"] for s in symbols - ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file} for {agent.get_active_project_config().language.name}" + ), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file} for {agent.get_active_project().language.name}" @pytest.mark.parametrize( "serena_agent,symbol_name,def_file,ref_file", @@ -86,7 +85,7 @@ class TestSerenaAgent: refs = json.loads(result) assert any( ref["location"]["relative_path"] == ref_file for ref in refs - ), f"Expected to find reference to {symbol_name} in {ref_file} for {agent._active_project_config.language.name}. refs={refs}" + ), f"Expected to find reference to {symbol_name} in {ref_file} for {agent._active_project.language.name}. refs={refs}" @pytest.mark.parametrize( "serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file", @@ -180,7 +179,7 @@ class TestSerenaAgent: and expected_kind.lower() in s["kind"].lower() and expected_file in s["location"]["relative_path"] for s in symbols - ), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project_config.language.name}. Symbols: {symbols}" + ), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project.language.name}. Symbols: {symbols}" @pytest.mark.parametrize( "serena_agent,name_path", @@ -215,4 +214,4 @@ class TestSerenaAgent: symbols = json.loads(result) assert ( not symbols - ), f"Expected to find no symbols for {name_path} for {agent._active_project_config.language.name}. Symbols found: {symbols}" + ), f"Expected to find no symbols for {name_path} for {agent._active_project.language.name}. Symbols found: {symbols}"