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
This commit is contained in:
Michael Panchenko committed 2025-05-26 21:47:19 +02:00
1 parent 13c71bb216
commit 22e72a5769
6 files changed
+281 -368

No files matched your search

+13 -22
View File
@@ -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
+257 -336
View File
@@ -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.
+1 -1
View File
@@ -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()))
+2
View File
@@ -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")
+2 -2
View File
@@ -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:
+6 -7
View File
@@ -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}"