mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-20 00:15:41 +00:00
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)
This commit is contained in:
1 parent
4c92934c8a
commit
2e1e5e59aa
8 files changed
+434
-189
No files matched your search
@@ -128,12 +128,23 @@ Serena can read, write and execute code, read logs and the terminal output.
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### MCP Server (Claude Desktop)
|
### Setup and Configuration
|
||||||
|
|
||||||
1. Install `uv` (instructions [here](https://docs.astral.sh/uv/getting-started/installation/))
|
1. Install `uv` (instructions [here](https://docs.astral.sh/uv/getting-started/installation/))
|
||||||
2. Clone the repository to `/path/to/serena`.
|
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).
|
3. Copy `serena_config.template.yml` to `serena_config.yml` and adjust settings.
|
||||||
4. Configure the MCP server in your client.
|
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,
|
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:
|
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
|
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 (`\\`).
|
(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
|
## 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`)
|
Here is the full list of Serena's tools with a short description (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.
|
|
||||||
|
|
||||||
|
* `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.
|
||||||
@@ -1,56 +1,42 @@
|
|||||||
# absolute path to the project
|
# absolute path to the project
|
||||||
project_root: /path/to/project
|
project_root: /path/to/project
|
||||||
|
|
||||||
# language of the project (csharp, python, rust, java, typescript, javascript, go, or ruby)
|
# language of the project (csharp, python, rust, java, typescript, javascript, go, or ruby)
|
||||||
# Special requirements:
|
# Special requirements:
|
||||||
# * csharp: Requires the presence of a .sln file in the project folder.
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
language: python
|
language: python
|
||||||
|
|
||||||
# list of directories to ignore: either names (e.g. "temp") or relative paths (e.g. "build/foo")
|
# list of directories to ignore: either names (e.g. "temp") or relative paths (e.g. "build/foo")
|
||||||
ignored_dirs: [".git", "temp"]
|
ignored_dirs: [".git", "temp"]
|
||||||
|
|
||||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
# 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.
|
# Below is the complete list of tools for convenience.
|
||||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
# execute `uv run serena-list-tools`.
|
# execute `uv run serena-list-tools`.
|
||||||
#
|
#
|
||||||
# check_onboarding_performed
|
# check_onboarding_performed
|
||||||
# create_text_file
|
# create_text_file
|
||||||
# delete_lines
|
# delete_lines
|
||||||
# delete_memory
|
# delete_memory
|
||||||
# execute_shell_command
|
# execute_shell_command
|
||||||
# find_referencing_symbols
|
# find_referencing_symbols
|
||||||
# find_symbol
|
# find_symbol
|
||||||
# get_dir_overview
|
# get_dir_overview
|
||||||
# get_document_overview
|
# get_document_overview
|
||||||
# insert_after_symbol
|
# insert_after_symbol
|
||||||
# insert_at_line
|
# insert_at_line
|
||||||
# insert_before_symbol
|
# insert_before_symbol
|
||||||
# list_dir
|
# list_dir
|
||||||
# list_memories
|
# list_memories
|
||||||
# onboarding
|
# onboarding
|
||||||
# prepare_for_new_conversation
|
# prepare_for_new_conversation
|
||||||
# read_file
|
# read_file
|
||||||
# read_memory
|
# read_memory
|
||||||
# replace_symbol_body
|
# replace_symbol_body
|
||||||
# search_in_all_code
|
# search_in_all_code
|
||||||
# summarize_changes
|
# summarize_changes
|
||||||
# think_about_collected_information
|
# think_about_collected_information
|
||||||
# think_about_task_adherence
|
# think_about_task_adherence
|
||||||
# think_about_whether_you_are_done
|
# think_about_whether_you_are_done
|
||||||
# write_memory
|
# write_memory
|
||||||
excluded_tools: []
|
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
|
|
||||||
@@ -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
|
||||||
@@ -4,4 +4,4 @@ __version__ = "0.1.0-dev1"
|
|||||||
def serena_root_path() -> str:
|
def serena_root_path() -> str:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
return str(Path(__file__).parent.parent.absolute())
|
return str(Path(__file__).parent.parent.parent.absolute())
|
||||||
+255
-65
@@ -6,26 +6,27 @@ import inspect
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import sys
|
|
||||||
import traceback
|
import traceback
|
||||||
from abc import ABC
|
from abc import ABC
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable, Generator, Iterable
|
from collections.abc import Callable, Generator, Iterable
|
||||||
from logging import Logger
|
from logging import Logger
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, TypeVar, cast
|
from typing import Any, Self, TypeVar, cast
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from sensai.util import logging
|
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 import SyncLanguageServer
|
||||||
from multilspy.multilspy_config import Language, MultilspyConfig
|
from multilspy.multilspy_config import Language, MultilspyConfig
|
||||||
from multilspy.multilspy_logger import MultilspyLogger
|
from multilspy.multilspy_logger import MultilspyLogger
|
||||||
from multilspy.multilspy_types import SymbolKind
|
from multilspy.multilspy_types import SymbolKind
|
||||||
|
from serena import serena_root_path
|
||||||
from serena.gui_log_viewer import GuiLogViewer, GuiLogViewerHandler
|
from serena.gui_log_viewer import GuiLogViewer, GuiLogViewerHandler
|
||||||
from serena.llm.prompt_factory import PromptFactory
|
from serena.llm.prompt_factory import PromptFactory
|
||||||
from serena.symbol import SymbolLocation, SymbolManager
|
from serena.symbol import SymbolLocation, SymbolManager
|
||||||
|
from serena.util.class_decorators import singleton
|
||||||
from serena.util.file_system import scan_directory
|
from serena.util.file_system import scan_directory
|
||||||
from serena.util.inspection import iter_subclasses
|
from serena.util.inspection import iter_subclasses
|
||||||
from serena.util.shell import execute_shell_command
|
from serena.util.shell import execute_shell_command
|
||||||
@@ -36,6 +37,67 @@ TTool = TypeVar("TTool", bound="Tool")
|
|||||||
SUCCESS_RESULT = "OK"
|
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:
|
class LinesRead:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.files: dict[str, set[tuple[int, int]]] = defaultdict(lambda: set())
|
self.files: dict[str, set[tuple[int, int]]] = defaultdict(lambda: set())
|
||||||
@@ -53,94 +115,149 @@ class LinesRead:
|
|||||||
|
|
||||||
|
|
||||||
class SerenaAgent:
|
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 project_file_path: the configuration file (.yml) of the project to load immediately;
|
||||||
:param start_language_server: whether to start the language server immediately and manage its
|
if None, do not load any project (must use project selection tool to activate a project).
|
||||||
lifecycle internally
|
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):
|
# open GUI log window if enabled
|
||||||
print(f"Project file not found: {project_file_path}", file=sys.stderr)
|
self._gui_log_handler: GuiLogViewerHandler | None = None
|
||||||
sys.exit(1)
|
if self.serena_config.gui_log_window_enabled:
|
||||||
|
|
||||||
# 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:
|
|
||||||
if platform.system() == "Darwin":
|
if platform.system() == "Darwin":
|
||||||
log.warning("GUI log window is not supported on macOS")
|
log.warning("GUI log window is not supported on macOS")
|
||||||
else:
|
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:
|
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}")
|
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)
|
Logger.root.setLevel(log_level)
|
||||||
self._gui_log_handler = GuiLogViewerHandler(GuiLogViewer(title="Serena Logs"), level=log_level, format_string=LOG_FORMAT)
|
self._gui_log_handler = GuiLogViewerHandler(GuiLogViewer(title="Serena Logs"), level=log_level, format_string=LOG_FORMAT)
|
||||||
Logger.root.addHandler(self._gui_log_handler)
|
Logger.root.addHandler(self._gui_log_handler)
|
||||||
|
|
||||||
log.info(
|
log.info(f"Starting Serena server (process id={os.getpid()}, parent process id={os.getppid()})")
|
||||||
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)
|
|
||||||
|
|
||||||
self.prompt_factory = PromptFactory()
|
self.prompt_factory = PromptFactory()
|
||||||
self.symbol_manager = SymbolManager(self.language_server, self)
|
self._project_activation_callback = project_activation_callback
|
||||||
self.memories_manager = MemoriesManager(os.path.join(self.get_serena_managed_dir(), "memories"))
|
|
||||||
self.lines_read = LinesRead()
|
# 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
|
# find all tool classes and instantiate them
|
||||||
excluded_tools = project_config.get("excluded_tools", [])
|
|
||||||
self._all_tools: dict[type[Tool], Tool] = {}
|
self._all_tools: dict[type[Tool], Tool] = {}
|
||||||
self.tools: dict[type[Tool], Tool] = {}
|
|
||||||
for tool_class in iter_tool_classes():
|
for tool_class in iter_tool_classes():
|
||||||
tool_instance = tool_class(self)
|
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
|
self._all_tools[tool_class] = tool_instance
|
||||||
if (tool_name := tool_class.get_name()) in excluded_tools:
|
self._active_tools = dict(self._all_tools)
|
||||||
log.info(f"Skipping tool {tool_name} because it is in the exclude list")
|
log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name() for tool in self._all_tools.values()])}")
|
||||||
continue
|
|
||||||
self.tools[tool_class] = tool_instance
|
|
||||||
log.info(f"Loaded tools: {', '.join([tool.get_name() for tool in self.tools.values()])}")
|
|
||||||
|
|
||||||
# If GUI log window is enabled, set the tool names for highlighting
|
# If GUI log window is enabled, set the tool names for highlighting
|
||||||
if self._gui_log_handler is not None:
|
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)
|
self._gui_log_handler.log_viewer.set_tool_names(tool_names)
|
||||||
# start the language server if requested
|
|
||||||
if self._start_language_server:
|
# activate a project configuration (if provided or if there is only a single project available)
|
||||||
log.info("Starting the language server ...")
|
project_config: ProjectConfig | None = None
|
||||||
self.language_server.start()
|
if project_file_path is not None:
|
||||||
if not self.language_server.is_running():
|
if not os.path.exists(project_file_path):
|
||||||
raise RuntimeError(f"Failed to start the language server for {self.language} and project {self.project_root}")
|
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:
|
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()
|
self.language_server.start()
|
||||||
if not self.language_server.is_running():
|
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:
|
def get_tool(self, tool_class: type[TTool]) -> TTool:
|
||||||
return self._all_tools[tool_class] # type: ignore
|
return self._all_tools[tool_class] # type: ignore
|
||||||
|
|
||||||
def print_tool_overview(self) -> None:
|
def print_tool_overview(self) -> None:
|
||||||
_print_tool_overview(self.tools.values())
|
_print_tool_overview(self._active_tools.values())
|
||||||
|
|
||||||
def get_serena_managed_dir(self) -> str:
|
|
||||||
return os.path.join(self.project_root, ".serena")
|
|
||||||
|
|
||||||
def mark_file_modified(self, relativ_path: str) -> None:
|
def mark_file_modified(self, relativ_path: str) -> None:
|
||||||
|
assert self.lines_read is not None
|
||||||
self.lines_read.invalidate_lines_read(relativ_path)
|
self.lines_read.invalidate_lines_read(relativ_path)
|
||||||
|
|
||||||
def __del__(self) -> None:
|
def __del__(self) -> None:
|
||||||
@@ -150,8 +267,9 @@ class SerenaAgent:
|
|||||||
if not hasattr(self, "_is_initialized"):
|
if not hasattr(self, "_is_initialized"):
|
||||||
return
|
return
|
||||||
log.info("SerenaAgent is shutting down ...")
|
log.info("SerenaAgent is shutting down ...")
|
||||||
if self._start_language_server:
|
if self.is_language_server_running():
|
||||||
log.info("Stopping the language server ...")
|
log.info("Stopping the language server ...")
|
||||||
|
assert self.language_server is not None
|
||||||
self.language_server.stop()
|
self.language_server.stop()
|
||||||
if self._gui_log_handler:
|
if self._gui_log_handler:
|
||||||
log.info("Stopping the GUI log window ...")
|
log.info("Stopping the GUI log window ...")
|
||||||
@@ -195,14 +313,17 @@ class Component(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def language_server(self) -> SyncLanguageServer:
|
def language_server(self) -> SyncLanguageServer:
|
||||||
|
assert self.agent.language_server is not None
|
||||||
return self.agent.language_server
|
return self.agent.language_server
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def project_root(self) -> str:
|
def project_root(self) -> str:
|
||||||
return self.agent.project_root
|
assert self.project_config is not None
|
||||||
|
return self.project_config.project_root
|
||||||
|
|
||||||
@property
|
@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
|
return self.agent.project_config
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -211,12 +332,19 @@ class Component(ABC):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def memories_manager(self) -> MemoriesManager:
|
def memories_manager(self) -> MemoriesManager:
|
||||||
|
assert self.agent.memories_manager is not None
|
||||||
return self.agent.memories_manager
|
return self.agent.memories_manager
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def symbol_manager(self) -> SymbolManager:
|
def symbol_manager(self) -> SymbolManager:
|
||||||
|
assert self.agent.symbol_manager is not None
|
||||||
return self.agent.symbol_manager
|
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)
|
_DEFAULT_MAX_ANSWER_LENGTH = int(2e5)
|
||||||
|
|
||||||
@@ -286,25 +414,50 @@ class Tool(Component):
|
|||||||
"""
|
"""
|
||||||
Applies the tool with the given arguments
|
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()
|
apply_fn = self.get_apply_fn()
|
||||||
|
|
||||||
if log_call:
|
if log_call:
|
||||||
self._log_tool_application(inspect.currentframe())
|
self._log_tool_application(inspect.currentframe())
|
||||||
|
|
||||||
try:
|
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)
|
result = apply_fn(**kwargs)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not catch_exceptions:
|
if not catch_exceptions:
|
||||||
raise
|
raise
|
||||||
msg = f"Error executing tool: {e}\n{traceback.format_exc()}"
|
msg = f"Error executing tool: {e}\n{traceback.format_exc()}"
|
||||||
log.error(f"Error executing tool: {e}", exc_info=e)
|
log.error(f"Error executing tool: {e}", exc_info=e)
|
||||||
result = msg
|
result = msg
|
||||||
|
|
||||||
if log_call:
|
if log_call:
|
||||||
log.info(f"Result: {result}")
|
log.info(f"Result: {result}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ToolMarkerDoesNotRequireActiveProject:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReadFileTool(Tool):
|
class ReadFileTool(Tool):
|
||||||
"""
|
"""
|
||||||
Reads a file within the project directory.
|
Reads a file within the project directory.
|
||||||
@@ -331,7 +484,7 @@ class ReadFileTool(Tool):
|
|||||||
if end_line is None:
|
if end_line is None:
|
||||||
result_lines = result_lines[start_line:]
|
result_lines = result_lines[start_line:]
|
||||||
else:
|
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_lines = result_lines[start_line : end_line + 1]
|
||||||
result = "\n".join(result_lines)
|
result = "\n".join(result_lines)
|
||||||
|
|
||||||
@@ -379,7 +532,7 @@ class ListDirTool(Tool):
|
|||||||
os.path.join(self.project_root, relative_path),
|
os.path.join(self.project_root, relative_path),
|
||||||
relative_to=self.project_root,
|
relative_to=self.project_root,
|
||||||
recursive=recursive,
|
recursive=recursive,
|
||||||
ignored_dirs=self.project_config["ignored_dirs"],
|
ignored_dirs=self.project_config.ignored_dirs,
|
||||||
)
|
)
|
||||||
result = json.dumps({"dirs": dirs, "files": files})
|
result = json.dumps({"dirs": dirs, "files": files})
|
||||||
return self._limit_length(result, max_answer_chars)
|
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 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
|
: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)
|
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."
|
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)
|
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)
|
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]:
|
def iter_tool_classes() -> Generator[type[Tool], None, None]:
|
||||||
return iter_subclasses(Tool)
|
return iter_subclasses(Tool)
|
||||||
|
|
||||||
|
|||||||
+14
-11
@@ -81,7 +81,7 @@ _patch_gemini_schema_conversion()
|
|||||||
class SerenaAgnoToolkit(Toolkit):
|
class SerenaAgnoToolkit(Toolkit):
|
||||||
def __init__(self, serena_agent: SerenaAgent):
|
def __init__(self, serena_agent: SerenaAgent):
|
||||||
super().__init__("Serena")
|
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)
|
self.functions[tool.get_name()] = self._create_agno_function(tool)
|
||||||
log.info("Agno agent functions: %s", list(self.functions.keys()))
|
log.info("Agno agent functions: %s", list(self.functions.keys()))
|
||||||
|
|
||||||
@@ -128,22 +128,25 @@ class SerenaAgnoAgentProvider:
|
|||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Serena coding assistant")
|
parser = argparse.ArgumentParser(description="Serena coding assistant")
|
||||||
parser.add_argument(
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
project_file = Path(args.project_file).resolve()
|
if args.project_file:
|
||||||
# If project file path is relative, make it absolute by joining with project root
|
project_file = Path(args.project_file).resolve()
|
||||||
if not project_file.is_absolute():
|
# If project file path is relative, make it absolute by joining with project root
|
||||||
# Get the project root directory (parent of scripts directory)
|
if not project_file.is_absolute():
|
||||||
project_root = Path(serena_root_path())
|
# Get the project root directory (parent of scripts directory)
|
||||||
project_file = project_root / args.project_file
|
project_root = Path(serena_root_path())
|
||||||
|
project_file = project_root / args.project_file
|
||||||
|
|
||||||
# Ensure the path is normalized and absolute
|
# Ensure the path is normalized and absolute
|
||||||
project_file = project_file.resolve()
|
project_file = str(project_file.resolve())
|
||||||
|
else:
|
||||||
|
project_file = None
|
||||||
|
|
||||||
with LogTime("Loading Serena agent"):
|
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,
|
# 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.
|
# for agno-ui to work as a conversation, we use a persistent storage on disk.
|
||||||
|
|||||||
@@ -301,6 +301,13 @@ class GuiLogViewerHandler(logging.Handler):
|
|||||||
if not self.log_viewer.running:
|
if not self.log_viewer.running:
|
||||||
self.log_viewer.start()
|
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):
|
def emit(self, record):
|
||||||
"""
|
"""
|
||||||
Emit a log record to the ThreadedLogViewer.
|
Emit a log record to the ThreadedLogViewer.
|
||||||
|
|||||||
+41
-26
@@ -2,6 +2,7 @@
|
|||||||
The Serena Model Context Protocol (MCP) Server
|
The Serena Model Context Protocol (MCP) Server
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
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.tools.base import Tool as MCPTool
|
||||||
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
|
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
|
||||||
from sensai.util import logging
|
from sensai.util import logging
|
||||||
|
from sensai.util.helper import mark_used
|
||||||
|
|
||||||
from serena.agent import SerenaAgent, Tool
|
from serena.agent import SerenaAgent, Tool
|
||||||
|
from serena.gui_log_viewer import GuiLogViewerHandler
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"
|
LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"
|
||||||
@@ -41,7 +44,6 @@ class SerenaMCPRequestContext:
|
|||||||
def make_tool(
|
def make_tool(
|
||||||
tool: Tool,
|
tool: Tool,
|
||||||
) -> MCPTool:
|
) -> MCPTool:
|
||||||
"""Create a Tool from a function."""
|
|
||||||
func_name = tool.get_name()
|
func_name = tool.get_name()
|
||||||
|
|
||||||
apply_fn = getattr(tool, "apply")
|
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:
|
def create_mcp_server() -> FastMCP:
|
||||||
argv = sys.argv[1:]
|
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
|
@asynccontextmanager
|
||||||
async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]:
|
async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]:
|
||||||
"""Manage server startup and shutdown lifecycle."""
|
"""Manage server startup and shutdown lifecycle."""
|
||||||
try:
|
nonlocal agent
|
||||||
yield
|
mark_used(mcp_server)
|
||||||
finally:
|
yield
|
||||||
for agent in _SERENA_AGENT_REGISTRY.values():
|
|
||||||
agent.language_server.stop()
|
|
||||||
|
|
||||||
mcp_settings = Settings(lifespan=server_lifespan)
|
mcp_settings = Settings(lifespan=server_lifespan)
|
||||||
mcp = FastMCP(**mcp_settings.model_dump())
|
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
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user