Merge pull request #320 from oraios/improve-ide-context-exposed-tools

Improve handling of IDE context when determining exposed tools, refactoring
This commit is contained in:
Dominik Jain authored and GitHub committed 2025-07-20 00:00:13 +02:00
commit e0be200e8f
4 files changed
+67 -73

No files matched your search

+52 -45
View File
@@ -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"]]:
"""
+5 -12
View File
@@ -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
+4 -4
View File
@@ -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())
+6 -12
View File
@@ -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}"