mirror of
https://github.com/tiennm99/serena.git
synced 2026-08-13 06:23:43 +00:00
Merge pull request #34 from oraios/feature/project-change-tool
Support switching projects from within Serena (via tools)
This commit is contained in:
+27
-6
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: []
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user