From 0def7aaecc3e90114eed9f34bce7e036f28bc587 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 19 Jul 2025 22:16:50 +0200 Subject: [PATCH 1/4] Remove obsolete flag returned by ProjectConfig.load which was never correctly returned anyway (due to a logic error) --- src/serena/agent.py | 17 ++++++----------- src/serena/config/serena_config.py | 15 ++++----------- src/serena/project.py | 5 ++--- src/serena/tools/config_tools.py | 16 +++++----------- 4 files changed, 17 insertions(+), 36 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 09ae4880..7c4b2004 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -399,17 +399,17 @@ 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 activate_project_from_path_or_name(self, project_root_or_name: str) -> tuple[Project, 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, 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. + + :return: a tuple of the project instance and a Boolean indicating whether the project was newly + created """ 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}.") @@ -419,16 +419,11 @@ class SerenaAgent: 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) + project_instance = self.serena_config.add_project_from_path(project_root_or_name) 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 + return project_instance, new_project_generated 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..3264d25c 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) 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..5e7fbe85 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 @@ -53,12 +52,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..db3b524f 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) + active_project, new_project_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." + 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}" From 4506c87d995fa1aea42f07a6b6495fd2313c2491 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 19 Jul 2025 22:26:40 +0200 Subject: [PATCH 2/4] Move flag indicating new project creation into Project representation --- src/serena/agent.py | 6 ++---- src/serena/config/serena_config.py | 2 +- src/serena/project.py | 3 ++- src/serena/tools/config_tools.py | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 7c4b2004..64f60d38 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -399,7 +399,7 @@ 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]: + 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 it was not registered, @@ -409,7 +409,6 @@ class SerenaAgent: :return: a tuple of the project instance and a Boolean indicating whether the project was newly created """ - new_project_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}.") @@ -420,10 +419,9 @@ class SerenaAgent: f"Existing project names: {self.serena_config.project_names}" ) project_instance = self.serena_config.add_project_from_path(project_root_or_name) - new_project_generated = True log.info(f"Added new project {project_instance.project_name} for path {project_instance.project_root}.") self._activate_project(project_instance) - return project_instance, new_project_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 3264d25c..b671a8d5 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -462,7 +462,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): 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() diff --git a/src/serena/project.py b/src/serena/project.py index 5e7fbe85..b73a252d 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -15,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 diff --git a/src/serena/tools/config_tools.py b/src/serena/tools/config_tools.py index db3b524f..51b782ad 100644 --- a/src/serena/tools/config_tools.py +++ b/src/serena/tools/config_tools.py @@ -15,8 +15,8 @@ class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): :param project: the name of a registered project to activate or a path to a project directory """ - active_project, new_project_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.\n" From fcca03db9cdb0cc598c176a35190ea652d7735ca Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 19 Jul 2025 22:36:57 +0200 Subject: [PATCH 3/4] Add method SerenaAgent.load_project_from_path_or_name --- src/serena/agent.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 64f60d38..f5c34cfa 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -399,27 +399,39 @@ 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) -> Project: + 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). + Get a project instance from a path or a name. - :return: a tuple of the project instance and a Boolean indicating whether the project was newly - created + :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 """ 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}" - ) + 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}.") + 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 From b63f30d77ff54a0cad9b0bcc25842142f214b24a Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sat, 19 Jul 2025 22:55:42 +0200 Subject: [PATCH 4/4] Refactor handling of IDE context when determining exposed tools --- src/serena/agent.py | 46 +++++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index f5c34cfa..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.