mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-14 14:17:49 +00:00
15 files changed
+1707
-538
No files matched your search
+4
-2
@@ -24,7 +24,6 @@ dependencies = [
|
||||
"python-dotenv>=1.0.0, <2",
|
||||
"mcp>=1.5.0",
|
||||
"fastapi>=0.115.12",
|
||||
"fastmcp>=0.4.1",
|
||||
"sensai-utils>=1.4.0",
|
||||
"pydantic>=2.10.6",
|
||||
"types-pyyaml>=6.0.12.20241230",
|
||||
@@ -107,7 +106,9 @@ exclude = "^build/|^docs/"
|
||||
PYDEVD_DISABLE_FILE_VALIDATION = "1"
|
||||
|
||||
[tool.poe.tasks]
|
||||
test = "pytest test --color=yes"
|
||||
# Uses PYTEST_MARKERS env var for default markers (defaults to "not java and not rust")
|
||||
# Set PYTEST_MARKERS="" to run all tests
|
||||
test = "pytest test --color=yes -m \"${PYTEST_MARKERS:-not java and not rust}\""
|
||||
_black_check = "black --check --exclude src/multilspy/ src scripts test"
|
||||
_ruff_check = "ruff check --exclude .venv/ --exclude src/multilspy/ src scripts test"
|
||||
_black_format = "black --exclude .venv/|src/multilspy/ src scripts test"
|
||||
@@ -259,4 +260,5 @@ markers = [
|
||||
"typescript: language server running for TypeScript",
|
||||
"php: language server running for PHP",
|
||||
"snapshot: snapshot tests",
|
||||
"isolated_process: test runs with process isolated agent",
|
||||
]
|
||||
@@ -317,7 +317,7 @@ class LanguageServer:
|
||||
|
||||
return False
|
||||
|
||||
async def _shutdown(self, timeout: float = 10.0):
|
||||
async def _shutdown(self, timeout: float = 5.0):
|
||||
"""
|
||||
A robust shutdown process designed to terminate cleanly on all platforms, including Windows,
|
||||
by explicitly closing all I/O pipes.
|
||||
@@ -946,7 +946,7 @@ class LanguageServer:
|
||||
self._document_symbols_cache[cache_key] = (file_data.content_hash, result)
|
||||
self._cache_has_changed = True
|
||||
return result
|
||||
|
||||
|
||||
async def request_full_symbol_tree(self, within_relative_path: str | None = None, include_body: bool = False) -> List[multilspy_types.UnifiedSymbolInformation]:
|
||||
"""
|
||||
Will go through all files in the project or within a relative path and build a tree of symbols.
|
||||
@@ -1750,12 +1750,9 @@ class SyncLanguageServer:
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.loop_thread = threading.Thread(target=self.loop.run_forever, daemon=True)
|
||||
self.loop_thread.start()
|
||||
ctx = self.language_server.start_server()
|
||||
asyncio.run_coroutine_threadsafe(ctx.__aenter__(), loop=self.loop).result()
|
||||
self.start()
|
||||
yield self
|
||||
self.language_server.logger.log("SyncLS Startup: exiting LS context", logging.DEBUG)
|
||||
self.stop()
|
||||
|
||||
def request_definition(self, file_path: str, line: int, column: int) -> List[multilspy_types.Location]:
|
||||
@@ -1763,12 +1760,15 @@ class SyncLanguageServer:
|
||||
Raise a [textDocument/definition](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition) request to the Language Server
|
||||
for the symbol at the given line and column in the given file. Wait for the response and return the result.
|
||||
|
||||
:param relative_file_path: The relative path of the file that has the symbol for which definition should be looked up
|
||||
:param line: The line number of the symbol
|
||||
:param column: The column number of the symbol
|
||||
:param file_path: The path of the file relative to the repository root.
|
||||
:param line: The line number (zero-indexed).
|
||||
:param column: The column number (zero-indexed).
|
||||
|
||||
:return List[multilspy_types.Location]: A list of locations where the symbol is defined
|
||||
:return: A list of Locations defining the symbol.
|
||||
"""
|
||||
if not self.is_running():
|
||||
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
|
||||
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
self.language_server.request_definition(file_path, line, column), self.loop
|
||||
).result(timeout=self.timeout)
|
||||
@@ -1776,15 +1776,18 @@ class SyncLanguageServer:
|
||||
|
||||
def request_references(self, file_path: str, line: int, column: int) -> List[multilspy_types.Location]:
|
||||
"""
|
||||
Raise a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server
|
||||
to find references to the symbol at the given line and column in the given file. Wait for the response and return the result.
|
||||
Raises a [textDocument/references](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references) request to the Language Server
|
||||
for the symbol at the given line and column in the given file. Wait for the response and return the result.
|
||||
|
||||
:param relative_file_path: The relative path of the file that has the symbol for which references should be looked up
|
||||
:param line: The line number of the symbol
|
||||
:param column: The column number of the symbol
|
||||
:param file_path: The path of the file relative to the repository root.
|
||||
:param line: The line number (zero-indexed).
|
||||
:param column: The column number (zero-indexed).
|
||||
|
||||
:return List[multilspy_types.Location]: A list of locations where the symbol is referenced
|
||||
:return: A list of Locations referencing the symbol.
|
||||
"""
|
||||
if not self.is_running():
|
||||
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
|
||||
|
||||
try:
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
self.language_server.request_references(file_path, line, column), self.loop
|
||||
@@ -1870,6 +1873,9 @@ class SyncLanguageServer:
|
||||
|
||||
:return: A list of root symbols representing the top-level packages/modules in the project.
|
||||
"""
|
||||
if not self.is_running():
|
||||
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
|
||||
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
self.language_server.request_full_symbol_tree(within_relative_path, include_body), self.loop
|
||||
).result(timeout=self.timeout)
|
||||
@@ -1882,7 +1888,9 @@ class SyncLanguageServer:
|
||||
Maps relative paths of all contained files to info about top-level symbols in the file
|
||||
(name, kind, line, column).
|
||||
"""
|
||||
assert self.loop
|
||||
if not self.is_running():
|
||||
raise RuntimeError("Language server is not running. Cannot make requests to a stopped language server.")
|
||||
|
||||
result = asyncio.run_coroutine_threadsafe(
|
||||
self.language_server.request_dir_overview(relative_dir_path), self.loop
|
||||
).result(timeout=self.timeout)
|
||||
@@ -2255,8 +2263,6 @@ class SyncLanguageServer:
|
||||
shutdown_type = "Nuclear" if is_windows else "Graceful"
|
||||
self.language_server.logger.log(f"{shutdown_type} shutdown complete - all references cleared", logging.INFO)
|
||||
|
||||
|
||||
|
||||
def save_cache(self):
|
||||
"""
|
||||
Save the cache to a file.
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
Provides Python specific instantiation of the LanguageServer class. Contains various configurations and settings specific to Python.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Tuple
|
||||
|
||||
@@ -38,6 +40,10 @@ class PyrightServer(LanguageServer):
|
||||
"python",
|
||||
)
|
||||
|
||||
# Event to signal when initial workspace analysis is complete
|
||||
self.analysis_complete = asyncio.Event()
|
||||
self.found_source_files = False
|
||||
|
||||
@override
|
||||
def is_ignored_dirname(self, dirname: str) -> bool:
|
||||
return super().is_ignored_dirname(dirname) or dirname in ["venv", "__pycache__"]
|
||||
@@ -145,16 +151,19 @@ class PyrightServer(LanguageServer):
|
||||
@asynccontextmanager
|
||||
async def start_server(self) -> AsyncIterator["PyrightServer"]:
|
||||
"""
|
||||
Starts the Pyright Language Server, waits for the server to be ready and yields the LanguageServer instance.
|
||||
Starts the Pyright Language Server and waits for initial workspace analysis to complete.
|
||||
|
||||
This prevents zombie processes by ensuring Pyright has finished its initial background
|
||||
tasks before we consider the server ready.
|
||||
|
||||
Usage:
|
||||
```
|
||||
async with lsp.start_server():
|
||||
# LanguageServer has been initialized and ready to serve requests
|
||||
# LanguageServer has been initialized and workspace analysis is complete
|
||||
await lsp.request_definition(...)
|
||||
await lsp.request_references(...)
|
||||
# Shutdown the LanguageServer on exit from scope
|
||||
# LanguageServer has been shutdown
|
||||
# LanguageServer has been shutdown cleanly
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -164,13 +173,33 @@ class PyrightServer(LanguageServer):
|
||||
async def do_nothing(params):
|
||||
return
|
||||
|
||||
async def check_experimental_status(params):
|
||||
if params["quiescent"] == True:
|
||||
async def window_log_message(msg):
|
||||
"""
|
||||
Monitor Pyright's log messages to detect when initial analysis is complete.
|
||||
Pyright logs "Found X source files" when it finishes scanning the workspace.
|
||||
"""
|
||||
message_text = msg.get("message", "")
|
||||
self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO)
|
||||
|
||||
# Look for "Found X source files" which indicates workspace scanning is complete
|
||||
# Unfortunately, pyright is unreliable and there seems to be no better way
|
||||
if re.search(r"Found \d+ source files?", message_text):
|
||||
self.logger.log("Pyright workspace scanning complete", logging.INFO)
|
||||
self.found_source_files = True
|
||||
self.analysis_complete.set()
|
||||
self.completions_available.set()
|
||||
|
||||
async def window_log_message(msg):
|
||||
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
|
||||
async def check_experimental_status(params):
|
||||
"""
|
||||
Also listen for experimental/serverStatus as a backup signal
|
||||
"""
|
||||
if params.get("quiescent") == True:
|
||||
self.logger.log("Received experimental/serverStatus with quiescent=true", logging.INFO)
|
||||
if not self.found_source_files:
|
||||
self.analysis_complete.set()
|
||||
self.completions_available.set()
|
||||
|
||||
# Set up notification handlers
|
||||
self.server.on_request("client/registerCapability", do_nothing)
|
||||
self.server.on_notification("language/status", do_nothing)
|
||||
self.server.on_notification("window/logMessage", window_log_message)
|
||||
@@ -195,6 +224,23 @@ class PyrightServer(LanguageServer):
|
||||
self.logger.log(f"Received initialize response from pyright server: {init_response}", logging.INFO)
|
||||
|
||||
# Verify that the server supports our required features
|
||||
self.server.notify.initialized({})
|
||||
assert "textDocumentSync" in init_response["capabilities"]
|
||||
assert "completionProvider" in init_response["capabilities"]
|
||||
assert "definitionProvider" in init_response["capabilities"]
|
||||
|
||||
yield self
|
||||
# Complete the initialization handshake
|
||||
self.server.notify.initialized({})
|
||||
|
||||
# Wait for Pyright to complete its initial workspace analysis
|
||||
# This prevents zombie processes by ensuring background tasks finish
|
||||
self.logger.log("Waiting for Pyright to complete initial workspace analysis...", logging.INFO)
|
||||
try:
|
||||
await asyncio.wait_for(self.analysis_complete.wait(), timeout=1.0)
|
||||
self.logger.log("Pyright initial analysis complete, server ready", logging.INFO)
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.log("Timeout waiting for Pyright analysis completion, proceeding anyway", logging.WARNING)
|
||||
# Fallback: assume analysis is complete after timeout
|
||||
self.analysis_complete.set()
|
||||
self.completions_available.set()
|
||||
|
||||
yield self
|
||||
+17
-5
@@ -186,12 +186,22 @@ class TypeScriptLanguageServer(LanguageServer):
|
||||
|
||||
async def window_log_message(msg):
|
||||
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
|
||||
|
||||
|
||||
async def check_experimental_status(params):
|
||||
"""
|
||||
Also listen for experimental/serverStatus as a backup signal
|
||||
"""
|
||||
if params.get("quiescent") == True:
|
||||
self.server_ready.set()
|
||||
self.completions_available.set()
|
||||
|
||||
self.server.on_request("client/registerCapability", register_capability_handler)
|
||||
self.server.on_notification("window/logMessage", window_log_message)
|
||||
self.server.on_request("workspace/executeClientCommand", execute_client_command_handler)
|
||||
self.server.on_notification("$/progress", do_nothing)
|
||||
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
|
||||
self.server.on_notification("experimental/serverStatus", check_experimental_status)
|
||||
|
||||
async with super().start_server():
|
||||
self.logger.log("Starting TypeScript server process", logging.INFO)
|
||||
@@ -213,11 +223,13 @@ class TypeScriptLanguageServer(LanguageServer):
|
||||
}
|
||||
|
||||
self.server.notify.initialized({})
|
||||
self.completions_available.set()
|
||||
|
||||
# TypeScript server is typically ready immediately after initialization
|
||||
self.server_ready.set()
|
||||
await self.server_ready.wait()
|
||||
try:
|
||||
await asyncio.wait_for(self.server_ready.wait(), timeout=1.0)
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.log("Timeout waiting for TypeScript server to become ready, proceeding anyway", logging.INFO)
|
||||
# Fallback: assume server is ready after timeout
|
||||
self.server_ready.set()
|
||||
self.completions_available.set()
|
||||
|
||||
yield self
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class MultilspyLogger:
|
||||
self.logger.setLevel(log_level)
|
||||
self.json_format = json_format
|
||||
|
||||
def log(self, debug_message: str, level: int, sanitized_error_message: str = "") -> None:
|
||||
def log(self, debug_message: str, level: int, sanitized_error_message: str = "", stacklevel: int = 2) -> None:
|
||||
"""
|
||||
Log the debug and santized messages using the logger
|
||||
"""
|
||||
@@ -59,6 +59,7 @@ class MultilspyLogger:
|
||||
self.logger.log(
|
||||
level=level,
|
||||
msg=debug_log_line.json(),
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
else:
|
||||
self.logger.log(level, debug_message)
|
||||
self.logger.log(level, debug_message, stacklevel=stacklevel)
|
||||
+231
-36
@@ -24,6 +24,7 @@ from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar, Union, cast
|
||||
|
||||
import yaml
|
||||
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
||||
from overrides import override
|
||||
from pathspec import PathSpec
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
@@ -106,12 +107,12 @@ def get_serena_managed_dir(project_root: str | Path) -> str:
|
||||
def is_running_in_docker() -> bool:
|
||||
"""Check if we're running inside a Docker container."""
|
||||
# Check for Docker-specific files
|
||||
if os.path.exists('/.dockerenv'):
|
||||
if os.path.exists("/.dockerenv"):
|
||||
return True
|
||||
# Check cgroup for docker references
|
||||
try:
|
||||
with open('/proc/self/cgroup', 'r') as f:
|
||||
return 'docker' in f.read()
|
||||
with open("/proc/self/cgroup") as f:
|
||||
return "docker" in f.read()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
@@ -317,6 +318,59 @@ class SerenaConfigBase(ABC):
|
||||
else:
|
||||
raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}")
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert configuration to dictionary for serialization."""
|
||||
return {
|
||||
"projects": [
|
||||
{
|
||||
"project_root": project.project_root,
|
||||
"project_config": {
|
||||
"project_name": project.project_config.project_name,
|
||||
"language": project.project_config.language,
|
||||
"ignored_paths": project.project_config.ignored_paths,
|
||||
"ignore_all_files_in_gitignore": project.project_config.ignore_all_files_in_gitignore,
|
||||
},
|
||||
}
|
||||
for project in self.projects
|
||||
],
|
||||
"gui_log_window_enabled": self.gui_log_window_enabled,
|
||||
"log_level": self.log_level,
|
||||
"trace_lsp_communication": self.trace_lsp_communication,
|
||||
"web_dashboard": self.web_dashboard,
|
||||
"tool_timeout": self.tool_timeout,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SerenaConfigBase":
|
||||
"""Create configuration from dictionary."""
|
||||
projects = []
|
||||
for project_data in data.get("projects", []):
|
||||
project_config = ProjectConfig(
|
||||
project_name=project_data["project_config"]["project_name"],
|
||||
language=project_data["project_config"]["language"],
|
||||
ignored_paths=project_data["project_config"]["ignored_paths"],
|
||||
ignore_all_files_in_gitignore=project_data["project_config"]["ignore_all_files_in_gitignore"],
|
||||
)
|
||||
project = Project(project_root=project_data["project_root"], project_config=project_config)
|
||||
projects.append(project)
|
||||
|
||||
# Create a basic config class that can be instantiated
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DeserializedConfig(SerenaConfigBase):
|
||||
def _add_new_project(self, project: Project) -> None:
|
||||
self.projects.append(project)
|
||||
|
||||
return DeserializedConfig(
|
||||
projects=projects,
|
||||
gui_log_window_enabled=data.get("gui_log_window_enabled", False),
|
||||
log_level=data.get("log_level", logging.INFO),
|
||||
trace_lsp_communication=data.get("trace_lsp_communication", False),
|
||||
web_dashboard=data.get("web_dashboard", True),
|
||||
tool_timeout=data.get("tool_timeout", DEFAULT_TOOL_TIMEOUT),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class SerenaConfig(SerenaConfigBase):
|
||||
@@ -394,6 +448,7 @@ class SerenaConfig(SerenaConfigBase):
|
||||
instance.log_level = loaded_commented_yaml.get("log_level", loaded_commented_yaml.get("gui_log_level", logging.INFO))
|
||||
instance.web_dashboard = loaded_commented_yaml.get("web_dashboard", True)
|
||||
instance.tool_timeout = loaded_commented_yaml.get("tool_timeout", DEFAULT_TOOL_TIMEOUT)
|
||||
instance.trace_lsp_communication = loaded_commented_yaml.get("trace_lsp_communication", False)
|
||||
|
||||
# re-save the configuration file if any migrations were performed
|
||||
if num_project_migrations > 0:
|
||||
@@ -513,6 +568,75 @@ class MemoriesManagerMDFilesInProject(MemoriesManager):
|
||||
return f"Memory {name} deleted."
|
||||
|
||||
|
||||
def create_serena_config(
|
||||
serena_config: SerenaConfigBase | None = None,
|
||||
context: SerenaAgentContext | None = None,
|
||||
modes: list[SerenaAgentMode] | None = None,
|
||||
enable_web_dashboard: bool | None = None,
|
||||
enable_gui_log_window: bool | None = None,
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
||||
trace_lsp_communication: bool | None = None,
|
||||
tool_timeout: float | None = None,
|
||||
) -> SerenaConfig:
|
||||
"""
|
||||
Create a SerenaConfig instance without instantiating a full SerenaAgent.
|
||||
|
||||
This function extracts the configuration creation logic from SerenaAgent.__init__
|
||||
to allow creating configurations independently for process isolation and other use cases.
|
||||
|
||||
:param serena_config: the base Serena configuration or None to read from default location
|
||||
:param context: the context in which the agent will operate
|
||||
:param modes: list of modes in which the agent will operate
|
||||
:param enable_web_dashboard: Whether to enable the web dashboard
|
||||
:param enable_gui_log_window: Whether to enable the GUI log window
|
||||
:param log_level: Log level
|
||||
:param trace_lsp_communication: Whether to trace LSP communication
|
||||
:param tool_timeout: Timeout in seconds for tool execution
|
||||
:return: A fully configured SerenaConfig instance
|
||||
"""
|
||||
# obtain serena configuration
|
||||
if serena_config is not None:
|
||||
# If a complete SerenaConfig is provided, use it directly
|
||||
if isinstance(serena_config, SerenaConfig):
|
||||
config = serena_config
|
||||
else:
|
||||
# For SerenaConfigBase instances (like test configs), create an in-memory SerenaConfig
|
||||
# that preserves the base config attributes without loading from file
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
|
||||
config = SerenaConfig.__new__(SerenaConfig) # Create without calling __init__
|
||||
# Initialize basic attributes from base config
|
||||
config.projects = getattr(serena_config, "projects", [])
|
||||
config.gui_log_window_enabled = serena_config.gui_log_window_enabled
|
||||
config.log_level = serena_config.log_level
|
||||
config.trace_lsp_communication = serena_config.trace_lsp_communication
|
||||
config.web_dashboard = serena_config.web_dashboard
|
||||
config.tool_timeout = serena_config.tool_timeout
|
||||
# Set empty yaml for in-memory config
|
||||
config.loaded_commented_yaml = CommentedMap()
|
||||
else:
|
||||
config = SerenaConfig.from_config_file()
|
||||
|
||||
# Apply parameter overrides
|
||||
if enable_web_dashboard is not None:
|
||||
config.web_dashboard = enable_web_dashboard
|
||||
if enable_gui_log_window is not None:
|
||||
config.gui_log_window_enabled = enable_gui_log_window
|
||||
if log_level is not None:
|
||||
log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper())
|
||||
# transform to int
|
||||
config.log_level = logging.getLevelNamesMapping()[log_level]
|
||||
if trace_lsp_communication is not None:
|
||||
config.trace_lsp_communication = trace_lsp_communication
|
||||
if tool_timeout is not None:
|
||||
config.tool_timeout = tool_timeout
|
||||
|
||||
# Note: Project registration/activation is handled separately by the caller
|
||||
# since it involves complex logic that may require the full agent context
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class SerenaAgent:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -539,23 +663,20 @@ class SerenaAgent:
|
||||
:param enable_web_dashboard: Whether to enable the web dashboard. If not specified, will take the value from the serena configuration.
|
||||
:param enable_gui_log_window: Whether to enable the GUI log window. It currently does not work on macOS, and setting this to True will be ignored then.
|
||||
If not specified, will take the value from the serena configuration.
|
||||
:param gui_log_level: Log level for the GUI log window. If not specified, will take the value from the serena configuration.
|
||||
:param log_level: Log level for the GUI log window. If not specified, will take the value from the serena configuration.
|
||||
:param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration.
|
||||
"""
|
||||
# obtain serena configuration
|
||||
self.serena_config = serena_config or SerenaConfig.from_config_file()
|
||||
if enable_web_dashboard is not None:
|
||||
self.serena_config.web_dashboard = enable_web_dashboard
|
||||
if enable_gui_log_window is not None:
|
||||
self.serena_config.gui_log_window_enabled = enable_gui_log_window
|
||||
if log_level is not None:
|
||||
log_level = cast(Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], log_level.upper())
|
||||
# transform to int
|
||||
self.serena_config.log_level = logging.getLevelNamesMapping()[log_level]
|
||||
if trace_lsp_communication is not None:
|
||||
self.serena_config.trace_lsp_communication = trace_lsp_communication
|
||||
if tool_timeout is not None:
|
||||
self.serena_config.tool_timeout = tool_timeout
|
||||
# obtain serena configuration using the decoupled factory function
|
||||
self.serena_config = create_serena_config(
|
||||
serena_config=serena_config,
|
||||
context=context,
|
||||
modes=modes,
|
||||
enable_web_dashboard=enable_web_dashboard,
|
||||
enable_gui_log_window=enable_gui_log_window,
|
||||
log_level=log_level,
|
||||
trace_lsp_communication=trace_lsp_communication,
|
||||
tool_timeout=tool_timeout,
|
||||
)
|
||||
|
||||
# adjust log level
|
||||
serena_log_level = self.serena_config.log_level
|
||||
@@ -582,7 +703,7 @@ class SerenaAgent:
|
||||
|
||||
# instantiate all tool classes
|
||||
self._all_tools: dict[type[Tool], Tool] = {tool_class: tool_class(self) for tool_class in ToolRegistry.get_all_tool_classes()}
|
||||
tool_names = [tool.get_name() for tool in self._all_tools.values()]
|
||||
tool_names = [tool.get_name_from_cls() 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:
|
||||
@@ -619,7 +740,7 @@ class SerenaAgent:
|
||||
modes = SerenaAgentMode.load_default_modes()
|
||||
self._context = context
|
||||
self._modes = modes
|
||||
log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name() for tool in self._all_tools.values()])}")
|
||||
log.info(f"Loaded tools ({len(self._all_tools)}): {', '.join([tool.get_name_from_cls() for tool in self._all_tools.values()])}")
|
||||
|
||||
self._active_tools: dict[type[Tool], Tool] = {}
|
||||
self._update_active_tools()
|
||||
@@ -804,7 +925,7 @@ class SerenaAgent:
|
||||
"""
|
||||
:return: the list of names of the active tools for the current project
|
||||
"""
|
||||
return sorted([tool.get_name() for tool in self.get_active_tool_classes()])
|
||||
return sorted([tool.get_name_from_cls() for tool in self.get_active_tool_classes()])
|
||||
|
||||
def tool_is_active(self, tool_class: type["Tool"] | str) -> bool:
|
||||
"""
|
||||
@@ -821,6 +942,8 @@ class SerenaAgent:
|
||||
:return: a string overview of the current configuration, including the active and available configuration options
|
||||
"""
|
||||
result_str = "Current configuration:\n"
|
||||
result_str += f"Serena version: {serena_version()}\n"
|
||||
result_str += f"Loglevel: {self.serena_config.log_level}, trace_lsp_communication={self.serena_config.trace_lsp_communication}\n"
|
||||
if self._active_project is not None:
|
||||
result_str += f"Active project: {self._active_project.project_name}\n"
|
||||
else:
|
||||
@@ -848,7 +971,7 @@ class SerenaAgent:
|
||||
result_str += " " + ", ".join(chunk) + "\n"
|
||||
|
||||
# Available but not active tools
|
||||
all_tool_names = sorted([tool.get_name() for tool in self._all_tools.values()])
|
||||
all_tool_names = sorted([tool.get_name_from_cls() for tool in self._all_tools.values()])
|
||||
inactive_tool_names = [tool for tool in all_tool_names if tool not in active_tool_names]
|
||||
if inactive_tool_names:
|
||||
result_str += "Available but not active tools:\n"
|
||||
@@ -865,6 +988,14 @@ class SerenaAgent:
|
||||
"""
|
||||
Starts/resets the language server for the current project
|
||||
"""
|
||||
tool_timeout = self.serena_config.tool_timeout
|
||||
if tool_timeout is None or tool_timeout < 0:
|
||||
ls_timeout = None
|
||||
else:
|
||||
if tool_timeout < 10:
|
||||
raise ValueError(f"Tool timeout must be at least 10 seconds, but is {tool_timeout} seconds")
|
||||
ls_timeout = tool_timeout - 5 # the LS timeout is for a single call, it should be smaller than the tool timeout
|
||||
|
||||
# stop the language server if it is running
|
||||
if self.is_language_server_running():
|
||||
assert self.language_server is not None
|
||||
@@ -897,6 +1028,7 @@ class SerenaAgent:
|
||||
multilspy_config,
|
||||
ls_logger,
|
||||
self._active_project.project_root,
|
||||
timeout=ls_timeout,
|
||||
)
|
||||
self.language_server.start()
|
||||
if not self.language_server.is_running():
|
||||
@@ -980,7 +1112,31 @@ class ToolMarkerDoesNotRequireActiveProject:
|
||||
pass
|
||||
|
||||
|
||||
class Tool(Component):
|
||||
class ToolInterface(ABC):
|
||||
"""Protocol defining the complete interface that make_tool() expects from a tool."""
|
||||
|
||||
@abstractmethod
|
||||
def get_name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_apply_docstring(self) -> str:
|
||||
"""Get the docstring for the tool application, used by the MCP server."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_apply_fn_metadata(self) -> FuncMetadata:
|
||||
"""Get the metadata for the tool application function, used by the MCP server."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs: Any) -> str:
|
||||
"""Apply the tool with logging and exception handling."""
|
||||
...
|
||||
|
||||
|
||||
class Tool(Component, ToolInterface):
|
||||
# NOTE: each tool should implement the apply method, which is then used in
|
||||
# the central method of the Tool class `apply_ex`.
|
||||
# Failure to do so will result in a RuntimeError at tool execution time.
|
||||
@@ -992,7 +1148,7 @@ class Tool(Component):
|
||||
# and to validate the tool call arguments.
|
||||
|
||||
@classmethod
|
||||
def get_name(cls) -> str:
|
||||
def get_name_from_cls(cls) -> str:
|
||||
name = cls.__name__
|
||||
if name.endswith("Tool"):
|
||||
name = name[:-4]
|
||||
@@ -1000,6 +1156,9 @@ class Tool(Component):
|
||||
name = "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
|
||||
return name
|
||||
|
||||
def get_name(self) -> str:
|
||||
return self.get_name_from_cls()
|
||||
|
||||
def get_apply_fn(self) -> Callable:
|
||||
apply_fn = getattr(self, "apply")
|
||||
if apply_fn is None:
|
||||
@@ -1022,12 +1181,48 @@ class Tool(Component):
|
||||
return ""
|
||||
return docstring.strip()
|
||||
|
||||
def get_function_description(self) -> str:
|
||||
apply_fn = self.get_apply_fn()
|
||||
@classmethod
|
||||
def get_apply_docstring_from_cls(cls) -> str:
|
||||
"""Get the docstring for the apply method from the class (static metadata).
|
||||
Needed for creating MCP tools in a separate process without running into serialization issues.
|
||||
"""
|
||||
# First try to get from __dict__ to handle dynamic docstring changes
|
||||
if "apply" in cls.__dict__:
|
||||
apply_fn = cls.__dict__["apply"]
|
||||
else:
|
||||
# Fall back to getattr for inherited methods
|
||||
apply_fn = getattr(cls, "apply", None)
|
||||
if apply_fn is None:
|
||||
raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?")
|
||||
|
||||
docstring = apply_fn.__doc__
|
||||
if docstring is None:
|
||||
raise Exception(f"Missing docstring for {self}")
|
||||
return docstring
|
||||
if not docstring:
|
||||
raise AttributeError(f"apply method has no (or empty) docstring in {cls}. Did you forget to implement it?")
|
||||
return docstring.strip()
|
||||
|
||||
def get_apply_docstring(self) -> str:
|
||||
"""Get the docstring for the apply method (instance method implementing ToolProtocol)."""
|
||||
return self.get_apply_docstring_from_cls()
|
||||
|
||||
def get_apply_fn_metadata(self) -> FuncMetadata:
|
||||
"""Get the metadata for the apply method (instance method implementing ToolProtocol)."""
|
||||
return self.get_apply_fn_metadata_from_cls()
|
||||
|
||||
@classmethod
|
||||
def get_apply_fn_metadata_from_cls(cls) -> FuncMetadata:
|
||||
"""Get the metadata for the apply method from the class (static metadata).
|
||||
Needed for creating MCP tools in a separate process without running into serialization issues.
|
||||
"""
|
||||
# First try to get from __dict__ to handle dynamic docstring changes
|
||||
if "apply" in cls.__dict__:
|
||||
apply_fn = cls.__dict__["apply"]
|
||||
else:
|
||||
# Fall back to getattr for inherited methods
|
||||
apply_fn = getattr(cls, "apply", None)
|
||||
if apply_fn is None:
|
||||
raise AttributeError(f"apply method not defined in {cls}. Did you forget to implement it?")
|
||||
|
||||
return func_metadata(apply_fn, skip_names=["self", "cls"])
|
||||
|
||||
def _log_tool_application(self, frame: Any) -> None:
|
||||
params = {}
|
||||
@@ -1039,7 +1234,7 @@ class Tool(Component):
|
||||
params.update(value)
|
||||
else:
|
||||
params[param] = value
|
||||
log.info(f"{self.get_name()}: {dict_string(params)}")
|
||||
log.info(f"{self.get_name_from_cls()}: {dict_string(params)}")
|
||||
|
||||
@staticmethod
|
||||
def _limit_length(result: str, max_answer_chars: int) -> str:
|
||||
@@ -1061,9 +1256,9 @@ class Tool(Component):
|
||||
|
||||
try:
|
||||
if not self.is_active():
|
||||
return f"Error: Tool '{self.get_name()}' is not active. Active tools: {self.agent.get_active_tool_names()}"
|
||||
return f"Error: Tool '{self.get_name_from_cls()}' is not active. Active tools: {self.agent.get_active_tool_names()}"
|
||||
except Exception as e:
|
||||
return f"RuntimeError while checking if tool {self.get_name()} is active: {e}"
|
||||
return f"RuntimeError while checking if tool {self.get_name_from_cls()} is active: {e}"
|
||||
|
||||
if log_call:
|
||||
self._log_tool_application(inspect.currentframe())
|
||||
@@ -1081,7 +1276,7 @@ class Tool(Component):
|
||||
|
||||
# apply the actual tool with a timeout
|
||||
execution_fn = lambda: apply_fn(**kwargs)
|
||||
execution_result = execute_with_timeout(execution_fn, self.agent.serena_config.tool_timeout, self.get_name())
|
||||
execution_result = execute_with_timeout(execution_fn, self.agent.serena_config.tool_timeout, self.get_name_from_cls())
|
||||
if execution_result.status == ExecutionResult.Status.SUCCESS:
|
||||
result = cast(str, execution_result.result_value)
|
||||
else:
|
||||
@@ -1634,7 +1829,7 @@ class DeleteLinesTool(Tool, ToolMarkerCanEdit):
|
||||
"""
|
||||
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."
|
||||
return f"Error: Must call `{read_lines_tool.get_name_from_cls()}` first to read exactly the affected lines."
|
||||
self.symbol_manager.delete_lines(relative_path, start_line, end_line)
|
||||
return SUCCESS_RESULT
|
||||
|
||||
@@ -2096,7 +2291,7 @@ def _iter_tool_classes(same_module_only: bool = True) -> Generator[type[Tool], N
|
||||
yield tool_class
|
||||
|
||||
|
||||
_TOOL_REGISTRY_DICT: dict[str, type[Tool]] = {tool_class.get_name(): tool_class for tool_class in _iter_tool_classes()}
|
||||
_TOOL_REGISTRY_DICT: dict[str, type[Tool]] = {tool_class.get_name_from_cls(): tool_class for tool_class in _iter_tool_classes()}
|
||||
"""maps tool name to the corresponding tool class"""
|
||||
|
||||
|
||||
@@ -2132,7 +2327,7 @@ class ToolRegistry:
|
||||
|
||||
tool_dict: dict[str, type[Tool] | Tool] = {}
|
||||
for tool_class in tools:
|
||||
tool_dict[tool_class.get_name()] = tool_class
|
||||
tool_dict[tool_class.get_name_from_cls()] = tool_class
|
||||
for tool_name in sorted(tool_dict.keys()):
|
||||
tool_class = tool_dict[tool_name]
|
||||
print(f" * `{tool_name}`: {tool_class.get_tool_description().strip()}")
|
||||
+2
-2
@@ -25,7 +25,7 @@ class SerenaAgnoToolkit(Toolkit):
|
||||
def __init__(self, serena_agent: SerenaAgent):
|
||||
super().__init__("Serena")
|
||||
for tool in serena_agent.get_exposed_tool_instances():
|
||||
self.functions[tool.get_name()] = self._create_agno_function(tool)
|
||||
self.functions[tool.get_name_from_cls()] = self._create_agno_function(tool)
|
||||
log.info("Agno agent functions: %s", list(self.functions.keys()))
|
||||
|
||||
@staticmethod
|
||||
@@ -39,7 +39,7 @@ class SerenaAgnoToolkit(Toolkit):
|
||||
return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs)
|
||||
|
||||
function = Function.from_callable(tool.get_apply_fn())
|
||||
function.name = tool.get_name()
|
||||
function.name = tool.get_name_from_cls()
|
||||
function.entrypoint = entrypoint
|
||||
function.skip_entrypoint_processing = True
|
||||
return function
|
||||
|
||||
+18
-6
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
import queue
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
@@ -68,12 +68,19 @@ class ResponseToolNames(BaseModel):
|
||||
class SerenaDashboardAPI:
|
||||
log = logging.getLogger(__qualname__)
|
||||
|
||||
def __init__(self, memory_log_handler: MemoryLogHandler, tool_names: list[str]) -> None:
|
||||
def __init__(
|
||||
self, memory_log_handler: MemoryLogHandler, tool_names: list[str], shutdown_callback: Callable[[], None] | None = None
|
||||
) -> None:
|
||||
self._memory_log_handler = memory_log_handler
|
||||
self._tool_names = tool_names
|
||||
self._shutdown_callback = shutdown_callback
|
||||
self._app = FastAPI(title="Serena Dashboard")
|
||||
self._setup_routes()
|
||||
|
||||
@property
|
||||
def memory_log_handler(self) -> MemoryLogHandler:
|
||||
return self._memory_log_handler
|
||||
|
||||
def _setup_routes(self) -> None:
|
||||
self._app.mount("/dashboard", StaticFiles(directory=SERENA_DASHBOARD_DIR), name="dashboard")
|
||||
|
||||
@@ -90,11 +97,16 @@ class SerenaDashboardAPI:
|
||||
return ResponseToolNames(tool_names=self._tool_names)
|
||||
|
||||
async def _shutdown(self) -> None:
|
||||
print("Shutdown initiated by dashbaord ...", file=sys.stderr)
|
||||
log.info("Shutting down Serena")
|
||||
# noinspection PyUnresolvedReferences
|
||||
# noinspection PyProtectedMember
|
||||
os._exit(0)
|
||||
if self._shutdown_callback:
|
||||
self._shutdown_callback()
|
||||
else:
|
||||
# Try to use the global shutdown function from process_isolated_agent
|
||||
from serena.process_isolated_agent import request_global_shutdown
|
||||
|
||||
request_global_shutdown()
|
||||
# noinspection PyProtectedMember
|
||||
os._exit(0)
|
||||
|
||||
@staticmethod
|
||||
def _find_first_free_port(start_port: int) -> int:
|
||||
|
||||
+105
-39
@@ -2,7 +2,13 @@
|
||||
The Serena Model Context Protocol (MCP) Server
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
@@ -10,18 +16,24 @@ from logging import Formatter, Logger, StreamHandler
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import click # Add click import
|
||||
import click
|
||||
import docstring_parser
|
||||
from mcp.server.fastmcp import server
|
||||
from mcp.server.fastmcp.server import FastMCP, Settings
|
||||
from mcp.server.fastmcp.tools.base import Tool as MCPTool
|
||||
from mcp.server.fastmcp.utilities.func_metadata import func_metadata
|
||||
from sensai.util import logging
|
||||
from sensai.util.helper import mark_used
|
||||
|
||||
from serena.agent import SerenaAgent, Tool, show_fatal_exception_safe
|
||||
from serena.agent import SerenaAgent, ToolInterface, create_serena_config, show_fatal_exception_safe
|
||||
from serena.config import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES
|
||||
from serena.process_isolated_agent import (
|
||||
ProcessIsolatedDashboard,
|
||||
ProcessIsolatedSerenaAgent,
|
||||
ProcessIsolatedTool,
|
||||
global_shutdown_event,
|
||||
request_global_shutdown,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"
|
||||
@@ -46,18 +58,12 @@ class SerenaMCPRequestContext:
|
||||
|
||||
|
||||
def make_tool(
|
||||
tool: Tool,
|
||||
tool: ToolInterface,
|
||||
) -> MCPTool:
|
||||
func_name = tool.get_name()
|
||||
|
||||
apply_fn = getattr(tool, "apply")
|
||||
if apply_fn is None:
|
||||
raise ValueError(f"Tool does not have an apply method: {tool}")
|
||||
|
||||
func_doc = apply_fn.__doc__ or ""
|
||||
func_doc = tool.get_apply_docstring() or ""
|
||||
func_arg_metadata = tool.get_apply_fn_metadata()
|
||||
is_async = False
|
||||
|
||||
func_arg_metadata = func_metadata(apply_fn)
|
||||
parameters = func_arg_metadata.arg_model.model_json_schema()
|
||||
|
||||
docstring = docstring_parser.parse(func_doc)
|
||||
@@ -68,17 +74,17 @@ def make_tool(
|
||||
func_doc = f"{docstring.description.strip().strip('.')}."
|
||||
else:
|
||||
func_doc = ""
|
||||
if (docstring.returns) and (docstring_returns := docstring.returns.description):
|
||||
if docstring.returns and (docstring_returns_descr := docstring.returns.description):
|
||||
# Only add a space before "Returns" if func_doc is not empty
|
||||
prefix = " " if func_doc else ""
|
||||
func_doc = f"{func_doc}{prefix}Returns {docstring_returns.strip().strip('.')}."
|
||||
func_doc = f"{func_doc}{prefix}Returns {docstring_returns_descr.strip().strip('.')}."
|
||||
|
||||
# Parse the parameter descriptions from the docstring and add pass its description
|
||||
# to the parameters schema.
|
||||
docstring_params = {param.arg_name: param for param in docstring.params}
|
||||
parameters_properties: dict[str, dict[str, Any]] = parameters["properties"]
|
||||
for parameter, properties in parameters_properties.items():
|
||||
if (param_doc := docstring_params.get(parameter)) and (param_doc.description):
|
||||
if (param_doc := docstring_params.get(parameter)) and param_doc.description:
|
||||
param_desc = f"{param_doc.description.strip().strip('.') + '.'}"
|
||||
properties["description"] = param_desc[0].upper() + param_desc[1:]
|
||||
|
||||
@@ -93,6 +99,7 @@ def make_tool(
|
||||
fn_metadata=func_arg_metadata,
|
||||
is_async=is_async,
|
||||
context_kwarg=None,
|
||||
annotations=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -107,9 +114,9 @@ def create_mcp_server_and_agent(
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
||||
trace_lsp_communication: bool | None = None,
|
||||
tool_timeout: float | None = None,
|
||||
) -> tuple[FastMCP, SerenaAgent]:
|
||||
) -> tuple[FastMCP, ProcessIsolatedSerenaAgent]:
|
||||
"""
|
||||
Create an MCP server.
|
||||
Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination.
|
||||
|
||||
:param project: "Either an absolute path to the project directory or a name of an already registered project. "
|
||||
"If the project passed here hasn't been registered yet, it will be registered automatically and can be activated by its name "
|
||||
@@ -130,11 +137,11 @@ def create_mcp_server_and_agent(
|
||||
context_instance = SerenaAgentContext.load(context)
|
||||
modes_instances = [SerenaAgentMode.load(mode) for mode in modes]
|
||||
|
||||
if project is not None:
|
||||
log.info(f"Will use project at mcp server startup: {project}")
|
||||
|
||||
try:
|
||||
agent = SerenaAgent(
|
||||
project=project,
|
||||
# Callback disabled for the time being (see above)
|
||||
# project_activation_callback=update_tools
|
||||
serena_config = create_serena_config(
|
||||
context=context_instance,
|
||||
modes=modes_instances,
|
||||
enable_web_dashboard=enable_web_dashboard,
|
||||
@@ -143,39 +150,100 @@ def create_mcp_server_and_agent(
|
||||
trace_lsp_communication=trace_lsp_communication,
|
||||
tool_timeout=tool_timeout,
|
||||
)
|
||||
serena_agent_process = ProcessIsolatedSerenaAgent(project=project, serena_config=serena_config)
|
||||
|
||||
# Start process-isolated dashboard if enabled
|
||||
serena_dashboard_process = None
|
||||
if serena_config.web_dashboard:
|
||||
serena_dashboard_process = ProcessIsolatedDashboard(tool_names=[])
|
||||
except Exception as e:
|
||||
show_fatal_exception_safe(e)
|
||||
raise
|
||||
|
||||
def update_tools() -> None:
|
||||
"""Update the tools in the MCP server."""
|
||||
"""Update the tools in the MCP server - adapted for process isolation."""
|
||||
nonlocal mcp, serena_agent_process
|
||||
|
||||
# Get tool names from process-isolated agent
|
||||
# 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, agent
|
||||
tools = agent.get_exposed_tool_instances()
|
||||
tool_names = serena_agent_process.get_exposed_tool_names()
|
||||
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)
|
||||
for tool_name in tool_names:
|
||||
process_isolated_tool = ProcessIsolatedTool(process_agent=serena_agent_process, tool_name=tool_name)
|
||||
mcp_tool = make_tool(process_isolated_tool)
|
||||
mcp._tool_manager._tools[tool_name] = mcp_tool
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(mcp_server: FastMCP) -> AsyncIterator[None]:
|
||||
"""Manage server startup and shutdown lifecycle."""
|
||||
nonlocal agent
|
||||
mark_used(mcp_server)
|
||||
yield
|
||||
if agent.language_server is not None:
|
||||
agent.language_server.stop()
|
||||
|
||||
def signal_handler(signum: int, frame: Any) -> None:
|
||||
log.info(f"Received signal {signum} in main process")
|
||||
request_global_shutdown()
|
||||
|
||||
def force_exit() -> None:
|
||||
time.sleep(2.0) # Wait 2 seconds for graceful shutdown
|
||||
log.warning("Forcing exit after timeout")
|
||||
os._exit(1)
|
||||
|
||||
threading.Thread(target=force_exit, daemon=True).start()
|
||||
|
||||
# Install signal handlers
|
||||
sigint_singal = signal.signal(signal.SIGINT, signal_handler)
|
||||
sigterm_signal = signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
if serena_dashboard_process is not None:
|
||||
log.info("Starting dashboard process")
|
||||
serena_dashboard_process.start()
|
||||
log.info("Starting serena agent process")
|
||||
serena_agent_process.start()
|
||||
update_tools()
|
||||
|
||||
async def monitor_global_shutdown() -> None:
|
||||
"""Monitor the global shutdown event and trigger local shutdown."""
|
||||
while not global_shutdown_event.is_set():
|
||||
# Poll the multiprocessing Event in async context
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
log.info("Global shutdown event detected, initiating server shutdown")
|
||||
request_global_shutdown()
|
||||
# Send SIGTERM to self to trigger graceful shutdown
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
# Start monitoring task
|
||||
monitor_task = asyncio.create_task(monitor_global_shutdown())
|
||||
|
||||
try:
|
||||
yield
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
log.info("Received shutdown signal")
|
||||
request_global_shutdown()
|
||||
except Exception as e:
|
||||
log.error(f"Error in server lifespan: {e}")
|
||||
request_global_shutdown()
|
||||
finally:
|
||||
# Cancel monitor task
|
||||
monitor_task.cancel()
|
||||
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await monitor_task
|
||||
|
||||
serena_agent_process.stop()
|
||||
if serena_dashboard_process is not None:
|
||||
serena_dashboard_process.stop()
|
||||
request_global_shutdown()
|
||||
log.info("Shutting down all processes")
|
||||
signal.signal(signal.SIGINT, sigint_singal)
|
||||
signal.signal(signal.SIGTERM, sigterm_signal)
|
||||
|
||||
mcp_settings = Settings(lifespan=server_lifespan, host=host, port=port)
|
||||
mcp = FastMCP(**mcp_settings.model_dump())
|
||||
|
||||
update_tools()
|
||||
|
||||
return mcp, agent
|
||||
return mcp, serena_agent_process
|
||||
|
||||
|
||||
class ProjectType(click.ParamType):
|
||||
@@ -313,6 +381,7 @@ def start_mcp_server(
|
||||
# This is for backward compatibility with the old CLI, should be removed in the future!
|
||||
project_file = project_file_arg if project_file_arg is not None else project_file_opt
|
||||
|
||||
# Use process isolation by default to prevent asyncio event loop contamination
|
||||
mcp_server, agent = create_mcp_server_and_agent(
|
||||
project=project_file,
|
||||
host=host,
|
||||
@@ -334,9 +403,6 @@ def start_mcp_server(
|
||||
f"Used path: {project_file}"
|
||||
)
|
||||
|
||||
log.info(
|
||||
f"Starting serena agent in MCP server with config:\n{agent.get_current_config_overview()}."
|
||||
f"\n Log level: {agent.serena_config.log_level}"
|
||||
)
|
||||
log.info(f"Starting MCP server with config:\n{agent.serena_config}.\n Log level: {agent.serena_config.log_level}")
|
||||
|
||||
mcp_server.run(transport=transport)
|
||||
@@ -0,0 +1,612 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import traceback
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum
|
||||
from logging.handlers import QueueHandler
|
||||
from multiprocessing.connection import Connection
|
||||
from multiprocessing.sharedctypes import Synchronized
|
||||
from multiprocessing.synchronize import Event as EventClass
|
||||
from typing import Any, Literal, Self
|
||||
|
||||
import uvicorn
|
||||
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata
|
||||
|
||||
from serena.agent import SerenaAgent, SerenaConfig, SerenaConfigBase, Tool, ToolInterface, ToolRegistry
|
||||
from serena.config import SerenaAgentContext, SerenaAgentMode
|
||||
from serena.dashboard import MemoryLogHandler, SerenaDashboardAPI
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Global synchronization primitives
|
||||
_global_log_queue: multiprocessing.Queue = multiprocessing.Queue()
|
||||
_dashboard_ready_event = multiprocessing.Event()
|
||||
_dashboard_port_value = multiprocessing.Value("i", 0)
|
||||
global_shutdown_event = multiprocessing.Event()
|
||||
|
||||
|
||||
def request_global_shutdown() -> None:
|
||||
"""Signal the global shutdown event."""
|
||||
global_shutdown_event.set()
|
||||
log.info("Global shutdown event set")
|
||||
|
||||
|
||||
def _dashboard_worker(
|
||||
tool_names: list[str],
|
||||
log_q: "multiprocessing.Queue[Any]",
|
||||
dashboard_ready_event: EventClass,
|
||||
port_value: "Synchronized[int]",
|
||||
shutdown_evt: EventClass,
|
||||
) -> None:
|
||||
"""Entry point for the dashboard process."""
|
||||
# Route all logging to the shared queue
|
||||
root = logging.getLogger()
|
||||
root.handlers.clear()
|
||||
root.setLevel(logging.DEBUG)
|
||||
root.addHandler(QueueHandler(log_q))
|
||||
|
||||
async def _process_logs(api: SerenaDashboardAPI) -> None:
|
||||
while not shutdown_evt.is_set():
|
||||
while not log_q.empty():
|
||||
record = log_q.get_nowait()
|
||||
if record is None:
|
||||
break
|
||||
api.memory_log_handler.emit(record)
|
||||
# Small delay to avoid busy waiting
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _monitor_shutdown() -> None:
|
||||
# Poll the multiprocessing Event in async context
|
||||
# Check every 100ms until shutdown is requested
|
||||
loop = asyncio.get_event_loop()
|
||||
while True:
|
||||
# Check in executor to avoid blocking
|
||||
result = await loop.run_in_executor(None, shutdown_evt.is_set)
|
||||
if result:
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _async_main() -> None:
|
||||
api = SerenaDashboardAPI(
|
||||
memory_log_handler=MemoryLogHandler(),
|
||||
tool_names=tool_names,
|
||||
shutdown_callback=shutdown_evt.set,
|
||||
)
|
||||
# Pick a free port and signal readiness
|
||||
port = api._find_first_free_port(0x5EDA)
|
||||
port_value.value = port
|
||||
|
||||
# Start server first, then log processing
|
||||
config = uvicorn.Config(
|
||||
app=api._app,
|
||||
host="0.0.0.0",
|
||||
port=port,
|
||||
workers=1,
|
||||
log_config=None,
|
||||
log_level="critical",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
# Serve and await shutdown signal
|
||||
server_task = asyncio.create_task(server.serve())
|
||||
shutdown_task = asyncio.create_task(_monitor_shutdown())
|
||||
logging_loop_task = asyncio.create_task(_process_logs(api))
|
||||
dashboard_ready_event.set()
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
[server_task, shutdown_task, logging_loop_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if shutdown_task in done:
|
||||
server.should_exit = True
|
||||
await server_task
|
||||
|
||||
# Cancel remaining tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
try:
|
||||
asyncio.run(_async_main())
|
||||
except BaseException:
|
||||
logging.exception("Dashboard worker crashed")
|
||||
finally:
|
||||
logging.info("Dashboard worker exiting")
|
||||
|
||||
|
||||
def _shutdown_process(proc: multiprocessing.Process, timeout: float = 1.0) -> None:
|
||||
"""Helper to shutdown a process gracefully."""
|
||||
if proc.is_alive():
|
||||
proc.terminate()
|
||||
proc.join(timeout=timeout)
|
||||
if proc.is_alive():
|
||||
log.error("Process did not terminate gracefully, forcing kill")
|
||||
proc.kill()
|
||||
proc.join(timeout=timeout)
|
||||
if proc.is_alive():
|
||||
log.error("Process did not respond to kill")
|
||||
|
||||
|
||||
class ProcessIsolatedDashboard:
|
||||
"""Dashboard running in a separate process to avoid asyncio contamination."""
|
||||
|
||||
def __init__(self, tool_names: list[str]):
|
||||
self.tool_names = tool_names
|
||||
self.process: multiprocessing.Process | None = None
|
||||
|
||||
def start(self, timeout: float = 10.0) -> None:
|
||||
"""Start the dashboard process."""
|
||||
if self.process is not None:
|
||||
raise RuntimeError("Dashboard already started")
|
||||
|
||||
self.process = multiprocessing.Process(
|
||||
target=_dashboard_worker,
|
||||
args=(self.tool_names, _global_log_queue, _dashboard_ready_event, _dashboard_port_value, global_shutdown_event),
|
||||
daemon=True,
|
||||
)
|
||||
self.process.start()
|
||||
|
||||
if not _dashboard_ready_event.wait(timeout):
|
||||
self.process.terminate()
|
||||
self.process.join(timeout=1.0)
|
||||
self.process = None
|
||||
raise RuntimeError("Dashboard failed to start within timeout")
|
||||
|
||||
port = _dashboard_port_value.value
|
||||
log.info(f"Dashboard started on port {port}")
|
||||
webbrowser.open(f"http://localhost:{port}/dashboard/index.html")
|
||||
|
||||
def stop(self, timeout: float = 1.0) -> None:
|
||||
"""Signal shutdown and wait for the dashboard process to exit."""
|
||||
if self.process is None:
|
||||
return
|
||||
|
||||
log.info("Stopping dashboard process")
|
||||
request_global_shutdown()
|
||||
_shutdown_process(self.process, timeout=timeout)
|
||||
self.process = None
|
||||
|
||||
|
||||
class SerenaAgentWorker:
|
||||
"""Worker process that hosts the actual SerenaAgent."""
|
||||
|
||||
class RequestMethod(StrEnum):
|
||||
INITIALIZE = "initialize"
|
||||
TOOL_CALL = "tool_call"
|
||||
GET_ACTIVE_TOOL_NAMES = "get_active_tool_names"
|
||||
IS_LANGUAGE_SERVER_RUNNING = "is_language_server_running"
|
||||
RESET_LANGUAGE_SERVER = "reset_language_server"
|
||||
GET_EXPOSED_TOOL_NAMES = "get_exposed_tool_names"
|
||||
SHUTDOWN = "shutdown"
|
||||
|
||||
def __init__(self, conn: Connection):
|
||||
self.conn = conn
|
||||
self.agent: SerenaAgent | None = None
|
||||
|
||||
def run(self, log_queue: "multiprocessing.Queue[str]") -> None:
|
||||
"""Main worker loop - runs in separate process."""
|
||||
qh = QueueHandler(log_queue)
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG)
|
||||
root.addHandler(qh)
|
||||
|
||||
log.info("SerenaAgent worker process started")
|
||||
try:
|
||||
while not global_shutdown_event.is_set():
|
||||
try:
|
||||
# Use polling to avoid blocking indefinitely
|
||||
if self.conn.poll(timeout=0.5): # Poll every 500ms
|
||||
try:
|
||||
request = self.conn.recv()
|
||||
if request is None: # Explicit shutdown signal
|
||||
break
|
||||
|
||||
response = self._handle_request(request)
|
||||
self.conn.send(response)
|
||||
except EOFError:
|
||||
# Connection closed - parent process terminated
|
||||
log.info("Connection closed, worker shutting down")
|
||||
break
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
# Connection broken, can't communicate
|
||||
log.info("Connection broken, worker shutting down")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Error processing request: {e}")
|
||||
try:
|
||||
response = {
|
||||
"error": f"Worker process error: {e!s}",
|
||||
"traceback": traceback.format_exc(),
|
||||
}
|
||||
self.conn.send(response)
|
||||
except (EOFError, BrokenPipeError, ConnectionResetError):
|
||||
# Connection is broken, can't send error response
|
||||
log.info("Connection broken during error response, shutting down")
|
||||
break
|
||||
# Continue polling if no data available
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Error in worker main loop: {e}")
|
||||
break
|
||||
except Exception as e:
|
||||
log.error(f"Fatal error in worker process: {e}")
|
||||
finally:
|
||||
self._cleanup()
|
||||
log.info("SerenaAgent worker process stopped")
|
||||
os._exit(0) # Exit without raising any further exceptions
|
||||
|
||||
def _handle_request(self, request: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Handle a single request."""
|
||||
try:
|
||||
method = request["method"]
|
||||
params = request.get("params", {})
|
||||
|
||||
match method:
|
||||
case self.RequestMethod.INITIALIZE:
|
||||
return self._initialize(params)
|
||||
case self.RequestMethod.TOOL_CALL:
|
||||
return self._tool_call(params)
|
||||
case self.RequestMethod.GET_ACTIVE_TOOL_NAMES:
|
||||
return self._get_active_tool_names()
|
||||
case self.RequestMethod.IS_LANGUAGE_SERVER_RUNNING:
|
||||
return self._is_language_server_running()
|
||||
case self.RequestMethod.RESET_LANGUAGE_SERVER:
|
||||
return self._reset_language_server()
|
||||
case self.RequestMethod.GET_EXPOSED_TOOL_NAMES:
|
||||
return self._get_exposed_tool_names()
|
||||
case self.RequestMethod.SHUTDOWN:
|
||||
return self.shutdown()
|
||||
case _:
|
||||
return {"error": f"Unknown method: {method}"}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def _initialize(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Initialize the SerenaAgent."""
|
||||
try:
|
||||
# Extract all possible initialization parameters
|
||||
project = params.get("project")
|
||||
project_activation_callback = params.get("project_activation_callback")
|
||||
serena_config = params.get("serena_config")
|
||||
context = params.get("context")
|
||||
modes = params.get("modes")
|
||||
enable_web_dashboard = params.get("enable_web_dashboard")
|
||||
enable_gui_log_window = params.get("enable_gui_log_window")
|
||||
log_level = params.get("log_level")
|
||||
trace_lsp_communication = params.get("trace_lsp_communication")
|
||||
tool_timeout = params.get("tool_timeout")
|
||||
|
||||
# Handle legacy config_dict parameter for backward compatibility
|
||||
if "config" in params and serena_config is None:
|
||||
config_dict = params["config"]
|
||||
serena_config = SerenaConfig.from_dict(config_dict)
|
||||
elif serena_config is not None and isinstance(serena_config, dict):
|
||||
serena_config = SerenaConfig.from_dict(serena_config)
|
||||
|
||||
self.agent = SerenaAgent(
|
||||
project=project,
|
||||
project_activation_callback=project_activation_callback,
|
||||
serena_config=serena_config,
|
||||
context=context,
|
||||
modes=modes,
|
||||
enable_web_dashboard=enable_web_dashboard,
|
||||
enable_gui_log_window=enable_gui_log_window,
|
||||
log_level=log_level,
|
||||
trace_lsp_communication=trace_lsp_communication,
|
||||
tool_timeout=tool_timeout,
|
||||
)
|
||||
return {"result": "SerenaAgent initialized successfully"}
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to initialize SerenaAgent: {e!s}", "traceback": traceback.format_exc()}
|
||||
|
||||
def _tool_call(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Execute a tool call."""
|
||||
if self.agent is None:
|
||||
return {"error": "SerenaAgent not initialized"}
|
||||
|
||||
try:
|
||||
tool_name = params["tool_name"]
|
||||
tool_params = params["tool_params"]
|
||||
|
||||
# Get the tool by name
|
||||
tool = None
|
||||
for tool_instance in self.agent._active_tools.values():
|
||||
if tool_instance.get_name_from_cls() == tool_name:
|
||||
tool = tool_instance
|
||||
break
|
||||
|
||||
if tool is None:
|
||||
return {"error": f"Tool '{tool_name}' not found or not active"}
|
||||
|
||||
# Execute the tool
|
||||
result = tool.apply_ex(**tool_params)
|
||||
|
||||
return {"result": result}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def _get_active_tool_names(self) -> dict[str, Any]:
|
||||
"""Get list of active tool names."""
|
||||
if self.agent is None:
|
||||
return {"error": "SerenaAgent not initialized"}
|
||||
|
||||
try:
|
||||
tool_names = self.agent.get_active_tool_names()
|
||||
return {"result": tool_names}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def _is_language_server_running(self) -> dict[str, Any]:
|
||||
"""Check if language server is running."""
|
||||
if self.agent is None:
|
||||
return {"error": "SerenaAgent not initialized"}
|
||||
|
||||
try:
|
||||
is_running = self.agent.is_language_server_running()
|
||||
return {"result": is_running}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def _reset_language_server(self) -> dict[str, Any]:
|
||||
"""Reset the language server."""
|
||||
if self.agent is None:
|
||||
return {"error": "SerenaAgent not initialized"}
|
||||
|
||||
try:
|
||||
self.agent.reset_language_server()
|
||||
return {"result": "Language server reset successfully"}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def _get_exposed_tool_names(self) -> dict[str, Any]:
|
||||
"""Get exposed tool names for MCP tool creation."""
|
||||
if self.agent is None:
|
||||
return {"error": "SerenaAgent not initialized"}
|
||||
|
||||
try:
|
||||
tool_instances = self.agent.get_exposed_tool_instances()
|
||||
# Return only tool names - metadata will be reconstructed from ToolRegistry
|
||||
tool_names = [tool.get_name_from_cls() for tool in tool_instances]
|
||||
return {"result": tool_names}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def shutdown(self) -> dict[str, Any]:
|
||||
try:
|
||||
log.info("Shutting down SerenaAgent worker process on request")
|
||||
self._cleanup()
|
||||
request_global_shutdown()
|
||||
# Return successful response before exiting
|
||||
return {"result": "Shutdown initiated"}
|
||||
except Exception as e:
|
||||
log.error(f"Error during shutdown: {e}")
|
||||
return {"error": str(e), "traceback": traceback.format_exc()}
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.agent is not None:
|
||||
try:
|
||||
if self.agent.is_language_server_running() and self.agent.language_server is not None:
|
||||
self.agent.language_server.stop()
|
||||
except Exception as e:
|
||||
log.error(f"Error stopping language server: {e}")
|
||||
self.agent = None
|
||||
|
||||
|
||||
class ProcessIsolatedSerenaAgent:
|
||||
"""Process-isolated wrapper for SerenaAgent that prevents asyncio contamination."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project: str | None = None,
|
||||
project_activation_callback: Callable[[], None] | None = None,
|
||||
serena_config: SerenaConfigBase | None = None,
|
||||
context: SerenaAgentContext | None = None,
|
||||
modes: list[SerenaAgentMode] | None = None,
|
||||
enable_web_dashboard: bool | None = None,
|
||||
enable_gui_log_window: bool | None = None,
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
|
||||
trace_lsp_communication: bool | None = None,
|
||||
tool_timeout: float | None = None,
|
||||
):
|
||||
# Store all initialization parameters to pass to worker
|
||||
self._init_params = {
|
||||
"project": project,
|
||||
"project_activation_callback": project_activation_callback,
|
||||
"serena_config": serena_config,
|
||||
"context": context,
|
||||
"modes": modes,
|
||||
"enable_web_dashboard": enable_web_dashboard,
|
||||
"enable_gui_log_window": enable_gui_log_window,
|
||||
"log_level": log_level,
|
||||
"trace_lsp_communication": trace_lsp_communication,
|
||||
"tool_timeout": tool_timeout,
|
||||
}
|
||||
|
||||
# Keep serena_config for compatibility
|
||||
if serena_config is not None:
|
||||
self.serena_config = serena_config
|
||||
else:
|
||||
# Use the default config loader
|
||||
self.serena_config = SerenaConfig.from_config_file()
|
||||
self.process: multiprocessing.Process | None = None
|
||||
self.project = project
|
||||
self.conn: Connection | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the worker process."""
|
||||
if self.process is not None:
|
||||
raise RuntimeError("ProcessIsolatedSerenaAgent already started")
|
||||
|
||||
log.info("Starting process-isolated SerenaAgent")
|
||||
|
||||
# Create communication pipe
|
||||
parent_conn, child_conn = multiprocessing.Pipe()
|
||||
self.conn = parent_conn
|
||||
|
||||
# Create and start worker process, passing along the dashboard's queue if available
|
||||
worker = SerenaAgentWorker(child_conn)
|
||||
self.process = multiprocessing.Process(target=worker.run, args=[_global_log_queue])
|
||||
self.process.start()
|
||||
|
||||
# Initialize the agent in the worker process
|
||||
try:
|
||||
# TODO: instead pass config and project to worker, use at startup of agent there
|
||||
# Prepare initialization parameters, converting complex objects to dict if present
|
||||
init_params = self._init_params.copy()
|
||||
if init_params["serena_config"] is not None and hasattr(init_params["serena_config"], "to_dict"):
|
||||
# Convert SerenaConfigBase to dict for serialization
|
||||
serena_config_obj = init_params["serena_config"]
|
||||
init_params["serena_config"] = serena_config_obj.to_dict() # type: ignore
|
||||
# init_params["project"] = self.project
|
||||
# Note: context and modes are not serializable across processes,
|
||||
# so they will be None and SerenaAgent will use defaults
|
||||
init_params["context"] = None
|
||||
init_params["modes"] = None
|
||||
# project_activation_callback cannot be serialized across processes
|
||||
init_params["project_activation_callback"] = None
|
||||
# Disable web dashboard in worker process - it will run in MCP process instead
|
||||
init_params["enable_web_dashboard"] = False
|
||||
|
||||
self._make_request_with_result(SerenaAgentWorker.RequestMethod.INITIALIZE, init_params)
|
||||
except Exception as e:
|
||||
self.stop()
|
||||
raise RuntimeError(f"Failed to initialize SerenaAgent: {e}") from e
|
||||
|
||||
log.info("Process-isolated SerenaAgent started successfully")
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the worker process."""
|
||||
if self.process is None:
|
||||
return
|
||||
log.info("Stopping SerenaAgent process")
|
||||
try:
|
||||
# Close connection to signal worker to shutdown
|
||||
if self.conn is not None:
|
||||
self.conn.close()
|
||||
_shutdown_process(self.process, timeout=2.0)
|
||||
self.process = None
|
||||
except KeyboardInterrupt:
|
||||
log.warning("Keyboard interrupt during shutdown - forcing termination")
|
||||
if self.process and self.process.is_alive():
|
||||
self.process.kill()
|
||||
self.process.join(timeout=1.0)
|
||||
except Exception as e:
|
||||
log.error(f"Error stopping worker process: {e}")
|
||||
finally:
|
||||
self.process = None
|
||||
self.conn = None
|
||||
|
||||
log.info("SerenaAgent stopped")
|
||||
|
||||
def _make_request(self, method: SerenaAgentWorker.RequestMethod, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Make a request to the worker process."""
|
||||
if self.process is None or not self.process.is_alive():
|
||||
raise RuntimeError("Worker process is not running")
|
||||
|
||||
if self.conn is None:
|
||||
raise RuntimeError("Connection is not initialized")
|
||||
|
||||
request = {"method": method, "params": params or {}}
|
||||
|
||||
# Send request
|
||||
try:
|
||||
self.conn.send(request)
|
||||
except (EOFError, BrokenPipeError) as e:
|
||||
raise RuntimeError("Failed to send request: worker process may have crashed") from e
|
||||
|
||||
# Wait for response with timeout
|
||||
timeout = self.serena_config.tool_timeout
|
||||
if self.conn.poll(timeout):
|
||||
try:
|
||||
return self.conn.recv()
|
||||
except (EOFError, BrokenPipeError) as e:
|
||||
raise RuntimeError("Failed to receive response: worker process may have crashed") from e
|
||||
else:
|
||||
raise TimeoutError(f"Request {method} timed out after {timeout} seconds")
|
||||
|
||||
def _make_request_with_result(self, method: SerenaAgentWorker.RequestMethod, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Make a request and return the result, raising an exception if there's an error."""
|
||||
response = self._make_request(method, params)
|
||||
if "error" in response:
|
||||
raise RuntimeError(f"Request {method} failed: {response['error']}")
|
||||
return response["result"]
|
||||
|
||||
def tool_call(self, tool_name: str, **tool_params: Any) -> str:
|
||||
"""Call a tool in the worker process."""
|
||||
return self._make_request_with_result(
|
||||
SerenaAgentWorker.RequestMethod.TOOL_CALL, {"tool_name": tool_name, "tool_params": tool_params}
|
||||
)
|
||||
|
||||
def get_tool(self, tool_cls: type[Tool]) -> "ProcessIsolatedTool":
|
||||
"""Get a process-isolated tool that delegates to this agent."""
|
||||
tool_name = tool_cls.get_name_from_cls()
|
||||
return ProcessIsolatedTool(self, tool_name)
|
||||
|
||||
def get_active_tool_names(self) -> list[str]:
|
||||
"""Get list of active tool names."""
|
||||
return self._make_request_with_result(SerenaAgentWorker.RequestMethod.GET_ACTIVE_TOOL_NAMES)
|
||||
|
||||
def is_language_server_running(self) -> bool:
|
||||
"""Check if language server is running."""
|
||||
return self._make_request_with_result(SerenaAgentWorker.RequestMethod.IS_LANGUAGE_SERVER_RUNNING)
|
||||
|
||||
def reset_language_server(self) -> None:
|
||||
"""Reset the language server."""
|
||||
self._make_request_with_result(SerenaAgentWorker.RequestMethod.RESET_LANGUAGE_SERVER)
|
||||
|
||||
def shutdown_from_dashboard(self) -> None:
|
||||
"""Request shutdown from dashboard."""
|
||||
self._make_request_with_result(SerenaAgentWorker.RequestMethod.SHUTDOWN)
|
||||
|
||||
def get_exposed_tool_names(self) -> list[str]:
|
||||
"""Get tool names for MCP tool creation."""
|
||||
return self._make_request_with_result(SerenaAgentWorker.RequestMethod.GET_EXPOSED_TOOL_NAMES)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
self.stop()
|
||||
|
||||
|
||||
class ProcessIsolatedTool(ToolInterface):
|
||||
"""A clean tool wrapper that delegates to ProcessIsolatedSerenaAgent."""
|
||||
|
||||
def __init__(self, process_agent: ProcessIsolatedSerenaAgent, tool_name: str):
|
||||
self.process_agent = process_agent
|
||||
self._tool_name = tool_name
|
||||
|
||||
@property
|
||||
def _tool_class(self) -> type[Tool]:
|
||||
return ToolRegistry.get_tool_class_by_name(self._tool_name)
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Get the tool name for this process-isolated tool."""
|
||||
return self._tool_name
|
||||
|
||||
def get_apply_docstring(self) -> str:
|
||||
"""Get the docstring for the apply method."""
|
||||
# in the actual tool, this is a classmethod
|
||||
return self._tool_class.get_apply_docstring_from_cls()
|
||||
|
||||
def get_apply_fn_metadata(self) -> FuncMetadata:
|
||||
"""Get the metadata for the apply method."""
|
||||
# in the actual tool, this is a classmethod
|
||||
return self._tool_class.get_apply_fn_metadata_from_cls()
|
||||
|
||||
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs: Any) -> str:
|
||||
"""Apply the tool with logging and exception handling."""
|
||||
try:
|
||||
return self.process_agent.tool_call(self._tool_name, **kwargs)
|
||||
except Exception as e:
|
||||
if catch_exceptions:
|
||||
return f"Error executing tool {self._tool_name}: {e!s}"
|
||||
raise
|
||||
+10
-7
@@ -2,7 +2,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sensai.util.logging import LOG_DEFAULT_FORMAT, configure
|
||||
from sensai.util.logging import configure
|
||||
|
||||
from multilspy.language_server import SyncLanguageServer
|
||||
from multilspy.multilspy_config import Language, MultilspyConfig
|
||||
@@ -27,18 +27,21 @@ def get_repo_path(language: Language) -> Path:
|
||||
return Path(__file__).parent / "resources" / "repos" / language / "test_repo"
|
||||
|
||||
|
||||
def create_ls(language: Language, repo_path: str | None = None, ignored_paths: list[str] | None = None) -> SyncLanguageServer:
|
||||
def create_ls(
|
||||
language: Language,
|
||||
repo_path: str | None = None,
|
||||
ignored_paths: list[str] | None = None,
|
||||
trace_lsp_communication: bool = False,
|
||||
log_level: int = logging.INFO,
|
||||
) -> SyncLanguageServer:
|
||||
ignored_paths = ignored_paths or []
|
||||
if repo_path is None:
|
||||
repo_path = str(get_repo_path(language))
|
||||
gitignore_parser = GitignoreParser(str(repo_path))
|
||||
for spec in gitignore_parser.get_ignore_specs():
|
||||
ignored_paths.extend(spec.patterns)
|
||||
config = MultilspyConfig(code_language=language, ignored_paths=ignored_paths)
|
||||
logger = MultilspyLogger(log_level=logging.DEBUG)
|
||||
|
||||
# Simple way: Use basicConfig to set format for all handlers
|
||||
logging.basicConfig(format=LOG_DEFAULT_FORMAT, level=logging.DEBUG, force=True) # Override existing configuration
|
||||
config = MultilspyConfig(code_language=language, ignored_paths=ignored_paths, trace_lsp_communication=trace_lsp_communication)
|
||||
logger = MultilspyLogger(log_level=log_level)
|
||||
|
||||
return SyncLanguageServer.create(config, logger, repo_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Tests for make_tool consistency between regular tools and ProcessIsolatedTool."""
|
||||
|
||||
import pytest
|
||||
from mcp.server.fastmcp.tools.base import Tool as MCPTool
|
||||
|
||||
from serena.agent import SerenaAgent, ToolRegistry
|
||||
from serena.mcp import make_tool
|
||||
from serena.process_isolated_agent import ProcessIsolatedSerenaAgent, ProcessIsolatedTool
|
||||
from test.serena.test_serena_agent import SerenaConfigForTests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def in_memory_config():
|
||||
"""Create an in-memory configuration for tests."""
|
||||
return SerenaConfigForTests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def regular_agent(in_memory_config):
|
||||
"""Create a regular SerenaAgent for comparison."""
|
||||
return SerenaAgent(serena_config=in_memory_config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def process_isolated_agent(in_memory_config):
|
||||
"""Create a ProcessIsolatedSerenaAgent for comparison."""
|
||||
agent = ProcessIsolatedSerenaAgent(serena_config=in_memory_config)
|
||||
agent.start()
|
||||
yield agent
|
||||
agent.stop()
|
||||
|
||||
|
||||
class TestMakeToolProcessIsolation:
|
||||
"""Test that make_tool produces identical metadata for regular and process-isolated tools."""
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ToolRegistry.get_tool_names())
|
||||
def test_make_tool_metadata_consistency(
|
||||
self, tool_name: str, regular_agent: SerenaAgent, process_isolated_agent: ProcessIsolatedSerenaAgent
|
||||
):
|
||||
"""Test that make_tool produces identical metadata for regular and process-isolated tools."""
|
||||
# Get regular tool instance
|
||||
tool_class = ToolRegistry.get_tool_class_by_name(tool_name)
|
||||
regular_tool = regular_agent.get_tool(tool_class)
|
||||
|
||||
# Get ProcessIsolatedTool instance
|
||||
isolated_tool = ProcessIsolatedTool(process_isolated_agent, tool_name)
|
||||
|
||||
# Create MCP tools from both
|
||||
regular_mcp_tool = make_tool(regular_tool)
|
||||
isolated_mcp_tool = make_tool(isolated_tool)
|
||||
|
||||
# Verify both are MCPTool instances
|
||||
assert isinstance(regular_mcp_tool, MCPTool)
|
||||
assert isinstance(isolated_mcp_tool, MCPTool)
|
||||
|
||||
# Test name consistency
|
||||
assert regular_mcp_tool.name == isolated_mcp_tool.name
|
||||
assert regular_mcp_tool.name == tool_name
|
||||
|
||||
# Test description consistency
|
||||
assert regular_mcp_tool.description == isolated_mcp_tool.description, (
|
||||
f"Tool {tool_name}: descriptions differ\n"
|
||||
f"Regular: {regular_mcp_tool.description}\n"
|
||||
f"Isolated: {isolated_mcp_tool.description}"
|
||||
)
|
||||
|
||||
# Test parameters schema consistency
|
||||
assert regular_mcp_tool.parameters == isolated_mcp_tool.parameters, (
|
||||
f"Tool {tool_name}: parameter schemas differ\n"
|
||||
f"Regular: {regular_mcp_tool.parameters}\n"
|
||||
f"Isolated: {isolated_mcp_tool.parameters}"
|
||||
)
|
||||
|
||||
# Test function metadata consistency (compare schemas, not class objects)
|
||||
regular_schema = regular_mcp_tool.fn_metadata.arg_model.model_json_schema()
|
||||
isolated_schema = isolated_mcp_tool.fn_metadata.arg_model.model_json_schema()
|
||||
assert (
|
||||
regular_schema == isolated_schema
|
||||
), f"Tool {tool_name}: function metadata schemas differ\nRegular: {regular_schema}\nIsolated: {isolated_schema}"
|
||||
|
||||
# Test async flag consistency
|
||||
assert regular_mcp_tool.is_async == isolated_mcp_tool.is_async
|
||||
|
||||
# Test context kwarg consistency
|
||||
assert regular_mcp_tool.context_kwarg == isolated_mcp_tool.context_kwarg
|
||||
|
||||
@pytest.mark.parametrize("tool_name", ToolRegistry.get_tool_names()[:5]) # Test first 5 tools for faster execution
|
||||
def test_tool_protocol_methods_consistency(
|
||||
self, tool_name: str, regular_agent: SerenaAgent, process_isolated_agent: ProcessIsolatedSerenaAgent
|
||||
):
|
||||
"""Test that Tool methods return identical results for regular and process-isolated tools."""
|
||||
# Get regular tool instance
|
||||
tool_class = ToolRegistry.get_tool_class_by_name(tool_name)
|
||||
regular_tool = regular_agent.get_tool(tool_class)
|
||||
|
||||
# Get ProcessIsolatedTool instance
|
||||
isolated_tool = ProcessIsolatedTool(process_isolated_agent, tool_name)
|
||||
|
||||
# Test get_name()
|
||||
assert regular_tool.get_name_from_cls() == isolated_tool.get_name()
|
||||
assert regular_tool.get_name_from_cls() == tool_name
|
||||
|
||||
# Test get_apply_docstring()
|
||||
regular_docstring = regular_tool.get_apply_docstring()
|
||||
isolated_docstring = isolated_tool.get_apply_docstring()
|
||||
assert (
|
||||
regular_docstring == isolated_docstring
|
||||
), f"Tool {tool_name}: docstrings differ\nRegular: {regular_docstring}\nIsolated: {isolated_docstring}"
|
||||
|
||||
# Test get_apply_fn_metadata()
|
||||
regular_metadata = regular_tool.get_apply_fn_metadata()
|
||||
isolated_metadata = isolated_tool.get_apply_fn_metadata()
|
||||
|
||||
# Compare metadata properties (compare schemas, not class objects)
|
||||
regular_schema = regular_metadata.arg_model.model_json_schema()
|
||||
isolated_schema = isolated_metadata.arg_model.model_json_schema()
|
||||
assert (
|
||||
regular_schema == isolated_schema
|
||||
), f"Tool {tool_name}: metadata schemas differ\nRegular: {regular_schema}\nIsolated: {isolated_schema}"
|
||||
|
||||
def test_process_isolated_tool_uses_tool_registry(self, process_isolated_agent: ProcessIsolatedSerenaAgent):
|
||||
"""Test that ProcessIsolatedTool correctly uses ToolRegistry for metadata."""
|
||||
tool_name = ToolRegistry.get_tool_names()[0] # Use first available tool
|
||||
isolated_tool = ProcessIsolatedTool(process_isolated_agent, tool_name)
|
||||
|
||||
# Verify that the tool uses ToolRegistry
|
||||
assert isolated_tool._tool_class == ToolRegistry.get_tool_class_by_name(tool_name)
|
||||
|
||||
# Verify that metadata comes from the tool class
|
||||
expected_docstring = isolated_tool._tool_class.get_apply_docstring_from_cls()
|
||||
expected_metadata = isolated_tool._tool_class.get_apply_fn_metadata_from_cls()
|
||||
|
||||
assert isolated_tool.get_apply_docstring() == expected_docstring
|
||||
# Compare schemas, not class objects
|
||||
isolated_schema = isolated_tool.get_apply_fn_metadata().arg_model.model_json_schema()
|
||||
expected_schema = expected_metadata.arg_model.model_json_schema()
|
||||
assert isolated_schema == expected_schema
|
||||
|
||||
def test_tool_registry_completeness(self, regular_agent: SerenaAgent, process_isolated_agent: ProcessIsolatedSerenaAgent):
|
||||
"""Test that all tools are available in both agents and the registry."""
|
||||
# Get tool names from both agents
|
||||
regular_active_tools = set(regular_agent.get_active_tool_names())
|
||||
regular_all_tools = set(tool.get_name_from_cls() for tool in regular_agent.get_exposed_tool_instances())
|
||||
isolated_tool_names = set(process_isolated_agent.get_exposed_tool_names())
|
||||
|
||||
# The process isolated agent should have all tools (exposed, not just active)
|
||||
assert regular_all_tools == isolated_tool_names, (
|
||||
f"Tool sets differ:\n"
|
||||
f"Regular exposed only: {regular_all_tools - isolated_tool_names}\n"
|
||||
f"Isolated only: {isolated_tool_names - regular_all_tools}"
|
||||
)
|
||||
|
||||
# Active tools should be a subset of all tools
|
||||
assert regular_active_tools.issubset(
|
||||
regular_all_tools
|
||||
), f"Some active tools not in exposed tools: {regular_active_tools - regular_all_tools}"
|
||||
|
||||
# All tools should be in the registry
|
||||
registry_tool_names = set(ToolRegistry.get_tool_names())
|
||||
assert regular_all_tools.issubset(registry_tool_names), f"Some tools not in registry: {regular_all_tools - registry_tool_names}"
|
||||
@@ -224,7 +224,7 @@ def test_make_tool_missing_apply() -> None:
|
||||
""",
|
||||
"",
|
||||
),
|
||||
("", ""),
|
||||
("Description without params.", "Description without params."),
|
||||
],
|
||||
)
|
||||
def test_make_tool_descriptions(docstring, expected_description) -> None:
|
||||
@@ -287,7 +287,7 @@ def test_make_tool_all_tools(tool_class) -> None:
|
||||
|
||||
# Basic validation
|
||||
assert isinstance(mcp_tool, MCPTool)
|
||||
assert mcp_tool.name == tool_class.get_name()
|
||||
assert mcp_tool.name == tool_class.get_name_from_cls()
|
||||
|
||||
# The description should be a string (either from docstring or default)
|
||||
assert isinstance(mcp_tool.description, str)
|
||||
@@ -6,8 +6,9 @@ from dataclasses import dataclass
|
||||
import pytest
|
||||
|
||||
from multilspy.multilspy_config import Language
|
||||
from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, SerenaAgent, SerenaConfigBase
|
||||
from test.conftest import LanguageParamRequest, get_repo_path
|
||||
from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, Project, ProjectConfig, SerenaAgent, SerenaConfigBase
|
||||
from serena.process_isolated_agent import ProcessIsolatedSerenaAgent
|
||||
from test.conftest import get_repo_path
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -19,20 +20,71 @@ class SerenaConfigForTests(SerenaConfigBase):
|
||||
gui_log_window_enabled: bool = False
|
||||
web_dashboard: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
# Initialize with empty projects list if not already set
|
||||
if not hasattr(self, "projects") or self.projects is None:
|
||||
self.projects = []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serena_config():
|
||||
return SerenaConfigForTests()
|
||||
"""Create an in-memory configuration for tests with test repositories pre-registered."""
|
||||
# Create test projects for all supported languages
|
||||
test_projects = []
|
||||
for language in [Language.PYTHON, Language.GO, Language.JAVA, Language.RUST, Language.TYPESCRIPT, Language.PHP]:
|
||||
repo_path = get_repo_path(language)
|
||||
if repo_path.exists():
|
||||
project_name = f"test_repo_{language}"
|
||||
project = Project(
|
||||
project_root=str(repo_path),
|
||||
project_config=ProjectConfig(
|
||||
project_name=project_name,
|
||||
language=language,
|
||||
ignored_paths=[],
|
||||
excluded_tools=set(),
|
||||
read_only=False,
|
||||
ignore_all_files_in_gitignore=True,
|
||||
initial_prompt="",
|
||||
encoding="utf-8",
|
||||
),
|
||||
)
|
||||
test_projects.append(project)
|
||||
|
||||
config = SerenaConfigForTests()
|
||||
config.projects = test_projects
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serena_agent(request: LanguageParamRequest, serena_config) -> SerenaAgent:
|
||||
def serena_agent(request: pytest.FixtureRequest, serena_config):
|
||||
language = Language(request.param)
|
||||
repo = get_repo_path(language)
|
||||
return SerenaAgent(project=str(repo), serena_config=serena_config)
|
||||
project_name = f"test_repo_{language}"
|
||||
|
||||
# Check if this test should use process isolation by looking at the test parameters
|
||||
isolated_process = False
|
||||
if hasattr(request, "node") and hasattr(request.node, "callspec"):
|
||||
# Get the isolated_process parameter value from the test
|
||||
params = request.node.callspec.params
|
||||
isolated_process = params.get("isolated_process", False)
|
||||
|
||||
if isolated_process:
|
||||
agent = ProcessIsolatedSerenaAgent(project=project_name, serena_config=serena_config)
|
||||
agent.start()
|
||||
|
||||
# Add cleanup to stop the process
|
||||
def cleanup():
|
||||
agent.stop()
|
||||
|
||||
request.addfinalizer(cleanup)
|
||||
return agent
|
||||
else:
|
||||
return SerenaAgent(project=project_name, serena_config=serena_config)
|
||||
|
||||
|
||||
class TestSerenaAgent:
|
||||
@pytest.mark.parametrize(
|
||||
"isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,symbol_name,expected_kind,expected_file",
|
||||
[
|
||||
@@ -45,16 +97,20 @@ class TestSerenaAgent:
|
||||
],
|
||||
indirect=["serena_agent"],
|
||||
)
|
||||
def test_find_symbol(self, serena_agent: SerenaAgent, symbol_name: str, expected_kind: str, expected_file: str):
|
||||
def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str, isolated_process: bool):
|
||||
agent = serena_agent
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
result = find_symbol_tool.apply(symbol_name)
|
||||
result = find_symbol_tool.apply_ex(name_path=symbol_name)
|
||||
|
||||
symbols = json.loads(result)
|
||||
assert any(
|
||||
symbol_name in s["name_path"] and expected_kind.lower() in s["kind"].lower() and expected_file in s["relative_path"]
|
||||
for s in symbols
|
||||
), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file} for {agent.get_active_project().language.name}"
|
||||
), f"Expected to find {symbol_name} ({expected_kind}) in {expected_file}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,symbol_name,def_file,ref_file",
|
||||
[
|
||||
@@ -79,23 +135,30 @@ class TestSerenaAgent:
|
||||
],
|
||||
indirect=["serena_agent"],
|
||||
)
|
||||
def test_find_symbol_references(self, serena_agent: SerenaAgent, symbol_name: str, def_file: str, ref_file: str) -> None:
|
||||
def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str, isolated_process: bool) -> None:
|
||||
agent = serena_agent
|
||||
|
||||
# Find the symbol location first
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
result = find_symbol_tool.apply(symbol_name, relative_path=def_file)
|
||||
result = find_symbol_tool.apply_ex(name_path=symbol_name, relative_path=def_file)
|
||||
|
||||
time.sleep(1)
|
||||
symbols = json.loads(result)
|
||||
# Find the definition
|
||||
def_symbol = symbols[0]
|
||||
|
||||
# Now find references
|
||||
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
|
||||
result = find_refs_tool.apply(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"])
|
||||
result = find_refs_tool.apply_ex(name_path=def_symbol["name_path"], relative_path=def_symbol["relative_path"])
|
||||
|
||||
refs = json.loads(result)
|
||||
assert any(
|
||||
ref["relative_path"] == ref_file for ref in refs
|
||||
), f"Expected to find reference to {symbol_name} in {ref_file} for {agent._active_project.language.name}. refs={refs}"
|
||||
), f"Expected to find reference to {symbol_name} in {ref_file}. refs={refs}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,name_path,substring_matching,expected_symbol_name,expected_kind,expected_file",
|
||||
[
|
||||
@@ -164,16 +227,18 @@ class TestSerenaAgent:
|
||||
)
|
||||
def test_find_symbol_name_path(
|
||||
self,
|
||||
serena_agent: SerenaAgent,
|
||||
serena_agent,
|
||||
name_path: str,
|
||||
substring_matching: bool,
|
||||
expected_symbol_name: str,
|
||||
expected_kind: str,
|
||||
expected_file: str,
|
||||
isolated_process: bool,
|
||||
):
|
||||
agent = serena_agent
|
||||
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
result = find_symbol_tool.apply(
|
||||
result = find_symbol_tool.apply_ex(
|
||||
name_path=name_path,
|
||||
depth=0,
|
||||
relative_path=None,
|
||||
@@ -182,6 +247,7 @@ class TestSerenaAgent:
|
||||
exclude_kinds=None,
|
||||
substring_matching=substring_matching,
|
||||
)
|
||||
|
||||
symbols = json.loads(result)
|
||||
assert any(
|
||||
expected_symbol_name == s["name_path"].split("/")[-1]
|
||||
@@ -190,6 +256,9 @@ class TestSerenaAgent:
|
||||
for s in symbols
|
||||
), f"Expected to find {name_path} ({expected_kind}) in {expected_file} for {agent._active_project.language.name}. Symbols: {symbols}"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"isolated_process", [pytest.param(False, id="direct"), pytest.param(True, id="isolated", marks=pytest.mark.isolated_process)]
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"serena_agent,name_path",
|
||||
[
|
||||
@@ -210,17 +279,18 @@ class TestSerenaAgent:
|
||||
)
|
||||
def test_find_symbol_name_path_no_match(
|
||||
self,
|
||||
serena_agent: SerenaAgent,
|
||||
serena_agent,
|
||||
name_path: str,
|
||||
isolated_process: bool,
|
||||
):
|
||||
agent = serena_agent
|
||||
|
||||
find_symbol_tool = agent.get_tool(FindSymbolTool)
|
||||
result = find_symbol_tool.apply(
|
||||
result = find_symbol_tool.apply_ex(
|
||||
name_path=name_path,
|
||||
depth=0,
|
||||
substring_matching=True,
|
||||
)
|
||||
|
||||
symbols = json.loads(result)
|
||||
assert (
|
||||
not symbols
|
||||
), f"Expected to find no symbols for {name_path} for {agent._active_project.language.name}. Symbols found: {symbols}"
|
||||
assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}"
|
||||
Reference in new issue
Block a user