From 2e1e5e59aaf8b2f5eb5873bd7bab4e911fea642f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Sun, 6 Apr 2025 19:49:08 +0200 Subject: [PATCH] Support project changes from within Serena (via tools) * Establish new Serena-wide configuration file serena_config.yml with * Setting whether to enable project activation * List of project configuration files * GUI logger configuration (no longer part of project configuration) * Add new tools for project activation (activate_project, get_active_project) * Project configuration file is no longer required for startup (now optional): If project activation is enabled, no project must be provided (but at least one project must be configured) --- README.md | 76 +++-- myproject.demo.yml => myproject.template.yml | 98 +++--- serena_config.template.yml | 28 ++ src/serena/__init__.py | 2 +- src/serena/agent.py | 320 +++++++++++++++---- src/serena/agno.py | 25 +- src/serena/gui_log_viewer.py | 7 + src/serena/mcp.py | 67 ++-- 8 files changed, 434 insertions(+), 189 deletions(-) rename myproject.demo.yml => myproject.template.yml (55%) create mode 100644 serena_config.template.yml diff --git a/README.md b/README.md index 7564734..147c664 100644 --- a/README.md +++ b/README.md @@ -128,12 +128,23 @@ Serena can read, write and execute code, read logs and the terminal output. ## Quick Start -### MCP Server (Claude Desktop) +### Setup and Configuration 1. Install `uv` (instructions [here](https://docs.astral.sh/uv/getting-started/installation/)) 2. Clone the repository to `/path/to/serena`. -3. Create a configuration file for your project, say `myproject.yml` based on the template in [myproject.demo.yml](myproject.demo.yml). -4. Configure the MCP server in your client. +3. Copy `serena_config.template.yml` to `serena_config.yml` and adjust settings. +4. Copy `myproject.template.yml` to `myproject.yml` and adjust the settings specific to your project. + (Add one such file for each project you want Serena to work on.) +5. If you want Serena to dynamically switch between projects, add the list of all project files + created in the previous step to the `projects` list in `serena_config.yml`. + +After this initial setup, continue with one of the sections below, depending on how you +want to use Serena. + +### MCP Server (Claude Desktop) + +1. Create a configuration file for your project, say `myproject.yml` based on the template in [myproject.template.yml](myproject.template.yml). +2. Configure the MCP server in your client. For [Claude Desktop](https://claude.ai/download) (available for Windows and macOS), go to File / Settings / Developer / MCP Servers / Edit Config, which will let you open the JSON file `claude_desktop_config.json`. Add the following (with adjusted paths) to enable Serena: @@ -147,6 +158,9 @@ Serena can read, write and execute code, read logs and the terminal output. } } ``` + + :info: The path to the project file is optional if you have set `enable_project_activation` in your configuration, + as this setting will allow you to simply instruct Claude to activate the project you want to work on. If you are using paths containing backslashes for paths on Windows (note that you can also just use forward slashes), be sure to escape them correctly (`\\`). @@ -549,31 +563,33 @@ For details on contributing, see [here](/CONTRIBUTING.md). ## Full List of Tools -Here the full list of Serena's default tools with a short description (the output of `uv run serena-list-tools`) - - * `check_onboarding_performed`: Checks whether the onboarding was already performed. - * `create_text_file`: Creates/overwrites a file in the project directory. - * `delete_lines`: Deletes a range of lines within a file. - * `delete_memory`: Deletes a memory from Serena's project-specific memory store. - * `execute_shell_command`: Executes a shell command. - * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). - * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). - * `get_dir_overview`: Gets an overview of the top-level symbols defined in all files within a given directory. - * `get_document_overview`: Gets an overview of the top-level symbols defined in a given file. - * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. - * `insert_at_line`: Inserts content at a given line in a file. - * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. - * `list_dir`: Lists files and directories in the given directory (optionally with recursion). - * `list_memories`: Lists memories in Serena's project-specific memory store. - * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). - * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). - * `read_file`: Reads a file within the project directory. - * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. - * `replace_symbol_body`: Replaces the full definition of a symbol. - * `search_in_all_code`: Performs a search for a pattern in all code files (and only in code files) in the project. - * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. - * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. - * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. - * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. - * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +Here is the full list of Serena's tools with a short description (output of `uv run serena-list-tools`): +* `activate_project`: Activates a project by name. +* `check_onboarding_performed`: Checks whether the onboarding was already performed. +* `create_text_file`: Creates/overwrites a file in the project directory. +* `delete_lines`: Deletes a range of lines within a file. +* `delete_memory`: Deletes a memory from Serena's project-specific memory store. +* `execute_shell_command`: Executes a shell command. +* `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +* `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +* `get_active_project`: Gets the name of the currently active project (if any) and lists existing projects +* `get_dir_overview`: Gets an overview of the top-level symbols defined in all files within a given directory. +* `get_document_overview`: Gets an overview of the top-level symbols defined in a given file. +* `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +* `insert_at_line`: Inserts content at a given line in a file. +* `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +* `list_dir`: Lists files and directories in the given directory (optionally with recursion). +* `list_memories`: Lists memories in Serena's project-specific memory store. +* `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +* `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +* `read_file`: Reads a file within the project directory. +* `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +* `replace_lines`: Replaces a range of lines within a file with new content. +* `replace_symbol_body`: Replaces the full definition of a symbol. +* `search_in_all_code`: Performs a search for a pattern in all code files (and only in code files) in the project. +* `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +* `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +* `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +* `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +* `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. \ No newline at end of file diff --git a/myproject.demo.yml b/myproject.template.yml similarity index 55% rename from myproject.demo.yml rename to myproject.template.yml index 74c277d..d1c4cce 100644 --- a/myproject.demo.yml +++ b/myproject.template.yml @@ -1,56 +1,42 @@ -# absolute path to the project -project_root: /path/to/project - -# language of the project (csharp, python, rust, java, typescript, javascript, go, or ruby) -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: python - -# list of directories to ignore: either names (e.g. "temp") or relative paths (e.g. "build/foo") -ignored_dirs: [".git", "temp"] - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run serena-list-tools`. -# -# check_onboarding_performed -# create_text_file -# delete_lines -# delete_memory -# execute_shell_command -# find_referencing_symbols -# find_symbol -# get_dir_overview -# get_document_overview -# insert_after_symbol -# insert_at_line -# insert_before_symbol -# list_dir -# list_memories -# onboarding -# prepare_for_new_conversation -# read_file -# read_memory -# replace_symbol_body -# search_in_all_code -# summarize_changes -# think_about_collected_information -# think_about_task_adherence -# think_about_whether_you_are_done -# write_memory -excluded_tools: [] - -# 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, -# especially when using the agno playground, since the tool calls are not always shown, -# and the input params are never shown in the agno UI. -# When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. -# Note: unfortunately, the various entities starting the Serena server or agent do so in -# mysterious ways, often starting multiple instances of the process without shutting down -# previous instances. This leads to multiple log windows being opened, and only the last -# window being updated. Since we can't control how agno or Claude Desktop starts Serena, we have to live with this limitation for now. -gui_log_window: True - -# minimum log level for the GUI log window (10 = debug, 20 = info, 30 = warning, 40 = error) -gui_log_level: 20 +# absolute path to the project +project_root: /path/to/project + +# language of the project (csharp, python, rust, java, typescript, javascript, go, or ruby) +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: python + +# list of directories to ignore: either names (e.g. "temp") or relative paths (e.g. "build/foo") +ignored_dirs: [".git", "temp"] + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run serena-list-tools`. +# +# check_onboarding_performed +# create_text_file +# delete_lines +# delete_memory +# execute_shell_command +# find_referencing_symbols +# find_symbol +# get_dir_overview +# get_document_overview +# insert_after_symbol +# insert_at_line +# insert_before_symbol +# list_dir +# list_memories +# onboarding +# prepare_for_new_conversation +# read_file +# read_memory +# replace_symbol_body +# search_in_all_code +# summarize_changes +# think_about_collected_information +# think_about_task_adherence +# think_about_whether_you_are_done +# write_memory +excluded_tools: [] diff --git a/serena_config.template.yml b/serena_config.template.yml new file mode 100644 index 0000000..989f2e3 --- /dev/null +++ b/serena_config.template.yml @@ -0,0 +1,28 @@ +# 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 .yml project files here (which you can switch between using "activate_project"). +# Serena will know the projects by their base filename, i.e. "MyProject.yml" will be project "MyProject" to Serena. +# Paths should either be relative to the directory this configuration file is in or absolute. +projects: + - myproject.yml + +# 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, +# especially when using the agno playground, since the tool calls are not always shown, +# and the input params are never shown in the agno UI. +# When used as MCP server for Claude Desktop, the logs are primarily for troubleshooting. +# Note: unfortunately, the various entities starting the Serena server or agent do so in +# mysterious ways, often starting multiple instances of the process without shutting down +# previous instances. This leads to multiple log windows being opened, and only the last +# window being updated. Since we can't control how agno or Claude Desktop start Serena, +# we have to live with this limitation for now. +gui_log_window: True + +# minimum log level for the GUI log window (10 = debug, 20 = info, 30 = warning, 40 = error) +gui_log_level: 20 diff --git a/src/serena/__init__.py b/src/serena/__init__.py index aa295c0..9a6ae1b 100644 --- a/src/serena/__init__.py +++ b/src/serena/__init__.py @@ -4,4 +4,4 @@ __version__ = "0.1.0-dev1" def serena_root_path() -> str: from pathlib import Path - return str(Path(__file__).parent.parent.absolute()) + return str(Path(__file__).parent.parent.parent.absolute()) diff --git a/src/serena/agent.py b/src/serena/agent.py index b598213..cc7b4fe 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -6,26 +6,27 @@ import inspect import json import os import platform -import sys import traceback from abc import ABC from collections import defaultdict from collections.abc import Callable, Generator, Iterable from logging import Logger from pathlib import Path -from typing import Any, TypeVar, cast +from typing import Any, Self, TypeVar, cast import yaml from sensai.util import logging -from sensai.util.string import dict_string +from sensai.util.string import ToStringMixin, dict_string from multilspy import SyncLanguageServer from multilspy.multilspy_config import Language, MultilspyConfig from multilspy.multilspy_logger import MultilspyLogger from multilspy.multilspy_types import SymbolKind +from serena import serena_root_path from serena.gui_log_viewer import GuiLogViewer, GuiLogViewerHandler from serena.llm.prompt_factory import PromptFactory from serena.symbol import SymbolLocation, SymbolManager +from serena.util.class_decorators import singleton from serena.util.file_system import scan_directory from serena.util.inspection import iter_subclasses from serena.util.shell import execute_shell_command @@ -36,6 +37,67 @@ TTool = TypeVar("TTool", bound="Tool") SUCCESS_RESULT = "OK" +class ProjectConfig(ToStringMixin): + SERENA_MANAGED_DIR = ".serena" + + def __init__(self, config_dict: dict[str, Any], project_name: str): + self.project_name: str = project_name + self.language: Language = Language(config_dict["language"]) + self.project_root: str = str(Path(config_dict["project_root"]).resolve()) + self.ignored_dirs: list[str] = config_dict.get("ignored_dirs", []) + self.excluded_tools: set[str] = set(config_dict.get("excluded_tools", [])) + + def _tostring_excludes(self) -> list[str]: + return ["ignored_dirs", "excluded_tools"] + + @classmethod + def project_name_from_yml_path(cls, yml_file_path: str | Path) -> str: + return Path(yml_file_path).stem + + @classmethod + def from_yml(cls, yml_path: str | Path) -> Self: + with open(yml_path, encoding="utf-8") as f: + config_dict = yaml.safe_load(f) + return cls(config_dict, cls.project_name_from_yml_path(yml_path)) + + def get_serena_managed_dir(self) -> str: + return os.path.join(self.project_root, self.SERENA_MANAGED_DIR) + + +@singleton +class SerenaConfig: + """ + Handles user-defined Serena configuration based on the configuration file + """ + + CONFIG_FILE = "serena_config.yml" + + def __init__(self) -> None: + config_file = os.path.join(serena_root_path(), self.CONFIG_FILE) + if not os.path.exists(config_file): + raise FileNotFoundError(f"Serena configuration file not found: {config_file}") + with open(config_file, encoding="utf-8") as f: + config_yaml = yaml.safe_load(f) + + self.projects: dict[str, Path] = {} + for project_yaml_path in config_yaml["projects"]: + project_yaml_path = Path(project_yaml_path) + if not project_yaml_path.is_absolute(): + project_yaml_path = Path(serena_root_path()) / project_yaml_path + project_name = project_yaml_path.stem + self.projects[project_name] = project_yaml_path + self.project_names = list(self.projects.keys()) + + self.gui_log_window_enabled = config_yaml.get("gui_log_window", True) + self.gui_log_window_level = config_yaml.get("gui_log_level", logging.INFO) + self.enable_project_activation = config_yaml.get("enable_project_activation", True) + + def read_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 ProjectConfig.from_yml(self.projects[project_name]) + + class LinesRead: def __init__(self) -> None: self.files: dict[str, set[tuple[int, int]]] = defaultdict(lambda: set()) @@ -53,94 +115,149 @@ class LinesRead: class SerenaAgent: - def __init__(self, project_file_path: str, start_language_server: bool = False): + def __init__(self, project_file_path: str | None = None, project_activation_callback: Callable[[], None] | None = None): """ - :param project_file_path: the project configuration file path (.yml) - :param start_language_server: whether to start the language server immediately and manage its - lifecycle internally + :param project_file_path: the configuration file (.yml) of the project to load immediately; + 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_activation_callback: a callback function to be called when a project is activated. """ - self._start_language_server = start_language_server + # obtain serena configuration + self.serena_config = SerenaConfig() - if not os.path.exists(project_file_path): - print(f"Project file not found: {project_file_path}", file=sys.stderr) - sys.exit(1) - - # read project configuration - with open(project_file_path, encoding="utf-8") as f: - project_config = yaml.safe_load(f) - self.project_config = project_config - self.language = Language(project_config["language"]) - self.project_root = str(Path(project_config["project_root"]).resolve()) - - # enable GUI log window - enable_gui_log = project_config.get("gui_log_window", True) - self._gui_log_handler = None - if enable_gui_log: + # open GUI log window if enabled + self._gui_log_handler: GuiLogViewerHandler | None = None + if self.serena_config.gui_log_window_enabled: if platform.system() == "Darwin": log.warning("GUI log window is not supported on macOS") else: - log_level = project_config.get("gui_log_level", logging.INFO) + log_level = self.serena_config.gui_log_window_level if Logger.root.level > log_level: log.info(f"Root logger level is higher than GUI log level; changing the root logger level to {log_level}") Logger.root.setLevel(log_level) self._gui_log_handler = GuiLogViewerHandler(GuiLogViewer(title="Serena Logs"), level=log_level, format_string=LOG_FORMAT) Logger.root.addHandler(self._gui_log_handler) - log.info( - f"Starting serena server for project {project_file_path} (language={self.language}, root={self.project_root}); " - f"process id={os.getpid()}, parent process id={os.getppid()}" - ) - - # create and start the language server instance - self._config = MultilspyConfig(code_language=self.language) - self._ls_logger = MultilspyLogger() - self.language_server = SyncLanguageServer.create(self._config, self._ls_logger, self.project_root) + log.info(f"Starting Serena server (process id={os.getpid()}, parent process id={os.getppid()})") self.prompt_factory = PromptFactory() - self.symbol_manager = SymbolManager(self.language_server, self) - self.memories_manager = MemoriesManager(os.path.join(self.get_serena_managed_dir(), "memories")) - self.lines_read = LinesRead() + self._project_activation_callback = project_activation_callback + + # project-specific instances, which will be initialized upon project activation + self.project_config: ProjectConfig | 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 - excluded_tools = project_config.get("excluded_tools", []) self._all_tools: dict[type[Tool], Tool] = {} - self.tools: dict[type[Tool], Tool] = {} for tool_class in iter_tool_classes(): tool_instance = tool_class(self) + if not self.serena_config.enable_project_activation: + if tool_class in (GetActiveProjectTool, 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 - if (tool_name := tool_class.get_name()) in excluded_tools: - log.info(f"Skipping tool {tool_name} because it is in the exclude list") - continue - self.tools[tool_class] = tool_instance - log.info(f"Loaded tools: {', '.join([tool.get_name() for tool in self.tools.values()])}") + self._active_tools = dict(self._all_tools) + log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name() for tool in self._all_tools.values()])}") # If GUI log window is enabled, set the tool names for highlighting if self._gui_log_handler is not None: - tool_names = [tool.get_name() for tool in self.tools.values()] + tool_names = [tool.get_name() for tool in self._active_tools.values()] self._gui_log_handler.log_viewer.set_tool_names(tool_names) - # start the language server if requested - if self._start_language_server: - log.info("Starting the language server ...") - self.language_server.start() - if not self.language_server.is_running(): - raise RuntimeError(f"Failed to start the language server for {self.language} and project {self.project_root}") + + # activate a project configuration (if provided or if there is only a single project available) + project_config: ProjectConfig | None = None + if project_file_path is not None: + if not os.path.exists(project_file_path): + raise FileNotFoundError(f"Project file not found: {project_file_path}") + project_config = ProjectConfig.from_yml(project_file_path) + 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 = self.serena_config.read_project_configuration(self.serena_config.project_names[0]) + if project_config is not None: + self.activate_project(project_config) + 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.") + + def get_exposed_tools(self) -> list["Tool"]: + """ + :return: the list of tools that are to be exposed/registered in the client + """ + if self.serena_config.enable_project_activation: + # With project activation, we must expose all tools and handle tool activation within Serena + # (because clients to not react to changed tools) + return list(self._all_tools.values()) + else: + return list(self._active_tools.values()) + + def activate_project(self, project_config: ProjectConfig) -> None: + log.info(f"Activating {project_config}") + self.project_config = project_config + + # handle project-specific tool exclusions (if any) + if self.project_config.excluded_tools: + self._active_tools = { + key: tool for key, tool in self._all_tools.items() if tool.get_name() not in project_config.excluded_tools + } + log.info(f"Active tools after exclusions ({len(self._active_tools)}): {', '.join(self.get_active_tool_names())}") + else: + self._active_tools = dict(self._all_tools) + + # start the language server + self.reset_language_server() + assert self.language_server is not None + + # initialize project-specific instances + self.symbol_manager = SymbolManager(self.language_server, self) + self.memories_manager = MemoriesManager(os.path.join(self.project_config.get_serena_managed_dir(), "memories")) + self.lines_read = LinesRead() + + if self._project_activation_callback is not None: + self._project_activation_callback() + + def get_active_tool_names(self) -> list[str]: + """ + :return: the list of names of the active tools for the current project + """ + return sorted([tool.get_name() for tool in self._active_tools.values()]) + + def is_language_server_running(self) -> bool: + return self.language_server is not None and self.language_server.is_running() def reset_language_server(self) -> None: - self.language_server.stop() + """ + Starts/resets the language server for the current project + """ + # stop the language server if it is running + if self.is_language_server_running(): + log.info("Stopping the language server ...") + assert self.language_server is not None + self.language_server.stop() + self.language_server = None + + # instantiate and start the language server + assert self.project_config is not None + multilspy_config = MultilspyConfig(code_language=self.project_config.language) + ls_logger = MultilspyLogger() + self.language_server = SyncLanguageServer.create(multilspy_config, ls_logger, self.project_config.project_root) self.language_server.start() if not self.language_server.is_running(): - raise RuntimeError(f"Failed to start (reset) the language server for {self.language} and project {self.project_root}") + raise RuntimeError(f"Failed to start the language server for {self.project_config}") def get_tool(self, tool_class: type[TTool]) -> TTool: return self._all_tools[tool_class] # type: ignore def print_tool_overview(self) -> None: - _print_tool_overview(self.tools.values()) - - def get_serena_managed_dir(self) -> str: - return os.path.join(self.project_root, ".serena") + _print_tool_overview(self._active_tools.values()) def mark_file_modified(self, relativ_path: str) -> None: + assert self.lines_read is not None self.lines_read.invalidate_lines_read(relativ_path) def __del__(self) -> None: @@ -150,8 +267,9 @@ class SerenaAgent: if not hasattr(self, "_is_initialized"): return log.info("SerenaAgent is shutting down ...") - if self._start_language_server: + if self.is_language_server_running(): log.info("Stopping the language server ...") + assert self.language_server is not None self.language_server.stop() if self._gui_log_handler: log.info("Stopping the GUI log window ...") @@ -195,14 +313,17 @@ class Component(ABC): @property def language_server(self) -> SyncLanguageServer: + assert self.agent.language_server is not None return self.agent.language_server @property def project_root(self) -> str: - return self.agent.project_root + assert self.project_config is not None + return self.project_config.project_root @property - def project_config(self) -> dict[str, Any]: + def project_config(self) -> ProjectConfig: + assert self.agent.project_config is not None return self.agent.project_config @property @@ -211,12 +332,19 @@ class Component(ABC): @property def memories_manager(self) -> MemoriesManager: + assert self.agent.memories_manager is not None return self.agent.memories_manager @property def symbol_manager(self) -> SymbolManager: + assert self.agent.symbol_manager is not None return self.agent.symbol_manager + @property + def lines_read(self) -> LinesRead: + assert self.agent.lines_read is not None + return self.agent.lines_read + _DEFAULT_MAX_ANSWER_LENGTH = int(2e5) @@ -286,25 +414,50 @@ class Tool(Component): """ Applies the tool with the given arguments """ - if not self.language_server.is_running(): - self.agent.reset_language_server() - apply_fn = self.get_apply_fn() + if log_call: self._log_tool_application(inspect.currentframe()) + try: + # check whether the tool requires an active project and language server + if not isinstance(self, ToolMarkerDoesNotRequireActiveProject): + if self.agent.project_config is None: + return ( + "Error: No active project. Ask to user to select a project from this list: " + + f"{self.agent.serena_config.project_names}" + ) + if not self.agent.is_language_server_running(): + log.info("Language server is not running. Starting it ...") + self.agent.reset_language_server() + + # check whether the tool is enabled + if self.agent.project_config is not None and self.get_name() in self.agent.project_config.excluded_tools: + return ( + f"Error: Tool '{self.get_name()}' is disabled for the active project ('{self.project_config.project_name}'); " + f"active tools: {self.agent.get_active_tool_names()}" + ) + + # apply the actual tool result = apply_fn(**kwargs) + except Exception as e: if not catch_exceptions: raise msg = f"Error executing tool: {e}\n{traceback.format_exc()}" log.error(f"Error executing tool: {e}", exc_info=e) result = msg + if log_call: log.info(f"Result: {result}") + return result +class ToolMarkerDoesNotRequireActiveProject: + pass + + class ReadFileTool(Tool): """ Reads a file within the project directory. @@ -331,7 +484,7 @@ class ReadFileTool(Tool): if end_line is None: result_lines = result_lines[start_line:] else: - self.agent.lines_read.add_lines_read(relative_path, (start_line, end_line)) + self.lines_read.add_lines_read(relative_path, (start_line, end_line)) result_lines = result_lines[start_line : end_line + 1] result = "\n".join(result_lines) @@ -379,7 +532,7 @@ class ListDirTool(Tool): os.path.join(self.project_root, relative_path), relative_to=self.project_root, recursive=recursive, - ignored_dirs=self.project_config["ignored_dirs"], + ignored_dirs=self.project_config.ignored_dirs, ) result = json.dumps({"dirs": dirs, "files": files}) return self._limit_length(result, max_answer_chars) @@ -661,7 +814,7 @@ class DeleteLinesTool(Tool): :param start_line: the 0-based index of the first line to be deleted :param end_line: the 0-based index of the last line to be deleted """ - if not self.agent.lines_read.were_lines_read(relative_path, (start_line, end_line)): + if not self.lines_read.were_lines_read(relative_path, (start_line, end_line)): read_lines_tool = self.agent.get_tool(ReadFileTool) return f"Error: Must call `{read_lines_tool.get_name()}` first to read exactly the affected lines." self.symbol_manager.delete_lines(relative_path, start_line, end_line) @@ -985,6 +1138,43 @@ class ExecuteShellCommandTool(Tool): return self._limit_length(result, max_answer_chars) +class GetActiveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Gets the name of the currently active project (if any) and lists existing projects + """ + + def apply( + self, + ) -> str: + """ + Gets the name of the currently active project (if any) and returns the list of all available projects. + To change the current project, use the `activate_project` tool. + + :return: an object containing the name of the currently activated project (if any) and the list of all available projects + """ + active_project = None if self.agent.project_config is None else self.agent.project_config.project_name + return json.dumps({"active_project": active_project, "available_projects": self.agent.serena_config.project_names}) + + +class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject): + """ + Activates a project by name. + """ + + def apply(self, project_name: str) -> str: + """ + Activates the project with the given name + + :param project_name: the name of the project to activate + """ + try: + project_config = self.agent.serena_config.read_project_configuration(project_name) + except ValueError as e: + return str(e) + self.agent.activate_project(project_config) + return SUCCESS_RESULT + + def iter_tool_classes() -> Generator[type[Tool], None, None]: return iter_subclasses(Tool) diff --git a/src/serena/agno.py b/src/serena/agno.py index 80f3316..09893f2 100644 --- a/src/serena/agno.py +++ b/src/serena/agno.py @@ -81,7 +81,7 @@ _patch_gemini_schema_conversion() class SerenaAgnoToolkit(Toolkit): def __init__(self, serena_agent: SerenaAgent): super().__init__("Serena") - for tool in serena_agent.tools.values(): + for tool in serena_agent.get_exposed_tools(): self.functions[tool.get_name()] = self._create_agno_function(tool) log.info("Agno agent functions: %s", list(self.functions.keys())) @@ -128,22 +128,25 @@ class SerenaAgnoAgentProvider: parser = argparse.ArgumentParser(description="Serena coding assistant") parser.add_argument( - "--project-file", required=True, help="Path to the project file, either absolute or relative to the root directory" + "--project-file", required=False, help="Path to the project file, either absolute or relative to the root directory" ) args = parser.parse_args() - project_file = Path(args.project_file).resolve() - # If project file path is relative, make it absolute by joining with project root - if not project_file.is_absolute(): - # Get the project root directory (parent of scripts directory) - project_root = Path(serena_root_path()) - project_file = project_root / args.project_file + if args.project_file: + project_file = Path(args.project_file).resolve() + # If project file path is relative, make it absolute by joining with project root + if not project_file.is_absolute(): + # Get the project root directory (parent of scripts directory) + project_root = Path(serena_root_path()) + project_file = project_root / args.project_file - # Ensure the path is normalized and absolute - project_file = project_file.resolve() + # Ensure the path is normalized and absolute + project_file = str(project_file.resolve()) + else: + project_file = None with LogTime("Loading Serena agent"): - serena_agent = SerenaAgent(str(project_file), start_language_server=True) + serena_agent = SerenaAgent(project_file) # Even though we don't want to keep history between sessions, # for agno-ui to work as a conversation, we use a persistent storage on disk. diff --git a/src/serena/gui_log_viewer.py b/src/serena/gui_log_viewer.py index b2bdebe..aa5dc51 100644 --- a/src/serena/gui_log_viewer.py +++ b/src/serena/gui_log_viewer.py @@ -301,6 +301,13 @@ class GuiLogViewerHandler(logging.Handler): if not self.log_viewer.running: self.log_viewer.start() + @classmethod + def is_instance_registered(cls) -> bool: + for h in logging.Logger.root.handlers: + if isinstance(h, cls): + return True + return False + def emit(self, record): """ Emit a log record to the ThreadedLogViewer. diff --git a/src/serena/mcp.py b/src/serena/mcp.py index 871aa96..2300394 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -2,6 +2,7 @@ The Serena Model Context Protocol (MCP) Server """ +import time import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -13,8 +14,10 @@ from mcp.server.fastmcp.server import FastMCP, Settings from mcp.server.fastmcp.tools.base import Tool as MCPTool from mcp.server.fastmcp.utilities.func_metadata import func_metadata from sensai.util import logging +from sensai.util.helper import mark_used from serena.agent import SerenaAgent, Tool +from serena.gui_log_viewer import GuiLogViewerHandler log = logging.getLogger(__name__) LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s" @@ -41,7 +44,6 @@ class SerenaMCPRequestContext: def make_tool( tool: Tool, ) -> MCPTool: - """Create a Tool from a function.""" func_name = tool.get_name() apply_fn = getattr(tool, "apply") @@ -68,42 +70,55 @@ def make_tool( ) -_SERENA_AGENT_REGISTRY: dict[str, SerenaAgent] = {} -"""Map of project file paths to their corresponding SerenaAgent instances.""" - - -def _get_create_serena_agent(project_file_path: str) -> SerenaAgent: - if project_file_path not in _SERENA_AGENT_REGISTRY: - log.info(f"Creating new SerenaAgent for project file: {project_file_path}") - _SERENA_AGENT_REGISTRY[project_file_path] = SerenaAgent(project_file_path, start_language_server=True) - else: - log.info(f"Reusing existing SerenaAgent for project file: {project_file_path}") - return _SERENA_AGENT_REGISTRY[project_file_path] - - def create_mcp_server() -> FastMCP: argv = sys.argv[1:] - if len(argv) != 1: - print("\nUsage: mcp_server <.yml project file>", file=sys.stderr) - sys.exit(1) - project_file_path = argv[0] + if (len(argv) == 1 and argv[0] == "--help") or len(argv) > 1: + print("\nUsage: mcp_server [.yml project file]", file=sys.stderr) + sys.exit(0) - agent = _get_create_serena_agent(project_file_path) + mcp: FastMCP | None = None + + def update_tools() -> None: + """Update the tools in the MCP server.""" + # Tools may change as a result of project activation. + # NOTE: While we could pass updated tool information on to the MCP server via the callback, Claude Desktop does not, + # 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 + tools = agent.get_exposed_tools() + if mcp is not None: + mcp._tool_manager._tools = {} + for tool in tools: + # noinspection PyProtectedMember + mcp._tool_manager._tools[tool.get_name()] = make_tool(tool) + + project_file_path = argv[0] if len(argv) == 1 else None + try: + agent = SerenaAgent( + project_file_path, + # Callback disabled for the time being (see above) + # project_activation_callback=update_tools + ) + except Exception as e: + # allow any initialization errors to be viewed for some time in the GUI before exiting + if GuiLogViewerHandler.is_instance_registered(): + log.error(f"Failed to initialize agent: {e}", exc_info=e) + time.sleep(60) + raise @asynccontextmanager async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]: """Manage server startup and shutdown lifecycle.""" - try: - yield - finally: - for agent in _SERENA_AGENT_REGISTRY.values(): - agent.language_server.stop() + nonlocal agent + mark_used(mcp_server) + yield mcp_settings = Settings(lifespan=server_lifespan) mcp = FastMCP(**mcp_settings.model_dump()) - for tool in agent.tools.values(): - mcp._tool_manager._tools[tool.get_name()] = make_tool(tool) + + update_tools() + return mcp