Merge pull request #170 from oraios/fix_deadlocks

Fix deadlocks
This commit is contained in:
Michael Panchenko authored and GitHub committed 2025-06-16 02:52:20 +02:00
commit 68164848c8
15 files changed
+1707 -538

No files matched your search

+4 -2
View File
@@ -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",
]
+26 -20
View File
@@ -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
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+612
View File
@@ -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
View File
@@ -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}"
+2 -2
View File
@@ -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)
+90 -20
View File
@@ -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}"
Generated
+372 -388
View File
File diff suppressed because it is too large. Load diff