Merge branch 'main' into refactoring/simplify_ls_setup

This commit is contained in:
Michael Panchenko committed 2025-07-06 17:28:38 +02:00
commit 1af8748f6f
71 files changed
+5041 -3059

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ jobs:
codespell:
name: Check for spelling errors
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Checkout
+1 -2
View File
@@ -4,8 +4,6 @@ on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
@@ -14,6 +12,7 @@ env:
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: write
+1
View File
@@ -6,6 +6,7 @@ on:
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
id-token: write
pages: write
+5 -2
View File
@@ -1,8 +1,11 @@
name: Upload Python Package
on:
release:
types: [created]
workflow_dispatch: # Manual trigger only - workflow disabled from auto-running
#on:
# release:
# types: [created]
jobs:
deploy:
+41
View File
@@ -35,6 +35,13 @@ jobs:
- name: Install gopls
shell: bash
run: go install golang.org/x/tools/gopls@latest
- name: Set up Elixir
if: runner.os != 'Windows'
uses: erlef/setup-beam@v1
with:
elixir-version: '1.18.4'
otp-version: '26.1'
- name: Prepare java
uses: actions/setup-java@v3
with:
@@ -44,6 +51,40 @@ jobs:
uses: DeLaGuardo/setup-clojure@13.4
with:
cli: latest
- name: Install Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.5.0"
terraform_wrapper: false
- name: Install terraform-ls (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
Write-Host "Installing terraform-ls manually for Windows"
$ver = '0.32.7'
$zip = "terraform-ls_${ver}_windows_amd64.zip"
Invoke-WebRequest -Uri "https://releases.hashicorp.com/terraform-ls/$ver/$zip" -OutFile $zip
$dest = "$env:USERPROFILE\terraform-ls"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Expand-Archive $zip -DestinationPath $dest -Force
Write-Host "terraform-ls installed to: $dest"
echo "$dest" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install terraform-ls (Linux/macOS)
if: runner.os != 'Windows'
shell: bash
run: |
ver=0.32.7
os=$(uname | tr '[:upper:]' '[:lower:]')
echo "Installing terraform-ls ${ver} for ${os}"
curl -sSL -o tfls.zip \
"https://releases.hashicorp.com/terraform-ls/${ver}/terraform-ls_${ver}_${os}_amd64.zip"
mkdir -p "$HOME/bin"
unzip -q tfls.zip -d "$HOME/bin"
chmod +x "$HOME/bin/terraform-ls"
echo "$HOME/bin" >> "$GITHUB_PATH"
echo "terraform-ls installed to $HOME/bin"
- name: Install uv
shell: bash
run: curl -LsSf https://astral.sh/uv/install.sh | sh
+5
View File
@@ -222,3 +222,8 @@ tmp/
# Claude settings
.claude/settings.local.json
# Elixir
/test/resources/repos/elixir/test_repo/deps
# Exception: Don't ignore Elixir test repository lib directory (contains source code)
!/test/resources/repos/elixir/test_repo/lib
@@ -1,126 +0,0 @@
# Process Isolation: Current Status and Architecture
## Current Status: Disabled by Default
Process isolation is **disabled by default** in the current Serena implementation:
- **Configuration**: `USE_PROCESS_ISOLATION = False` in `src/serena/constants.py:22`
- **Reason**: No longer needed with solid-lsp architecture
- **Default Operation**: Single process mode with MCP server, agent, and language servers in same process
## Why Process Isolation Is No Longer Needed
### Historical Context
Process isolation was **mandatory** when using multilspy because:
1. **Asyncio Contamination**: Multilspy leaked coroutines into MCP server's event loop
2. **Event Loop Conflicts**: Multiple asyncio contexts caused deadlocks
3. **Only Solution**: Complete process separation was the only way to prevent issues
### Current Architecture Benefits
With solid-lsp, process isolation became **unnecessary** because:
1. **Clean Async Boundaries**: No coroutine leakage between MCP server and language server contexts
2. **Single Process Safety**: Solid-lsp designed to work safely within MCP server process
3. **Performance Gains**: Direct method calls instead of expensive IPC
## Architecture Components (Still Present)
The process isolation infrastructure remains **available but unused** by default:
### Core Components (`src/serena/process_isolated_agent.py`)
#### 1. **ProcessIsolatedSerenaAgent** (lines 392-550)
- **Purpose**: Wrapper that manages isolated agent process
- **Communication**: Uses `multiprocessing.Pipe()` for bidirectional communication
- **Process Management**: Creates and manages `SerenaAgentWorker` subprocess
- **Status**: **Available but not used by default**
#### 2. **SerenaAgentWorker** (lines 173-389)
- **Purpose**: Worker process hosting actual SerenaAgent
- **Event Loop**: Polling loop checking for requests every 500ms
- **Request Handling**: INITIALIZE, TOOL_CALL, SHUTDOWN, etc.
- **Status**: **Available but not used by default**
#### 3. **ProcessIsolatedDashboard** (lines 133-170)
- **Purpose**: Runs web dashboard in separate process
- **Integration**: Async web server with port management
- **Status**: **Available but not used by default**
### Global Synchronization (lines 25-28)
```python
_global_log_queue: multiprocessing.Queue = multiprocessing.Queue()
_dashboard_ready_event = multiprocessing.Event()
_dashboard_port_value = multiprocessing.Value("i", 0)
global_shutdown_event = multiprocessing.Event()
```
**Status**: **Available but not actively used**
## Current MCP Factory Selection
In `src/serena/mcp.py:590`:
```python
if not USE_PROCESS_ISOLATION:
mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file)
else:
mcp_factory = SerenaMCPFactoryWithProcessIsolation(context=context, project=project_file)
```
### Default Flow
1. **`USE_PROCESS_ISOLATION = False`** → **`SerenaMCPFactorySingleProcess`**
2. **Single Process**: MCP server, SerenaAgent, and solid-lsp in same process
3. **Direct Communication**: No IPC overhead, direct method calls
### Fallback Option
If `USE_PROCESS_ISOLATION = True`:
1. **`SerenaMCPFactoryWithProcessIsolation`** would be used
2. **Separate Processes**: MCP server and SerenaAgent in different processes
3. **IPC Communication**: Higher latency but complete isolation
## Benefits of Current Single Process Architecture
### 1. **Performance Improvements**
- **Lower Latency**: Direct method calls vs IPC communication
- **Reduced Memory**: No duplicate process memory footprints
- **Faster Startup**: No process creation and initialization overhead
### 2. **Operational Simplicity**
- **Unified Logging**: All components log to same destination
- **Simpler Debugging**: Single process, unified stack traces
- **Resource Management**: Simpler cleanup and shutdown procedures
### 3. **Stability Benefits**
- **No IPC Failures**: Eliminated inter-process communication failure modes
- **Consistent State**: No synchronization issues between processes
- **Reliable Shutdown**: No orphaned processes or cleanup complexity
## When Process Isolation Might Still Be Useful
While not needed by default, process isolation could still be beneficial for:
### 1. **Fault Isolation**
- **Agent Crashes**: Prevent agent failures from affecting MCP server
- **Memory Protection**: Isolate memory leaks to specific processes
- **Recovery**: Restart failed components without full system restart
### 2. **Resource Management**
- **Memory Limits**: Constrain memory usage of specific components
- **CPU Isolation**: Prevent CPU-intensive operations from blocking MCP server
- **Security**: Additional process boundaries for security-sensitive environments
### 3. **Debugging and Development**
- **Component Isolation**: Debug specific components in isolation
- **Performance Analysis**: Measure resource usage per component
- **Development Safety**: Prevent development errors from affecting stable components
## Configuration Management
### Enabling Process Isolation
To re-enable process isolation:
1. **Set**: `USE_PROCESS_ISOLATION = True` in `src/serena/constants.py`
2. **Result**: Automatic fallback to `SerenaMCPFactoryWithProcessIsolation`
3. **Trade-off**: Higher resource usage but complete component isolation
### Current Recommendation
- **Default**: Keep `USE_PROCESS_ISOLATION = False` for optimal performance
- **Special Cases**: Enable only when specific isolation requirements exist
- **Testing**: Both modes should be tested to ensure compatibility
The current architecture successfully eliminated the need for process isolation while maintaining the capability as a fallback option for specialized use cases.
-1
View File
@@ -17,7 +17,6 @@ The following tasks should generally be executed using `uv run poe <task_name>`.
"typescript: language server running for TypeScript",
"php: language server running for PHP",
"snapshot: snapshot tests for symbolic editing operations",
"isolated_process: test runs with process isolated agent",
]
```
By default, `uv run poe test` uses the markers set in the env var `PYTEST_MARKERS`, or, if it unset, uses `-m "not java and not rust and not isolated process"`.
+2 -1
View File
@@ -11,4 +11,5 @@
"sensai",
"vibing"
],
}
}
+6 -3
View File
@@ -9,8 +9,6 @@ Status of the `main` branch. Changes prior to the next official version change w
* The MCP server is the only asyncio-based component now, which resolves cross-component loop contamination,
such that process isolation is no longer required.
Neither are non-graceful shutdowns on Windows.
* **Support for Clojure**
* Better default, extended parameters and description for restricting the search in `search_for_pattern`
* **Improved editing tools**: The editing logic was simplified and improved, making it more robust.
* The "minimal indentation" logic was removed, because LLMs did not understand it.
* The logic for the insertion of empty lines was improved (mostly controlled by the LLM now)
@@ -18,7 +16,12 @@ Status of the `main` branch. Changes prior to the next official version change w
* allows the language server to be initialized in the background, making the MCP server respond to requests
immediately upon startup,
* ensures that all tool executions are fully synchronized (executed linearly).
* Better support for C# by switching from `omnisharp` to Microsoft's official C# language server.
* `SearchForPatternTool`: Better default, extended parameters and description for restricting the search
* Language support:
* Better support for C# by switching from `omnisharp` to Microsoft's official C# language server.
* **Add support for Clojure**
* Configuration:
* Add option `web_dashboard_open_on_launch` (allowing the dashboard to be enabled without opening a browser window)
Fixes:
* Fix `ExecuteShellCommandTool` and `GetCurrentConfigTool` hanging on Windows
+14 -4
View File
@@ -73,6 +73,7 @@ With Serena, we provide
* Rust
* C# (requires dotnet to be installed. We switched the underlying language server recently, please report any issues you encounter)
* Java (_Note_: startup is slow, initial startup especially so. There may be issues with java on macos and linux, we are working on it.)
* Elixir (Requires NextLS and Elixir install; **Windows not supported** - Next LS does not provide Windows binaries)
* Clojure
* C/C++ (You may experience issues with finding references, we are working on it)
* indirect support (may require some code changes/manual installation) for:
@@ -201,9 +202,15 @@ This and other settings can be adjusted in the [configuration](#configuration) a
git clone https://github.com/oraios/serena
cd serena
```
2. Optionally create a config file from the template and adjust it according to your preferences.
2. Optionally create the configuration file in your home directory, i.e.
* `~/.serena/serena_config.yml` on Linux and macOS, or
* `%USERPROFILE%\.serena\serena_config.yml` on Windows.
by copying the template and then adjusting it according to your needs:
```shell
cp src/serena/resources/serena_config.template.yml serena_config.yml
mkdir ~/.serena
cp src/serena/resources/serena_config.template.yml ~/.serena/serena_config.yml
```
If you just want the default config, you can skip this part, and a config file will be created when you first run Serena.
3. Run the server with `uv`:
@@ -279,7 +286,9 @@ Run with parameter `--help` to get a list of available options.
Serena's behavior (active tools and prompts as well as logging configuration, etc.) is configured in four places:
1. The `serena_config.yml` for general settings that apply to all clients and projects
1. The `serena_config.yml` for general settings that apply to all clients and projects.
It is located in your user directory under `.serena/serena_config.yml`.
If you do not explicitly create the file, it will be auto-generated when you first run Serena.
2. In the arguments passed to the `serena-mcp-server` in your client's config (see below),
which will apply to all sessions started by the respective client. In particular, the [context](#contexts) parameter
should be set appropriately for Serena to be best adjusted to existing tools and capabilities of your client.
@@ -346,7 +355,8 @@ claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena-
```
ℹ️ Once in Claude Code, you should ask Claude to "Read the initial instructions" as your first prompt, such that it will receive information
on how to use Serena's tools.
on how to use Serena's tools. Provide this prompt whenever you start a new conversation and perhaps even after a compacting operation,
such that Claude is correctly primed to use Serena's tools at all times.
### Claude Desktop
+4 -2
View File
@@ -104,7 +104,7 @@ PYDEVD_DISABLE_FILE_VALIDATION = "1"
# Uses PYTEST_MARKERS env var for default markers
# For custom markers, one can either adjust the env var or just use -m option in the command line,
# as the second -m option will override the first one.
test = "pytest test -vv -m \"${PYTEST_MARKERS:-not java and not rust and not isolated_process}\""
test = "pytest test -vv -m \"${PYTEST_MARKERS:-not java and not rust}\""
_black_check = "black --check src scripts test"
_ruff_check = "ruff check src scripts test"
_black_format = "black src scripts test"
@@ -224,6 +224,7 @@ ignore = [
"TID252", # forbids relative imports
"B904", # forces use of raise from other_exception
"RUF012", # forbids mutable attributes as ClassVar
"SIM117", # forbids nested with statements
]
unfixable = ["F841", "F601", "F602", "B018"]
extend-fixable = ["F401", "B905", "W291"]
@@ -246,8 +247,9 @@ markers = [
"typescript: language server running for TypeScript",
"php: language server running for PHP",
"csharp: language server running for C#",
"elixir: language server running for Elixir",
"terraform: language server running for Terraform",
"snapshot: snapshot tests for symbolic editing operations",
"isolated_process: test runs with process isolated agent",
]
[tool.codespell]
+1 -12
View File
@@ -8,19 +8,8 @@ from pprint import pprint
from serena.agent import *
from serena.constants import REPO_ROOT
@dataclass
class InMemorySerenaConfig(SerenaConfigBase):
"""
In-memory implementation of Serena configuration with the GUI disabled.
"""
gui_log_window_enabled: bool = False
web_dashboard: bool = False
if __name__ == "__main__":
agent = SerenaAgent(project=REPO_ROOT, serena_config=InMemorySerenaConfig())
agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=False))
# apply a tool
find_refs_tool = agent.get_tool(FindReferencingSymbolsTool)
+1 -1
View File
@@ -1,4 +1,4 @@
from serena.config import SerenaAgentContext, SerenaAgentMode
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
if __name__ == "__main__":
print("---------- Available modes: ----------")
+32 -1839
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -14,9 +14,10 @@ from agno.tools.toolkit import Toolkit
from dotenv import load_dotenv
from sensai.util.logging import LogTime
from serena.agent import SerenaAgent, Tool, show_fatal_exception_safe
from serena.config import SerenaAgentContext
from serena.agent import SerenaAgent, Tool
from serena.config.context_mode import SerenaAgentContext
from serena.constants import REPO_ROOT
from serena.util.exception import show_fatal_exception_safe
log = logging.getLogger(__name__)
View File
Whitespace-only changes.
File renamed without changes.
+407
View File
@@ -0,0 +1,407 @@
"""
The Serena Model Context Protocol (MCP) Server
"""
import os
import shutil
from copy import deepcopy
from dataclasses import dataclass, field
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any, Self, TypeVar
import yaml
from ruamel.yaml.comments import CommentedMap
from sensai.util import logging
from sensai.util.string import ToStringMixin
from serena.constants import (
DEFAULT_ENCODING,
PROJECT_TEMPLATE_FILE,
REPO_ROOT,
SELENA_CONFIG_TEMPLATE_FILE,
SERENA_MANAGED_DIR_NAME,
)
from serena.tools import Tool, ToolRegistry
from serena.util.general import load_yaml, save_yaml
from serena.util.inspection import determine_programming_language_composition
from solidlsp.ls_config import Language
if TYPE_CHECKING:
pass
log = logging.getLogger(__name__)
T = TypeVar("T")
DEFAULT_TOOL_TIMEOUT: float = 240
class SerenaConfigError(Exception):
pass
def get_serena_managed_dir(project_root: str | Path) -> str:
return os.path.join(project_root, SERENA_MANAGED_DIR_NAME)
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"):
return True
# Check cgroup for docker references
try:
with open("/proc/self/cgroup") as f:
return "docker" in f.read()
except FileNotFoundError:
return False
@dataclass
class ProjectConfig(ToStringMixin):
project_name: str
language: Language
ignored_paths: list[str] = field(default_factory=list)
excluded_tools: set[str] = field(default_factory=set)
read_only: bool = False
ignore_all_files_in_gitignore: bool = True
initial_prompt: str = ""
encoding: str = DEFAULT_ENCODING
SERENA_DEFAULT_PROJECT_FILE = "project.yml"
@classmethod
def autogenerate(cls, project_root: str | Path, project_name: str | None = None, save_to_disk: bool = True) -> Self:
"""
Autogenerate a project configuration for a given project root.
:param project_root: the path to the project root
:param project_name: the name of the project; if None, the name of the project will be the name of the directory
containing the project
:param save_to_disk: whether to save the project configuration to disk
:return: the project configuration
"""
project_root = Path(project_root).resolve()
if not project_root.exists():
raise FileNotFoundError(f"Project root not found: {project_root}")
project_name = project_name or project_root.name
language_composition = determine_programming_language_composition(str(project_root))
if len(language_composition) == 0:
raise ValueError(
f"Failed to autogenerate project.yaml: no programming language detected in project {project_root}. "
f"You can either add some files that correspond to one of the supported programming languages, "
f"or create the file {os.path.join(project_root, cls.rel_path_to_project_yml())} manually and specify the language there."
)
# find the language with the highest percentage
dominant_language = max(language_composition.keys(), key=lambda lang: language_composition[lang])
config_with_comments = load_yaml(PROJECT_TEMPLATE_FILE, preserve_comments=True)
config_with_comments["project_name"] = project_name
config_with_comments["language"] = dominant_language
if save_to_disk:
save_yaml(str(project_root / cls.rel_path_to_project_yml()), config_with_comments, preserve_comments=True)
return cls._from_dict(config_with_comments)
@classmethod
def rel_path_to_project_yml(cls) -> str:
return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE)
@classmethod
def _from_dict(cls, data: dict[str, Any]) -> Self:
"""
Create a ProjectConfig instance from a configuration dictionary
"""
language_str = data["language"].lower()
project_name = data["project_name"]
# backwards compatibility
if language_str == "javascript":
log.warning(f"Found deprecated project language `javascript` in project {project_name}, please change to `typescript`")
language_str = "typescript"
try:
language = Language(language_str)
except ValueError as e:
raise ValueError(f"Invalid language: {data['language']}.\nValid languages are: {[l.value for l in Language]}") from e
return cls(
project_name=project_name,
language=language,
ignored_paths=data.get("ignored_paths", []),
excluded_tools=set(data.get("excluded_tools", [])),
read_only=data.get("read_only", False),
ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True),
initial_prompt=data.get("initial_prompt", ""),
encoding=data.get("encoding", DEFAULT_ENCODING),
)
@classmethod
def load(cls, project_root: Path | str, autogenerate: bool = True) -> Self:
"""
Load a ProjectConfig instance from the path to the project root.
"""
project_root = Path(project_root)
yaml_path = project_root / cls.rel_path_to_project_yml()
if not yaml_path.exists():
if autogenerate:
return cls.autogenerate(project_root)
else:
raise FileNotFoundError(f"Project configuration file not found: {yaml_path}")
with open(yaml_path, encoding="utf-8") as f:
yaml_data = yaml.safe_load(f)
if "project_name" not in yaml_data:
yaml_data["project_name"] = project_root.name
return cls._from_dict(yaml_data)
def get_excluded_tool_classes(self) -> set[type["Tool"]]:
return set(ToolRegistry.get_tool_class_by_name(tool_name) for tool_name in self.excluded_tools)
@dataclass
class Project:
project_root: str
project_config: ProjectConfig
@property
def project_name(self) -> str:
return self.project_config.project_name
@property
def language(self) -> Language:
return self.project_config.language
@classmethod
def load(cls, project_root: str | Path, autogenerate: bool = True) -> Self:
project_root = Path(project_root).resolve()
if not project_root.exists():
raise FileNotFoundError(f"Project root not found: {project_root}")
project_config = ProjectConfig.load(project_root, autogenerate=autogenerate)
return cls(project_root=str(project_root), project_config=project_config)
def path_to_project_yml(self) -> str:
return os.path.join(self.project_root, self.project_config.rel_path_to_project_yml())
@dataclass(kw_only=True)
class SerenaConfig:
"""
Holds the Serena agent configuration, which is typically loaded from a YAML configuration file
(when instantiated via :method:`from_config_file`), which is updated when projects are added or removed.
For testing purposes, it can also be instantiated directly with the desired parameters.
"""
projects: list[Project] = field(default_factory=list)
gui_log_window_enabled: bool = False
log_level: int = logging.INFO
trace_lsp_communication: bool = False
web_dashboard: bool = True
web_dashboard_open_on_launch: bool = True
tool_timeout: float = DEFAULT_TOOL_TIMEOUT
loaded_commented_yaml: CommentedMap | None = None
config_file_path: str | None = None
"""
the path to the configuration file to which updates of the configuration shall be saved;
if None, the configuration is not saved to disk
"""
CONFIG_FILE = "serena_config.yml"
CONFIG_FILE_DOCKER = "serena_config.docker.yml" # Docker-specific config file; auto-generated if missing, mounted via docker-compose for user customization
@classmethod
def _generate_config_file(cls, config_file_path: str) -> None:
"""
Generates a Serena configuration file at the specified path from the template file.
:param config_file_path: the path where the configuration file should be generated
"""
log.info(f"Auto-generating Serena configuration file in {config_file_path}")
loaded_commented_yaml = load_yaml(SELENA_CONFIG_TEMPLATE_FILE, preserve_comments=True)
save_yaml(config_file_path, loaded_commented_yaml, preserve_comments=True)
@classmethod
def _determine_config_file_path(cls) -> str:
"""
:return: the location where the Serena configuration file is stored/should be stored
"""
if is_running_in_docker():
return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER)
else:
config_path = str(Path.home() / SERENA_MANAGED_DIR_NAME / cls.CONFIG_FILE)
# if the config file does not exist, check if we can migrate it from the old location
if not os.path.exists(config_path):
old_config_path = os.path.join(REPO_ROOT, cls.CONFIG_FILE)
if os.path.exists(old_config_path):
log.info(f"Moving Serena configuration file from {old_config_path} to {config_path}")
os.makedirs(os.path.dirname(config_path), exist_ok=True)
shutil.move(old_config_path, config_path)
return config_path
@classmethod
def from_config_file(cls, generate_if_missing: bool = True) -> "SerenaConfig":
"""
Static constructor to create SerenaConfig from the configuration file
"""
config_file_path = cls._determine_config_file_path()
# create the configuration file from the template if necessary
if not os.path.exists(config_file_path):
if not generate_if_missing:
raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}")
log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...")
cls._generate_config_file(config_file_path)
# load the configuration
log.info(f"Loading Serena configuration from {config_file_path}")
try:
loaded_commented_yaml = load_yaml(config_file_path, preserve_comments=True)
except Exception as e:
raise ValueError(f"Error loading Serena configuration from {config_file_path}: {e}") from e
# create the configuration instance
instance = cls(loaded_commented_yaml=loaded_commented_yaml, config_file_path=config_file_path)
# read projects
if "projects" not in loaded_commented_yaml:
raise SerenaConfigError("`projects` key not found in Serena configuration. Please update your `serena_config.yml` file.")
# load list of known projects
instance.projects = []
num_project_migrations = 0
for path in loaded_commented_yaml["projects"]:
path = Path(path).resolve()
if not path.exists() or (path.is_dir() and not (path / ProjectConfig.rel_path_to_project_yml()).exists()):
log.warning(f"Project path {path} does not exist or does not contain a project configuration file, skipping.")
continue
if path.is_file():
path = cls._migrate_out_of_project_config_file(path)
if path is None:
continue
num_project_migrations += 1
project = Project.load(path)
instance.projects.append(project)
# set other configuration parameters
if is_running_in_docker():
instance.gui_log_window_enabled = False # not supported in Docker
else:
instance.gui_log_window_enabled = loaded_commented_yaml.get("gui_log_window", False)
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.web_dashboard_open_on_launch = loaded_commented_yaml.get("web_dashboard_open_on_launch", 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:
log.info(
f"Migrated {num_project_migrations} project configurations from legacy format to in-project configuration; re-saving configuration"
)
instance.save()
return instance
@classmethod
def _migrate_out_of_project_config_file(cls, path: Path) -> Path | None:
"""
Migrates a legacy project configuration file (which is a YAML file containing the project root) to the
in-project configuration file (project.yml) inside the project root directory.
:param path: the path to the legacy project configuration file
:return: the project root path if the migration was successful, None otherwise.
"""
log.info(f"Found legacy project configuration file {path}, migrating to in-project configuration.")
try:
with open(path, encoding="utf-8") as f:
project_config_data = yaml.safe_load(f)
if "project_name" not in project_config_data:
project_name = path.stem
with open(path, "a", encoding="utf-8") as f:
f.write(f"\nproject_name: {project_name}")
project_root = project_config_data["project_root"]
shutil.move(str(path), str(Path(project_root) / ProjectConfig.rel_path_to_project_yml()))
return Path(project_root).resolve()
except Exception as e:
log.error(f"Error migrating configuration file: {e}")
return None
@cached_property
def project_paths(self) -> list[str]:
return sorted(project.project_root for project in self.projects)
@cached_property
def project_names(self) -> list[str]:
return sorted(project.project_config.project_name for project in self.projects)
def get_project(self, project_root_or_name: str) -> Project | None:
for project in self.projects:
if project.project_config.project_name == project_root_or_name:
return project
if os.path.isdir(project_root_or_name):
project_root = Path(project_root_or_name).resolve()
for project in self.projects:
if Path(project.project_root).resolve() == project_root:
return project
return None
def add_project_from_path(self, project_root: Path | str, project_name: str | None = None) -> tuple[Project, bool]:
"""
Add a project to the Serena configuration from a given path. Will raise a FileExistsError if the
name or path is already registered.
:param project_root: the path to the project to add
:param project_name: the name of the project to add; if None, the name of the project will be the name of the directory
containing the project
:return: the project that was added and a boolean indicating whether a new project configuration was generated and
saved to disk. It may be that no new project configuration was generated if the project configuration already
exists on disk but the project itself was not added yet to the Serena configuration.
"""
project_root = Path(project_root).resolve()
if not project_root.exists():
raise FileNotFoundError(f"Error: Path does not exist: {project_root}")
if not project_root.is_dir():
raise FileNotFoundError(f"Error: Path is not a directory: {project_root}")
if project_name is None:
project_name = project_root.name
for already_registered_project in self.projects:
if already_registered_project.project_name == project_name:
raise FileExistsError(
f"Project name '{project_name}' already exists and points to {already_registered_project.project_root}."
)
if str(already_registered_project.project_root) == str(project_root):
raise FileExistsError(
f"Project with path {project_root} was already added with name '{already_registered_project.project_name}'."
)
try:
project_config = ProjectConfig.load(project_root)
new_project_config_generated = False
except FileNotFoundError:
project_config = ProjectConfig.autogenerate(project_root, save_to_disk=True)
new_project_config_generated = True
new_project = Project(project_root=str(project_root), project_config=project_config)
self.projects.append(new_project)
self.save()
return new_project, new_project_config_generated
def remove_project(self, project_name: str) -> None:
# find the index of the project with the desired name and remove it
for i, project in enumerate(self.projects):
if project.project_name == project_name:
del self.projects[i]
break
else:
raise ValueError(f"Project '{project_name}' not found in Serena configuration; valid project names: {self.project_names}")
self.save()
def save(self) -> None:
"""
Saves the configuration to the file from which it was loaded (if any)
"""
if self.config_file_path is None:
return
assert self.loaded_commented_yaml is not None, "Cannot save configuration without loaded YAML"
loaded_original_yaml = deepcopy(self.loaded_commented_yaml)
# projects are unique absolute paths
# we also canonicalize them before saving
loaded_original_yaml["projects"] = sorted({str(Path(project.project_root).resolve()) for project in self.projects})
save_yaml(self.config_file_path, loaded_original_yaml, preserve_comments=True)
-2
View File
@@ -19,6 +19,4 @@ DEFAULT_MODES = ("interactive", "editing")
PROJECT_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "project.template.yml")
SELENA_CONFIG_TEMPLATE_FILE = str(_serena_pkg_path / "resources" / "serena_config.template.yml")
USE_PROCESS_ISOLATION = False
SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(funcName)s:%(lineno)d - %(message)s"
+1 -4
View File
@@ -128,11 +128,8 @@ class SerenaDashboardAPI:
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
# noinspection PyUnresolvedReferences
os._exit(0)
@staticmethod
+22 -266
View File
@@ -2,20 +2,14 @@
The Serena Model Context Protocol (MCP) Server
"""
import asyncio
import contextlib
import os
import signal
import sys
import threading
import time
from abc import abstractmethod
from collections.abc import AsyncIterator, Iterator, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from logging import Formatter, Logger, StreamHandler
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal, cast
import click
import docstring_parser
@@ -26,24 +20,13 @@ from pydantic_settings import SettingsConfigDict
from sensai.util import logging
from serena.agent import (
ActivateProjectTool,
Project,
SerenaAgent,
SerenaConfig,
ToolInterface,
ToolRegistry,
create_serena_config,
show_fatal_exception_safe,
)
from serena.config import RegisteredContext, SerenaAgentContext, SerenaAgentMode
from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES, USE_PROCESS_ISOLATION
from serena.process_isolated_agent import (
ProcessIsolatedDashboard,
ProcessIsolatedSerenaAgent,
ProcessIsolatedTool,
global_shutdown_event,
request_global_shutdown,
)
from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode
from serena.constants import DEFAULT_CONTEXT, DEFAULT_MODES
from serena.tools import ToolInterface
from serena.util.exception import show_fatal_exception_safe
log = logging.getLogger(__name__)
LOG_FORMAT = "%(levelname)-5s %(asctime)-15s %(name)s:%(funcName)s:%(lineno)d - %(message)s"
@@ -165,15 +148,23 @@ class SerenaMCPFactory:
:param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration.
"""
try:
serena_config = create_serena_config(
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,
)
config = SerenaConfig.from_config_file()
# update configuration with the provided parameters
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())
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
modes_instances = [SerenaAgentMode.load(mode) for mode in modes]
self._instantiate_agent(serena_config, modes_instances)
self._instantiate_agent(config, modes_instances)
except Exception as e:
show_fatal_exception_safe(e)
@@ -224,235 +215,6 @@ class SerenaMCPFactorySingleProcess(SerenaMCPFactory):
yield
class SerenaMCPFactoryWithProcessIsolation(SerenaMCPFactory):
"""
MCP server factory with process isolation for the SerenaAgent and its language server; they run in a separate process
from the MCP server.
"""
def __init__(self, context: str = DEFAULT_CONTEXT, project: str | None = None):
"""
:param context: The context name or path to context file
: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
afterward.
"""
super().__init__(context=context, project=project)
self.active_tool_names: set[str] | None = None
self.serena_agent_process: ProcessIsolatedSerenaAgent | None = None
self.serena_dashboard_process: ProcessIsolatedDashboard | None = None
@staticmethod
def _determine_active_tool_names(context: SerenaAgentContext, project: Project | None) -> set[str]:
"""
Determine the names of tools that should be included in this session based on the context.
"""
tools_excluded_in_this_session = context.get_excluded_tool_classes()
# if a project has been loaded, it will be activated at startup and in ide-assistant context,
# we assume that no other project will be activated in this session.
# Therefore, we exclude the activate project tool
is_ide_assistant = context.name == RegisteredContext.IDE_ASSISTANT.value
if is_ide_assistant and project is not None:
tools_excluded_in_this_session.extend(project.project_config.get_excluded_tool_classes())
tools_excluded_in_this_session.append(ActivateProjectTool)
tool_names_excluded_in_this_session = {tool.get_name_from_cls() for tool in tools_excluded_in_this_session}
all_tool_names = set(ToolRegistry.get_tool_names())
tool_names_included_in_this_session = all_tool_names - tool_names_excluded_in_this_session
return tool_names_included_in_this_session
@staticmethod
def make_mcp_tool(tool: ToolInterface) -> MCPTool:
func_name = tool.get_name()
func_doc = tool.get_apply_docstring() or ""
func_arg_metadata = tool.get_apply_fn_metadata()
is_async = False
parameters = func_arg_metadata.arg_model.model_json_schema()
docstring = docstring_parser.parse(func_doc)
# Mount the tool description as a combination of the docstring description and
# the return value description, if it exists.
if docstring.description:
func_doc = f"{docstring.description.strip().strip('.')}."
else:
func_doc = ""
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_descr.strip().strip('.')}."
# Parse the parameter descriptions from the docstring and add pass its description
# to the parameter 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:
param_desc = f"{param_doc.description.strip().strip('.') + '.'}"
properties["description"] = param_desc[0].upper() + param_desc[1:]
def execute_fn(**kwargs) -> str: # type: ignore
return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs)
return MCPTool(
fn=execute_fn,
name=func_name,
description=func_doc,
parameters=parameters,
fn_metadata=func_arg_metadata,
is_async=is_async,
context_kwarg=None,
annotations=None,
)
def _iter_tools(self) -> Iterator[ToolInterface]:
assert self.active_tool_names is not None
assert self.serena_agent_process is not None
for tool_name in self.active_tool_names:
yield ProcessIsolatedTool(process_agent=self.serena_agent_process, tool_name=tool_name)
# noinspection PyProtectedMember
def _set_mcp_tools(self, mcp: FastMCP) -> None:
"""Update the tools in the MCP server"""
if mcp is not None:
mcp._tool_manager._tools = {}
for tool in self._iter_tools():
mcp_tool = self.make_mcp_tool(tool)
mcp._tool_manager._tools[tool.get_name()] = mcp_tool
def _instantiate_agent(self, serena_config: SerenaConfig, modes: list[SerenaAgentMode]) -> None:
if self.project is not None:
self.project_instance = serena_config.get_project(self.project)
self.serena_agent_process = ProcessIsolatedSerenaAgent(
project=self.project, serena_config=serena_config, modes=modes, context=self.context
)
self.active_tool_names = self._determine_active_tool_names(self.context, self.project_instance)
if serena_config.web_dashboard:
assert self.active_tool_names is not None
self.serena_dashboard_process = ProcessIsolatedDashboard(tool_names=sorted(self.active_tool_names))
def create_mcp_server(
self,
host: str = "0.0.0.0",
port: int = 8000,
modes: Sequence[str] = DEFAULT_MODES,
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,
) -> FastMCP:
"""
Create an MCP server with process-isolated SerenaAgent to prevent asyncio contamination.
:param host: The host to bind to
:param port: The port to bind to
:param modes: List of mode names or paths to mode files
: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 log_level: Log level. If not specified, will take the value from the serena configuration.
:param trace_lsp_communication: Whether to trace the communication between Serena and the language servers.
This is useful for debugging language server issues.
:param tool_timeout: Timeout in seconds for tool execution. If not specified, will take the value from the serena configuration.
"""
try:
serena_config = create_serena_config(
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,
)
modes_instances = [SerenaAgentMode.load(mode) for mode in modes]
self._instantiate_agent(serena_config, modes_instances)
except Exception as e:
show_fatal_exception_safe(e)
raise
# Override model_config to disable the use of `.env` files for reading settings, because user projects are likely to contain
# `.env` files (e.g. containing LOG_LEVEL) that are not supposed to override the MCP settings;
# retain only FASTMCP_ prefix for already set environment variables.
Settings.model_config = SettingsConfigDict(env_prefix="FASTMCP_")
mcp_settings = Settings(lifespan=self.server_lifespan, host=host, port=port)
mcp = FastMCP(**mcp_settings.model_dump())
return mcp
@asynccontextmanager
async def server_lifespan(self, mcp_server: FastMCP) -> AsyncIterator[None]:
"""Manage server startup and shutdown lifecycle."""
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")
# noinspection PyProtectedMember
# noinspection PyUnresolvedReferences
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 self.serena_dashboard_process is not None:
log.info("Starting dashboard process")
assert self.serena_dashboard_process is not None
self.serena_dashboard_process.start()
log.info("Starting serena agent process")
assert self.serena_agent_process is not None
self.serena_agent_process.start()
self._set_mcp_tools(mcp_server)
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())
log.info("MCP server lifetime setup complete")
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
self.serena_agent_process.stop()
if self.serena_dashboard_process is not None:
self.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)
class ProjectType(click.ParamType):
name = "[PROJECT_NAME|PROJECT_PATH]"
@@ -588,13 +350,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
mcp_factory: SerenaMCPFactory
if not USE_PROCESS_ISOLATION:
mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file)
else:
mcp_factory = SerenaMCPFactoryWithProcessIsolation(context=context, project=project_file)
# Use process isolation by default to prevent asyncio event loop contamination
mcp_factory = SerenaMCPFactorySingleProcess(context=context, project=project_file)
mcp_server = mcp_factory.create_mcp_server(
host=host,
port=port,
-571
View File
@@ -1,571 +0,0 @@
import asyncio
import contextlib
import logging
import multiprocessing
import os
import threading
import traceback
import webbrowser
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
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 Flask server in a thread
def run_flask_server() -> None:
api._app.run(host="0.0.0.0", port=port, debug=False, use_reloader=False, threaded=True)
server_thread = threading.Thread(target=run_flask_server, daemon=True)
server_thread.start()
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(
[shutdown_task, logging_loop_task],
return_when=asyncio.FIRST_COMPLETED,
)
# 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."""
if self.agent is not None:
return {"result": "SerenaAgent already initialized"}
try:
# Extract all possible initialization parameters
context_param = params.get("context")
project = params.get("project")
serena_config = SerenaConfig.from_json_dict(params["serena_config"])
context = SerenaAgentContext.from_json_dict(context_param) if context_param is not None else None
modes = [SerenaAgentMode.from_json_dict(m) for m in params["modes"]]
log_level = params.get("log_level")
trace_lsp_communication = params.get("trace_lsp_communication")
tool_timeout = params.get("tool_timeout")
self.agent = SerenaAgent(
project=project,
serena_config=serena_config,
context=context,
modes=modes,
enable_web_dashboard=False,
enable_gui_log_window=False,
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,
serena_config: SerenaConfigBase | None = None,
context: SerenaAgentContext | None = None,
modes: list[SerenaAgentMode] | None = None,
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None,
trace_lsp_communication: bool | None = None,
tool_timeout: float | None = None,
):
self.project = project
self.serena_config = serena_config or SerenaConfig.from_config_file()
self.context = context
self.modes = modes or []
self.log_level = log_level
self.trace_lsp_communication = trace_lsp_communication
self.tool_timeout = tool_timeout
self.process: multiprocessing.Process | None = None
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 # type: ignore
# Create and start worker process, passing along the dashboard's queue if available
worker = SerenaAgentWorker(child_conn) # type: ignore
self.process = multiprocessing.Process(target=worker.run, args=[_global_log_queue])
self.process.start()
# Prepare initialization parameters, converting complex objects to dict if present
init_params = {
"project": self.project,
"serena_config": self.serena_config.to_json_dict(),
"context": self.context.to_json_dict() if self.context is not None else None,
"modes": [m.to_json_dict() for m in self.modes],
"log_level": self.log_level,
"trace_lsp_communication": self.trace_lsp_communication,
"tool_timeout": self.tool_timeout,
}
# Initialize the agent in the worker process
try:
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
@@ -20,6 +20,12 @@ web_dashboard: True
# shows Serena's current session logs - as an alternative to the GUI log window which
# is supported on all platforms.
web_dashboard_open_on_launch: True
# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard
# is enabled). If set to False, you can still open the dashboard manually by navigating to
# http://localhost:24282/dashboard/ in your web browser.
# If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc.
log_level: 20
# the minimum log level for the GUI log window and the dashboard (10 = debug, 20 = info, 30 = warning, 40 = error)
+17 -4
View File
@@ -189,8 +189,11 @@ class SymbolLocation:
if self.relative_path is not None:
self.relative_path = self.relative_path.replace("/", os.path.sep)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def to_dict(self, include_relative_path: bool = True) -> dict[str, Any]:
result = asdict(self)
if not include_relative_path:
result.pop("relative_path", None)
return result
def has_position_in_file(self) -> bool:
return self.relative_path is not None and self.line is not None and self.column is not None
@@ -426,7 +429,13 @@ class Symbol(ToStringMixin):
return result
def to_dict(
self, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, include_children_body: bool = False
self,
kind: bool = False,
location: bool = False,
depth: int = 0,
include_body: bool = False,
include_children_body: bool = False,
include_relative_path=True,
) -> dict[str, Any]:
"""
Converts the symbol to a dictionary.
@@ -439,6 +448,8 @@ class Symbol(ToStringMixin):
Note that the body of the children is part of the body of the parent symbol,
so there is usually no need to set this to True unless you want process the output
and pass the children without passing the parent body to the LM.
:param include_relative_path: whether to include the relative path of the symbol in the location
entry. Relative paths of the symbol's children are always excluded.
:return: a dictionary representation of the symbol
"""
result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()}
@@ -447,7 +458,7 @@ class Symbol(ToStringMixin):
result["kind"] = self.kind
if location:
result["location"] = self.location.to_dict()
result["location"] = self.location.to_dict(include_relative_path=include_relative_path)
body_start_line, body_end_line = self.get_body_line_numbers()
result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line}
@@ -466,6 +477,8 @@ class Symbol(ToStringMixin):
depth=depth - 1,
include_body=include_children_body,
include_children_body=include_children_body,
# all children have the same relative path as the parent
include_relative_path=False,
)
)
return children
+8
View File
@@ -0,0 +1,8 @@
# ruff: noqa
from .tools_base import *
from .file_tools import *
from .ls_tools import *
from .memory_tools import *
from .cmd_tools import *
from .config_tools import *
from .workflow_tools import *
+43
View File
@@ -0,0 +1,43 @@
"""
Tools supporting the execution of (external) commands
"""
from serena.tools import TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerCanEdit
from serena.util.shell import execute_shell_command
class ExecuteShellCommandTool(Tool, ToolMarkerCanEdit):
"""
Executes a shell command.
"""
def apply(
self,
command: str,
cwd: str | None = None,
capture_stderr: bool = True,
max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH,
) -> str:
"""
Execute a shell command and return its output.
IMPORTANT: you should always consider the memory about suggested shell commands before using this tool.
If this memory was not loaded in the current conversation, you should load it using the `read_memory` tool
before using this tool.
You should have at least once looked at the suggested shell commands from the corresponding memory
created during the onboarding process before using this tool.
Never execute unsafe shell commands like `rm -rf /` or similar! Generally be very careful with deletions.
:param command: the shell command to execute
:param cwd: the working directory to execute the command in. If None, the project root will be used.
:param capture_stderr: whether to capture and return stderr output
:param max_answer_chars: if the output is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task.
:return: a JSON object containing the command's stdout and optionally stderr output
"""
_cwd = cwd or self.get_project_root()
result = execute_shell_command(command, cwd=_cwd, capture_stderr=capture_stderr)
result = result.json()
return self._limit_length(result, max_answer_chars)
+89
View File
@@ -0,0 +1,89 @@
import json
from serena.config.context_mode import SerenaAgentMode
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject
class ActivateProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
"""
Activates a project by name.
"""
def apply(self, project: str) -> str:
"""
Activates the project with the given name.
:param project: the name of a registered project to activate or a path to a project directory
"""
from ..config.serena_config import ProjectConfig
active_project, new_project_generated, new_project_config_generated = self.agent.activate_project_from_path_or_name(project)
if new_project_generated:
result_str = (
f"Created and activated a new project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}. "
+ "You can activate this project later by name."
)
else:
result_str = f"Activated existing project with name {active_project.project_name} at {active_project.project_root}, language: {active_project.project_config.language.value}"
if new_project_config_generated:
result_str += (
f"\nNote: A new project configuration was autogenerated because the given path did not contain a {ProjectConfig.SERENA_DEFAULT_PROJECT_FILE} file."
+ f"You can now edit the project configuration in the file {active_project.path_to_project_yml()}. In particular, you may want to edit the project name and the initial prompt."
)
if active_project.project_config.initial_prompt:
result_str += f"\nAdditional project information:\n {active_project.project_config.initial_prompt}"
result_str += (
f"\nAvailable memories:\n {json.dumps(list(self.memories_manager.list_memories()))}"
+ "You should not read these memories directly, but rather use the `read_memory` tool to read them later if needed for the task."
)
result_str += f"\nAvailable tools:\n {json.dumps(self.agent.get_active_tool_names())}"
return result_str
class RemoveProjectTool(Tool, ToolMarkerDoesNotRequireActiveProject):
"""
Removes a project from the Serena configuration.
"""
def apply(self, project_name: str) -> str:
"""
Removes a project from the Serena configuration.
:param project_name: Name of the project to remove
"""
self.agent.serena_config.remove_project(project_name)
return f"Successfully removed project '{project_name}' from configuration."
class SwitchModesTool(Tool):
"""
Activates modes by providing a list of their names
"""
def apply(self, modes: list[str]) -> str:
"""
Activates the desired modes, like ["editing", "interactive"] or ["planning", "one-shot"]
:param modes: the names of the modes to activate
"""
mode_instances = [SerenaAgentMode.load(mode) for mode in modes]
self.agent.set_modes(mode_instances)
# Inform the Agent about the activated modes and the currently active tools
result_str = f"Successfully activated modes: {', '.join([mode.name for mode in mode_instances])}" + "\n"
result_str += "\n".join([mode_instance.prompt for mode_instance in mode_instances]) + "\n"
result_str += f"Currently active tools: {', '.join(self.agent.get_active_tool_names())}"
return result_str
class GetCurrentConfigTool(Tool):
"""
Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
"""
def apply(self) -> str:
"""
Print the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
"""
return self.agent.get_current_config_overview()
+393
View File
@@ -0,0 +1,393 @@
"""
File and file system-related tools, specifically for
* listing directory contents
* reading files
* creating files
* editing at the file level
"""
import json
import os
import re
from collections import defaultdict
from fnmatch import fnmatch
from pathlib import Path
from serena.text_utils import search_files
from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, EditedFileContext, Tool, ToolMarkerCanEdit
from serena.util.file_system import scan_directory
class ReadFileTool(Tool):
"""
Reads a file within the project directory.
"""
def apply(
self, relative_path: str, start_line: int = 0, end_line: int | None = None, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH
) -> str:
"""
Reads the given file or a chunk of it. Generally, symbolic operations
like find_symbol or find_referencing_symbols should be preferred if you know which symbols you are looking for.
Reading the entire file is only recommended if there is no other way to get the content required for the task.
:param relative_path: the relative path to the file to read
:param start_line: the 0-based index of the first line to be retrieved.
:param end_line: the 0-based index of the last line to be retrieved (inclusive). If None, read until the end of the file.
:param max_answer_chars: if the file (chunk) is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task.
:return: the full text of the file at the given relative path
"""
self.agent.validate_relative_path(relative_path)
result = self.language_server.retrieve_full_file_content(relative_path)
result_lines = result.splitlines()
if end_line is None:
result_lines = result_lines[start_line:]
else:
self.lines_read.add_lines_read(relative_path, (start_line, end_line))
result_lines = result_lines[start_line : end_line + 1]
result = "\n".join(result_lines)
return self._limit_length(result, max_answer_chars)
class CreateTextFileTool(Tool, ToolMarkerCanEdit):
"""
Creates/overwrites a file in the project directory.
"""
def apply(self, relative_path: str, content: str) -> str:
"""
Write a new file (or overwrite an existing file). For existing files, it is strongly recommended
to use symbolic operations like replace_symbol_body or insert_after_symbol/insert_before_symbol, if possible.
You can also use insert_at_line to insert content at a specific line for existing files if the symbolic operations
are not the right choice for what you want to do.
If ever used on an existing file, the content has to be the complete content of that file (so it
may never end with something like "The remaining content of the file is left unchanged.").
For operations that just replace a part of a file, use the replace_lines or the symbolic editing tools instead.
:param relative_path: the relative path to the file to create
:param content: the (utf-8-encoded) content to write to the file
:return: a message indicating success or failure
"""
self.agent.validate_relative_path(relative_path)
abs_path = (Path(self.get_project_root()) / relative_path).resolve()
will_overwrite_existing = abs_path.exists()
abs_path.parent.mkdir(parents=True, exist_ok=True)
abs_path.write_text(content, encoding="utf-8")
answer = f"File created: {relative_path}."
if will_overwrite_existing:
answer += " Overwrote existing file."
return answer
class ListDirTool(Tool):
"""
Lists files and directories in the given directory (optionally with recursion).
"""
def apply(self, relative_path: str, recursive: bool, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str:
"""
Lists all non-gitignored files and directories in the given directory (optionally with recursion).
:param relative_path: the relative path to the directory to list; pass "." to scan the project root
:param recursive: whether to scan subdirectories recursively
:param max_answer_chars: if the output is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task.
:return: a JSON object with the names of directories and files within the given directory
"""
self.agent.validate_relative_path(relative_path)
dirs, files = scan_directory(
os.path.join(self.get_project_root(), relative_path),
relative_to=self.get_project_root(),
recursive=recursive,
is_ignored_dir=self.agent.path_is_gitignored,
is_ignored_file=self.agent.path_is_gitignored,
)
result = json.dumps({"dirs": dirs, "files": files})
return self._limit_length(result, max_answer_chars)
class FindFileTool(Tool):
"""
Finds files in the given relative paths
"""
def apply(self, file_mask: str, relative_path: str) -> str:
"""
Finds non-gitignored files matching the given file mask within the given relative path
:param file_mask: the filename or file mask (using the wildcards * or ?) to search for
:param relative_path: the relative path to the directory to search in; pass "." to scan the project root
:return: a JSON object with the list of matching files
"""
self.agent.validate_relative_path(relative_path)
dir_to_scan = os.path.join(self.get_project_root(), relative_path)
# find the files by ignoring everything that doesn't match
def is_ignored_file(abs_path: str) -> bool:
if self.agent.path_is_gitignored(abs_path):
return True
filename = os.path.basename(abs_path)
return not fnmatch(filename, file_mask)
dirs, files = scan_directory(
path=dir_to_scan,
recursive=True,
is_ignored_dir=self.agent.path_is_gitignored,
is_ignored_file=is_ignored_file,
relative_to=self.get_project_root(),
)
result = json.dumps({"files": files})
return result
class ReplaceRegexTool(Tool, ToolMarkerCanEdit):
"""
Replaces content in a file by using regular expressions.
"""
def apply(
self,
relative_path: str,
regex: str,
repl: str,
allow_multiple_occurrences: bool = False,
) -> str:
r"""
Replaces one or more occurrences of the given regular expression.
This is the preferred way to replace content in a file whenever the symbol-level
tools are not appropriate.
Even large sections of code can be replaced by providing a concise regular expression of
the form "beginning.*?end-of-text-to-be-replaced".
Always try to use wildcards to avoid specifying the exact content of the code to be replaced,
especially if it spans several lines.
IMPORTANT: REMEMBER TO USE WILDCARDS WHEN APPROPRIATE! I WILL BE VERY UNHAPPY IF YOU WRITE LONG REGEXES WITHOUT USING WILDCARDS INSTEAD!
:param relative_path: the relative path to the file
:param regex: a Python-style regular expression, matches of which will be replaced.
Dot matches all characters, multi-line matching is enabled.
:param repl: the string to replace the matched content with, which may contain
backreferences like \1, \2, etc.
:param allow_multiple_occurrences: if True, the regex may match multiple occurrences in the file
and all of them will be replaced.
If this is set to False and the regex matches multiple occurrences, an error will be returned
(and you may retry with a revised, more specific regex).
"""
self.agent.validate_relative_path(relative_path)
with EditedFileContext(relative_path, self.agent) as context:
original_content = context.get_original_content()
updated_content, n = re.subn(regex, repl, original_content, flags=re.DOTALL | re.MULTILINE)
if n == 0:
return f"Error: No matches found for regex '{regex}' in file '{relative_path}'."
if not allow_multiple_occurrences and n > 1:
return (
f"Error: Regex '{regex}' matches {n} occurrences in file '{relative_path}'. "
"Please revise the regex to be more specific or enable allow_multiple_occurrences if this is expected."
)
context.set_updated_content(updated_content)
return SUCCESS_RESULT
class DeleteLinesTool(Tool, ToolMarkerCanEdit):
"""
Deletes a range of lines within a file.
"""
def apply(
self,
relative_path: str,
start_line: int,
end_line: int,
) -> str:
"""
Deletes the given lines in the file.
Requires that the same range of lines was previously read using the `read_file` tool to verify correctness
of the operation.
:param relative_path: the relative path to the file
:param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted
"""
if not self.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_from_cls()}` first to read exactly the affected lines."
self.symbol_manager.delete_lines(relative_path, start_line, end_line)
return SUCCESS_RESULT
class ReplaceLinesTool(Tool, ToolMarkerCanEdit):
"""
Replaces a range of lines within a file with new content.
"""
def apply(
self,
relative_path: str,
start_line: int,
end_line: int,
content: str,
) -> str:
"""
Replaces the given range of lines in the given file.
Requires that the same range of lines was previously read using the `read_file` tool to verify correctness
of the operation.
:param relative_path: the relative path to the file
:param start_line: the 0-based index of the first line to be deleted
:param end_line: the 0-based index of the last line to be deleted
:param content: the content to insert
"""
if not content.endswith("\n"):
content += "\n"
result = self.agent.get_tool(DeleteLinesTool).apply(relative_path, start_line, end_line)
if result != SUCCESS_RESULT:
return result
self.agent.get_tool(InsertAtLineTool).apply(relative_path, start_line, content)
return SUCCESS_RESULT
class InsertAtLineTool(Tool, ToolMarkerCanEdit):
"""
Inserts content at a given line in a file.
"""
def apply(
self,
relative_path: str,
line: int,
content: str,
) -> str:
"""
Inserts the given content at the given line in the file, pushing existing content of the line down.
In general, symbolic insert operations like insert_after_symbol or insert_before_symbol should be preferred if you know which
symbol you are looking for.
However, this can also be useful for small targeted edits of the body of a longer symbol (without replacing the entire body).
:param relative_path: the relative path to the file
:param line: the 0-based index of the line to insert content at
:param content: the content to be inserted
"""
if not content.endswith("\n"):
content += "\n"
self.symbol_manager.insert_at_line(relative_path, line, content)
return SUCCESS_RESULT
class SearchForPatternTool(Tool):
"""
Performs a search for a pattern in the project.
"""
def apply(
self,
substring_pattern: str,
context_lines_before: int = 0,
context_lines_after: int = 0,
paths_include_glob: str | None = None,
paths_exclude_glob: str | None = None,
relative_path: str = "",
restrict_search_to_code_files: bool = False,
max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH,
) -> str:
"""
Offers a flexible search for arbitrary patterns in the codebase, including the
possibility to search in non-code files.
Generally, symbolic operations like find_symbol or find_referencing_symbols
should be preferred if you know which symbols you are looking for.
Pattern Matching Logic:
For each match, the returned result will contain the full lines where the
substring pattern is found, as well as optionally some lines before and after it. The pattern will be compiled with
DOTALL, meaning that the dot will match all characters including newlines.
This also means that it never makes sense to have .* at the beginning or end of the pattern,
but it may make sense to have it in the middle for complex patterns.
If a pattern matches multiple lines, all those lines will be part of the match.
Be careful to not use greedy quantifiers unnecessarily, it is usually better to use non-greedy quantifiers like .*? to avoid
matching too much content.
File Selection Logic:
The files in which the search is performed can be restricted very flexibly.
Using `restrict_search_to_code_files` is useful if you are only interested in code symbols (i.e., those
symbols that can be manipulated with symbolic tools like find_symbol).
You can also restrict the search to a specific file or directory,
and provide glob patterns to include or exclude certain files on top of that.
The globs are matched against relative file paths from the project root (not to the `relative_path` parameter that
is used to further restrict the search).
Smartly combining the various restrictions allows you to perform very targeted searches.
:param substring_pattern: Regular expression for a substring pattern to search for
:param context_lines_before: Number of lines of context to include before each match
:param context_lines_after: Number of lines of context to include after each match
:param paths_include_glob: optional glob pattern specifying files to include in the search.
Matches against relative file paths from the project root (e.g., "*.py", "src/**/*.ts").
Only matches files, not directories.
:param paths_exclude_glob: optional glob pattern specifying files to exclude from the search.
Matches against relative file paths from the project root (e.g., "*test*", "**/*_generated.py").
Takes precedence over paths_include_glob. Only matches files, not directories.
:param relative_path: only subpaths of this path (relative to the repo root) will be analyzed. If a path to a single
file is passed, only that will be searched. The path must exist, otherwise a `FileNotFoundError` is raised.
:param max_answer_chars: if the output is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task. Instead, if the output is too long, you should
make a stricter query.
:param restrict_search_to_code_files: whether to restrict the search to only those files where
analyzed code symbols can be found. Otherwise, will search all non-ignored files.
Set this to True if your search is only meant to discover code that can be manipulated with symbolic tools.
For example, for finding classes or methods from a name pattern.
Setting to False is a better choice if you also want to search in non-code files, like in html or yaml files,
which is why it is the default.
:return: A JSON object mapping file paths to lists of matched consecutive lines (with context, if requested).
"""
abs_path = os.path.join(self.get_project_root(), relative_path)
if not os.path.exists(abs_path):
raise FileNotFoundError(f"Relative path {relative_path} does not exist.")
if restrict_search_to_code_files:
matches = self.language_server.search_files_for_pattern(
pattern=substring_pattern,
relative_path=relative_path,
context_lines_before=context_lines_before,
context_lines_after=context_lines_after,
paths_include_glob=paths_include_glob,
paths_exclude_glob=paths_exclude_glob,
)
else:
if os.path.isfile(abs_path):
rel_paths_to_search = [relative_path]
else:
dirs, rel_paths_to_search = scan_directory(
path=abs_path,
recursive=True,
is_ignored_dir=self.agent.path_is_gitignored,
is_ignored_file=self.agent.path_is_gitignored,
relative_to=self.get_project_root(),
)
# TODO (maybe): not super efficient to walk through the files again and filter if glob patterns are provided
# but it probably never matters and this version required no further refactoring
matches = search_files(
rel_paths_to_search,
substring_pattern,
root_path=self.get_project_root(),
paths_include_glob=paths_include_glob,
paths_exclude_glob=paths_exclude_glob,
)
# group matches by file
file_to_matches: dict[str, list[str]] = defaultdict(list)
for match in matches:
assert match.source_file_path is not None
file_to_matches[match.source_file_path].append(match.to_display_string())
result = json.dumps(file_to_matches)
return self._limit_length(result, max_answer_chars)
+279
View File
@@ -0,0 +1,279 @@
"""
Language server-related tools
"""
import json
from collections.abc import Sequence
from copy import copy
from typing import Any
from serena.tools import SUCCESS_RESULT, TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool, ToolMarkerCanEdit
from solidlsp.ls_types import SymbolKind
def _sanitize_symbol_dict(symbol_dict: dict[str, Any]) -> dict[str, Any]:
"""
Sanitize a symbol dictionary inplace by removing unnecessary information.
"""
# We replace the location entry, which repeats line information already included in body_location
# and has unnecessary information on column, by just the relative path.
symbol_dict = copy(symbol_dict)
s_relative_path = symbol_dict.get("location", {}).get("relative_path")
if s_relative_path is not None:
symbol_dict["relative_path"] = s_relative_path
symbol_dict.pop("location", None)
# also remove name, name_path should be enough
symbol_dict.pop("name")
return symbol_dict
class RestartLanguageServerTool(Tool):
"""Restarts the language server, may be necessary when edits not through Serena happen."""
def apply(self) -> str:
"""Use this tool only on explicit user request or after confirmation.
It may be necessary to restart the language server if the user performs edits
not through Serena, so the language server state becomes outdated and further editing attempts lead to errors.
If such editing errors happen, you should suggest using this tool.
"""
self.agent.reset_language_server()
return SUCCESS_RESULT
class GetSymbolsOverviewTool(Tool):
"""
Gets an overview of the top-level symbols defined in a given file or directory.
"""
def apply(self, relative_path: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str:
"""
Gets an overview of the given file or directory.
For each analyzed file, we list the top-level symbols in the file (name_path, kind).
Use this tool to get a high-level understanding of the code symbols.
Calling this is often a good idea before more targeted reading, searching or editing operations on the code symbols.
:param relative_path: the relative path to the file or directory to get the overview of
:param max_answer_chars: if the overview is longer than this number of characters,
no content will be returned. Don't adjust unless there is really no other way to get the content
required for the task. If the overview is too long, you should use a smaller directory instead,
(e.g. a subdirectory).
:return: a JSON object mapping relative paths of all contained files to info about top-level symbols in the file (name_path, kind).
"""
path_to_symbol_infos = self.language_server.request_overview(relative_path)
result = {}
for file_path, symbols in path_to_symbol_infos.items():
# TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables
# The language server methods would need to be adjusted for this.
result[file_path] = [{"name_path": symbol[0], "kind": int(symbol[1])} for symbol in symbols]
result_json_str = json.dumps(result)
return self._limit_length(result_json_str, max_answer_chars)
class FindSymbolTool(Tool):
"""
Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
"""
def apply(
self,
name_path: str,
depth: int = 0,
relative_path: str | None = None,
include_body: bool = False,
include_kinds: list[int] | None = None,
exclude_kinds: list[int] | None = None,
substring_matching: bool = False,
max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH,
) -> str:
"""
Retrieves information on all symbols/code entities (classes, methods, etc.) based on the given `name_path`,
which represents a pattern for the symbol's path within the symbol tree of a single file.
The returned symbol location can be used for edits or further queries.
Specify `depth > 0` to retrieve children (e.g., methods of a class).
The matching behavior is determined by the structure of `name_path`, which can
either be a simple name (e.g. "method") or a name path like "class/method" (relative name path)
or "/class/method" (absolute name path). Note that the name path is not a path in the file system
but rather a path in the symbol tree **within a single file**. Thus, file or directory names should never
be included in the `name_path`. For restricting the search to a single file or directory,
the `within_relative_path` parameter should be used instead. The retrieved symbols' `name_path` attribute
will always be composed of symbol names, never file or directory names.
Key aspects of the name path matching behavior:
- Trailing slashes in `name_path` play no role and are ignored.
- The name of the retrieved symbols will match (either exactly or as a substring)
the last segment of `name_path`, while other segments will restrict the search to symbols that
have a desired sequence of ancestors.
- If there is no starting or intermediate slash in `name_path`, there is no
restriction on the ancestor symbols. For example, passing `method` will match
against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc.
- If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols
with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against
`class/method` as well as `nested_class/class/method` but not `method`.
- If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning
that the first segment of it must match the first segment of the symbol's name path.
For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`.
Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`.
:param name_path: The name path pattern to search for, see above for details.
:param depth: Depth to retrieve descendants (e.g., 1 for class methods/attributes).
:param relative_path: Optional. Restrict search to this file or directory. If None, searches entire codebase.
If a directory is passed, the search will be restricted to the files in that directory.
If a file is passed, the search will be restricted to that file.
If you have some knowledge about the codebase, you should use this parameter, as it will significantly
speed up the search as well as reduce the number of results.
:param include_body: If True, include the symbol's source code. Use judiciously.
:param include_kinds: Optional. List of LSP symbol kind integers to include. (e.g., 5 for Class, 12 for Function).
Valid kinds: 1=file, 2=module, 3=namespace, 4=package, 5=class, 6=method, 7=property, 8=field, 9=constructor, 10=enum,
11=interface, 12=function, 13=variable, 14=constant, 15=string, 16=number, 17=boolean, 18=array, 19=object,
20=key, 21=null, 22=enum member, 23=struct, 24=event, 25=operator, 26=type parameter
:param exclude_kinds: Optional. List of LSP symbol kind integers to exclude. Takes precedence over `include_kinds`.
:param substring_matching: If True, use substring matching for the last segment of `name`.
:param max_answer_chars: Max characters for the JSON result. If exceeded, no content is returned.
:return: JSON string: a list of symbols (with locations) matching the name.
"""
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
symbols = self.symbol_manager.find_by_name(
name_path,
include_body=include_body,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
substring_matching=substring_matching,
within_relative_path=relative_path,
)
symbol_dicts = [_sanitize_symbol_dict(s.to_dict(kind=True, location=True, depth=depth, include_body=include_body)) for s in symbols]
result = json.dumps(symbol_dicts)
return self._limit_length(result, max_answer_chars)
class FindReferencingSymbolsTool(Tool):
"""
Finds symbols that reference the symbol at the given location (optionally filtered by type).
"""
def apply(
self,
name_path: str,
relative_path: str,
include_kinds: list[int] | None = None,
exclude_kinds: list[int] | None = None,
max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH,
) -> str:
"""
Finds symbols that reference the symbol at the given `name_path`. The result will contain metadata about the referencing symbols
as well as a short code snippet around the reference (unless `include_body` is True, then the short snippet will be omitted).
Note that among other kinds of references, this function can be used to find (direct) subclasses of a class,
as subclasses are referencing symbols that have the kind class.
:param name_path: for finding the symbol to find references for, same logic as in the `find_symbol` tool.
:param relative_path: the relative path to the file containing the symbol for which to find references.
Note that here you can't pass a directory but must pass a file.
:param include_kinds: same as in the `find_symbol` tool.
:param exclude_kinds: same as in the `find_symbol` tool.
:param max_answer_chars: same as in the `find_symbol` tool.
:return: a list of JSON objects with the symbols referencing the requested symbol
"""
include_body = False # It is probably never a good idea to include the body of the referencing symbols
parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None
parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None
references_in_symbols = self.symbol_manager.find_referencing_symbols(
name_path,
relative_file_path=relative_path,
include_body=include_body,
include_kinds=parsed_include_kinds,
exclude_kinds=parsed_exclude_kinds,
)
reference_dicts = []
for ref in references_in_symbols:
ref_dict = ref.symbol.to_dict(kind=True, location=True, depth=0, include_body=include_body)
ref_dict = _sanitize_symbol_dict(ref_dict)
if not include_body:
ref_relative_path = ref.symbol.location.relative_path
assert ref_relative_path is not None, f"Referencing symbol {ref.symbol.name} has no relative path, this is likely a bug."
content_around_ref = self.language_server.retrieve_content_around_line(
relative_file_path=ref_relative_path, line=ref.line, context_lines_before=1, context_lines_after=1
)
ref_dict["content_around_reference"] = content_around_ref.to_display_string()
reference_dicts.append(ref_dict)
result = json.dumps(reference_dicts)
return self._limit_length(result, max_answer_chars)
class ReplaceSymbolBodyTool(Tool, ToolMarkerCanEdit):
"""
Replaces the full definition of a symbol.
"""
def apply(
self,
name_path: str,
relative_path: str,
body: str,
) -> str:
r"""
Replaces the body of the symbol with the given `name_path`.
:param name_path: for finding the symbol to replace, same logic as in the `find_symbol` tool.
:param relative_path: the relative path to the file containing the symbol
:param body: the new symbol body. Important: Begin directly with the symbol definition and provide no
leading indentation for the first line (but do indent the rest of the body according to the context).
"""
self.symbol_manager.replace_body(
name_path,
relative_file_path=relative_path,
body=body,
use_same_indentation=False,
)
return SUCCESS_RESULT
class InsertAfterSymbolTool(Tool, ToolMarkerCanEdit):
"""
Inserts content after the end of the definition of a given symbol.
"""
def apply(
self,
name_path: str,
relative_path: str,
body: str,
) -> str:
"""
Inserts the given body/content after the end of the definition of the given symbol (via the symbol's location).
A typical use case is to insert a new class, function, method, field or variable assignment.
:param name_path: name path of the symbol after which to insert content (definitions in the `find_symbol` tool apply)
:param relative_path: the relative path to the file containing the symbol
:param body: the body/content to be inserted. The inserted code shall begin with the next line after
the symbol.
"""
self.symbol_manager.insert_after_symbol(name_path, relative_file_path=relative_path, body=body, use_same_indentation=False)
return SUCCESS_RESULT
class InsertBeforeSymbolTool(Tool, ToolMarkerCanEdit):
"""
Inserts content before the beginning of the definition of a given symbol.
"""
def apply(
self,
name_path: str,
relative_path: str,
body: str,
) -> str:
"""
Inserts the given body/content before the beginning of the definition of the given symbol (via the symbol's location).
A typical use case is to insert a new class, function, method, field or variable assignment.
It also can be used to insert a new import statement before the first symbol in the file.
:param name_path: name path of the symbol before which to insert content (definitions in the `find_symbol` tool apply)
:param relative_path: the relative path to the file containing the symbol
:param body: the body/content to be inserted before the line in which the referenced symbol is defined
"""
self.symbol_manager.insert_before_symbol(name_path, relative_file_path=relative_path, body=body, use_same_indentation=False)
return SUCCESS_RESULT
+69
View File
@@ -0,0 +1,69 @@
import json
from serena.tools import TOOL_DEFAULT_MAX_ANSWER_LENGTH, Tool
class WriteMemoryTool(Tool):
"""
Writes a named memory (for future reference) to Serena's project-specific memory store.
"""
def apply(self, memory_name: str, content: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str:
"""
Write some information about this project that can be useful for future tasks to a memory.
Use markdown formatting for the content.
The information should be short and to the point.
The memory name should be meaningful, such that from the name you can infer what the information is about.
It is better to have multiple small memories than to have a single large one because
memories will be read one by one and we only ever want to read relevant memories.
This tool is either called during the onboarding process or when you have identified
something worth remembering about the project from the past conversation.
"""
if len(content) > max_answer_chars:
raise ValueError(
f"Content for {memory_name} is too long. Max length is {max_answer_chars} characters. " + "Please make the content shorter."
)
return self.memories_manager.save_memory(memory_name, content)
class ReadMemoryTool(Tool):
"""
Reads the memory with the given name from Serena's project-specific memory store.
"""
def apply(self, memory_file_name: str, max_answer_chars: int = TOOL_DEFAULT_MAX_ANSWER_LENGTH) -> str:
"""
Read the content of a memory file. This tool should only be used if the information
is relevant to the current task. You can infer whether the information
is relevant from the memory file name.
You should not read the same memory file multiple times in the same conversation.
"""
return self.memories_manager.load_memory(memory_file_name)
class ListMemoriesTool(Tool):
"""
Lists memories in Serena's project-specific memory store.
"""
def apply(self) -> str:
"""
List available memories. Any memory can be read using the `read_memory` tool.
"""
return json.dumps(self.memories_manager.list_memories())
class DeleteMemoryTool(Tool):
"""
Deletes a memory from Serena's project-specific memory store.
"""
def apply(self, memory_file_name: str) -> str:
"""
Delete a memory file. Should only happen if a user asks for it explicitly,
for example by saying that the information retrieved from a memory file is no longer correct
or no longer relevant for the project.
"""
return self.memories_manager.delete_memory(memory_file_name)
+372
View File
@@ -0,0 +1,372 @@
import inspect
import os
import traceback
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator, Iterable
from copy import copy
from types import TracebackType
from typing import TYPE_CHECKING, Any, Self, TypeVar
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
from sensai.util import logging
from sensai.util.string import dict_string
from serena.prompt_factory import PromptFactory
from serena.symbol import SymbolManager
from serena.util.inspection import iter_subclasses
from solidlsp import SolidLanguageServer
if TYPE_CHECKING:
from serena.agent import LinesRead, MemoriesManager, SerenaAgent
log = logging.getLogger(__name__)
T = TypeVar("T")
SUCCESS_RESULT = "OK"
class Component(ABC):
def __init__(self, agent: "SerenaAgent"):
self.agent = agent
@property
def language_server(self) -> SolidLanguageServer:
assert self.agent.language_server is not None
return self.agent.language_server
def get_project_root(self) -> str:
"""
:return: the root directory of the active project, raises a ValueError if no active project configuration is set
"""
return self.agent.get_project_root()
@property
def prompt_factory(self) -> PromptFactory:
return self.agent.prompt_factory
@property
def memories_manager(self) -> "MemoriesManager":
assert self.agent.memories_manager is not None
return self.agent.memories_manager
@property
def symbol_manager(self) -> SymbolManager:
assert self.agent.symbol_manager is not None
return self.agent.symbol_manager
@property
def lines_read(self) -> "LinesRead":
assert self.agent.lines_read is not None
return self.agent.lines_read
TOOL_DEFAULT_MAX_ANSWER_LENGTH = int(2e5)
class ToolMarkerCanEdit:
"""
Marker class for all tools that can perform editing operations on files.
"""
class ToolMarkerDoesNotRequireActiveProject:
pass
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.
# The apply method is not declared as part of the base Tool interface since we cannot
# know the signature of the (input parameters of the) method in advance.
#
# The docstring and types of the apply method are used to generate the tool description
# (which is use by the LLM, so a good description is important)
# and to validate the tool call arguments.
@classmethod
def get_name_from_cls(cls) -> str:
name = cls.__name__
if name.endswith("Tool"):
name = name[:-4]
# convert to snake_case
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:
raise RuntimeError(f"apply not defined in {self}. Did you forget to implement it?")
return apply_fn
@classmethod
def can_edit(cls) -> bool:
"""
Returns whether this tool can perform editing operations on code.
:return: True if the tool can edit code, False otherwise
"""
return issubclass(cls, ToolMarkerCanEdit)
@classmethod
def get_tool_description(cls) -> str:
docstring = cls.__doc__
if docstring is None:
return ""
return docstring.strip()
@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 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 = {}
ignored_params = {"self", "log_call", "catch_exceptions", "args", "apply_fn"}
for param, value in frame.f_locals.items():
if param in ignored_params:
continue
if param == "kwargs":
params.update(value)
else:
params[param] = value
log.info(f"{self.get_name_from_cls()}: {dict_string(params)}")
@staticmethod
def _limit_length(result: str, max_answer_chars: int) -> str:
if (n_chars := len(result)) > max_answer_chars:
result = (
f"The answer is too long ({n_chars} characters). "
+ "Please try a more specific tool query or raise the max_answer_chars parameter."
)
return result
def is_active(self) -> bool:
return self.agent.tool_is_active(self.__class__)
def apply_ex(self, log_call: bool = True, catch_exceptions: bool = True, **kwargs) -> str: # type: ignore
"""
Applies the tool with the given arguments
"""
def task() -> str:
apply_fn = self.get_apply_fn()
try:
if not self.is_active():
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_from_cls()} is active: {e}"
if log_call:
self._log_tool_application(inspect.currentframe())
try:
# check whether the tool requires an active project and language server
if not isinstance(self, ToolMarkerDoesNotRequireActiveProject):
if self.agent._active_project is None:
return (
"Error: No active project. Ask to user to select a project from this list: "
+ f"{self.agent.serena_config.project_names}"
)
if not self.agent.is_language_server_running():
log.info("Language server is not running. Starting it ...")
self.agent.reset_language_server()
# apply the actual tool
result = apply_fn(**kwargs)
except Exception as e:
if not catch_exceptions:
raise
msg = f"Error executing tool: {e}\n{traceback.format_exc()}"
log.error(
f"Error executing tool: {e}. "
f"Consider restarting the language server to solve this (especially, if it's a timeout of a symbolic operation)",
exc_info=e,
)
result = msg
if log_call:
log.info(f"Result: {result}")
try:
self.language_server.save_cache()
except Exception as e:
log.error(f"Error saving language server cache: {e}")
return result
future = self.agent.issue_task(task, name=self.__class__.__name__)
return future.result(timeout=self.agent.serena_config.tool_timeout)
class EditedFileContext:
"""
Context manager for file editing.
Create the context, then use `set_updated_content` to set the new content, the original content
being provided in `original_content`.
When exiting the context without an exception, the updated content will be written back to the file.
"""
def __init__(self, relative_path: str, agent: "SerenaAgent"):
self._project = agent.get_active_project()
assert self._project is not None
self._abs_path = os.path.join(self._project.project_root, relative_path)
if not os.path.isfile(self._abs_path):
raise FileNotFoundError(f"File {self._abs_path} does not exist.")
with open(self._abs_path, encoding=self._project.project_config.encoding) as f:
self._original_content = f.read()
self._updated_content: str | None = None
def __enter__(self) -> Self:
return self
def get_original_content(self) -> str:
"""
:return: the original content of the file before any modifications.
"""
return self._original_content
def set_updated_content(self, content: str) -> None:
"""
Sets the updated content of the file, which will be written back to the file
when the context is exited without an exception.
:param content: the updated content of the file
"""
self._updated_content = content
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
if self._updated_content is not None and exc_type is None:
assert self._project is not None
with open(self._abs_path, "w", encoding=self._project.project_config.encoding) as f:
f.write(self._updated_content)
log.info(f"Updated content written to {self._abs_path}")
# Language servers should automatically detect the change and update its state accordingly.
# If they do not, we may have to add a call to notify it.
class ToolRegistry:
_tool_dict: dict[str, type[Tool]] | None = None
"""maps tool name to the corresponding tool class"""
@staticmethod
def _iter_tool_classes() -> Generator[type[Tool], None, None]:
"""
Iterate over Tool subclasses.
"""
for cls in iter_subclasses(Tool):
if cls.__module__.startswith("serena.tools"):
yield cls
@classmethod
def _get_tool_dict(cls) -> dict[str, type[Tool]]:
if cls._tool_dict is None:
cls._tool_dict = {}
for tool_class in cls._iter_tool_classes():
name = tool_class.get_name_from_cls()
if name in cls._tool_dict:
raise ValueError(f"Duplicate tool name found: {name}. Tool classes must have unique names.")
cls._tool_dict[name] = tool_class
return cls._tool_dict
@classmethod
def get_tool_class_by_name(cls, tool_name: str) -> type[Tool]:
try:
return cls._get_tool_dict()[tool_name]
except KeyError as e:
available_tools = "\n".join(ToolRegistry.get_tool_names())
raise ValueError(f"Tool with name {tool_name} not found. Available tools:\n{available_tools}") from e
@classmethod
def get_all_tool_classes(cls) -> list[type[Tool]]:
return list(cls._get_tool_dict().values())
@classmethod
def get_tool_names(cls) -> list[str]:
return list(cls._get_tool_dict().keys())
@classmethod
def tool_dict(cls) -> dict[str, type[Tool]]:
"""Maps tool name to the corresponding tool class"""
return copy(cls._get_tool_dict())
@classmethod
def print_tool_overview(cls, tools: Iterable[type[Tool] | Tool] | None = None) -> None:
"""
Print a summary of the tools. If no tools are passed, a summary of all tools is printed.
"""
if tools is None:
tools = cls._get_tool_dict().values()
tool_dict: dict[str, type[Tool] | Tool] = {}
for tool_class in tools:
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()}")
+139
View File
@@ -0,0 +1,139 @@
"""
Tools supporting the general workflow of the agent
"""
import json
import platform
from serena.tools import Tool, ToolMarkerDoesNotRequireActiveProject
class CheckOnboardingPerformedTool(Tool):
"""
Checks whether project onboarding was already performed.
"""
def apply(self) -> str:
"""
Checks whether project onboarding was already performed.
You should always call this tool before beginning to actually work on the project/after activating a project,
but after calling the initial instructions tool.
"""
from .memory_tools import ListMemoriesTool
list_memories_tool = self.agent.get_tool(ListMemoriesTool)
memories = json.loads(list_memories_tool.apply())
if len(memories) == 0:
return (
"Onboarding not performed yet (no memories available). "
+ "You should perform onboarding by calling the `onboarding` tool before proceeding with the task."
)
else:
return f"""The onboarding was already performed, below is the list of available memories.
Do not read them immediately, just remember that they exist and that you can read them later, if it is necessary
for the current task.
Some memories may be based on previous conversations, others may be general for the current project.
You should be able to tell which one you need based on the name of the memory.
{memories}"""
class OnboardingTool(Tool):
"""
Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
"""
def apply(self) -> str:
"""
Call this tool if onboarding was not performed yet.
You will call this tool at most once per conversation.
:return: instructions on how to create the onboarding information
"""
system = platform.system()
return self.prompt_factory.create_onboarding_prompt(system=system)
class ThinkAboutCollectedInformationTool(Tool):
"""
Thinking tool for pondering the completeness of collected information.
"""
def apply(self) -> str:
"""
Think about the collected information and whether it is sufficient and relevant.
This tool should ALWAYS be called after you have completed a non-trivial sequence of searching steps like
find_symbol, find_referencing_symbols, search_files_for_pattern, read_file, etc.
"""
return self.prompt_factory.create_think_about_collected_information()
class ThinkAboutTaskAdherenceTool(Tool):
"""
Thinking tool for determining whether the agent is still on track with the current task.
"""
def apply(self) -> str:
"""
Think about the task at hand and whether you are still on track.
Especially important if the conversation has been going on for a while and there
has been a lot of back and forth.
This tool should ALWAYS be called before you insert, replace, or delete code.
"""
return self.prompt_factory.create_think_about_task_adherence()
class ThinkAboutWhetherYouAreDoneTool(Tool):
"""
Thinking tool for determining whether the task is truly completed.
"""
def apply(self) -> str:
"""
Whenever you feel that you are done with what the user has asked for, it is important to call this tool.
"""
return self.prompt_factory.create_think_about_whether_you_are_done()
class SummarizeChangesTool(Tool):
"""
Provides instructions for summarizing the changes made to the codebase.
"""
def apply(self) -> str:
"""
Summarize the changes you have made to the codebase.
This tool should always be called after you have fully completed any non-trivial coding task,
but only after the think_about_whether_you_are_done call.
"""
return self.prompt_factory.create_summarize_changes()
class PrepareForNewConversationTool(Tool):
"""
Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
"""
def apply(self) -> str:
"""
Instructions for preparing for a new conversation. This tool should only be called on explicit user request.
"""
return self.prompt_factory.create_prepare_for_new_conversation()
class InitialInstructionsTool(Tool, ToolMarkerDoesNotRequireActiveProject):
"""
Gets the initial instructions for the current project.
Should only be used in settings where the system prompt cannot be set,
e.g. in clients you have no control over, like Claude Desktop.
"""
def apply(self) -> str:
"""
Get the initial instructions for the current coding project.
You should always call this tool before starting to work (including using any other tool) on any programming task!
The only exception is when a user asks you to activate a project, in which case you should call the `activate_project` first
instead and then call this tool.
"""
return self.agent.create_system_prompt()
+23
View File
@@ -0,0 +1,23 @@
import sys
from serena.agent import log
def show_fatal_exception_safe(e: Exception) -> None:
"""
Shows the given exception in the GUI log viewer on the main thread and ensures that the exception is logged or at
least printed to stderr.
"""
# Log the error and print it to stderr
log.error(f"Fatal exception: {e}", exc_info=e)
print(f"Fatal exception: {e}", file=sys.stderr)
# attempt to show the error in the GUI
try:
# NOTE: The import can fail on macOS if Tk is not available (depends on Python interpreter installation, which uv
# used as a base); while tkinter as such is always available, its dependencies can be unavailable on macOS.
from serena.gui_log_viewer import show_fatal_exception
show_fatal_exception(e)
except:
pass
+1
View File
@@ -0,0 +1 @@
language_servers/static
@@ -0,0 +1,90 @@
# Elixir Language Server Integration
This directory contains the integration for Elixir language support using [Next LS](https://github.com/elixir-tools/next-ls) from the elixir-tools project.
> **⚠️ Windows Not Supported**: Next LS does not provide Windows binaries, so Elixir language server integration is only available on Linux and macOS.
## Known Issues
### Next LS v0.23.3 Timeout Enumeration Bug
There is a known intermittent bug in Next LS v0.23.3 where `textDocument/definition` requests can fail with:
```
Protocol.UndefinedError: protocol Enumerable not implemented for :timeout of type Atom
```
This bug is tracked in [Next LS Issue #543](https://github.com/elixir-tools/next-ls/issues/543) and primarily occurs in CI environments. The affected test (`test_request_defining_symbol_none`) is marked as expected to fail until this upstream bug is resolved.
## Prerequisites
Before using the Elixir language server integration, you need to have:
1. **Elixir** installed and available in your PATH
- Install from: https://elixir-lang.org/install.html
- Verify with: `elixir --version`
2. **Next LS** installed and available in your PATH
- Install from: https://github.com/elixir-tools/next-ls#installation
- Verify with: `nextls --version`
## Features
The Elixir integration provides:
- **Language Server Protocol (LSP) support** via Next LS
- **File extension recognition** for `.ex` and `.exs` files
- **Project structure awareness** with proper handling of Elixir-specific directories:
- `_build/` - Compiled artifacts (ignored)
- `deps/` - Dependencies (ignored)
- `.elixir_ls/` - ElixirLS artifacts (ignored)
- `cover/` - Coverage reports (ignored)
- `lib/` - Source code (not ignored)
- `test/` - Test files (not ignored)
## Configuration
The integration uses the default Next LS configuration with:
- **MIX_ENV**: `dev`
- **MIX_TARGET**: `host`
- **Experimental completions**: Disabled by default
- **Credo extension**: Enabled by default
## Usage
The Elixir language server is automatically selected when working with Elixir projects. It will be used for:
- Code completion
- Go to definition
- Find references
- Document symbols
- Hover information
- Code formatting
- Diagnostics (via Credo integration)
### Important: Project Compilation
Next LS requires your Elixir project to be **compiled** for optimal performance, especially for:
- Cross-file reference resolution
- Complete symbol information
- Accurate go-to-definition
**For production use**: Ensure your project is compiled with `mix compile` before using the language server.
**For testing**: The test suite automatically compiles the test repositories before running tests to ensure optimal Next LS performance.
## Testing
Run the Elixir-specific tests with:
```bash
pytest test/solidlsp/elixir/ -m elixir
```
## Implementation Details
- **Main class**: `ElixirTools` in `elixir_tools.py`
- **Initialization parameters**: Defined in `initialize_params.json`
- **Language identifier**: `"elixir"`
- **Command**: `nextls --stdio`
The implementation follows the same patterns as other language servers in this project, inheriting from `SolidLanguageServer` and providing Elixir-specific configuration and behavior.
@@ -0,0 +1 @@
@@ -0,0 +1,292 @@
import json
import logging
import os
import pathlib
import stat
import subprocess
import threading
import time
from pathlib import PurePath
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
class ElixirTools(SolidLanguageServer):
"""
Provides Elixir specific instantiation of the LanguageServer class using Next LS from elixir-tools.
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
# For Elixir projects, we should ignore:
# - _build: compiled artifacts
# - deps: dependencies
# - node_modules: if the project has JavaScript components
# - .elixir_ls: ElixirLS artifacts (in case both are present)
# - cover: coverage reports
return super().is_ignored_dirname(dirname) or dirname in ["_build", "deps", "node_modules", ".elixir_ls", "cover"]
def _is_next_ls_internal_file(self, abs_path: str) -> bool:
"""Check if an absolute path is a Next LS internal file that should be ignored."""
return any(
pattern in abs_path
for pattern in [
".burrito", # Next LS runtime directory
"next_ls_erts-", # Next LS Erlang runtime
"_next_ls_private_", # Next LS private files
"/priv/monkey/", # Next LS monkey patching directory
]
)
@override
def _send_references_request(self, relative_file_path: str, line: int, column: int):
"""Override to filter out Next LS internal files from references."""
from solidlsp.ls_utils import PathUtils
# Get the raw response from the parent implementation
raw_response = super()._send_references_request(relative_file_path, line, column)
if raw_response is None:
return None
# Filter out Next LS internal files
filtered_response = []
for item in raw_response:
if isinstance(item, dict) and "uri" in item:
abs_path = PathUtils.uri_to_path(item["uri"])
if self._is_next_ls_internal_file(abs_path):
self.logger.log(f"Filtering out Next LS internal file: {abs_path}", logging.DEBUG)
continue
filtered_response.append(item)
return filtered_response
@staticmethod
def _get_elixir_version():
"""Get the installed Elixir version or None if not found."""
try:
result = subprocess.run(["elixir", "--version"], capture_output=True, text=True, check=False)
if result.returncode == 0:
return result.stdout.strip()
except FileNotFoundError:
return None
return None
def setupRuntimeDependencies(self, logger: LanguageServerLogger, config: LanguageServerConfig) -> str:
"""
Setup runtime dependencies for Next LS.
Downloads the Next LS binary for the current platform and returns the path to the executable.
"""
# Check if Elixir is available first
elixir_version = self._get_elixir_version()
if not elixir_version:
raise RuntimeError(
"Elixir is not installed. Please install Elixir from https://elixir-lang.org/install.html and make sure it is added to your PATH."
)
logger.log(f"Found Elixir: {elixir_version}", logging.INFO)
platformId = PlatformUtils.get_platform_id()
with open(str(PurePath(os.path.dirname(__file__), "runtime_dependencies.json")), encoding="utf-8") as f:
runtimeDependencies = json.load(f)
del runtimeDependencies["_description"]
os.makedirs(str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static")), exist_ok=True)
# Map platform IDs to runtime dependency keys
platform_mapping = {
"linux-x64": "linux-x64",
"osx-x64": "darwin-x64",
"osx-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"darwin-arm64": "darwin-arm64",
"win-x64": "win-x64",
}
platform_key = platform_mapping.get(platformId.value)
if not platform_key:
raise RuntimeError(f"Unsupported platform for Next LS: {platformId.value}")
# Check for Windows and provide a helpful error message
if platformId.value.startswith("win"):
raise RuntimeError(
"Windows is not supported by Next LS. The Next LS project does not provide Windows binaries. "
"Consider using Windows Subsystem for Linux (WSL) or a virtual machine with Linux/macOS."
)
dependency = runtimeDependencies["next_ls"][platform_key]
next_ls_dir = str(PurePath(os.path.abspath(os.path.dirname(__file__)), "static", dependency["relative_extraction_path"]))
os.makedirs(next_ls_dir, exist_ok=True)
executable_path = str(PurePath(next_ls_dir, dependency["executable_name"]))
binary_path = str(PurePath(next_ls_dir, dependency["binary_name"]))
if not os.path.exists(executable_path):
logger.log(f"Downloading Next LS binary from {dependency['url']}", logging.INFO)
FileUtils.download_file(logger, dependency["url"], binary_path)
# Make the binary executable on Unix-like systems
if not platformId.value.startswith("win"):
os.chmod(binary_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
# Create a symlink or copy with the expected name
if binary_path != executable_path:
if os.path.exists(executable_path):
os.remove(executable_path)
if platformId.value.startswith("win"):
# On Windows, copy the file
import shutil
shutil.copy2(binary_path, executable_path)
else:
# On Unix-like systems, create a symlink
os.symlink(os.path.basename(binary_path), executable_path)
assert os.path.exists(executable_path), f"Next LS executable not found at {executable_path}"
logger.log(f"Next LS binary ready at: {executable_path}", logging.INFO)
return executable_path
def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str):
nextls_executable_path = self.setupRuntimeDependencies(logger, config)
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=f'"{nextls_executable_path}" --stdio', cwd=repository_root_path),
"elixir",
)
self.server_ready = threading.Event()
self.request_id = 0
# Set generous timeout for Next LS which can be slow to initialize and respond
self.set_request_timeout(180.0) # 60 seconds for all environments
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Next LS Language Server.
"""
with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f:
d = json.load(f)
del d["_description"]
d["processId"] = os.getpid()
assert d["rootPath"] == "$rootPath"
d["rootPath"] = repository_absolute_path
assert d["rootUri"] == "$rootUri"
d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri()
assert d["workspaceFolders"][0]["uri"] == "$uri"
d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri()
assert d["workspaceFolders"][0]["name"] == "$name"
d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path)
return d
def _start_server(self):
"""Start Next LS server process"""
def register_capability_handler(params):
return
def window_log_message(msg):
"""Handle window/logMessage notifications from Next LS"""
message_text = msg.get("message", "")
self.logger.log(f"LSP: window/logMessage: {message_text}", logging.INFO)
# Check for the specific Next LS readiness signal
# Based on Next LS source: "Runtime for folder #{name} is ready..."
if "Runtime for folder" in message_text and "is ready..." in message_text:
self.logger.log("Next LS runtime is ready based on official log message", logging.INFO)
self.server_ready.set()
def do_nothing(params):
return
def check_server_ready(params):
"""
Handle $/progress notifications from Next LS.
Keep as fallback for error detection, but primary readiness detection
is now done via window/logMessage handler.
"""
value = params.get("value", {})
# Check for initialization completion progress (fallback signal)
if value.get("kind") == "end":
message = value.get("message", "")
if "has initialized!" in message:
self.logger.log("Next LS initialization progress completed", logging.INFO)
# Note: We don't set server_ready here - we wait for the log message
def work_done_progress(params):
"""
Handle $/workDoneProgress notifications from Next LS.
Keep for completeness but primary readiness detection is via window/logMessage.
"""
value = params.get("value", {})
if value.get("kind") == "end":
self.logger.log("Next LS work done progress completed", logging.INFO)
# Note: We don't set server_ready here - we wait for the log message
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", check_server_ready)
self.server.on_notification("window/workDoneProgress/create", do_nothing)
self.server.on_notification("$/workDoneProgress", work_done_progress)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.logger.log("Starting Next LS server process", logging.INFO)
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request from LSP client to LSP server and awaiting response",
logging.INFO,
)
init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities - be more lenient with Next LS
self.logger.log(f"Next LS capabilities: {list(init_response['capabilities'].keys())}", logging.INFO)
# Next LS may not provide all capabilities immediately, so we check for basic ones
assert "textDocumentSync" in init_response["capabilities"], f"Missing textDocumentSync in {init_response['capabilities']}"
# Some capabilities might be optional or provided later
if "completionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: completionProvider not available in initial capabilities", logging.WARNING)
if "definitionProvider" not in init_response["capabilities"]:
self.logger.log("Warning: definitionProvider not available in initial capabilities", logging.WARNING)
self.server.notify.initialized({})
self.completions_available.set()
# Wait for Next LS to send the specific "Runtime for folder X is ready..." log message
# This is the authoritative signal that Next LS is truly ready for requests
ready_timeout = 180.0
self.logger.log(f"Waiting up to {ready_timeout} seconds for Next LS runtime readiness...", logging.INFO)
if self.server_ready.wait(timeout=ready_timeout):
self.logger.log("Next LS is ready and available for requests", logging.INFO)
# Add a small settling period to ensure background indexing is complete
# Next LS often continues compilation/indexing in background after ready signal
settling_time = 120.0
self.logger.log(f"Allowing {settling_time} seconds for Next LS background indexing to complete...", logging.INFO)
time.sleep(settling_time)
self.logger.log("Next LS settling period complete", logging.INFO)
else:
error_msg = f"Next LS failed to initialize within {ready_timeout} seconds. This may indicate a problem with the Elixir installation, project compilation, or Next LS itself."
self.logger.log(error_msg, logging.ERROR)
raise RuntimeError(error_msg)
@@ -0,0 +1,97 @@
{
"_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize",
"processId": "os.getpid()",
"locale": "en",
"rootPath": "$rootPath",
"rootUri": "$rootUri",
"initializationOptions": {
"mix_env": "dev",
"mix_target": "host",
"experimental": {
"completions": {
"enable": false
}
},
"extensions": {
"credo": {
"enable": true,
"cli_options": []
}
}
},
"capabilities": {
"textDocument": {
"synchronization": {
"didSave": true,
"dynamicRegistration": true
},
"completion": {
"dynamicRegistration": true,
"completionItem": {
"snippetSupport": true,
"documentationFormat": [
"markdown",
"plaintext"
]
}
},
"definition": {
"dynamicRegistration": true
},
"references": {
"dynamicRegistration": true
},
"documentSymbol": {
"dynamicRegistration": true,
"hierarchicalDocumentSymbolSupport": true,
"symbolKind": {
"valueSet": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26
]
}
},
"hover": {
"dynamicRegistration": true,
"contentFormat": [
"markdown",
"plaintext"
]
},
"formatting": {
"dynamicRegistration": true
},
"codeAction": {
"dynamicRegistration": true,
"codeActionLiteralSupport": {
"codeActionKind": {
"valueSet": [
"quickfix",
"refactor",
"refactor.extract",
"refactor.inline",
"refactor.rewrite",
"source",
"source.organizeImports"
]
}
}
}
},
"workspace": {
"workspaceFolders": true,
"didChangeConfiguration": {
"dynamicRegistration": true
},
"executeCommand": {
"dynamicRegistration": true
}
}
},
"workspaceFolders": [
{
"uri": "$uri",
"name": "$name"
}
]
}
@@ -0,0 +1,33 @@
{
"_description": "This file lists the runtime dependencies for the Elixir Language Server (Next LS)",
"next_ls": {
"linux-x64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_linux_amd64",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_linux_amd64",
"executable_name": "nextls"
},
"darwin-x64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_amd64",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_darwin_amd64",
"executable_name": "nextls"
},
"darwin-arm64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_darwin_arm64",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_darwin_arm64",
"executable_name": "nextls"
},
"win-x64": {
"url": "https://github.com/elixir-tools/next-ls/releases/download/v0.23.3/next_ls_windows_amd64.exe",
"archiveType": "binary",
"relative_extraction_path": "next_ls",
"binary_name": "next_ls_windows_amd64.exe",
"executable_name": "nextls.exe"
}
}
}
@@ -0,0 +1,46 @@
{
"_description": "The parameters sent by the client when initializing the language server with the \"initialize\" request. More details at https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize",
"processId": "os.getpid()",
"locale": "en",
"rootPath": "$rootPath",
"rootUri": "$rootUri",
"capabilities": {
"textDocument": {
"synchronization": {
"didSave": true,
"dynamicRegistration": true
},
"completion": {
"dynamicRegistration": true,
"completionItem": {
"snippetSupport": true
}
},
"definition": {
"dynamicRegistration": true
},
"documentSymbol": {
"dynamicRegistration": true,
"hierarchicalDocumentSymbolSupport": true,
"symbolKind": {
"valueSet": [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26
]
}
}
},
"workspace": {
"workspaceFolders": true,
"didChangeConfiguration": {
"dynamicRegistration": true
}
}
},
"workspaceFolders": [
{
"uri": "$uri",
"name": "$name"
}
]
}
@@ -0,0 +1,37 @@
{
"_description": "Used to download the runtime dependencies for running terraform-ls. Obtained from https://releases.hashicorp.com/terraform-ls/",
"runtimeDependencies": [
{
"id": "TerraformLS",
"description": "terraform-ls for macOS (ARM64)",
"url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_arm64.zip",
"platformId": "osx-arm64",
"archiveType": "zip",
"binaryName": "terraform-ls"
},
{
"id": "TerraformLS",
"description": "terraform-ls for macOS (x64)",
"url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_darwin_amd64.zip",
"platformId": "osx-x64",
"archiveType": "zip",
"binaryName": "terraform-ls"
},
{
"id": "TerraformLS",
"description": "terraform-ls for Linux (x64)",
"url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_linux_amd64.zip",
"platformId": "linux-x64",
"archiveType": "zip",
"binaryName": "terraform-ls"
},
{
"id": "TerraformLS",
"description": "terraform-ls for Windows (x64)",
"url": "https://releases.hashicorp.com/terraform-ls/0.36.5/terraform-ls_0.36.5_windows_amd64.zip",
"platformId": "win-x64",
"archiveType": "zip",
"binaryName": "terraform-ls.exe"
}
]
}
@@ -0,0 +1,190 @@
import json
import logging
import os
import pathlib
import shutil
import stat
import subprocess
import threading
from overrides import override
from solidlsp.ls import SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_logger import LanguageServerLogger
from solidlsp.ls_utils import FileUtils, PlatformUtils
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo
class TerraformLS(SolidLanguageServer):
"""
Provides Terraform specific instantiation of the LanguageServer class using terraform-ls.
"""
@override
def is_ignored_dirname(self, dirname: str) -> bool:
return super().is_ignored_dirname(dirname) or dirname in [".terraform", "terraform.tfstate.d"]
def _get_terraform_version(self) -> str:
self.logger.log("Starting terraform version detection...", logging.DEBUG)
# 1. Try to find terraform using shutil.which
terraform_cmd = shutil.which("terraform")
if terraform_cmd is not None:
self.logger.log(f"Found terraform via shutil.which: {terraform_cmd}", logging.DEBUG)
# 2. Fallback to TERRAFORM_CLI_PATH (set by hashicorp/setup-terraform action)
if not terraform_cmd:
terraform_cli_path = os.environ.get("TERRAFORM_CLI_PATH")
if terraform_cli_path:
self.logger.log(f"Trying TERRAFORM_CLI_PATH: {terraform_cli_path}", logging.DEBUG)
if os.name == "nt":
terraform_binary = os.path.join(terraform_cli_path, "terraform.exe")
else:
terraform_binary = os.path.join(terraform_cli_path, "terraform")
if os.path.exists(terraform_binary):
terraform_cmd = terraform_binary
self.logger.log(f"Found terraform via TERRAFORM_CLI_PATH: {terraform_cmd}", logging.DEBUG)
if not terraform_cmd:
raise RuntimeError("Terraform executable not found. Please ensure Terraform is installed and accessible in your system's PATH.")
self.logger.log(f"Attempting to run: {terraform_cmd} version (with 15s timeout)", logging.DEBUG)
result = subprocess.run(
[terraform_cmd, "version"],
capture_output=True,
text=True,
check=False,
timeout=15, # CRITICAL: 15 second timeout to prevent hangs
)
if result.returncode == 0:
self.logger.log("terraform version command succeeded", logging.DEBUG)
return result.stdout.strip()
else:
raise RuntimeError(f"terraform version command failed with return code {result.returncode}: {result.stderr}")
def setup_runtime_dependencies(self) -> str:
"""
Setup runtime dependencies for terraform-ls.
Downloads and installs terraform-ls if not already present.
"""
# First check if Terraform is available
terraform_version = self._get_terraform_version()
if not terraform_version:
raise RuntimeError(
"Terraform executable not found or failed to execute. "
"Please ensure Terraform is installed and accessible in your system's PATH.\n"
"If it's installed, check for permission issues or corrupted installation.\n"
"Download from https://www.terraform.io/downloads"
)
platform_id = PlatformUtils.get_platform_id()
with open(os.path.join(os.path.dirname(__file__), "runtime_dependencies.json"), encoding="utf-8") as f:
d = json.load(f)
del d["_description"]
runtime_dependencies = d["runtimeDependencies"]
runtime_dependencies = [dependency for dependency in runtime_dependencies if dependency["platformId"] == platform_id.value]
assert (
len(runtime_dependencies) == 1
), f"Expected exactly one runtime dependency for platform {platform_id.value}, found {len(runtime_dependencies)}"
dependency = runtime_dependencies[0]
terraform_ls_dir = os.path.join(os.path.dirname(__file__), "static", "TerraformLS")
terraform_ls_executable_path = os.path.join(terraform_ls_dir, dependency["binaryName"])
if not os.path.exists(terraform_ls_dir):
os.makedirs(terraform_ls_dir)
if not os.path.exists(terraform_ls_executable_path):
self.logger.log(f"Downloading terraform-ls from {dependency['url']}", logging.INFO)
FileUtils.download_and_extract_archive(self.logger, dependency["url"], terraform_ls_dir, dependency["archiveType"])
assert os.path.exists(terraform_ls_executable_path), f"terraform-ls executable not found at {terraform_ls_executable_path}"
# Make the executable file executable on Unix-like systems
if platform_id.value != "win-x64":
os.chmod(terraform_ls_executable_path, stat.S_IEXEC | stat.S_IREAD)
return terraform_ls_executable_path
def __init__(self, config: LanguageServerConfig, logger: LanguageServerLogger, repository_root_path: str):
"""
Creates a TerraformLS instance. This class is not meant to be instantiated directly. Use LanguageServer.create() instead.
"""
terraform_ls_executable_path = self.setup_runtime_dependencies()
super().__init__(
config,
logger,
repository_root_path,
ProcessLaunchInfo(cmd=f"{terraform_ls_executable_path} serve", cwd=repository_root_path),
"terraform",
)
self.server_ready = threading.Event()
self.request_id = 0
def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams:
"""
Returns the initialize params for the Terraform Language Server.
"""
with open(os.path.join(os.path.dirname(__file__), "initialize_params.json"), encoding="utf-8") as f:
d = json.load(f)
del d["_description"]
d["processId"] = os.getpid()
assert d["rootPath"] == "$rootPath"
d["rootPath"] = repository_absolute_path
assert d["rootUri"] == "$rootUri"
d["rootUri"] = pathlib.Path(repository_absolute_path).as_uri()
assert d["workspaceFolders"][0]["uri"] == "$uri"
d["workspaceFolders"][0]["uri"] = pathlib.Path(repository_absolute_path).as_uri()
assert d["workspaceFolders"][0]["name"] == "$name"
d["workspaceFolders"][0]["name"] = os.path.basename(repository_absolute_path)
return d
def _start_server(self):
"""Start terraform-ls server process"""
def register_capability_handler(params):
return
def window_log_message(msg):
self.logger.log(f"LSP: window/logMessage: {msg}", logging.INFO)
def do_nothing(params):
return
self.server.on_request("client/registerCapability", register_capability_handler)
self.server.on_notification("window/logMessage", window_log_message)
self.server.on_notification("$/progress", do_nothing)
self.server.on_notification("textDocument/publishDiagnostics", do_nothing)
self.logger.log("Starting terraform-ls server process", logging.INFO)
self.server.start()
initialize_params = self._get_initialize_params(self.repository_root_path)
self.logger.log(
"Sending initialize request from LSP client to LSP server and awaiting response",
logging.INFO,
)
init_response = self.server.send.initialize(initialize_params)
# Verify server capabilities
assert "textDocumentSync" in init_response["capabilities"]
assert "completionProvider" in init_response["capabilities"]
assert "definitionProvider" in init_response["capabilities"]
self.server.notify.initialized({})
self.completions_available.set()
# terraform-ls server is typically ready immediately after initialization
self.server_ready.set()
self.server_ready.wait()
+10
View File
@@ -186,6 +186,16 @@ class SolidLanguageServer(ABC):
ls = ClojureLSP(config, logger, repository_root_path)
elif config.code_language == Language.ELIXIR:
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
ls = ElixirTools(config, logger, repository_root_path)
elif config.code_language == Language.TERRAFORM:
from solidlsp.language_servers.terraform_ls.terraform_ls import TerraformLS
ls = TerraformLS(config, logger, repository_root_path)
else:
logger.log(f"Language {config.code_language} is not supported", logging.ERROR)
raise LanguageServerException(f"Language {config.code_language} is not supported")
+6
View File
@@ -38,6 +38,8 @@ class Language(str, Enum):
CPP = "cpp"
PHP = "php"
CLOJURE = "clojure"
ELIXIR = "elixir"
TERRAFORM = "terraform"
def __str__(self) -> str:
return self.value
@@ -74,6 +76,10 @@ class Language(str, Enum):
return FilenameMatcher("*.php")
case self.CLOJURE:
return FilenameMatcher("*.clj", "*.cljs", "*.cljc", "*.edn") # codespell:ignore edn
case self.ELIXIR:
return FilenameMatcher("*.ex", "*.exs")
case self.TERRAFORM:
return FilenameMatcher("*.tf", "*.tfvars", "*.tfstate")
case _:
raise ValueError(f"Unhandled language: {self}")
Whitespace-only changes.
@@ -0,0 +1,211 @@
defmodule TestRepo.Examples do
@moduledoc """
Examples module demonstrating usage of models and services.
Similar to Python's examples directory, this shows how different modules work together.
"""
alias TestRepo.Models.{User, Item}
alias TestRepo.Services.{UserService, ItemService, OrderService}
defmodule UserManagement do
@doc """
Creates a complete user workflow example.
"""
def run_user_example do
# Start user service
{:ok, user_service} = UserService.start_link()
# Create users
{:ok, alice} = UserService.create_user(user_service, "1", "Alice", "alice@example.com", ["admin"])
{:ok, bob} = UserService.create_user(user_service, "2", "Bob", "bob@example.com", ["user"])
# Get users
{:ok, retrieved_alice} = UserService.get_user(user_service, "1")
# List all users
all_users = UserService.list_users(user_service)
# Clean up
GenServer.stop(user_service)
%{
created_alice: alice,
created_bob: bob,
retrieved_alice: retrieved_alice,
all_users: all_users
}
end
@doc """
Demonstrates user role management.
"""
def manage_user_roles do
user = User.new("role_user", "Role User", "role@example.com")
# Add roles
user_with_admin = User.add_role(user, "admin")
user_with_multiple = User.add_role(user_with_admin, "moderator")
# Check roles
has_admin = User.has_role?(user_with_multiple, "admin")
has_guest = User.has_role?(user_with_multiple, "guest")
%{
original_user: user,
user_with_roles: user_with_multiple,
has_admin: has_admin,
has_guest: has_guest
}
end
end
defmodule ShoppingExample do
@doc """
Creates a complete shopping workflow.
"""
def run_shopping_example do
# Create user and items
user = User.new("customer1", "Customer One", "customer@example.com")
item1 = Item.new("widget1", "Super Widget", 19.99, "electronics")
item2 = Item.new("gadget1", "Cool Gadget", 29.99, "electronics")
# Create order
order = OrderService.create_order("order1", user)
# Add items to order
order_with_item1 = OrderService.add_item_to_order(order, item1)
order_with_items = OrderService.add_item_to_order(order_with_item1, item2)
# Process the order
processed_order = OrderService.process_order(order_with_items)
completed_order = OrderService.complete_order(processed_order)
%{
user: user,
items: [item1, item2],
final_order: completed_order,
total_cost: completed_order.total
}
end
@doc """
Demonstrates item filtering and searching.
"""
def item_filtering_example do
# Start item service
{:ok, item_service} = ItemService.start_link()
# Create various items
ItemService.create_item(item_service, "laptop", "Gaming Laptop", 1299.99, "electronics")
ItemService.create_item(item_service, "book", "Elixir Guide", 39.99, "books")
ItemService.create_item(item_service, "phone", "Smartphone", 699.99, "electronics")
ItemService.create_item(item_service, "novel", "Great Novel", 19.99, "books")
# Get all items
all_items = ItemService.list_items(item_service)
# Filter by category
electronics = ItemService.list_items(item_service, "electronics")
books = ItemService.list_items(item_service, "books")
# Clean up
Agent.stop(item_service)
%{
all_items: all_items,
electronics: electronics,
books: books,
total_items: length(all_items),
electronics_count: length(electronics),
books_count: length(books)
}
end
end
defmodule IntegrationExample do
@doc """
Runs a complete e-commerce scenario.
"""
def run_full_scenario do
# Setup services
container = TestRepo.Services.create_service_container()
TestRepo.Services.setup_sample_data(container)
# Get sample data
{:ok, sample_user} = UserService.get_user(container.user_service, TestRepo.Services.sample_user_id())
{:ok, sample_item} = ItemService.get_item(container.item_service, TestRepo.Services.sample_item_id())
# Create additional items
{:ok, premium_item} = ItemService.create_item(
container.item_service,
"premium",
"Premium Product",
99.99,
"premium"
)
# Create order with multiple items
order = OrderService.create_order("big_order", sample_user, [sample_item])
order_with_premium = OrderService.add_item_to_order(order, premium_item)
# Process through order lifecycle
processing_order = OrderService.process_order(order_with_premium)
final_order = OrderService.complete_order(processing_order)
# Serialize everything for output
serialized_user = TestRepo.Services.serialize_model(sample_user)
serialized_order = TestRepo.Services.serialize_model(final_order)
# Clean up
GenServer.stop(container.user_service)
Agent.stop(container.item_service)
%{
scenario: "full_ecommerce",
user: serialized_user,
order: serialized_order,
total_revenue: final_order.total,
items_sold: length(final_order.items)
}
end
@doc """
Demonstrates error handling scenarios.
"""
def error_handling_example do
{:ok, user_service} = UserService.start_link()
# Try to create duplicate user
{:ok, _user1} = UserService.create_user(user_service, "dup", "User", "user@example.com")
duplicate_result = UserService.create_user(user_service, "dup", "Another User", "another@example.com")
# Try to get non-existent user
missing_user_result = UserService.get_user(user_service, "nonexistent")
# Try to delete non-existent user
delete_result = UserService.delete_user(user_service, "nonexistent")
GenServer.stop(user_service)
%{
duplicate_user_error: duplicate_result,
missing_user_error: missing_user_result,
delete_missing_error: delete_result
}
end
end
@doc """
Main function to run all examples.
"""
def run_all_examples do
%{
user_management: UserManagement.run_user_example(),
role_management: UserManagement.manage_user_roles(),
shopping: ShoppingExample.run_shopping_example(),
item_filtering: ShoppingExample.item_filtering_example(),
integration: IntegrationExample.run_full_scenario(),
error_handling: IntegrationExample.error_handling_example()
}
end
end
@@ -0,0 +1,23 @@
defmodule TestRepo.IgnoredDir.IgnoredModule do
@moduledoc """
This module is in a directory that should be ignored by the language server.
It's used for testing directory filtering functionality.
"""
alias TestRepo.Models.User
@doc """
This function references the User model to test that ignored directories
don't show up in symbol references.
"""
def create_ignored_user do
User.new("ignored", "Ignored User", "ignored@example.com")
end
@doc """
Another function that uses models.
"""
def process_ignored_user(user) do
User.add_role(user, "ignored_role")
end
end
@@ -0,0 +1,166 @@
defmodule TestRepo.Models do
@moduledoc """
Models module demonstrating various Elixir patterns including structs, protocols, and behaviours.
"""
defprotocol Serializable do
@doc "Convert model to map representation"
def to_map(model)
end
defmodule User do
@type t :: %__MODULE__{
id: String.t(),
name: String.t() | nil,
email: String.t(),
roles: list(String.t())
}
defstruct [:id, :name, :email, roles: []]
@doc """
Creates a new user.
## Examples
iex> TestRepo.Models.User.new("1", "Alice", "alice@example.com")
%TestRepo.Models.User{id: "1", name: "Alice", email: "alice@example.com", roles: []}
"""
def new(id, name, email, roles \\ []) do
%__MODULE__{id: id, name: name, email: email, roles: roles}
end
@doc """
Checks if user has a specific role.
"""
def has_role?(%__MODULE__{roles: roles}, role) do
role in roles
end
@doc """
Adds a role to the user.
"""
def add_role(%__MODULE__{roles: roles} = user, role) do
%{user | roles: [role | roles]}
end
end
defmodule Item do
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
price: float(),
category: String.t()
}
defstruct [:id, :name, :price, :category]
@doc """
Creates a new item.
## Examples
iex> TestRepo.Models.Item.new("1", "Widget", 19.99, "electronics")
%TestRepo.Models.Item{id: "1", name: "Widget", price: 19.99, category: "electronics"}
"""
def new(id, name, price, category) do
%__MODULE__{id: id, name: name, price: price, category: category}
end
@doc """
Formats price for display.
"""
def display_price(%__MODULE__{price: price}) do
"$#{:erlang.float_to_binary(price, decimals: 2)}"
end
@doc """
Checks if item is in a specific category.
"""
def in_category?(%__MODULE__{category: category}, target_category) do
category == target_category
end
end
defmodule Order do
alias TestRepo.Models.{User, Item}
@type t :: %__MODULE__{
id: String.t(),
user: User.t(),
items: list(Item.t()),
total: float(),
status: atom()
}
defstruct [:id, :user, items: [], total: 0.0, status: :pending]
@doc """
Creates a new order.
"""
def new(id, user, items \\ []) do
total = calculate_total(items)
%__MODULE__{id: id, user: user, items: items, total: total}
end
@doc """
Adds an item to the order.
"""
def add_item(%__MODULE__{items: items} = order, item) do
new_items = [item | items]
%{order | items: new_items, total: calculate_total(new_items)}
end
@doc """
Updates order status.
"""
def update_status(%__MODULE__{} = order, status) do
%{order | status: status}
end
defp calculate_total(items) do
Enum.reduce(items, 0.0, fn item, acc -> acc + item.price end)
end
end
# Protocol implementations
defimpl Serializable, for: User do
def to_map(%User{id: id, name: name, email: email, roles: roles}) do
%{id: id, name: name, email: email, roles: roles}
end
end
defimpl Serializable, for: Item do
def to_map(%Item{id: id, name: name, price: price, category: category}) do
%{id: id, name: name, price: price, category: category}
end
end
defimpl Serializable, for: Order do
def to_map(%Order{id: id, user: user, items: items, total: total, status: status}) do
%{
id: id,
user: Serializable.to_map(user),
items: Enum.map(items, &Serializable.to_map/1),
total: total,
status: status
}
end
end
@doc """
Factory function to create a sample user.
"""
def create_sample_user do
User.new("sample", "Sample User", "sample@example.com", ["user"])
end
@doc """
Factory function to create a sample item.
"""
def create_sample_item do
Item.new("sample", "Sample Item", 9.99, "sample")
end
end
@@ -0,0 +1,257 @@
defmodule TestRepo.Services do
@moduledoc """
Services module demonstrating function usage and dependencies.
Similar to Python's services.py, this module uses the models defined in TestRepo.Models.
"""
alias TestRepo.Models.{User, Item, Order, Serializable}
defmodule UserService do
use GenServer
# Client API
@doc """
Starts the UserService GenServer.
"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{}, opts)
end
@doc """
Creates a new user and stores it.
"""
def create_user(pid, id, name, email, roles \\ []) do
GenServer.call(pid, {:create_user, id, name, email, roles})
end
@doc """
Gets a user by ID.
"""
def get_user(pid, id) do
GenServer.call(pid, {:get_user, id})
end
@doc """
Lists all users.
"""
def list_users(pid) do
GenServer.call(pid, :list_users)
end
@doc """
Deletes a user by ID.
"""
def delete_user(pid, id) do
GenServer.call(pid, {:delete_user, id})
end
# Server callbacks
@impl true
def init(_) do
{:ok, %{}}
end
@impl true
def handle_call({:create_user, id, name, email, roles}, _from, users) do
if Map.has_key?(users, id) do
{:reply, {:error, "User with ID #{id} already exists"}, users}
else
user = User.new(id, name, email, roles)
new_users = Map.put(users, id, user)
{:reply, {:ok, user}, new_users}
end
end
@impl true
def handle_call({:get_user, id}, _from, users) do
case Map.get(users, id) do
nil -> {:reply, {:error, :not_found}, users}
user -> {:reply, {:ok, user}, users}
end
end
@impl true
def handle_call(:list_users, _from, users) do
user_list = Map.values(users)
{:reply, user_list, users}
end
@impl true
def handle_call({:delete_user, id}, _from, users) do
if Map.has_key?(users, id) do
new_users = Map.delete(users, id)
{:reply, :ok, new_users}
else
{:reply, {:error, :not_found}, users}
end
end
end
defmodule ItemService do
use Agent
@doc """
Starts the ItemService Agent.
"""
def start_link(opts \\ []) do
Agent.start_link(fn -> %{} end, opts)
end
@doc """
Creates a new item and stores it.
"""
def create_item(pid, id, name, price, category) do
Agent.get_and_update(pid, fn items ->
if Map.has_key?(items, id) do
{{:error, "Item with ID #{id} already exists"}, items}
else
item = Item.new(id, name, price, category)
new_items = Map.put(items, id, item)
{{:ok, item}, new_items}
end
end)
end
@doc """
Gets an item by ID.
"""
def get_item(pid, id) do
Agent.get(pid, fn items ->
case Map.get(items, id) do
nil -> {:error, :not_found}
item -> {:ok, item}
end
end)
end
@doc """
Lists all items, optionally filtered by category.
"""
def list_items(pid, category \\ nil) do
Agent.get(pid, fn items ->
item_list = Map.values(items)
case category do
nil -> item_list
cat -> Enum.filter(item_list, &Item.in_category?(&1, cat))
end
end)
end
@doc """
Deletes an item by ID.
"""
def delete_item(pid, id) do
Agent.get_and_update(pid, fn items ->
if Map.has_key?(items, id) do
new_items = Map.delete(items, id)
{:ok, new_items}
else
{{:error, :not_found}, items}
end
end)
end
end
defmodule OrderService do
@doc """
Creates a new order.
"""
def create_order(id, user, items \\ []) do
Order.new(id, user, items)
end
@doc """
Adds an item to an existing order.
"""
def add_item_to_order(order, item) do
Order.add_item(order, item)
end
@doc """
Updates the status of an order.
"""
def update_order_status(order, status) do
Order.update_status(order, status)
end
@doc """
Processes an order (changes status to :processing).
"""
def process_order(order) do
update_order_status(order, :processing)
end
@doc """
Completes an order (changes status to :completed).
"""
def complete_order(order) do
update_order_status(order, :completed)
end
@doc """
Cancels an order (changes status to :cancelled).
"""
def cancel_order(order) do
update_order_status(order, :cancelled)
end
end
@doc """
Factory function to create a service container.
"""
def create_service_container do
{:ok, user_service} = UserService.start_link()
{:ok, item_service} = ItemService.start_link()
%{
user_service: user_service,
item_service: item_service,
order_service: OrderService
}
end
@doc """
Helper function to serialize any model that implements the Serializable protocol.
"""
def serialize_model(model) do
Serializable.to_map(model)
end
# Module-level variables for testing
@sample_user_id "sample_user"
@sample_item_id "sample_item"
@doc """
Gets the sample user ID.
"""
def sample_user_id, do: @sample_user_id
@doc """
Gets the sample item ID.
"""
def sample_item_id, do: @sample_item_id
# Create some sample data at module load time
def setup_sample_data(container) do
# Create sample user
UserService.create_user(
container.user_service,
@sample_user_id,
"Sample User",
"sample@example.com",
["user", "customer"]
)
# Create sample item
ItemService.create_item(
container.item_service,
@sample_item_id,
"Sample Widget",
29.99,
"electronics"
)
end
end
@@ -0,0 +1,31 @@
defmodule TestRepo do
@moduledoc """
Documentation for `TestRepo`.
"""
@doc """
Hello world.
## Examples
iex> TestRepo.hello()
:world
"""
def hello do
:world
end
@doc """
Adds two numbers together.
## Examples
iex> TestRepo.add(2, 3)
5
"""
def add(a, b) do
a + b
end
end
@@ -0,0 +1,48 @@
defmodule TestRepo.Utils do
@moduledoc """
Utility functions for TestRepo.
"""
@doc """
Converts a string to uppercase.
## Examples
iex> TestRepo.Utils.upcase("hello")
"HELLO"
"""
def upcase(string) when is_binary(string) do
String.upcase(string)
end
@doc """
Calculates the factorial of a number.
## Examples
iex> TestRepo.Utils.factorial(5)
120
"""
def factorial(0), do: 1
def factorial(n) when n > 0 do
n * factorial(n - 1)
end
@doc """
Checks if a number is even.
## Examples
iex> TestRepo.Utils.even?(4)
true
iex> TestRepo.Utils.even?(3)
false
"""
def even?(n) when is_integer(n) do
rem(n, 2) == 0
end
end
@@ -0,0 +1,25 @@
defmodule TestRepo.MixProject do
use Mix.Project
def project do
[
app: :test_repo,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end
end
@@ -0,0 +1,6 @@
%{
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
}
@@ -0,0 +1,26 @@
defmodule TestRepo.Scripts.BuildScript do
@moduledoc """
Build script that references models.
This is in the scripts directory which should be ignored in some tests.
"""
alias TestRepo.Models.{User, Item}
@doc """
Script function that creates test data.
"""
def create_test_data do
user = User.new("script_user", "Script User", "script@example.com")
item = Item.new("script_item", "Script Item", 1.0, "script")
{user, item}
end
@doc """
Another script function referencing User.
"""
def cleanup_users do
# This would reference User in a real scenario
IO.puts("Cleaning up users...")
end
end
@@ -0,0 +1,169 @@
defmodule TestRepo.ModelsTest do
use ExUnit.Case
doctest TestRepo.Models
alias TestRepo.Models.{User, Item, Order, Serializable}
describe "User" do
test "creates a new user with default roles" do
user = User.new("1", "Alice", "alice@example.com")
assert user.id == "1"
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.roles == []
end
test "creates a user with specified roles" do
user = User.new("2", "Bob", "bob@example.com", ["admin", "user"])
assert user.roles == ["admin", "user"]
end
test "checks if user has role" do
user = User.new("3", "Charlie", "charlie@example.com", ["admin"])
assert User.has_role?(user, "admin")
refute User.has_role?(user, "guest")
end
test "adds role to user" do
user = User.new("4", "David", "david@example.com")
user_with_role = User.add_role(user, "moderator")
assert User.has_role?(user_with_role, "moderator")
assert length(user_with_role.roles) == 1
end
end
describe "Item" do
test "creates a new item" do
item = Item.new("widget1", "Super Widget", 19.99, "electronics")
assert item.id == "widget1"
assert item.name == "Super Widget"
assert item.price == 19.99
assert item.category == "electronics"
end
test "formats price for display" do
item = Item.new("item1", "Test Item", 29.99, "test")
assert Item.display_price(item) == "$29.99"
end
test "checks if item is in category" do
item = Item.new("book1", "Elixir Book", 39.99, "books")
assert Item.in_category?(item, "books")
refute Item.in_category?(item, "electronics")
end
end
describe "Order" do
setup do
user = User.new("customer1", "Customer", "customer@example.com")
item1 = Item.new("item1", "Item 1", 10.00, "category1")
item2 = Item.new("item2", "Item 2", 20.00, "category2")
%{user: user, item1: item1, item2: item2}
end
test "creates a new order", %{user: user} do
order = Order.new("order1", user)
assert order.id == "order1"
assert order.user == user
assert order.items == []
assert order.total == 0.0
assert order.status == :pending
end
test "creates order with items", %{user: user, item1: item1, item2: item2} do
order = Order.new("order2", user, [item1, item2])
assert length(order.items) == 2
assert order.total == 30.0
end
test "adds item to order", %{user: user, item1: item1, item2: item2} do
order = Order.new("order3", user, [item1])
order_with_item = Order.add_item(order, item2)
assert length(order_with_item.items) == 2
assert order_with_item.total == 30.0
end
test "updates order status", %{user: user} do
order = Order.new("order4", user)
processed_order = Order.update_status(order, :processing)
assert processed_order.status == :processing
end
end
describe "Serializable protocol" do
test "serializes User" do
user = User.new("1", "Alice", "alice@example.com", ["admin"])
serialized = Serializable.to_map(user)
expected = %{
id: "1",
name: "Alice",
email: "alice@example.com",
roles: ["admin"]
}
assert serialized == expected
end
test "serializes Item" do
item = Item.new("widget1", "Widget", 19.99, "electronics")
serialized = Serializable.to_map(item)
expected = %{
id: "widget1",
name: "Widget",
price: 19.99,
category: "electronics"
}
assert serialized == expected
end
test "serializes Order" do
user = User.new("1", "Alice", "alice@example.com")
item = Item.new("widget1", "Widget", 19.99, "electronics")
order = Order.new("order1", user, [item])
serialized = Serializable.to_map(order)
assert serialized.id == "order1"
assert serialized.total == 19.99
assert serialized.status == :pending
assert is_map(serialized.user)
assert is_list(serialized.items)
assert length(serialized.items) == 1
end
end
describe "factory functions" do
test "creates sample user" do
user = TestRepo.Models.create_sample_user()
assert user.id == "sample"
assert user.name == "Sample User"
assert user.email == "sample@example.com"
assert "user" in user.roles
end
test "creates sample item" do
item = TestRepo.Models.create_sample_item()
assert item.id == "sample"
assert item.name == "Sample Item"
assert item.price == 9.99
assert item.category == "sample"
end
end
end
@@ -0,0 +1,14 @@
defmodule TestRepoTest do
use ExUnit.Case
doctest TestRepo
test "greets the world" do
assert TestRepo.hello() == :world
end
test "adds numbers correctly" do
assert TestRepo.add(2, 3) == 5
assert TestRepo.add(-1, 1) == 0
assert TestRepo.add(0, 0) == 0
end
end
@@ -0,0 +1,28 @@
# Data sources for the Terraform configuration
# Get the latest Ubuntu AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Get available availability zones
data "aws_availability_zones" "available" {
state = "available"
}
# Get current AWS caller identity
data "aws_caller_identity" "current" {}
# Get current AWS region
data "aws_region" "current" {}
@@ -0,0 +1,126 @@
# Main Terraform configuration
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# EC2 Instance
resource "aws_instance" "web_server" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web_sg.id]
subnet_id = aws_subnet.public.id
user_data = <<-EOF
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
systemctl enable nginx
EOF
tags = {
Name = "${var.project_name}-web-server"
Environment = var.environment
Project = var.project_name
}
}
# S3 Bucket
resource "aws_s3_bucket" "app_bucket" {
bucket = "${var.project_name}-${var.environment}-bucket"
tags = {
Name = "${var.project_name}-bucket"
Environment = var.environment
Project = var.project_name
}
}
resource "aws_s3_bucket_versioning" "app_bucket_versioning" {
bucket = aws_s3_bucket.app_bucket.id
versioning_configuration {
status = "Enabled"
}
}
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
Environment = var.environment
Project = var.project_name
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
Environment = var.environment
Project = var.project_name
}
}
# Public Subnet
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-subnet"
Environment = var.environment
Project = var.project_name
}
}
# Security Group
resource "aws_security_group" "web_sg" {
name_prefix = "${var.project_name}-web-"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-web-sg"
Environment = var.environment
Project = var.project_name
}
}
@@ -0,0 +1,46 @@
# Output values for the Terraform configuration
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.web_server.id
}
output "instance_public_ip" {
description = "Public IP address of the EC2 instance"
value = aws_instance.web_server.public_ip
}
output "instance_public_dns" {
description = "Public DNS name of the EC2 instance"
value = aws_instance.web_server.public_dns
}
output "s3_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.app_bucket.bucket
}
output "s3_bucket_arn" {
description = "ARN of the S3 bucket"
value = aws_s3_bucket.app_bucket.arn
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "subnet_id" {
description = "ID of the public subnet"
value = aws_subnet.public.id
}
output "security_group_id" {
description = "ID of the security group"
value = aws_security_group.web_sg.id
}
output "application_url" {
description = "URL to access the application"
value = "http://${aws_instance.web_server.public_dns}"
}
@@ -0,0 +1,61 @@
# Input variables for the Terraform configuration
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-west-2"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
validation {
condition = contains([
"t3.micro", "t3.small", "t3.medium",
"t2.micro", "t2.small", "t2.medium"
], var.instance_type)
error_message = "Instance type must be a valid t2 or t3 instance type."
}
}
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "project_name" {
description = "Name of the project"
type = string
default = "terraform-test"
validation {
condition = can(regex("^[a-z0-9-]+$", var.project_name))
error_message = "Project name must contain only lowercase letters, numbers, and hyphens."
}
}
variable "enable_monitoring" {
description = "Enable CloudWatch monitoring"
type = bool
default = false
}
variable "allowed_cidr_blocks" {
description = "List of CIDR blocks allowed to access the application"
type = list(string)
default = ["0.0.0.0/0"]
}
variable "tags" {
description = "Additional tags to apply to resources"
type = map(string)
default = {}
}
+1 -1
View File
@@ -1,4 +1,4 @@
from serena.agent import CreateTextFileTool, ReadFileTool, Tool
from serena.tools import CreateTextFileTool, ReadFileTool, Tool
class TestEditMarker:
@@ -1,162 +0,0 @@
"""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 SerenaMCPFactory
from serena.process_isolated_agent import ProcessIsolatedSerenaAgent, ProcessIsolatedTool
from test.serena.test_serena_agent import SerenaConfigForTests
make_tool = SerenaMCPFactory.make_mcp_tool
@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}"
+7 -54
View File
@@ -1,32 +1,17 @@
import json
import os
import time
from dataclasses import dataclass
import pytest
import test.solidlsp.clojure as clj
from serena.agent import FindReferencingSymbolsTool, FindSymbolTool, Project, ProjectConfig, SerenaAgent, SerenaConfigBase
from serena.process_isolated_agent import ProcessIsolatedSerenaAgent
from serena.agent import SerenaAgent
from serena.config.serena_config import Project, ProjectConfig, SerenaConfig
from serena.tools import FindReferencingSymbolsTool, FindSymbolTool
from solidlsp.ls_config import Language
from test.conftest import get_repo_path
@dataclass
class SerenaConfigForTests(SerenaConfigBase):
"""
In-memory implementation of Serena configuration with the GUI disabled.
"""
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():
"""Create an in-memory configuration for tests with test repositories pre-registered."""
@@ -60,7 +45,7 @@ def serena_config():
)
test_projects.append(project)
config = SerenaConfigForTests()
config = SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)
config.projects = test_projects
return config
@@ -70,31 +55,10 @@ def serena_agent(request: pytest.FixtureRequest, serena_config):
language = Language(request.param)
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)
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",
[
@@ -115,7 +79,7 @@ class TestSerenaAgent:
],
indirect=["serena_agent"],
)
def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str, isolated_process: bool):
def test_find_symbol(self, serena_agent, symbol_name: str, expected_kind: str, expected_file: str):
agent = serena_agent
find_symbol_tool = agent.get_tool(FindSymbolTool)
result = find_symbol_tool.apply_ex(name_path=symbol_name)
@@ -126,9 +90,6 @@ class TestSerenaAgent:
for s in symbols
), 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",
[
@@ -161,7 +122,7 @@ class TestSerenaAgent:
],
indirect=["serena_agent"],
)
def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str, isolated_process: bool) -> None:
def test_find_symbol_references(self, serena_agent, symbol_name: str, def_file: str, ref_file: str) -> None:
agent = serena_agent
# Find the symbol location first
@@ -182,9 +143,6 @@ class TestSerenaAgent:
ref["relative_path"] == ref_file for ref in 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",
[
@@ -259,7 +217,6 @@ class TestSerenaAgent:
expected_symbol_name: str,
expected_kind: str,
expected_file: str,
isolated_process: bool,
):
agent = serena_agent
@@ -282,9 +239,6 @@ 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",
[
@@ -307,7 +261,6 @@ class TestSerenaAgent:
self,
serena_agent,
name_path: str,
isolated_process: bool,
):
agent = serena_agent
+28
View File
@@ -0,0 +1,28 @@
import platform
def _test_nextls_available() -> str:
"""Test if Next LS is available and return error reason if not."""
# Check if we're on Windows (Next LS doesn't support Windows)
if platform.system() == "Windows":
return "Next LS does not support Windows"
# Try to import and check Elixir availability
try:
from solidlsp.language_servers.elixir_tools.elixir_tools import ElixirTools
# Check if Elixir is installed
elixir_version = ElixirTools._get_elixir_version()
if not elixir_version:
return "Elixir is not installed or not in PATH"
return "" # No error, Next LS should be available
except ImportError as e:
return f"Failed to import ElixirTools: {e}"
except Exception as e:
return f"Error checking Next LS availability: {e}"
NEXTLS_UNAVAILABLE_REASON = _test_nextls_available()
NEXTLS_UNAVAILABLE = bool(NEXTLS_UNAVAILABLE_REASON)
+174
View File
@@ -0,0 +1,174 @@
"""
Elixir-specific test configuration and fixtures.
"""
import os
import subprocess
import time
from pathlib import Path
import pytest
def ensure_elixir_test_repo_compiled(repo_path: str) -> None:
"""Ensure the Elixir test repository dependencies are installed and project is compiled.
Next LS requires the project to be fully compiled and indexed before providing
complete references and symbol resolution. This function:
1. Installs dependencies via 'mix deps.get'
2. Compiles the project via 'mix compile'
This is essential in CI environments where dependencies aren't pre-installed.
Args:
repo_path: Path to the Elixir project root directory
"""
# Check if this looks like an Elixir project
mix_file = os.path.join(repo_path, "mix.exs")
if not os.path.exists(mix_file):
return
# Check if already compiled (optimization for repeated runs)
build_path = os.path.join(repo_path, "_build")
deps_path = os.path.join(repo_path, "deps")
if os.path.exists(build_path) and os.path.exists(deps_path):
print(f"Elixir test repository already compiled in {repo_path}")
return
try:
print("Installing dependencies and compiling Elixir test repository for optimal Next LS performance...")
# First, install dependencies with increased timeout for CI
print("=" * 60)
print("Step 1/2: Installing Elixir dependencies...")
print("=" * 60)
start_time = time.time()
deps_result = subprocess.run(
["mix", "deps.get"],
cwd=repo_path,
capture_output=True,
text=True,
timeout=180,
check=False, # 3 minutes for dependency installation (CI can be slow)
)
deps_duration = time.time() - start_time
print(f"Dependencies installation completed in {deps_duration:.2f} seconds")
# Always log the output for transparency
if deps_result.stdout.strip():
print("Dependencies stdout:")
print("-" * 40)
print(deps_result.stdout)
print("-" * 40)
if deps_result.stderr.strip():
print("Dependencies stderr:")
print("-" * 40)
print(deps_result.stderr)
print("-" * 40)
if deps_result.returncode != 0:
print(f"⚠️ Warning: Dependencies installation failed with exit code {deps_result.returncode}")
# Continue anyway - some projects might not have dependencies
else:
print("✓ Dependencies installed successfully")
# Then compile the project with increased timeout for CI
print("=" * 60)
print("Step 2/2: Compiling Elixir project...")
print("=" * 60)
start_time = time.time()
compile_result = subprocess.run(
["mix", "compile"],
cwd=repo_path,
capture_output=True,
text=True,
timeout=300,
check=False, # 5 minutes for compilation (Credo compilation can be slow in CI)
)
compile_duration = time.time() - start_time
print(f"Compilation completed in {compile_duration:.2f} seconds")
# Always log the output for transparency
if compile_result.stdout.strip():
print("Compilation stdout:")
print("-" * 40)
print(compile_result.stdout)
print("-" * 40)
if compile_result.stderr.strip():
print("Compilation stderr:")
print("-" * 40)
print(compile_result.stderr)
print("-" * 40)
if compile_result.returncode == 0:
print(f"✓ Elixir test repository compiled successfully in {repo_path}")
else:
print(f"⚠️ Warning: Compilation completed with exit code {compile_result.returncode}")
# Still continue - warnings are often non-fatal
print("=" * 60)
print(f"Total setup time: {time.time() - (start_time - compile_duration - deps_duration):.2f} seconds")
print("=" * 60)
except subprocess.TimeoutExpired as e:
print("=" * 60)
print(f"❌ TIMEOUT: Elixir setup timed out after {e.timeout} seconds")
print(f"Command: {' '.join(e.cmd)}")
print("This may indicate slow CI environment - Next LS may still work but with reduced functionality")
# Try to get partial output if available
if hasattr(e, "stdout") and e.stdout:
print("Partial stdout before timeout:")
print("-" * 40)
print(e.stdout)
print("-" * 40)
if hasattr(e, "stderr") and e.stderr:
print("Partial stderr before timeout:")
print("-" * 40)
print(e.stderr)
print("-" * 40)
print("=" * 60)
except FileNotFoundError:
print("❌ ERROR: 'mix' command not found - Elixir test repository may not be compiled")
print("Please ensure Elixir is installed and available in PATH")
except Exception as e:
print(f"❌ ERROR: Failed to prepare Elixir test repository: {e}")
@pytest.fixture(scope="session", autouse=True)
def setup_elixir_test_environment():
"""Automatically prepare Elixir test environment for all Elixir tests.
This fixture runs once per test session and automatically:
1. Installs dependencies via 'mix deps.get'
2. Compiles the Elixir test repository via 'mix compile'
It uses autouse=True so it runs automatically without needing to be explicitly
requested by tests. This ensures Next LS has a fully prepared project to work with.
Uses generous timeouts (3-5 minutes) to accommodate slow CI environments.
All output is logged for transparency and debugging.
"""
# Get the test repo path relative to this conftest.py file
test_repo_path = Path(__file__).parent.parent.parent / "resources" / "repos" / "elixir" / "test_repo"
ensure_elixir_test_repo_compiled(str(test_repo_path))
return str(test_repo_path)
@pytest.fixture(scope="session")
def elixir_test_repo_path(setup_elixir_test_environment):
"""Get the path to the prepared Elixir test repository.
This fixture depends on setup_elixir_test_environment to ensure dependencies
are installed and compilation has completed before returning the path.
"""
return setup_elixir_test_environment
+112
View File
@@ -0,0 +1,112 @@
"""
Basic integration tests for the Elixir language server functionality.
These tests validate the functionality of the language server APIs
like request_references using the test repository.
"""
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
class TestElixirBasic:
"""Basic Elixir language server functionality tests."""
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_references_function_definition(self, language_server: SolidLanguageServer):
"""Test finding references to a function definition."""
file_path = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(file_path)
# Find the User module's 'new' function
user_new_symbol = None
for symbol in symbols[0]: # Top level symbols
if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module
for child in symbol.get("children", []):
if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function
user_new_symbol = child
break
break
if not user_new_symbol or "selectionRange" not in user_new_symbol:
pytest.skip("User.new function or its selectionRange not found")
sel_start = user_new_symbol["selectionRange"]["start"]
references = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert references is not None
assert len(references) > 0
# Should find at least one reference (the definition itself)
found_definition = any(ref["uri"].endswith("models.ex") for ref in references)
assert found_definition, "Should find the function definition"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_references_create_user_function(self, language_server: SolidLanguageServer):
"""Test finding references to create_user function."""
file_path = os.path.join("lib", "services.ex")
symbols = language_server.request_document_symbols(file_path)
# Find the UserService module's 'create_user' function
create_user_symbol = None
for symbol in symbols[0]: # Top level symbols
if symbol.get("name") == "UserService" and symbol.get("kind") == 2: # Module
for child in symbol.get("children", []):
if child.get("name", "").startswith("def create_user(") and child.get("kind") == 12: # Function
create_user_symbol = child
break
break
if not create_user_symbol or "selectionRange" not in create_user_symbol:
pytest.skip("UserService.create_user function or its selectionRange not found")
sel_start = create_user_symbol["selectionRange"]["start"]
references = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert references is not None
assert len(references) > 0
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_referencing_symbols_function(self, language_server: SolidLanguageServer):
"""Test finding symbols that reference a specific function."""
file_path = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(file_path)
# Find the User module's 'new' function
user_new_symbol = None
for symbol in symbols[0]: # Top level symbols
if symbol.get("name") == "User" and symbol.get("kind") == 2: # Module
for child in symbol.get("children", []):
if child.get("name", "").startswith("def new(") and child.get("kind") == 12: # Function
user_new_symbol = child
break
break
if not user_new_symbol or "selectionRange" not in user_new_symbol:
pytest.skip("User.new function or its selectionRange not found")
sel_start = user_new_symbol["selectionRange"]["start"]
referencing_symbols = language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
assert referencing_symbols is not None
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_timeout_enumeration_bug(self, language_server: SolidLanguageServer):
"""Test that enumeration doesn't timeout (regression test)."""
# This should complete without timing out
symbols = language_server.request_document_symbols("lib/models.ex")
assert symbols is not None
# Test multiple symbol requests in succession
for _ in range(3):
symbols = language_server.request_document_symbols("lib/services.ex")
assert symbols is not None
@@ -0,0 +1,142 @@
from collections.abc import Generator
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.conftest import create_ls
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
@pytest.fixture(scope="module")
def ls_with_ignored_dirs() -> Generator[SolidLanguageServer, None, None]:
"""Fixture to set up an LS for the elixir test repo with the 'scripts' directory ignored."""
ignored_paths = ["scripts", "ignored_dir"]
ls = create_ls(ignored_paths=ignored_paths, language=Language.ELIXIR)
ls.start()
try:
yield ls
finally:
ls.stop()
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True)
def test_symbol_tree_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that request_full_symbol_tree ignores the configured directory."""
root = ls_with_ignored_dirs.request_full_symbol_tree()[0]
root_children = root["children"]
children_names = {child["name"] for child in root_children}
# Should have lib and test directories, but not scripts or ignored_dir
expected_dirs = {"lib", "test"}
assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}"
assert "scripts" not in children_names, f"scripts should not be in {children_names}"
assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names}"
@pytest.mark.parametrize("ls_with_ignored_dirs", [Language.ELIXIR], indirect=True)
def test_find_references_ignores_dir(ls_with_ignored_dirs: SolidLanguageServer):
"""Tests that find_references ignores the configured directory."""
# Location of User struct, which is referenced in scripts and ignored_dir
definition_file = "lib/models.ex"
# Find the User struct definition
symbols = ls_with_ignored_dirs.request_document_symbols(definition_file)
user_symbol = None
for symbol_group in symbols:
user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None)
if user_symbol:
break
if not user_symbol or "selectionRange" not in user_symbol:
pytest.skip("User symbol not found for reference testing")
sel_start = user_symbol["selectionRange"]["start"]
references = ls_with_ignored_dirs.request_references(definition_file, sel_start["line"], sel_start["character"])
# Assert that scripts and ignored_dir do not appear in the references
assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored"
@pytest.mark.parametrize("repo_path", [Language.ELIXIR], indirect=True)
def test_refs_and_symbols_with_glob_patterns(repo_path: Path) -> None:
"""Tests that refs and symbols with glob patterns are ignored."""
ignored_paths = ["*cripts", "ignored_*"] # codespell:ignore cripts
ls = create_ls(ignored_paths=ignored_paths, repo_path=str(repo_path), language=Language.ELIXIR)
ls.start()
try:
# Same as in the above tests
root = ls.request_full_symbol_tree()[0]
root_children = root["children"]
children_names = {child["name"] for child in root_children}
# Should have lib and test directories, but not scripts or ignored_dir
expected_dirs = {"lib", "test"}
assert expected_dirs.issubset(children_names), f"Expected {expected_dirs} to be in {children_names}"
assert "scripts" not in children_names, f"scripts should not be in {children_names} (glob pattern)"
assert "ignored_dir" not in children_names, f"ignored_dir should not be in {children_names} (glob pattern)"
# Test that the refs and symbols with glob patterns are ignored
definition_file = "lib/models.ex"
# Find the User struct definition
symbols = ls.request_document_symbols(definition_file)
user_symbol = None
for symbol_group in symbols:
user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None)
if user_symbol:
break
if user_symbol and "selectionRange" in user_symbol:
sel_start = user_symbol["selectionRange"]["start"]
references = ls.request_references(definition_file, sel_start["line"], sel_start["character"])
# Assert that scripts and ignored_dir do not appear in references
assert not any("scripts" in ref["relativePath"] for ref in references), "scripts should be ignored (glob)"
assert not any("ignored_dir" in ref["relativePath"] for ref in references), "ignored_dir should be ignored (glob)"
finally:
ls.stop()
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_default_ignored_directories(language_server: SolidLanguageServer):
"""Test that default Elixir directories are ignored."""
# Test that Elixir-specific directories are ignored by default
assert language_server.is_ignored_dirname("_build"), "_build should be ignored"
assert language_server.is_ignored_dirname("deps"), "deps should be ignored"
assert language_server.is_ignored_dirname(".elixir_ls"), ".elixir_ls should be ignored"
assert language_server.is_ignored_dirname("cover"), "cover should be ignored"
assert language_server.is_ignored_dirname("node_modules"), "node_modules should be ignored"
# Test that important directories are not ignored
assert not language_server.is_ignored_dirname("lib"), "lib should not be ignored"
assert not language_server.is_ignored_dirname("test"), "test should not be ignored"
assert not language_server.is_ignored_dirname("config"), "config should not be ignored"
assert not language_server.is_ignored_dirname("priv"), "priv should not be ignored"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_tree_excludes_build_dirs(language_server: SolidLanguageServer):
"""Test that symbol tree excludes build and dependency directories."""
symbol_tree = language_server.request_full_symbol_tree()
if symbol_tree:
root = symbol_tree[0]
children_names = {child["name"] for child in root.get("children", [])}
# Build and dependency directories should not appear
ignored_dirs = {"_build", "deps", ".elixir_ls", "cover", "node_modules"}
found_ignored = ignored_dirs.intersection(children_names)
assert len(found_ignored) == 0, f"Found ignored directories in symbol tree: {found_ignored}"
# Important directories should appear
important_dirs = {"lib", "test"}
found_important = important_dirs.intersection(children_names)
assert len(found_important) > 0, f"Expected to find important directories: {important_dirs}, got: {children_names}"
@@ -0,0 +1,157 @@
"""
Integration tests for Elixir language server with test repository.
These tests verify that the language server works correctly with a real Elixir project
and can perform advanced operations like cross-file symbol resolution.
"""
import os
from pathlib import Path
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
class TestElixirIntegration:
"""Integration tests for Elixir language server with test repository."""
@pytest.fixture
def elixir_test_repo_path(self):
"""Get the path to the Elixir test repository."""
test_dir = Path(__file__).parent.parent.parent
return str(test_dir / "resources" / "repos" / "elixir" / "test_repo")
def test_elixir_repo_structure(self, elixir_test_repo_path):
"""Test that the Elixir test repository has the expected structure."""
repo_path = Path(elixir_test_repo_path)
# Check that key files exist
assert (repo_path / "mix.exs").exists(), "mix.exs should exist"
assert (repo_path / "lib" / "test_repo.ex").exists(), "main module should exist"
assert (repo_path / "lib" / "utils.ex").exists(), "utils module should exist"
assert (repo_path / "lib" / "models.ex").exists(), "models module should exist"
assert (repo_path / "lib" / "services.ex").exists(), "services module should exist"
assert (repo_path / "lib" / "examples.ex").exists(), "examples module should exist"
assert (repo_path / "test" / "test_repo_test.exs").exists(), "test file should exist"
assert (repo_path / "test" / "models_test.exs").exists(), "models test should exist"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_cross_file_symbol_resolution(self, language_server: SolidLanguageServer):
"""Test that symbols can be resolved across different files."""
# Test that User struct from models.ex can be found when referenced in services.ex
services_file = os.path.join("lib", "services.ex")
# Find where User is referenced in services.ex
content = language_server.retrieve_full_file_content(services_file)
lines = content.split("\n")
user_reference_line = None
for i, line in enumerate(lines):
if "alias TestRepo.Models.{User" in line:
user_reference_line = i
break
if user_reference_line is None:
pytest.skip("Could not find User reference in services.ex")
# Try to find the definition
defining_symbol = language_server.request_defining_symbol(services_file, user_reference_line, 30)
if defining_symbol and "location" in defining_symbol:
# Should point to models.ex
assert "models.ex" in defining_symbol["location"]["uri"]
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_comprehensive_symbol_search(self, language_server: SolidLanguageServer):
"""Test comprehensive symbol search across the entire project."""
# Search for all function definitions
function_pattern = r"def\s+\w+\s*[\(\s]"
function_matches = language_server.search_files_for_pattern(function_pattern)
# Should find functions across multiple files
if function_matches:
files_with_functions = set()
for match in function_matches:
if match.source_file_path:
files_with_functions.add(os.path.basename(match.source_file_path))
# Should find functions in multiple files
expected_files = {"models.ex", "services.ex", "examples.ex", "utils.ex", "test_repo.ex"}
found_files = expected_files.intersection(files_with_functions)
assert len(found_files) > 0, f"Expected functions in {expected_files}, found in {files_with_functions}"
# Search for struct definitions
struct_pattern = r"defstruct\s+\["
struct_matches = language_server.search_files_for_pattern(struct_pattern)
if struct_matches:
# Should find structs primarily in models.ex
models_structs = [m for m in struct_matches if m.source_file_path and "models.ex" in m.source_file_path]
assert len(models_structs) > 0, "Should find struct definitions in models.ex"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_module_hierarchy_understanding(self, language_server: SolidLanguageServer):
"""Test that the language server understands Elixir module hierarchy."""
models_file = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(models_file)
if symbols:
# Flatten symbol structure
all_symbols = []
for symbol_group in symbols:
if isinstance(symbol_group, list):
all_symbols.extend(symbol_group)
else:
all_symbols.append(symbol_group)
symbol_names = [s.get("name", "") for s in all_symbols]
# Should understand nested module structure
expected_modules = ["TestRepo.Models", "User", "Item", "Order"]
found_modules = [name for name in expected_modules if any(name in symbol_name for symbol_name in symbol_names)]
assert len(found_modules) > 0, f"Expected modules {expected_modules}, found symbols {symbol_names}"
def test_file_extension_matching(self):
"""Test that the Elixir language recognizes the correct file extensions."""
language = Language.ELIXIR
matcher = language.get_source_fn_matcher()
# Test Elixir file extensions
assert matcher.is_relevant_filename("lib/test_repo.ex")
assert matcher.is_relevant_filename("test/test_repo_test.exs")
assert matcher.is_relevant_filename("config/config.exs")
assert matcher.is_relevant_filename("mix.exs")
assert matcher.is_relevant_filename("lib/models.ex")
assert matcher.is_relevant_filename("lib/services.ex")
# Test non-Elixir files
assert not matcher.is_relevant_filename("README.md")
assert not matcher.is_relevant_filename("lib/test_repo.py")
assert not matcher.is_relevant_filename("package.json")
assert not matcher.is_relevant_filename("Cargo.toml")
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_protocol_and_implementation_understanding(self, language_server: SolidLanguageServer):
"""Test that the language server understands Elixir protocols and implementations."""
# Search for protocol definitions
protocol_pattern = r"defprotocol\s+\w+"
protocol_matches = language_server.search_files_for_pattern(protocol_pattern, paths_include_glob="**/models.ex")
if protocol_matches:
# Should find the Serializable protocol
serializable_matches = [m for m in protocol_matches if "Serializable" in str(m)]
assert len(serializable_matches) > 0, "Should find Serializable protocol definition"
# Search for protocol implementations
impl_pattern = r"defimpl\s+\w+"
impl_matches = language_server.search_files_for_pattern(impl_pattern, paths_include_glob="**/models.ex")
if impl_matches:
# Should find multiple implementations
assert len(impl_matches) >= 3, f"Should find at least 3 protocol implementations, found {len(impl_matches)}"
@@ -0,0 +1,342 @@
"""
Tests for the Elixir language server symbol-related functionality.
These tests focus on the following methods:
- request_containing_symbol
- request_referencing_symbols
- request_defining_symbol
"""
import os
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_types import SymbolKind
from . import NEXTLS_UNAVAILABLE, NEXTLS_UNAVAILABLE_REASON
# These marks will be applied to all tests in this module
pytestmark = [pytest.mark.elixir, pytest.mark.skipif(NEXTLS_UNAVAILABLE, reason=f"Next LS not available: {NEXTLS_UNAVAILABLE_REASON}")]
class TestElixirLanguageServerSymbols:
"""Test the Elixir language server's symbol-related functionality."""
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_function(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a function."""
# Test for a position inside the create_user function
file_path = os.path.join("lib", "services.ex")
# Find the create_user function in the file
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
create_user_line = None
for i, line in enumerate(lines):
if "def create_user(" in line:
create_user_line = i + 2 # Go inside the function body
break
if create_user_line is None:
pytest.skip("Could not find create_user function")
containing_symbol = language_server.request_containing_symbol(file_path, create_user_line, 10, include_body=True)
# Verify that we found the containing symbol
if containing_symbol:
# Next LS returns the full function signature instead of just the function name
assert containing_symbol["name"] == "def create_user(pid, id, name, email, roles \\\\ [])"
assert containing_symbol["kind"] == SymbolKind.Method or containing_symbol["kind"] == SymbolKind.Function
if "body" in containing_symbol:
assert "def create_user" in containing_symbol["body"]
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_module(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a module."""
# Test for a position inside the UserService module but outside any function
file_path = os.path.join("lib", "services.ex")
# Find the UserService module definition
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_service_line = None
for i, line in enumerate(lines):
if "defmodule UserService do" in line:
user_service_line = i + 1 # Go inside the module
break
if user_service_line is None:
pytest.skip("Could not find UserService module")
containing_symbol = language_server.request_containing_symbol(file_path, user_service_line, 5)
# Verify that we found the containing symbol
if containing_symbol:
assert "UserService" in containing_symbol["name"]
assert containing_symbol["kind"] == SymbolKind.Module or containing_symbol["kind"] == SymbolKind.Class
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_nested(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol with nested scopes."""
# Test for a position inside a function which is inside a module
file_path = os.path.join("lib", "services.ex")
# Find a function inside UserService
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
function_body_line = None
for i, line in enumerate(lines):
if "def create_user(" in line:
function_body_line = i + 3 # Go deeper into the function body
break
if function_body_line is None:
pytest.skip("Could not find function body")
containing_symbol = language_server.request_containing_symbol(file_path, function_body_line, 15)
# Verify that we found the innermost containing symbol (the function)
if containing_symbol:
expected_names = ["create_user", "UserService"]
assert any(name in containing_symbol["name"] for name in expected_names)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_containing_symbol_none(self, language_server: SolidLanguageServer) -> None:
"""Test request_containing_symbol for a position with no containing symbol."""
# Test for a position outside any function/module (e.g., in module doc)
file_path = os.path.join("lib", "services.ex")
# Line 1-3 are likely in module documentation or imports
containing_symbol = language_server.request_containing_symbol(file_path, 2, 10)
# Should return None or an empty dictionary, or the top-level module
# This is acceptable behavior for module-level positions
assert containing_symbol is None or containing_symbol == {} or "TestRepo.Services" in str(containing_symbol)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_referencing_symbols_struct(self, language_server: SolidLanguageServer) -> None:
"""Test request_referencing_symbols for a struct."""
# Test referencing symbols for User struct
file_path = os.path.join("lib", "models.ex")
symbols = language_server.request_document_symbols(file_path)
user_symbol = None
for symbol_group in symbols:
user_symbol = next((s for s in symbol_group if "User" in s.get("name", "")), None)
if user_symbol:
break
if not user_symbol or "selectionRange" not in user_symbol:
pytest.skip("User symbol or its selectionRange not found")
sel_start = user_symbol["selectionRange"]["start"]
ref_symbols = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, sel_start["line"], sel_start["character"])
]
if ref_symbols:
services_references = [
symbol
for symbol in ref_symbols
if "location" in symbol and "uri" in symbol["location"] and "services.ex" in symbol["location"]["uri"]
]
# We expect some references from services.ex
assert len(services_references) >= 0 # At least attempt to find references
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_referencing_symbols_none(self, language_server: SolidLanguageServer) -> None:
"""Test request_referencing_symbols for a position with no symbol."""
file_path = os.path.join("lib", "services.ex")
# Line 3 is likely a blank line or comment
try:
ref_symbols = [ref.symbol for ref in language_server.request_referencing_symbols(file_path, 3, 0)]
# If we get here, make sure we got an empty result
assert ref_symbols == [] or ref_symbols is None
except Exception:
# The method might raise an exception for invalid positions
# which is acceptable behavior
pass
# Tests for request_defining_symbol
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_function_call(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a function call."""
# Find a place where User.new is called in services.ex
file_path = os.path.join("lib", "services.ex")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_new_call_line = None
for i, line in enumerate(lines):
if "User.new(" in line:
user_new_call_line = i
break
if user_new_call_line is None:
pytest.skip("Could not find User.new call")
# Try to find the definition of User.new
defining_symbol = language_server.request_defining_symbol(file_path, user_new_call_line, 15)
if defining_symbol:
assert defining_symbol.get("name") == "new" or "User" in defining_symbol.get("name", "")
if "location" in defining_symbol and "uri" in defining_symbol["location"]:
assert "models.ex" in defining_symbol["location"]["uri"]
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_struct_usage(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a struct usage."""
# Find a place where User struct is used in services.ex
file_path = os.path.join("lib", "services.ex")
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_usage_line = None
for i, line in enumerate(lines):
if "alias TestRepo.Models.{User" in line:
user_usage_line = i
break
if user_usage_line is None:
pytest.skip("Could not find User struct usage")
defining_symbol = language_server.request_defining_symbol(file_path, user_usage_line, 30)
if defining_symbol:
assert "User" in defining_symbol.get("name", "")
@pytest.mark.xfail(
reason="Known intermittent bug in Next LS v0.23.3: Protocol.UndefinedError for :timeout atom. "
"Occurs in CI environments but may pass locally. "
"See https://github.com/elixir-tools/next-ls/issues/543",
strict=False,
)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_defining_symbol_none(self, language_server: SolidLanguageServer) -> None:
"""Test request_defining_symbol for a position with no symbol."""
# Test for a position with no symbol (e.g., whitespace or comment)
file_path = os.path.join("lib", "services.ex")
# Line 3 is likely a blank line
defining_symbol = language_server.request_defining_symbol(file_path, 3, 0)
# Should return None or empty
assert defining_symbol is None or defining_symbol == {}
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_methods_integration(self, language_server: SolidLanguageServer) -> None:
"""Test integration between different symbol methods."""
file_path = os.path.join("lib", "models.ex")
# Find User struct definition
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
user_struct_line = None
for i, line in enumerate(lines):
if "defmodule User do" in line:
user_struct_line = i
break
if user_struct_line is None:
pytest.skip("Could not find User struct")
# Test containing symbol
containing = language_server.request_containing_symbol(file_path, user_struct_line + 5, 10)
if containing:
# Test that we can find references to this symbol
if "location" in containing and "range" in containing["location"]:
start_pos = containing["location"]["range"]["start"]
refs = [
ref.symbol for ref in language_server.request_referencing_symbols(file_path, start_pos["line"], start_pos["character"])
]
# We should find some references or none (both are valid outcomes)
assert isinstance(refs, list)
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None:
"""Test that symbol tree structure is correctly built."""
symbol_tree = language_server.request_full_symbol_tree()
# Should get a tree structure
assert len(symbol_tree) > 0
# Should have our test repository structure
root = symbol_tree[0]
assert "children" in root
# Look for lib directory
lib_dir = None
for child in root["children"]:
if child["name"] == "lib":
lib_dir = child
break
if lib_dir:
# Next LS returns module names instead of file names (e.g., 'services' instead of 'services.ex')
file_names = [child["name"] for child in lib_dir.get("children", [])]
expected_modules = ["models", "services", "examples", "utils", "test_repo"]
found_modules = [name for name in expected_modules if name in file_names]
assert len(found_modules) > 0, f"Expected to find some modules from {expected_modules}, but got {file_names}"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_request_dir_overview(self, language_server: SolidLanguageServer) -> None:
"""Test request_dir_overview functionality."""
lib_overview = language_server.request_dir_overview("lib")
# Should get an overview of the lib directory
assert lib_overview is not None
# Next LS returns keys like 'lib/services.ex' instead of just 'lib'
overview_keys = list(lib_overview.keys()) if hasattr(lib_overview, "keys") else []
lib_files = [key for key in overview_keys if key.startswith("lib/")]
assert len(lib_files) > 0, f"Expected to find lib/ files in overview keys: {overview_keys}"
# Should contain information about our modules
overview_text = str(lib_overview).lower()
expected_terms = ["models", "services", "user", "item"]
found_terms = [term for term in expected_terms if term in overview_text]
assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview"
# @pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
# def test_request_document_overview(self, language_server: SolidLanguageServer) -> None:
# """Test request_document_overview functionality."""
# # COMMENTED OUT: Next LS document overview doesn't contain expected terms
# # Next LS return value: [('TestRepo.Models', 2, 0, 0)] - only module info, no detailed content
# # Expected terms like 'user', 'item', 'order', 'struct', 'defmodule' are not present
# # This appears to be a limitation of Next LS document overview functionality
# #
# file_path = os.path.join("lib", "models.ex")
# doc_overview = language_server.request_document_overview(file_path)
#
# # Should get an overview of the models.ex file
# assert doc_overview is not None
#
# # Should contain information about our structs and functions
# overview_text = str(doc_overview).lower()
# expected_terms = ["user", "item", "order", "struct", "defmodule"]
# found_terms = [term for term in expected_terms if term in overview_text]
# assert len(found_terms) > 0, f"Expected to find some terms from {expected_terms} in overview"
@pytest.mark.parametrize("language_server", [Language.ELIXIR], indirect=True)
def test_containing_symbol_of_module_attribute(self, language_server: SolidLanguageServer) -> None:
"""Test containing symbol for module attributes."""
file_path = os.path.join("lib", "models.ex")
# Find a module attribute like @type or @doc
content = language_server.retrieve_full_file_content(file_path)
lines = content.split("\n")
attribute_line = None
for i, line in enumerate(lines):
if line.strip().startswith("@type") or line.strip().startswith("@doc"):
attribute_line = i
break
if attribute_line is None:
pytest.skip("Could not find module attribute")
containing_symbol = language_server.request_containing_symbol(file_path, attribute_line, 5)
if containing_symbol:
# Should be contained within a module
assert "name" in containing_symbol
# The containing symbol should be a module
expected_names = ["User", "Item", "Order", "TestRepo.Models"]
assert any(name in containing_symbol["name"] for name in expected_names)
@@ -0,0 +1,53 @@
"""
Basic integration tests for the Terraform language server functionality.
These tests validate the functionality of the language server APIs
like request_references using the test repository.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
@pytest.mark.terraform
class TestLanguageServerBasics:
"""Test basic functionality of the Terraform language server."""
@pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True)
def test_basic_definition(self, language_server: SolidLanguageServer) -> None:
"""Test basic definition lookup functionality."""
# Simple test to verify the language server is working
file_path = "main.tf"
# Just try to get document symbols - this should work without hanging
symbols = language_server.request_document_symbols(file_path)
assert len(symbols) > 0, "Should find at least some symbols in main.tf"
@pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True)
def test_request_references_aws_instance(self, language_server: SolidLanguageServer) -> None:
"""Test request_references on an aws_instance resource."""
# Get references to an aws_instance resource in main.tf
file_path = "main.tf"
# Find aws_instance resources
symbols = language_server.request_document_symbols(file_path)
aws_instance_symbol = next((s for s in symbols[0] if s.get("name") == 'resource "aws_instance" "web_server"'), None)
if not aws_instance_symbol or "selectionRange" not in aws_instance_symbol:
raise AssertionError("aws_instance symbol or its selectionRange not found")
sel_start = aws_instance_symbol["selectionRange"]["start"]
references = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert len(references) >= 1, "aws_instance should be referenced at least once"
@pytest.mark.parametrize("language_server", [Language.TERRAFORM], indirect=True)
def test_request_references_variable(self, language_server: SolidLanguageServer) -> None:
"""Test request_references on a variable."""
# Get references to a variable in variables.tf
file_path = "variables.tf"
# Find variable definitions
symbols = language_server.request_document_symbols(file_path)
var_symbol = next((s for s in symbols[0] if s.get("name") == 'variable "instance_type"'), None)
if not var_symbol or "selectionRange" not in var_symbol:
raise AssertionError("variable symbol or its selectionRange not found")
sel_start = var_symbol["selectionRange"]["start"]
references = language_server.request_references(file_path, sel_start["line"], sel_start["character"])
assert len(references) >= 1, "variable should be referenced at least once"