diff --git a/src/serena/agent.py b/src/serena/agent.py index 09ae4880..29352186 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -28,7 +28,7 @@ from serena.constants import ( from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI from serena.project import Project from serena.prompt_factory import SerenaPromptFactory -from serena.tools import Tool, ToolRegistry +from serena.tools import ActivateProjectTool, Tool, ToolRegistry from solidlsp import SolidLanguageServer if TYPE_CHECKING: @@ -177,30 +177,11 @@ class SerenaAgent: # determine the base toolset defining the set of exposed tools (which e.g. the MCP shall see), # limited by the Serena config, the context (which is fixed for the session) and JetBrains mode tool_inclusion_definitions: list[ToolInclusionDefinition] = [self.serena_config, self._context] + if self._context.name == RegisteredContext.IDE_ASSISTANT.value: + tool_inclusion_definitions.extend(self._ide_context_tool_inclusion_definitions(project)) if self.serena_config.jetbrains: tool_inclusion_definitions.append(SerenaAgentMode.from_name_internal("jetbrains")) - # In ide-assistant mode, apply additional tool exclusions - if self._context.name == RegisteredContext.IDE_ASSISTANT.value and project is not None: - additional_excluded_tools = ["activate_project"] - - # If a project is provided, check for excluded tools from project.yml - try: - # Try to load the project to get its excluded tools - project_instance = None - if os.path.isdir(project): - project_instance = Project.load(project, autogenerate=False) - else: - project_instance = self.serena_config.get_project(project) - - if project_instance is not None: - additional_excluded_tools.extend(project_instance.project_config.excluded_tools) - except (FileNotFoundError, ValueError): - # If project.yml doesn't exist or can't be loaded, no additional exclusions - pass - ide_assistant_exclusions = ToolInclusionDefinition(excluded_tools=additional_excluded_tools) - tool_inclusion_definitions.append(ide_assistant_exclusions) - self._base_tool_set = ToolSet.default().apply(*tool_inclusion_definitions) self._exposed_tools = {tc: t for tc, t in self._all_tools.items() if self._base_tool_set.includes_name(t.get_name())} log.info(f"Number of exposed tools: {len(self._exposed_tools)}") @@ -237,6 +218,27 @@ class SerenaAgent: except Exception as e: log.error(f"Error activating project '{project}' at startup: {e}") + def _ide_context_tool_inclusion_definitions(self, project_root_or_name: str | None) -> list[ToolInclusionDefinition]: + """ + In the IDE assistant context, the agent is assumed to work on a single project, and we thus + want to apply that project's tool exclusions/inclusions from the get-go, limiting the set + of tools that will be exposed to the client. + So if the project exists, we apply all the aforementioned exclusions. + + :param project_root_or_name: the project root path or project name + :return: + """ + tool_inclusion_definitions = [] + if project_root_or_name is not None: + # Note: Auto-generation is disabled, because the result must be returned instantaneously + # (project generation could take too much time), so as not to delay MCP server startup + # and provide responses to the client immediately. + project = self.load_project_from_path_or_name(project_root_or_name, autogenerate=False) + if project is not None: + tool_inclusion_definitions.append(ToolInclusionDefinition(excluded_tools=[ActivateProjectTool.get_name_from_cls()])) + tool_inclusion_definitions.append(project.project_config) + return tool_inclusion_definitions + def record_tool_usage_if_enabled(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None: """ Record the usage of a tool with the given input and output strings if tool usage statistics recording is enabled. @@ -399,36 +401,41 @@ class SerenaAgent: if self._project_activation_callback is not None: self._project_activation_callback() - def activate_project_from_path_or_name(self, project_root_or_name: str) -> tuple[Project, bool, bool]: + def load_project_from_path_or_name(self, project_root_or_name: str, autogenerate: bool) -> Project | None: """ - 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, - 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. + Get a project instance from a path or a name. + + :param project_root_or_name: the path to the project root or the name of the project + :param autogenerate: whether to autogenerate the project for the case where first argument is a directory + which does not yet contain a Serena project configuration file + :return: the project instance if it was found/could be created, None otherwise """ - new_project_generated = False - new_project_config_generated = False project_instance: Project | None = self.serena_config.get_project(project_root_or_name) if project_instance is not None: log.info(f"Found registered project {project_instance.project_name} at path {project_instance.project_root}.") - else: - if not os.path.isdir(project_root_or_name): - raise ProjectNotFoundError( - f"Project '{project_root_or_name}' not found: Not a valid project name or directory. " - f"Existing project names: {self.serena_config.project_names}" - ) - project_instance, new_project_config_generated = self.serena_config.add_project_from_path(project_root_or_name) - new_project_generated = True + elif autogenerate and os.path.isdir(project_root_or_name): + project_instance = self.serena_config.add_project_from_path(project_root_or_name) 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}." - ) + return project_instance + + def activate_project_from_path_or_name(self, project_root_or_name: str) -> Project: + """ + Activate a project from a path or a name. + If the project was already registered, it will just be activated. + If the argument is a path at which no Serena project previously existed, the project will be created beforehand. + Raises ProjectNotFoundError if the project could neither be found nor created. + + :return: a tuple of the project instance and a Boolean indicating whether the project was newly + created + """ + project_instance: Project | None = self.load_project_from_path_or_name(project_root_or_name, autogenerate=True) + if project_instance is None: + raise ProjectNotFoundError( + f"Project '{project_root_or_name}' not found: Not a valid project name or directory. " + f"Existing project names: {self.serena_config.project_names}" + ) self._activate_project(project_instance) - return project_instance, new_project_generated, new_project_config_generated + return project_instance def get_active_tool_classes(self) -> list[type["Tool"]]: """ diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 94b42ab8..b671a8d5 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -438,15 +438,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): return project return None - def add_project_from_path(self, project_root: Path | str) -> tuple["Project", bool]: + def add_project_from_path(self, project_root: Path | str) -> "Project": """ Add a project to the Serena configuration from a given path. Will raise a FileExistsError if a project already exists at the path. :param project_root: the path to the project to add - :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. + :return: the project that was added """ from ..project import Project @@ -462,18 +460,13 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): 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 + project_config = ProjectConfig.load(project_root, autogenerate=True) - new_project = Project(project_root=str(project_root), project_config=project_config) + new_project = Project(project_root=str(project_root), project_config=project_config, is_newly_created=True) self.projects.append(new_project) self.save() - return new_project, new_project_config_generated + return new_project def remove_project(self, project_name: str) -> None: # find the index of the project with the desired name and remove it diff --git a/src/serena/project.py b/src/serena/project.py index 1a482f3a..b73a252d 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -1,7 +1,6 @@ import logging import os from pathlib import Path -from typing import Self import pathspec @@ -16,9 +15,10 @@ log = logging.getLogger(__name__) class Project: - def __init__(self, project_root: str, project_config: ProjectConfig): + def __init__(self, project_root: str, project_config: ProjectConfig, is_newly_created: bool = False): self.project_root = project_root self.project_config = project_config + self.is_newly_created = is_newly_created # gather ignored paths from the project configuration and gitignore files ignored_patterns = project_config.ignored_paths @@ -53,12 +53,12 @@ class Project: return self.project_config.language @classmethod - def load(cls, project_root: str | Path, autogenerate: bool = True) -> Self: + def load(cls, project_root: str | Path, autogenerate: bool = True) -> "Project": 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, autogenerate=autogenerate) - return cls(project_root=str(project_root), project_config=project_config) + return Project(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()) diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index 7445b2ba..51b782ad 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -15,21 +15,15 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): :param project: the name of a registered project to activate or a path to a project directory """ - from ..config.serena_config import ProjectConfig - - active_project, new_project_generated, new_project_config_generated = self.agent.activate_project_from_path_or_name(project) - if new_project_generated: + active_project = self.agent.activate_project_from_path_or_name(project) + if active_project.is_newly_created: 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." + 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.\n" + f"The project's Serena configuration is in {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt." ) 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." - ) + 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 active_project.project_config.initial_prompt: result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}"