mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 10:16:00 +00:00
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:
4 files changed
+67
-73
No files matched your search
+52
-45
@@ -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"]]:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in new issue
Block a user