Merge pull request #34 from oraios/feature/project-change-tool

Support switching projects from within Serena (via tools)
This commit is contained in:
Dominik Jain
2025-04-07 01:11:51 +02:00
committed by GitHub
12 changed files with 590 additions and 219 deletions
+27 -6
View File
@@ -1,8 +1,29 @@
# Changelog
# Latest
## 06.04.2025
- New tool: FindReferencingCodeSnippets
- Adjusted prompt in CreateTextFileTool to prevent writing partial content (see [here](https://www.reddit.com/r/ClaudeAI/comments/1jpavtm/comment/mloek1x/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)).
- FindSymbolTool: allow passing a file for restricting search, not just a directory (Gemini was too dumb to pass directories)
Changes prior to the next official version change will appear here.
## 01.04.2025: Initial Release
# 2025-04-07
* Serena core:
* New tool: FindReferencingCodeSnippets
* Adjusted prompt in CreateTextFileTool to prevent writing partial content (see [here](https://www.reddit.com/r/ClaudeAI/comments/1jpavtm/comment/mloek1x/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button)).
* FindSymbolTool: allow passing a file for restricting search, not just a directory (Gemini was too dumb to pass directories)
* Allow Serena to switch between projects (project activation)
* Add central Serena configuration in `serena_config.yml`, which
* contains the list of available projects
* allows to configure whether project activation is enabled
* now contains the GUI logging configuration (project configurations no longer do)
* Add new tools `activate_project` and `get_active_project`
* Providing a project configuration file in the launch parameters is now optional
* Logging:
* Improve error reporting in case of initialization failure:
open a new GUI log window showing the error or ensure that the existing log window remains visible for some time
* Language servers:
* Fix C# language server initialization issue when the project path contains spaces
* Agno:
* Fix Agno reloading mechanism causing failures when initializing the sqlite memory database #8
* Fix Serena GUI log window not capturing logs after initialization
# 2025-04-01
Initial public version
+47 -31
View File
@@ -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,32 +563,34 @@ 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`)
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.
* `get_referencing_code_extracts`: Gets the code blocks that reference the symbol at the given location.
* `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.
* `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_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
* `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.
+43 -56
View File
@@ -1,56 +1,43 @@
# 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 you want Serena to work on (where all the source code, etc. is located)
# This is optional if this file is placed in the project directory under `.serena/project.yml`.
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: []
+3
View File
@@ -233,6 +233,9 @@ ignore = [
"W293",
"B009",
"SIM103", # forbids multiple returns
"SIM110", # requires use of any(...) instead of for-loop
"G001", # forbids str.format in log statements
"E722", # forbids unspecific except clause
]
unfixable = [
"F841",
+5 -2
View File
@@ -9,7 +9,10 @@ from serena.agno import SerenaAgnoAgentProvider
mark_used(Gemini, Claude)
logging.configure(level=logging.INFO)
# initialize logging (Note: since this module is reimported by serve_playground_app and the logging configuration
# is extended by SerenaAgentProvider, we must handle this here conditionally)
if __name__ == "__main__":
logging.configure(level=logging.INFO)
# Define the model to use (see Agno documentation for supported models; these are just examples)
model = Claude(id="claude-3-7-sonnet-20250219")
@@ -18,4 +21,4 @@ model = Claude(id="claude-3-7-sonnet-20250219")
app = Playground(agents=[SerenaAgnoAgentProvider.get_agent(model)]).get_app()
if __name__ == "__main__":
serve_playground_app("agno_agent:app", reload=False)
serve_playground_app("agno_agent:app", reload=False, log_config=None)
+28
View File
@@ -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
+29 -9
View File
@@ -1394,8 +1394,14 @@ class LanguageServer:
if self._cache_has_changed:
self.logger.log(f"Saving updated document symbols cache to {self._cache_path}", logging.INFO)
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
with open(self._cache_path, "wb") as f:
pickle.dump(self._document_symbols_cache, f)
try:
with open(self._cache_path, "wb") as f:
pickle.dump(self._document_symbols_cache, f)
except Exception as e:
self.logger.log(
f"Failed to save document symbols cache to {self._cache_path}: {e}. "
"Note: this may have resulted in a corrupted cache file.", logging.ERROR
)
def load_cache(self):
if not self._cache_path.exists():
@@ -1404,9 +1410,13 @@ class LanguageServer:
with open(self._cache_path, "rb") as f:
try:
self._document_symbols_cache = pickle.load(f)
except:
except Exception as e:
# cache often becomes corrupt, so just skip loading it
pass
self.logger.log(
f"Failed to load document symbols cache from {self._cache_path}: {e}. Possible cause: the cache file is corrupted. "
"Check for any errors related to saving the cache in the logs.",
logging.ERROR
)
@ensure_all_methods_implemented(LanguageServer)
@@ -1825,20 +1835,30 @@ class SyncLanguageServer:
self._server_context = self.language_server.start_server()
asyncio.run_coroutine_threadsafe(self._server_context.__aenter__(), loop=self.loop).result()
return self
def is_running(self) -> bool:
"""
Check if the language server is running.
"""
return self.loop is not None and self.loop_thread is not None and self.loop_thread.is_alive()
def stop(self) -> None:
"""
Shuts down the language server process and cleans up resources.
Must be called after start().
If the language server is not running, this method will log a warning and do nothing.
"""
if not self.loop or not self.loop_thread:
raise MultilspyException("Language Server not started")
if not self.is_running():
self.language_server.logger.log("Language server not running, skipping shutdown.", logging.INFO)
return
assert self.loop
asyncio.run_coroutine_threadsafe(self._server_context.__aexit__(None, None, None), loop=self.loop).result()
self.loop.call_soon_threadsafe(self.loop.stop)
self.loop_thread.join()
self.loop = None
self.loop_thread = None
self.save_cache()
def save_cache(self):
"""
+21 -2
View File
@@ -1,7 +1,26 @@
__version__ = "0.1.0-dev1"
__version__ = "2025-04-07"
def serena_root_path() -> str:
from pathlib import Path
return str(Path(__file__).parent.parent.absolute())
return str(Path(__file__).parent.parent.parent.absolute())
def serena_version() -> str:
"""
:return: the version of the package, including git status if available.
"""
version = __version__
try:
from sensai.util.git import git_status
from sensai.util.logging import LoggingDisabledContext
with LoggingDisabledContext():
git_status = git_status()
version += f"-{git_status.commit[:8]}"
if not git_status.is_clean:
version += "-dirty"
except:
pass
return version
+290 -75
View File
@@ -6,28 +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, Iterator
from contextlib import contextmanager
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 __version__
from serena import serena_root_path, serena_version
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
@@ -38,6 +37,74 @@ TTool = TypeVar("TTool", bound="Tool")
SUCCESS_RESULT = "OK"
class ProjectConfig(ToStringMixin):
SERENA_MANAGED_DIR = ".serena"
SERENA_DEFAULT_PROJECT_FILE = "project.yml"
def __init__(self, config_dict: dict[str, Any], project_name: str, project_root: Path | None = None):
self.project_name: str = project_name
self.language: Language = Language(config_dict["language"])
if project_root is None:
project_root = Path(config_dict["project_root"])
self.project_root: str = str(project_root.resolve())
self.ignored_dirs: list[str] = config_dict.get("ignored_dirs", [])
self.excluded_tools: set[str] = set(config_dict.get("excluded_tools", []))
@classmethod
def from_yml(cls, yml_path: Path) -> Self:
with open(yml_path, encoding="utf-8") as f:
config_dict = yaml.safe_load(f)
if yml_path.parent.name == cls.SERENA_MANAGED_DIR:
project_root = yml_path.parent.parent
project_name = project_root.name
else:
project_root = None
project_name = yml_path.stem
return cls(config_dict, project_name=project_name, project_root=project_root)
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)
# read projects
self.projects: dict[str, ProjectConfig] = {}
for project_config_path in config_yaml["projects"]:
project_config_path = Path(project_config_path)
if not project_config_path.is_absolute():
project_config_path = Path(serena_root_path()) / project_config_path
if project_config_path.is_dir(): # assume project file in default location
project_config_path = project_config_path / ProjectConfig.SERENA_MANAGED_DIR / ProjectConfig.SERENA_DEFAULT_PROJECT_FILE
if not project_config_path.is_file():
raise FileNotFoundError(f"Project file not found: {project_config_path}")
project_config = ProjectConfig.from_yml(project_config_path)
self.projects[project_config.project_name] = project_config
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 get_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 self.projects[project_name]
class LinesRead:
def __init__(self) -> None:
self.files: dict[str, set[tuple[int, int]]] = defaultdict(lambda: set())
@@ -55,86 +122,150 @@ 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 v{__version__} 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
config = MultilspyConfig(code_language=self.language)
logger = MultilspyLogger()
self.language_server = SyncLanguageServer.create(config, logger, self.project_root)
log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})")
log.info("Available projects: {}".format(", ".join(self.serena_config.project_names)))
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()
# 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(Path(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.get_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:
"""
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 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:
@@ -144,24 +275,15 @@ 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 ...")
self._gui_log_handler.stop_viewer()
Logger.root.removeHandler(self._gui_log_handler)
@contextmanager
def language_server_lifecycle_context(self) -> Iterator[None]:
"""
Context manager for the language server's lifecycle
"""
if self._start_language_server:
raise Exception("This context manager can only be used if the instance is created with start_language_server=True")
with self.language_server.start_server():
yield
class MemoriesManager:
def __init__(self, memory_dir: str):
@@ -196,12 +318,40 @@ class MemoriesManager:
class Component(ABC):
def __init__(self, agent: "SerenaAgent"):
self.agent = agent
self.language_server = agent.language_server
self.project_root = agent.project_root
self.project_config = agent.project_config
self.prompt_factory = agent.prompt_factory
self.memories_manager = agent.memories_manager
self.symbol_manager = agent.symbol_manager
@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:
assert self.project_config is not None
return self.project_config.project_root
@property
def project_config(self) -> ProjectConfig:
assert self.agent.project_config is not None
return self.agent.project_config
@property
def prompt_factory(self) -> PromptFactory:
return self.agent.prompt_factory
@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)
@@ -273,21 +423,49 @@ class Tool(Component):
Applies the tool with the given arguments
"""
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.
@@ -314,7 +492,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)
@@ -366,7 +544,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)
@@ -694,7 +872,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)
@@ -1018,6 +1196,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.get_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)
+19 -11
View File
@@ -16,6 +16,7 @@ from sensai.util.logging import LogTime
from serena import serena_root_path
from serena.agent import SerenaAgent, Tool
from serena.gui_log_viewer import show_fatal_exception
log = logging.getLogger(__name__)
@@ -81,7 +82,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 +129,29 @@ 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)
try:
serena_agent = SerenaAgent(project_file)
except Exception as e:
show_fatal_exception(e)
raise
# 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.
+39 -11
View File
@@ -3,10 +3,14 @@ import logging
import queue
import sys
import threading
import time
import tkinter as tk
import traceback
from enum import Enum, auto
from pathlib import Path
log = logging.getLogger(__name__)
class LogLevel(Enum):
DEBUG = auto()
@@ -57,8 +61,7 @@ class GuiLogViewer:
"""Start the log viewer in a separate thread."""
if not self.running:
self.print_status("Starting thread")
self.running = True
self.log_thread = threading.Thread(target=self._run_gui)
self.log_thread = threading.Thread(target=self.run_gui)
self.log_thread.daemon = True
self.log_thread.start()
return True
@@ -67,7 +70,6 @@ class GuiLogViewer:
def stop(self):
"""Stop the log viewer."""
if self.running:
self.running = False
# Add a sentinel value to the queue to signal the GUI to exit
self.message_queue.put(None)
return True
@@ -91,11 +93,7 @@ class GuiLogViewer:
message (str): The log message to display
"""
if self.running:
# Add the original message so we can determine log level correctly
self.message_queue.put(message)
return True
return False
self.message_queue.put(message)
def _determine_log_level(self, message):
"""
@@ -148,7 +146,7 @@ class GuiLogViewer:
start_index = self.text_widget.index("end-1c")
# Insert the message
self.text_widget.insert(tk.END, message + "\n")
self.text_widget.insert(tk.END, message + "\n", log_level.name)
# Convert start index to line/char format
line, char = map(int, start_index.split("."))
@@ -199,8 +197,9 @@ class GuiLogViewer:
if self.running:
self.root.after(100, self._process_queue)
def _run_gui(self):
"""Run the GUI in a separate thread."""
def run_gui(self):
"""Run the GUI"""
self.running = True
try:
self.root = tk.Tk()
self.root.title(self.title)
@@ -301,6 +300,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.
@@ -339,3 +345,25 @@ class GuiLogViewerHandler(logging.Handler):
"""
if self.log_viewer.running:
self.log_viewer.stop()
def show_fatal_exception(e: Exception, duration_secs: int = 60):
"""
Makes sure the given exception is shown in the GUI log viewer,
either an existing instance or a new one.
:param e: the exception to display
:param duration_secs: the duration for which to display the error before
terminating the program for the case where the log viewer is already present
in a daemon thread which will terminate when the program does
"""
if GuiLogViewerHandler.is_instance_registered():
# show in existing daemon thread, waiting for the given duration
log.error(f"Fatal error: {e}", exc_info=e)
time.sleep(duration_secs)
else:
# show in new window in main thread (user must close it)
log_viewer = GuiLogViewer()
exc_info = "".join(traceback.format_exception(type(e), e, e.__traceback__))
log_viewer.add_log(f"ERROR Fatal exception: {e}\n{exc_info}")
log_viewer.run_gui()
+39 -16
View File
@@ -16,6 +16,7 @@ from sensai.util import logging
from sensai.util.helper import mark_used
from serena.agent import SerenaAgent, Tool
from serena.gui_log_viewer import show_fatal_exception
log = logging.getLogger(__name__)
LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"
@@ -42,9 +43,6 @@ class SerenaMCPRequestContext:
def make_tool(
tool: Tool,
) -> MCPTool:
"""Create a Tool from a function."""
from mcp.server.fastmcp import Context
func_name = tool.get_name()
apply_fn = getattr(tool, "apply")
@@ -57,8 +55,7 @@ def make_tool(
func_arg_metadata = func_metadata(apply_fn)
parameters = func_arg_metadata.arg_model.model_json_schema()
def execute_fn(ctx: Context, **kwargs) -> str: # type: ignore
mark_used(ctx)
def execute_fn(**kwargs) -> str: # type: ignore
return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs)
return MCPTool(
@@ -68,29 +65,55 @@ def make_tool(
parameters=parameters,
fn_metadata=func_arg_metadata,
is_async=is_async,
context_kwarg="ctx",
context_kwarg=None,
)
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]
agent = SerenaAgent(project_file_path)
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)
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:
show_fatal_exception(e)
raise
@asynccontextmanager
async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[SerenaMCPRequestContext]:
async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]:
"""Manage server startup and shutdown lifecycle."""
with agent.language_server_lifecycle_context():
yield SerenaMCPRequestContext(agent=agent)
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