feat(lsp): add registry and runtime stubs
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Failing after 15s
CI / quality (pull_request) Successful in 20s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 36s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 2m23s
CI / unit_tests (pull_request) Failing after 5m47s
CI / docker (pull_request) Has been skipped

This commit is contained in:
2026-02-22 10:15:25 +00:00
parent ca1c341b18
commit 50316e97be
15 changed files with 2314 additions and 17 deletions
+123
View File
@@ -0,0 +1,123 @@
"""ASV benchmarks for LSP registry lookup performance.
Measures the performance of:
- LspServerConfig creation
- Registry register and get operations
- Registry list with filters (namespace, language)
- Registry remove
- Tool adapter spec generation
"""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
# Ensure the local *source* tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
# Force-reload the top-level package
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.lsp.models import ( # noqa: E402
LspCapability,
LspServerConfig,
)
from cleveragents.lsp.registry import LspRegistry # noqa: E402
from cleveragents.lsp.tool_adapter import LspToolAdapter # noqa: E402
def _make_config(
name: str,
command: str = "stub-cmd",
languages: list[str] | None = None,
capabilities: list[LspCapability] | None = None,
) -> LspServerConfig:
return LspServerConfig(
name=name,
command=command,
languages=languages or [],
capabilities=capabilities or [],
)
class TimeLspConfigCreation:
"""Benchmark LspServerConfig creation."""
def time_create_minimal(self) -> None:
LspServerConfig(name="local/test", command="test-cmd")
def time_create_with_languages(self) -> None:
LspServerConfig(
name="local/test",
command="test-cmd",
languages=["python", "javascript", "typescript"],
)
def time_create_with_capabilities(self) -> None:
LspServerConfig(
name="local/test",
command="test-cmd",
capabilities=list(LspCapability),
)
class TimeLspRegistryOperations:
"""Benchmark registry register, get, and remove."""
def setup(self) -> None:
self.registry = LspRegistry()
for i in range(100):
ns = "local" if i % 2 == 0 else "remote"
lang = "python" if i % 3 == 0 else "typescript"
self.registry.register(
_make_config(
f"{ns}/server-{i}",
languages=[lang],
capabilities=[LspCapability.DIAGNOSTICS],
)
)
def time_get_existing(self) -> None:
self.registry.get("local/server-0")
def time_get_missing(self) -> None:
self.registry.get("local/nonexistent")
def time_list_all(self) -> None:
self.registry.list_servers()
def time_list_with_namespace_filter(self) -> None:
self.registry.list_servers(namespace="local")
def time_list_with_language_filter(self) -> None:
self.registry.list_servers(language="python")
def time_contains_check(self) -> None:
_ = "local/server-0" in self.registry
class TimeLspToolAdapter:
"""Benchmark tool adapter spec generation."""
def setup(self) -> None:
self.config_few = _make_config(
"local/test",
capabilities=[LspCapability.DIAGNOSTICS, LspCapability.COMPLETIONS],
)
self.config_all = _make_config(
"local/test-all",
capabilities=list(LspCapability),
)
self.adapter = LspToolAdapter()
def time_generate_few_specs(self) -> None:
self.adapter.generate_tool_specs(self.config_few)
def time_generate_all_specs(self) -> None:
self.adapter.generate_tool_specs(self.config_all)
+120
View File
@@ -0,0 +1,120 @@
# LSP Integration
Language Server Protocol (LSP) servers are registered in a global **LSP Registry** (namespaced like all other entities) and bound to actor graph nodes via YAML configuration. When an actor activates, the LSP Runtime starts the appropriate language servers. LSP capabilities (diagnostics, type info, symbol navigation, completions, references, rename, code actions) are exposed to the actor as callable tools (via the `LspToolAdapter`) and as automatic context enrichment.
## Local-Mode Constraints
In **local mode** the LSP subsystem operates as a set of stubs:
| Component | Local-Mode Behavior |
|--------------------|----------------------------------------------------|
| `LspRegistry` | Fully functional — register, list, remove servers |
| `LspRuntime` | Raises `LspNotAvailableError` on every operation |
| `LspToolAdapter` | Generates tool specs whose handlers always raise |
The registry is available for configuration and validation purposes even in local mode. The runtime and tool adapter become functional only when server mode is enabled.
## Models
### LspCapability
Enum of LSP features that can be exposed as tools:
| Value | Description |
|----------------|------------------------------------------|
| `DIAGNOSTICS` | Retrieve diagnostics (errors/warnings) |
| `TYPE_INFO` | Get type information at a cursor position|
| `SYMBOLS` | List document and workspace symbols |
| `COMPLETIONS` | Get code completion suggestions |
| `REFERENCES` | Find all references to a symbol |
| `RENAME` | Rename a symbol across the workspace |
| `CODE_ACTIONS` | Retrieve available code actions |
| `FORMAT` | Format a document or selection |
### LspServerConfig
Registration record for a language server:
| Field | Type | Description |
|----------------|----------------------|--------------------------------------|
| `name` | `str` | Namespaced server name |
| `languages` | `list[str]` | Programming languages served |
| `command` | `str` | Executable command to start server |
| `args` | `list[str]` | Additional CLI arguments |
| `env` | `dict[str, str]` | Environment variables |
| `capabilities` | `list[LspCapability]`| Capabilities exposed by this server |
### LspBinding
Binds an LSP server to an actor graph node:
| Field | Type | Description |
|--------------------|-------------|------------------------------------------|
| `node_name` | `str` | Actor graph node name |
| `lsp_server_name` | `str` | Namespaced LSP server name |
| `languages` | `list[str]` | Subset of languages (empty = all) |
| `auto_detect` | `bool` | Auto-detect language from file context |
## Error Hierarchy
All errors inherit from `CleverAgentsError`:
```
CleverAgentsError
└── LspError (base for all LSP errors)
├── LspNotAvailableError (operation attempted in local mode)
└── LspServerNotFoundError (server not in registry)
```
## CLI Commands
| Command | Description |
|---------------------------|----------------------------------------|
| `agents lsp add` | Register server from YAML config |
| `agents lsp remove` | Remove a registered server |
| `agents lsp list` | List servers with optional filters |
| `agents lsp show` | Show full server details |
### Examples
```bash
# Register a Pyright LSP server
agents lsp add --config ./lsp/pyright.yaml
# List all Python language servers
agents lsp list --language python
# Show details for a specific server
agents lsp show local/pyright
# Remove a server
agents lsp remove local/pyright --yes
```
### YAML Configuration
```yaml
name: local/pyright
command: pyright-langserver
args: ["--stdio"]
languages: ["python"]
capabilities:
- diagnostics
- completions
- type_info
- references
- rename
env:
PYTHONPATH: ./src
```
## Thread Safety
The `LspRegistry` uses a `threading.RLock` to ensure safe concurrent access. All public methods (`register`, `get`, `list_servers`, `remove`) acquire the lock before reading or mutating state.
## Namespacing
LSP server names follow the project-wide namespacing pattern: `[[server:]namespace/]name`. For example:
- `local/pyright` — a locally-defined Pyright server
- `devops/clangd` — a clangd server in the devops namespace
+238
View File
@@ -0,0 +1,238 @@
@phase2 @domain @lsp @lsp_registry
Feature: LSP Registry and Runtime Stubs
As a system managing LSP server configurations
I want to register, look up, list, and remove LSP servers
And verify that the runtime stub raises in local mode
So that actors can discover language servers and bindings are validated
Background:
Given a clean LSP registry
# ---------------------------------------------------------------------------
# Register LSP server config
# ---------------------------------------------------------------------------
@lsp_reg_create
Scenario: Register a new LSP server config
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
When the LSP server is registered
Then the LSP registry should contain "local/pyright"
And the LSP registry should have 1 server
@lsp_reg_create
Scenario: Register LSP server with multiple languages
Given an LSP server config named "local/tsserver" for languages "typescript,javascript" with command "typescript-language-server"
When the LSP server is registered
Then the LSP registry should contain "local/tsserver"
And the LSP server "local/tsserver" should support language "typescript"
And the LSP server "local/tsserver" should support language "javascript"
@lsp_reg_create
Scenario: Register LSP server with capabilities
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server config has capabilities "diagnostics,completions,type_info"
When the LSP server is registered
Then the LSP server "local/pyright" should have 3 capabilities
@lsp_reg_create @error_handling
Scenario: Registering a duplicate server name is rejected
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server is registered
When a second LSP server with the same name "local/pyright" is registered
Then a ValueError should be raised mentioning "already registered"
# ---------------------------------------------------------------------------
# Lookup by name
# ---------------------------------------------------------------------------
@lsp_reg_get
Scenario: Lookup a registered server by name
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server is registered
When I look up LSP server "local/pyright"
Then the LSP lookup result should not be None
And the LSP lookup result name should be "local/pyright"
@lsp_reg_get
Scenario: Lookup a non-existent server returns None
When I look up LSP server "local/nonexistent"
Then the LSP lookup result should be None
@lsp_reg_get @error_handling
Scenario: Lookup with get_or_raise for missing server raises error
When I look up LSP server "local/missing" with get_or_raise
Then an LspServerNotFoundError should be raised for "local/missing"
# ---------------------------------------------------------------------------
# List with namespace filter
# ---------------------------------------------------------------------------
@lsp_reg_list
Scenario: List all registered servers
Given the following LSP servers are registered:
| name | language | command |
| local/pyright | python | pyright-langserver |
| local/tsserver | typescript | typescript-language-server |
| devops/clangd | c | clangd |
When I list all LSP servers
Then the LSP server list should contain 3 servers
@lsp_reg_list
Scenario: List servers filtered by namespace
Given the following LSP servers are registered:
| name | language | command |
| local/pyright | python | pyright-langserver |
| local/tsserver | typescript | typescript-language-server |
| devops/clangd | c | clangd |
When I list LSP servers with namespace "local"
Then the LSP server list should contain 2 servers
# ---------------------------------------------------------------------------
# List with language filter
# ---------------------------------------------------------------------------
@lsp_reg_list
Scenario: List servers filtered by language
Given the following LSP servers are registered:
| name | language | command |
| local/pyright | python | pyright-langserver |
| local/tsserver | typescript | typescript-language-server |
| devops/pylsp | python | pylsp |
When I list LSP servers with language "python"
Then the LSP server list should contain 2 servers
# ---------------------------------------------------------------------------
# Remove server
# ---------------------------------------------------------------------------
@lsp_reg_remove
Scenario: Remove a registered server
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server is registered
When I remove LSP server "local/pyright"
Then the LSP removal result should be True
And the LSP registry should have 0 servers
@lsp_reg_remove
Scenario: Remove a non-existent server returns False
When I remove LSP server "local/nonexistent"
Then the LSP removal result should be False
# ---------------------------------------------------------------------------
# Runtime stub raises LspNotAvailableError
# ---------------------------------------------------------------------------
@lsp_runtime
Scenario: Runtime start_server raises LspNotAvailableError
Given a local-mode LSP runtime
When I call start_server on the runtime with name "local/pyright" and workspace "/tmp"
Then an LspNotAvailableError should be raised
And the LSP error message should mention "local mode"
@lsp_runtime
Scenario: Runtime stop_server raises LspNotAvailableError
Given a local-mode LSP runtime
When I call stop_server on the runtime with name "local/pyright"
Then an LspNotAvailableError should be raised
@lsp_runtime
Scenario: Runtime get_diagnostics raises LspNotAvailableError
Given a local-mode LSP runtime
When I call get_diagnostics on the runtime with name "local/pyright" and file "/tmp/test.py"
Then an LspNotAvailableError should be raised
@lsp_runtime
Scenario: Runtime get_completions raises LspNotAvailableError
Given a local-mode LSP runtime
When I call get_completions on the runtime with name "local/pyright" file "/tmp/test.py" line 1 column 1
Then an LspNotAvailableError should be raised
# ---------------------------------------------------------------------------
# Tool adapter generates tool specs from capabilities
# ---------------------------------------------------------------------------
@lsp_tool_adapter
Scenario: Tool adapter generates specs for each capability
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server config has capabilities "diagnostics,completions"
When I generate tool specs from the adapter
Then the tool spec list should contain 2 specs
And the tool spec names should include "local/pyright/diagnostics"
And the tool spec names should include "local/pyright/completions"
@lsp_tool_adapter
Scenario: Tool adapter handler raises LspNotAvailableError in local mode
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server config has capabilities "diagnostics"
When I generate tool specs from the adapter
And I invoke the first tool spec handler
Then an LspNotAvailableError should be raised
# ---------------------------------------------------------------------------
# Binding validation (valid/invalid references)
# ---------------------------------------------------------------------------
@lsp_binding
Scenario: Valid binding with registered server passes
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server is registered
When I create a binding for node "code-editor" to server "local/pyright"
Then the binding should be valid
And the binding node_name should be "code-editor"
And the binding lsp_server_name should be "local/pyright"
@lsp_binding @error_handling
Scenario: Binding with non-namespaced server name is rejected
When I create a binding for node "code-editor" to server "pyright"
Then a ValidationError should be raised mentioning "namespaced"
@lsp_binding
Scenario: Binding auto_detect defaults to True
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
And the LSP server is registered
When I create a binding for node "code-editor" to server "local/pyright"
Then the binding auto_detect should be True
# ---------------------------------------------------------------------------
# Model validation
# ---------------------------------------------------------------------------
@lsp_model_validation
Scenario: LspServerConfig rejects non-namespaced name
When I try to create an LSP server config with name "pyright" and command "pyright-langserver"
Then a ValidationError should be raised mentioning "namespaced"
@lsp_model_validation
Scenario: LspServerConfig normalizes language strings to lowercase
Given an LSP server config named "local/pyright" for language "Python" with command "pyright-langserver"
Then the LSP server config language "python" should be present
@lsp_model_validation
Scenario: LspServerConfig namespace property extracts prefix
Given an LSP server config named "local/pyright" for language "python" with command "pyright-langserver"
Then the LSP server config namespace should be "local"
And the LSP server config short_name should be "pyright"
# ---------------------------------------------------------------------------
# Error hierarchy
# ---------------------------------------------------------------------------
@lsp_errors
Scenario: LspError inherits from CleverAgentsError
Then LspError should be a subclass of CleverAgentsError
@lsp_errors
Scenario: LspNotAvailableError has default message
When I create an LspNotAvailableError with default message
Then the LSP error message should contain "not available in local mode"
@lsp_errors
Scenario: LspServerNotFoundError includes server name
When I create an LspServerNotFoundError for server "local/pyright"
Then the LSP error message should contain "local/pyright"
And the error server_name should be "local/pyright"
@lsp_errors @error_handling
Scenario: LspServerNotFoundError rejects empty server name
When I try to create an LspServerNotFoundError with empty name
Then a ValueError should be raised
+486
View File
@@ -0,0 +1,486 @@
"""Step definitions for LSP Registry, Runtime, and Tool Adapter tests.
Covers LspRegistry, LspRuntime, LspToolAdapter, LspBinding,
LspServerConfig, and the LSP error hierarchy.
"""
from __future__ import annotations
from typing import Any
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError as PydanticValidationError
from cleveragents.core.exceptions import CleverAgentsError
from cleveragents.lsp.errors import (
LspError,
LspNotAvailableError,
LspServerNotFoundError,
)
from cleveragents.lsp.models import (
LspBinding,
LspCapability,
LspServerConfig,
)
from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.runtime import LspRuntime
from cleveragents.lsp.tool_adapter import LspToolAdapter
# ---------------------------------------------------------------------------
# Capability name -> enum mapping
# ---------------------------------------------------------------------------
_CAP_MAP: dict[str, LspCapability] = {c.value: c for c in LspCapability}
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean LSP registry")
def step_clean_lsp_registry(context: Context) -> None:
context.lsp_registry = LspRegistry()
context.lsp_config: LspServerConfig | None = None
context.lsp_result: Any = None
context.lsp_error: Exception | None = None
context.lsp_tool_specs: list[dict[str, Any]] = []
# ---------------------------------------------------------------------------
# Config creation
# ---------------------------------------------------------------------------
@given(
'an LSP server config named "{name}" for language "{language}" '
'with command "{command}"'
)
def step_lsp_config_single_lang(
context: Context, name: str, language: str, command: str
) -> None:
context.lsp_config = LspServerConfig(
name=name,
languages=[language.strip().lower()],
command=command,
)
@given(
'an LSP server config named "{name}" for languages "{languages}" '
'with command "{command}"'
)
def step_lsp_config_multi_lang(
context: Context, name: str, languages: str, command: str
) -> None:
lang_list = [lang.strip().lower() for lang in languages.split(",")]
context.lsp_config = LspServerConfig(
name=name,
languages=lang_list,
command=command,
)
@given('the LSP server config has capabilities "{caps_str}"')
def step_lsp_config_capabilities(context: Context, caps_str: str) -> None:
caps = [_CAP_MAP[c.strip()] for c in caps_str.split(",")]
assert context.lsp_config is not None
context.lsp_config = context.lsp_config.model_copy(update={"capabilities": caps})
# ---------------------------------------------------------------------------
# Register
# ---------------------------------------------------------------------------
@when("the LSP server is registered")
@given("the LSP server is registered")
def step_register_lsp_server(context: Context) -> None:
assert context.lsp_config is not None
context.lsp_registry.register(context.lsp_config)
@when('a second LSP server with the same name "{name}" is registered')
def step_register_duplicate(context: Context, name: str) -> None:
dup = LspServerConfig(name=name, command="dup-cmd", languages=["python"])
try:
context.lsp_registry.register(dup)
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
@given("the following LSP servers are registered:")
def step_register_table(context: Context) -> None:
for row in context.table:
config = LspServerConfig(
name=row["name"],
languages=[row["language"].strip().lower()],
command=row["command"],
)
context.lsp_registry.register(config)
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
@when('I look up LSP server "{name}"')
def step_lookup(context: Context, name: str) -> None:
context.lsp_result = context.lsp_registry.get(name)
@when('I look up LSP server "{name}" with get_or_raise')
def step_lookup_or_raise(context: Context, name: str) -> None:
try:
context.lsp_result = context.lsp_registry.get_or_raise(name)
context.lsp_error = None
except LspServerNotFoundError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------
@when("I list all LSP servers")
def step_list_all(context: Context) -> None:
context.lsp_result = context.lsp_registry.list_servers()
@when('I list LSP servers with namespace "{namespace}"')
def step_list_ns(context: Context, namespace: str) -> None:
context.lsp_result = context.lsp_registry.list_servers(namespace=namespace)
@when('I list LSP servers with language "{language}"')
def step_list_lang(context: Context, language: str) -> None:
context.lsp_result = context.lsp_registry.list_servers(language=language)
# ---------------------------------------------------------------------------
# Remove
# ---------------------------------------------------------------------------
@when('I remove LSP server "{name}"')
def step_remove(context: Context, name: str) -> None:
context.lsp_result = context.lsp_registry.remove(name)
# ---------------------------------------------------------------------------
# Runtime
# ---------------------------------------------------------------------------
@given("a local-mode LSP runtime")
def step_lsp_runtime(context: Context) -> None:
context.lsp_runtime = LspRuntime()
@when('I call start_server on the runtime with name "{name}" and workspace "{ws}"')
def step_runtime_start(context: Context, name: str, ws: str) -> None:
try:
context.lsp_runtime.start_server(name, ws)
context.lsp_error = None
except LspNotAvailableError as exc:
context.lsp_error = exc
@when('I call stop_server on the runtime with name "{name}"')
def step_runtime_stop(context: Context, name: str) -> None:
try:
context.lsp_runtime.stop_server(name)
context.lsp_error = None
except LspNotAvailableError as exc:
context.lsp_error = exc
@when('I call get_diagnostics on the runtime with name "{name}" and file "{file_path}"')
def step_runtime_diagnostics(context: Context, name: str, file_path: str) -> None:
try:
context.lsp_result = context.lsp_runtime.get_diagnostics(name, file_path)
context.lsp_error = None
except LspNotAvailableError as exc:
context.lsp_error = exc
@when(
'I call get_completions on the runtime with name "{name}" '
'file "{file_path}" line {line:d} column {col:d}'
)
def step_runtime_completions(
context: Context, name: str, file_path: str, line: int, col: int
) -> None:
try:
context.lsp_result = context.lsp_runtime.get_completions(
name, file_path, line, col
)
context.lsp_error = None
except LspNotAvailableError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Tool adapter
# ---------------------------------------------------------------------------
@when("I generate tool specs from the adapter")
def step_generate_specs(context: Context) -> None:
adapter = LspToolAdapter()
assert context.lsp_config is not None
context.lsp_tool_specs = adapter.generate_tool_specs(context.lsp_config)
@when("I invoke the first tool spec handler")
def step_invoke_handler(context: Context) -> None:
assert context.lsp_tool_specs, "No tool specs generated"
handler = context.lsp_tool_specs[0]["handler"]
try:
handler()
context.lsp_error = None
except LspNotAvailableError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Binding
# ---------------------------------------------------------------------------
@when('I create a binding for node "{node}" to server "{server}"')
def step_create_binding(context: Context, node: str, server: str) -> None:
try:
context.lsp_binding = LspBinding(
node_name=node,
lsp_server_name=server,
)
context.lsp_error = None
except PydanticValidationError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Model validation
# ---------------------------------------------------------------------------
@when('I try to create an LSP server config with name "{name}" and command "{command}"')
def step_invalid_config(context: Context, name: str, command: str) -> None:
try:
context.lsp_config = LspServerConfig(name=name, command=command)
context.lsp_error = None
except PydanticValidationError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Error creation
# ---------------------------------------------------------------------------
@when("I create an LspNotAvailableError with default message")
def step_create_not_available_error(context: Context) -> None:
context.lsp_error = LspNotAvailableError()
@when('I create an LspServerNotFoundError for server "{name}"')
def step_create_not_found_error(context: Context, name: str) -> None:
context.lsp_error = LspServerNotFoundError(name)
@when("I try to create an LspServerNotFoundError with empty name")
def step_create_not_found_empty(context: Context) -> None:
try:
LspServerNotFoundError("")
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
# ---------------------------------------------------------------------------
# Assertions
# ---------------------------------------------------------------------------
@then('the LSP registry should contain "{name}"')
def step_registry_contains(context: Context, name: str) -> None:
assert name in context.lsp_registry, f"Registry should contain '{name}'"
@then("the LSP registry should have {count:d} server")
@then("the LSP registry should have {count:d} servers")
def step_registry_count(context: Context, count: int) -> None:
assert len(context.lsp_registry) == count, (
f"Expected {count} servers, got {len(context.lsp_registry)}"
)
@then('the LSP server "{name}" should support language "{lang}"')
def step_server_supports_lang(context: Context, name: str, lang: str) -> None:
config = context.lsp_registry.get(name)
assert config is not None, f"Server '{name}' not found"
assert lang.lower() in config.languages, f"Server '{name}' should support '{lang}'"
@then('the LSP server "{name}" should have {count:d} capabilities')
def step_server_cap_count(context: Context, name: str, count: int) -> None:
config = context.lsp_registry.get(name)
assert config is not None, f"Server '{name}' not found"
assert len(config.capabilities) == count, (
f"Expected {count} capabilities, got {len(config.capabilities)}"
)
@then("the LSP lookup result should not be None")
def step_result_not_none(context: Context) -> None:
assert context.lsp_result is not None
@then("the LSP lookup result should be None")
def step_result_none(context: Context) -> None:
assert context.lsp_result is None
@then('the LSP lookup result name should be "{name}"')
def step_result_name(context: Context, name: str) -> None:
assert context.lsp_result.name == name
@then("the LSP server list should contain {count:d} servers")
def step_list_count(context: Context, count: int) -> None:
assert len(context.lsp_result) == count, (
f"Expected {count}, got {len(context.lsp_result)}"
)
@then("the LSP removal result should be True")
def step_removal_true(context: Context) -> None:
assert context.lsp_result is True
@then("the LSP removal result should be False")
def step_removal_false(context: Context) -> None:
assert context.lsp_result is False
@then('a ValueError should be raised mentioning "{text}"')
def step_value_error_msg(context: Context, text: str) -> None:
assert context.lsp_error is not None, "Expected an error but none was raised"
assert isinstance(context.lsp_error, ValueError), (
f"Expected ValueError, got {type(context.lsp_error).__name__}"
)
assert text in str(context.lsp_error), (
f"Error '{context.lsp_error}' should mention '{text}'"
)
@then("an LspNotAvailableError should be raised")
def step_not_available_error(context: Context) -> None:
assert context.lsp_error is not None, "Expected an error"
assert isinstance(context.lsp_error, LspNotAvailableError), (
f"Expected LspNotAvailableError, got {type(context.lsp_error).__name__}"
)
@then('the LSP error message should mention "{text}"')
@then('the LSP error message should contain "{text}"')
def step_lsp_error_mentions(context: Context, text: str) -> None:
assert context.lsp_error is not None
assert text in str(context.lsp_error), (
f"Error '{context.lsp_error}' should mention '{text}'"
)
@then('an LspServerNotFoundError should be raised for "{name}"')
def step_not_found_error(context: Context, name: str) -> None:
assert context.lsp_error is not None, "Expected an error"
assert isinstance(context.lsp_error, LspServerNotFoundError), (
f"Expected LspServerNotFoundError, got {type(context.lsp_error).__name__}"
)
assert name in str(context.lsp_error)
@then("the tool spec list should contain {count:d} specs")
def step_spec_count(context: Context, count: int) -> None:
assert len(context.lsp_tool_specs) == count, (
f"Expected {count} specs, got {len(context.lsp_tool_specs)}"
)
@then('the tool spec names should include "{name}"')
def step_spec_name_includes(context: Context, name: str) -> None:
names = [s["name"] for s in context.lsp_tool_specs]
assert name in names, f"Tool spec '{name}' not in {names}"
@then("the binding should be valid")
def step_binding_valid(context: Context) -> None:
assert context.lsp_error is None, f"Unexpected error: {context.lsp_error}"
assert hasattr(context, "lsp_binding"), "No binding created"
@then('the binding node_name should be "{name}"')
def step_binding_node(context: Context, name: str) -> None:
assert context.lsp_binding.node_name == name
@then('the binding lsp_server_name should be "{name}"')
def step_binding_server(context: Context, name: str) -> None:
assert context.lsp_binding.lsp_server_name == name
@then("the binding auto_detect should be True")
def step_binding_auto_detect(context: Context) -> None:
assert context.lsp_binding.auto_detect is True
@then('a ValidationError should be raised mentioning "{text}"')
def step_validation_error(context: Context, text: str) -> None:
assert context.lsp_error is not None, "Expected a validation error"
assert isinstance(context.lsp_error, PydanticValidationError), (
f"Expected ValidationError, got {type(context.lsp_error).__name__}"
)
assert text in str(context.lsp_error), (
f"Error should mention '{text}': {context.lsp_error}"
)
@then('the LSP server config language "{lang}" should be present')
def step_config_lang_present(context: Context, lang: str) -> None:
assert context.lsp_config is not None
assert lang in context.lsp_config.languages
@then('the LSP server config namespace should be "{ns}"')
def step_config_namespace(context: Context, ns: str) -> None:
assert context.lsp_config is not None
assert context.lsp_config.namespace == ns
@then('the LSP server config short_name should be "{short}"')
def step_config_short_name(context: Context, short: str) -> None:
assert context.lsp_config is not None
assert context.lsp_config.short_name == short
@then("LspError should be a subclass of CleverAgentsError")
def step_lsp_error_hierarchy(context: Context) -> None:
assert issubclass(LspError, CleverAgentsError)
@then('the error server_name should be "{name}"')
def step_error_server_name(context: Context, name: str) -> None:
assert hasattr(context.lsp_error, "server_name"), "Error has no server_name"
assert context.lsp_error.server_name == name
@then("a ValueError should be raised")
def step_value_error_raised(context: Context) -> None:
assert context.lsp_error is not None, "Expected a ValueError"
assert isinstance(context.lsp_error, ValueError), (
f"Expected ValueError, got {type(context.lsp_error).__name__}"
)
+17 -17
View File
@@ -2418,23 +2418,23 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
- [ ] Git [Luis]: `git push -u origin feature/m2-changeset-persistence` - [ ] Git [Luis]: `git push -u origin feature/m2-changeset-persistence`
- [ ] Forgejo PR [Luis]: Open PR from `feature/m2-changeset-persistence` to `master` with a suitable and thorough description - [ ] Forgejo PR [Luis]: Open PR from `feature/m2-changeset-persistence` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Jeff | Group: M2.7.lsp-stubs | Branch: feature/m2-lsp-stubs | Planned: Day 17 | Expected: Day 18) - Commit message: "feat(lsp): add registry and runtime stubs"** - [X] **COMMIT (Owner: Jeff | Group: M2.7.lsp-stubs | Branch: feature/m2-lsp-stubs | Planned: Day 17 | Expected: Day 18) - Commit message: "feat(lsp): add registry and runtime stubs"** Done: 2026-02-22
- [ ] Git [Jeff]: `git checkout master` - [X] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master` - [X] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m2-lsp-stubs` - [X] Git [Jeff]: `git checkout -b feature/m2-lsp-stubs`
- [ ] Code [Jeff]: Add LSP registry models and a local-mode runtime stub (no server transport) per spec. - [X] Code [Jeff]: Add LSP registry models and a local-mode runtime stub (no server transport) per spec.
- [ ] Code [Jeff]: Wire LSP registry lookups into actor compilation so node bindings are validated at compile time. - [X] Code [Jeff]: Wire LSP registry lookups into actor compilation so node bindings are validated at compile time.
- [ ] Docs [Jeff]: Update `docs/reference/lsp.md` with stub behavior and local-mode constraints. - [X] Docs [Jeff]: Update `docs/reference/lsp.md` with stub behavior and local-mode constraints.
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit) - [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Tests (Behave) [Jeff]: Add scenarios for registry validation and missing LSP binding errors. - [X] Tests (Behave) [Jeff]: Add scenarios for registry validation and missing LSP binding errors.
- [ ] Tests (Robot) [Jeff]: Add Robot CLI test listing registered LSP bindings. - [X] Tests (Robot) [Jeff]: Add Robot CLI test listing registered LSP bindings.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/lsp_registry_bench.py` for registry lookup overhead. - [X] Tests (ASV) [Jeff]: Add `benchmarks/lsp_registry_bench.py` for registry lookup overhead.
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%. - [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it. - [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index - [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
- [ ] Git [Jeff]: `git commit -m "feat(lsp): add registry and runtime stubs"` - [X] Git [Jeff]: `git commit -m "feat(lsp): add registry and runtime stubs"`
- [ ] Git [Jeff]: `git push -u origin feature/m2-lsp-stubs` - [X] Git [Jeff]: `git push -u origin feature/m2-lsp-stubs`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m2-lsp-stubs` to `master` with a suitable and thorough description - [X] Forgejo PR [Jeff]: Open PR from `feature/m2-lsp-stubs` to `master` with a suitable and thorough description
- [ ] **COMMIT (Owner: Brent | Group: M2.tests | Branch: feature/m2-actor-tool-smoke | Planned: Day 17 | Expected: Day 18) - Commit message: "test(e2e): add M2 actor + tool source smoke suite"** - [ ] **COMMIT (Owner: Brent | Group: M2.tests | Branch: feature/m2-actor-tool-smoke | Planned: Day 17 | Expected: Day 18) - Commit message: "test(e2e): add M2 actor + tool source smoke suite"**
- [ ] Git [Brent]: `git checkout master` - [ ] Git [Brent]: `git checkout master`
+145
View File
@@ -0,0 +1,145 @@
"""Helper script for Robot Framework LSP registry smoke tests.
Usage:
python helper_lsp_registry.py <command>
Commands:
register-and-get Register an LSP server and retrieve it
list-filter Register servers and list with namespace filter
remove-server Register and remove an LSP server
runtime-stub Verify runtime stub raises in local mode
tool-adapter Verify tool adapter generates specs
"""
from __future__ import annotations
import sys
from pathlib import Path
# Ensure src is on the path
src_dir = str(Path(__file__).resolve().parents[1] / "src")
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from cleveragents.lsp.errors import LspNotAvailableError # noqa: E402
from cleveragents.lsp.models import ( # noqa: E402
LspCapability,
LspServerConfig,
)
from cleveragents.lsp.registry import LspRegistry # noqa: E402
from cleveragents.lsp.runtime import LspRuntime # noqa: E402
from cleveragents.lsp.tool_adapter import LspToolAdapter # noqa: E402
def _make_config(
name: str,
command: str = "stub-cmd",
languages: list[str] | None = None,
capabilities: list[LspCapability] | None = None,
) -> LspServerConfig:
return LspServerConfig(
name=name,
command=command,
languages=languages or [],
capabilities=capabilities or [],
)
def cmd_register_and_get() -> None:
registry = LspRegistry()
config = _make_config("local/pyright", "pyright-langserver", ["python"])
registry.register(config)
result = registry.get("local/pyright")
assert result is not None, "LSP server not found"
assert result.name == "local/pyright"
assert "python" in result.languages
print("lsp-registry-ok")
def cmd_list_filter() -> None:
registry = LspRegistry()
registry.register(_make_config("local/pyright", languages=["python"]))
registry.register(_make_config("local/tsserver", languages=["typescript"]))
registry.register(_make_config("devops/clangd", languages=["c"]))
all_servers = registry.list_servers()
assert len(all_servers) == 3
local_servers = registry.list_servers(namespace="local")
assert len(local_servers) == 2
python_servers = registry.list_servers(language="python")
assert len(python_servers) == 1
print("lsp-list-ok")
def cmd_remove_server() -> None:
registry = LspRegistry()
registry.register(_make_config("local/pyright", languages=["python"]))
assert registry.remove("local/pyright") is True
assert registry.get("local/pyright") is None
assert registry.remove("local/pyright") is False
print("lsp-remove-ok")
def cmd_runtime_stub() -> None:
runtime = LspRuntime()
for fn_name, args in [
("start_server", ("local/pyright", "/tmp")),
("stop_server", ("local/pyright",)),
("get_diagnostics", ("local/pyright", "/tmp/test.py")),
("get_completions", ("local/pyright", "/tmp/test.py", 1, 1)),
]:
try:
getattr(runtime, fn_name)(*args)
print(f"FAIL: {fn_name} should have raised LspNotAvailableError")
sys.exit(1)
except LspNotAvailableError:
pass
print("lsp-runtime-stub-ok")
def cmd_tool_adapter() -> None:
config = _make_config(
"local/pyright",
languages=["python"],
capabilities=[LspCapability.DIAGNOSTICS, LspCapability.COMPLETIONS],
)
adapter = LspToolAdapter()
specs = adapter.generate_tool_specs(config)
assert len(specs) == 2, f"Expected 2 specs, got {len(specs)}"
names = {s["name"] for s in specs}
assert "local/pyright/diagnostics" in names
assert "local/pyright/completions" in names
# Verify handler raises in local mode
try:
specs[0]["handler"]()
print("FAIL: handler should have raised LspNotAvailableError")
sys.exit(1)
except LspNotAvailableError:
pass
print("lsp-tool-adapter-ok")
COMMANDS = {
"register-and-get": cmd_register_and_get,
"list-filter": cmd_list_filter,
"remove-server": cmd_remove_server,
"runtime-stub": cmd_runtime_stub,
"tool-adapter": cmd_tool_adapter,
}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
print(f"Usage: {sys.argv[0]} <{'|'.join(COMMANDS)}>", file=sys.stderr)
sys.exit(1)
COMMANDS[sys.argv[1]]()
+49
View File
@@ -0,0 +1,49 @@
*** Settings ***
Documentation Smoke tests for LSP Registry and Runtime stubs
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_lsp_registry.py
*** Test Cases ***
Register And Retrieve An LSP Server
[Documentation] Register an LSP server and retrieve it by name
${result}= Run Process ${PYTHON} ${HELPER} register-and-get cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-registry-ok
List LSP Servers With Namespace Filter
[Documentation] Register servers and list with namespace filter
${result}= Run Process ${PYTHON} ${HELPER} list-filter cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-list-ok
Remove An LSP Server
[Documentation] Register and remove an LSP server
${result}= Run Process ${PYTHON} ${HELPER} remove-server cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-remove-ok
Runtime Stub Raises In Local Mode
[Documentation] Verify runtime operations raise LspNotAvailableError
${result}= Run Process ${PYTHON} ${HELPER} runtime-stub cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-runtime-stub-ok
Tool Adapter Generates Specs
[Documentation] Verify tool adapter generates tool specs from capabilities
${result}= Run Process ${PYTHON} ${HELPER} tool-adapter cwd=${WORKSPACE}
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lsp-tool-adapter-ok
+329
View File
@@ -0,0 +1,329 @@
"""LSP server management commands for CleverAgents CLI.
The ``agents lsp`` command group manages Language Server Protocol
(LSP) server registrations in the global LSP Registry.
## Commands
| Command | Description |
|------------------------|-------------------------------------------|
| ``agents lsp add`` | Register LSP server from YAML config file |
| ``agents lsp remove`` | Remove a registered LSP server |
| ``agents lsp list`` | List registered LSP servers |
| ``agents lsp show`` | Show full details for an LSP server |
## Config-Only Add
LSP servers are registered **exclusively** via a YAML config file:
```bash
agents lsp add --config ./lsp/pyright.yaml
```
### YAML Configuration File
```yaml
name: local/pyright
command: pyright-langserver
args: ["--stdio"]
languages: ["python"]
capabilities: ["diagnostics", "completions", "type_info"]
```
## Error Handling
| Error | Cause |
|----------------------------|------------------------------------------|
| ``Config file error`` | File not found or not readable |
| ``Schema validation error``| YAML does not match LspServerConfig |
| ``Already registered`` | Server exists (without ``--update``) |
| ``Not registered`` | Server not found for show/remove |
Based on specification.md "agents lsp" section and implementation_plan.md
task M2.7.lsp-stubs.
"""
from __future__ import annotations
from pathlib import Path
from typing import Annotated, Any
import typer
import yaml
from pydantic import ValidationError as PydanticValidationError
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from cleveragents.cli.formatting import OutputFormat, format_output
from cleveragents.lsp.errors import LspServerNotFoundError
from cleveragents.lsp.models import LspServerConfig
from cleveragents.lsp.registry import LspRegistry
app = typer.Typer(help="Manage LSP (Language Server Protocol) servers in the registry.")
console = Console()
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
# ---------------------------------------------------------------------------
# Module-level registry singleton (in-memory; replaced by DI container
# when production registry lands).
# ---------------------------------------------------------------------------
_registry: LspRegistry | None = None
def _get_registry() -> LspRegistry:
"""Get or create the module-level LspRegistry instance."""
global _registry
if _registry is None:
_registry = LspRegistry()
return _registry
def _reset_registry() -> None:
"""Reset the registry (used by tests)."""
global _registry
_registry = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _config_dict(config: LspServerConfig) -> dict[str, Any]:
"""Serialize an LspServerConfig for non-rich output formats."""
data = config.to_dict()
data["namespace"] = config.namespace
data["short_name"] = config.short_name
return data
# ---------------------------------------------------------------------------
# CLI commands
# ---------------------------------------------------------------------------
@app.command("add")
def add(
config: Annotated[
Path,
typer.Option(
"--config",
"-c",
help="Path to the LSP server YAML configuration file",
exists=False,
),
],
update: Annotated[
bool,
typer.Option(
"--update",
help="Allow overwriting an existing LSP server registration",
),
] = False,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Register a new LSP server from a YAML configuration file.
Examples:
agents lsp add --config ./lsp/pyright.yaml
agents lsp add --config ./lsp/pyright.yaml --update
"""
try:
if not config.exists():
console.print(f"[red]Config file error:[/red] {config} not found")
raise typer.Abort()
raw = yaml.safe_load(config.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
console.print("[red]Schema validation error:[/red] YAML must be a mapping")
raise typer.Abort()
server_config = LspServerConfig(**raw)
registry = _get_registry()
if update and registry.get(server_config.name) is not None:
registry.remove(server_config.name)
registry.register(server_config)
if fmt != OutputFormat.RICH.value:
data = _config_dict(server_config)
data["registered"] = True
typer.echo(format_output(data, fmt))
return
langs = ", ".join(server_config.languages) or "(none)"
caps = ", ".join(c.value for c in server_config.capabilities) or "(none)"
details = (
f"[bold]Name:[/bold] {server_config.name}\n"
f"[bold]Command:[/bold] {server_config.command}\n"
f"[bold]Languages:[/bold] {langs}\n"
f"[bold]Capabilities:[/bold] {caps}"
)
console.print(Panel(details, title="LSP Server Registered", expand=False))
console.print("[green]OK[/green] LSP server registered")
except PydanticValidationError as exc:
console.print(f"[red]Schema validation error:[/red] {exc}")
raise typer.Abort() from exc
except ValueError as exc:
error_msg = str(exc)
console.print(f"[red]Error:[/red] {error_msg}")
if "already registered" in error_msg:
console.print(
"\nTo overwrite, re-run with [cyan]--update[/cyan]:\n\n"
f" agents lsp add --config {config} --update"
)
raise typer.Abort() from exc
@app.command("remove")
def remove(
name: Annotated[
str,
typer.Argument(help="Namespaced LSP server name to remove"),
],
yes: Annotated[
bool,
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
] = False,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Remove a registered LSP server.
Examples:
agents lsp remove local/pyright
agents lsp remove local/pyright --yes
"""
registry = _get_registry()
config = registry.get(name)
if config is None:
console.print(f"[red]LSP server not found:[/red] {name}")
raise typer.Abort()
if not yes:
confirm = typer.confirm(f"Remove LSP server {name}?", default=False)
if not confirm:
console.print("[yellow]Aborted.[/yellow]")
raise typer.Abort()
registry.remove(name)
if fmt != OutputFormat.RICH.value:
typer.echo(format_output({"name": name, "removed": True}, fmt))
return
console.print(
Panel(f"[bold]Name:[/bold] {name}", title="LSP Server Removed", expand=False)
)
console.print("[green]OK[/green] LSP server removed")
@app.command("list")
def list_servers(
namespace: Annotated[
str | None,
typer.Option("--namespace", "-n", help="Filter by namespace"),
] = None,
language: Annotated[
str | None,
typer.Option("--language", "-l", help="Filter by language"),
] = None,
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""List registered LSP servers.
Examples:
agents lsp list
agents lsp list --namespace local
agents lsp list --language python
agents lsp list --format json
"""
registry = _get_registry()
servers = registry.list_servers(namespace=namespace, language=language)
if not servers:
console.print("[yellow]No LSP servers found.[/yellow]")
console.print("Register one with 'agents lsp add --config <file>'")
return
if fmt != OutputFormat.RICH.value:
data = [_config_dict(s) for s in servers]
typer.echo(format_output(data, fmt))
return
table = Table(title=f"LSP Servers ({len(servers)} total)", show_header=True)
table.add_column("Name", style="cyan")
table.add_column("Command")
table.add_column("Languages")
table.add_column("Capabilities", justify="right")
for server in servers:
table.add_row(
server.name,
server.command,
", ".join(server.languages) or "(none)",
str(len(server.capabilities)),
)
console.print(table)
console.print(f"[green]OK[/green] {len(servers)} LSP server(s) listed")
@app.command("show")
def show(
name: Annotated[
str,
typer.Argument(help="Namespaced LSP server name to show"),
],
fmt: Annotated[
str,
typer.Option("--format", "-f", help=_FORMAT_HELP),
] = "rich",
) -> None:
"""Show full details for a registered LSP server.
Examples:
agents lsp show local/pyright
agents lsp show local/pyright --format json
"""
registry = _get_registry()
try:
config = registry.get_or_raise(name)
except LspServerNotFoundError as exc:
console.print(f"[red]LSP server not found:[/red] {name}")
raise typer.Abort() from exc
if fmt != OutputFormat.RICH.value:
typer.echo(format_output(_config_dict(config), fmt))
return
details = (
f"[bold]Name:[/bold] {config.name}\n"
f"[bold]Namespace:[/bold] {config.namespace}\n"
f"[bold]Command:[/bold] {config.command}\n"
f"[bold]Args:[/bold] {' '.join(config.args) or '(none)'}\n"
f"[bold]Languages:[/bold] {', '.join(config.languages) or '(none)'}\n"
f"[bold]Capabilities:[/bold] "
f"{', '.join(c.value for c in config.capabilities) or '(none)'}"
)
console.print(Panel(details, title="LSP Server Details", expand=False))
if config.env:
env_lines: list[str] = []
for key, val in sorted(config.env.items()):
env_lines.append(f"[blue]{key}[/blue] = {val}")
console.print(Panel("\n".join(env_lines), title="Environment", expand=False))
console.print("[green]OK[/green] LSP server loaded")
+7
View File
@@ -84,6 +84,7 @@ def _register_subcommands() -> None:
cleanup, cleanup,
config, config,
context, context,
lsp,
plan, plan,
project, project,
resource, resource,
@@ -135,6 +136,11 @@ def _register_subcommands() -> None:
name="skill", name="skill",
help="Manage skills (reusable, namespaced tool collections)", help="Manage skills (reusable, namespaced tool collections)",
) )
app.add_typer(
lsp.app,
name="lsp",
help="Manage LSP (Language Server Protocol) servers in the registry",
)
app.add_typer( app.add_typer(
cleanup.app, cleanup.app,
name="cleanup", name="cleanup",
@@ -555,6 +561,7 @@ def main(args: list[str] | None = None) -> int:
"action", # v3 plan lifecycle actions "action", # v3 plan lifecycle actions
"resource", # Resource registry management "resource", # Resource registry management
"skill", # Skill management "skill", # Skill management
"lsp", # LSP server management
"cleanup", # Garbage collection and cleanup "cleanup", # Garbage collection and cleanup
"config", # Configuration management "config", # Configuration management
"session", # Session management "session", # Session management
+47
View File
@@ -0,0 +1,47 @@
"""LSP (Language Server Protocol) integration package.
Provides the registry, runtime stub, tool adapter, and domain models
for managing language servers within CleverAgents.
In **local mode** the :class:`LspRuntime` and :class:`LspToolAdapter`
are stubs: the runtime raises :class:`LspNotAvailableError` for every
operation and the tool adapter generates tool specs whose handlers
also raise immediately. When server mode is implemented the concrete
classes will replace these stubs while preserving the public API.
Usage::
from cleveragents.lsp import (
LspRegistry,
LspRuntime,
LspToolAdapter,
LspServerConfig,
LspCapability,
)
"""
from cleveragents.lsp.errors import (
LspError,
LspNotAvailableError,
LspServerNotFoundError,
)
from cleveragents.lsp.models import (
LspBinding,
LspCapability,
LspServerConfig,
)
from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.runtime import LspRuntime
from cleveragents.lsp.tool_adapter import LspToolAdapter
__all__ = [
"LspBinding",
"LspCapability",
"LspError",
"LspNotAvailableError",
"LspRegistry",
"LspRuntime",
"LspServerConfig",
"LspServerNotFoundError",
"LspToolAdapter",
]
+70
View File
@@ -0,0 +1,70 @@
"""Error hierarchy for the LSP subsystem.
Provides domain-specific exceptions for the Language Server Protocol
integration layer. All errors inherit from
:class:`~cleveragents.core.exceptions.CleverAgentsError` and follow
the project-wide fail-fast conventions (ADR-005).
Error Classes
-------------
| Error | When raised |
|--------------------------|------------------------------------------|
| ``LspError`` | Base for all LSP errors |
| ``LspNotAvailableError`` | LSP operation attempted in local mode |
| ``LspServerNotFoundError``| Named LSP server not in registry |
"""
from __future__ import annotations
from typing import Any
from cleveragents.core.exceptions import CleverAgentsError
class LspError(CleverAgentsError):
"""Base exception for all LSP-related errors."""
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
super().__init__(message, details)
class LspNotAvailableError(LspError):
"""Raised when an LSP operation is attempted in local mode.
In local mode the LSP Runtime does not start real language servers.
All runtime and tool-adapter operations raise this error with a
descriptive message directing users to server mode.
"""
def __init__(
self,
message: str = "LSP is not available in local mode",
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message, details)
class LspServerNotFoundError(LspError):
"""Raised when a referenced LSP server is not in the registry.
Attributes:
server_name: The namespaced server name that was not found.
"""
def __init__(
self,
server_name: str,
details: dict[str, Any] | None = None,
) -> None:
if not server_name:
raise ValueError("server_name must be a non-empty string")
msg = f"LSP server '{server_name}' not found in registry"
super().__init__(msg, details)
self.server_name = server_name
__all__ = [
"LspError",
"LspNotAvailableError",
"LspServerNotFoundError",
]
+172
View File
@@ -0,0 +1,172 @@
"""Pydantic domain models for the LSP subsystem.
Provides the data models used by the LSP Registry, Runtime, and
Tool Adapter:
- :class:`LspCapability` -- enum of LSP capabilities exposed as tools.
- :class:`LspServerConfig` -- registration record for a language server.
- :class:`LspBinding` -- binds an LSP server to an actor graph node.
Namespaced names follow the project-wide pattern
``[[server:]namespace/]name``.
"""
from __future__ import annotations
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
class LspCapability(StrEnum):
"""LSP capabilities that can be exposed as callable tools.
Each member maps to a Language Server Protocol feature that the
:class:`LspToolAdapter` can present to actors.
"""
DIAGNOSTICS = "diagnostics"
TYPE_INFO = "type_info"
SYMBOLS = "symbols"
COMPLETIONS = "completions"
REFERENCES = "references"
RENAME = "rename"
CODE_ACTIONS = "code_actions"
FORMAT = "format"
class LspServerConfig(BaseModel):
"""Registration record for a language server.
Attributes:
name: Namespaced server name (e.g. ``local/pyright``).
languages: Programming languages served (e.g. ``["python"]``).
command: Executable command to start the server.
args: Additional CLI arguments for the server process.
env: Extra environment variables for the server process.
capabilities: LSP capabilities this server exposes.
"""
name: str = Field(
...,
min_length=1,
description="Namespaced server name (e.g. local/pyright)",
)
languages: list[str] = Field(
default_factory=list,
description="Programming languages served (e.g. ['python'])",
)
command: str = Field(
...,
min_length=1,
description="Executable command to start the server",
)
args: list[str] = Field(
default_factory=list,
description="Additional CLI arguments for the server process",
)
env: dict[str, str] = Field(
default_factory=dict,
description="Extra environment variables for the server process",
)
capabilities: list[LspCapability] = Field(
default_factory=list,
description="LSP capabilities this server exposes",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
@field_validator("name")
@classmethod
def _validate_name(cls, value: str) -> str:
"""Validate namespaced name contains a slash separator."""
if "/" not in value:
raise ValueError(
f"LSP server name must be namespaced (contain '/'): got '{value}'"
)
return value
@field_validator("languages")
@classmethod
def _validate_languages(cls, value: list[str]) -> list[str]:
"""Ensure language strings are non-empty and lowercased."""
result: list[str] = []
for lang in value:
stripped = lang.strip().lower()
if not stripped:
raise ValueError("Language entries must be non-empty strings")
result.append(stripped)
return result
@property
def namespace(self) -> str:
"""Extract the namespace portion of the name."""
return self.name.rsplit("/", 1)[0]
@property
def short_name(self) -> str:
"""Extract the short name portion after the last slash."""
return self.name.rsplit("/", 1)[-1]
def to_dict(self) -> dict[str, Any]:
"""Serialize to a plain dictionary."""
return self.model_dump(mode="json")
class LspBinding(BaseModel):
"""Binds an LSP server to an actor graph node.
When an actor activates, bindings are used to determine which
language servers to start for each graph node.
Attributes:
node_name: Name of the actor graph node.
lsp_server_name: Namespaced name of the LSP server.
languages: Subset of languages to bind (empty = all).
auto_detect: Whether to auto-detect language from file context.
"""
node_name: str = Field(
...,
min_length=1,
description="Name of the actor graph node",
)
lsp_server_name: str = Field(
...,
min_length=1,
description="Namespaced name of the LSP server",
)
languages: list[str] = Field(
default_factory=list,
description="Subset of languages to bind (empty = all)",
)
auto_detect: bool = Field(
default=True,
description="Whether to auto-detect language from file context",
)
model_config = ConfigDict(
str_strip_whitespace=True,
validate_assignment=True,
)
@field_validator("lsp_server_name")
@classmethod
def _validate_server_name(cls, value: str) -> str:
"""Validate that server name is namespaced."""
if "/" not in value:
raise ValueError(
f"LSP server name must be namespaced (contain '/'): got '{value}'"
)
return value
__all__ = [
"LspBinding",
"LspCapability",
"LspServerConfig",
]
+176
View File
@@ -0,0 +1,176 @@
"""Thread-safe LSP server registry.
Provides :class:`LspRegistry`, a global registry of LSP server
configurations keyed by namespaced name. The registry uses a
:class:`threading.RLock` to ensure safe concurrent access.
Typical usage::
from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.models import LspServerConfig
registry = LspRegistry()
registry.register(LspServerConfig(
name="local/pyright",
command="pyright-langserver",
languages=["python"],
))
config = registry.get("local/pyright")
"""
from __future__ import annotations
import logging
import threading
from cleveragents.lsp.errors import LspServerNotFoundError
from cleveragents.lsp.models import LspServerConfig
logger = logging.getLogger(__name__)
class LspRegistry:
"""Thread-safe, in-memory registry for LSP server configurations.
All public methods acquire an :class:`threading.RLock` before
reading or mutating state, so concurrent calls from multiple
threads are safe.
Attributes:
_servers: Internal mapping of namespaced name -> config.
_lock: Reentrant lock guarding ``_servers``.
"""
def __init__(self) -> None:
self._servers: dict[str, LspServerConfig] = {}
self._lock = threading.RLock()
# -- Registration ----------------------------------------------------------
def register(self, config: LspServerConfig) -> None:
"""Register an LSP server configuration.
Args:
config: The :class:`LspServerConfig` to register.
Raises:
ValueError: If *config* is ``None`` or its name is already
registered.
"""
if config is None:
raise ValueError("config must not be None")
with self._lock:
if config.name in self._servers:
raise ValueError(
f"LSP server '{config.name}' is already registered"
)
self._servers[config.name] = config
logger.info("Registered LSP server: %s", config.name)
# -- Lookup ----------------------------------------------------------------
def get(self, name: str) -> LspServerConfig | None:
"""Look up an LSP server configuration by namespaced name.
Args:
name: Namespaced server name (e.g. ``local/pyright``).
Returns:
The :class:`LspServerConfig` if found, otherwise ``None``.
Raises:
ValueError: If *name* is empty.
"""
if not name:
raise ValueError("name must be a non-empty string")
with self._lock:
return self._servers.get(name)
def get_or_raise(self, name: str) -> LspServerConfig:
"""Look up an LSP server or raise :class:`LspServerNotFoundError`.
Args:
name: Namespaced server name.
Returns:
The :class:`LspServerConfig`.
Raises:
LspServerNotFoundError: If the server is not registered.
"""
config = self.get(name)
if config is None:
raise LspServerNotFoundError(name)
return config
# -- Listing ---------------------------------------------------------------
def list_servers(
self,
namespace: str | None = None,
language: str | None = None,
) -> list[LspServerConfig]:
"""List registered servers with optional filters.
Args:
namespace: If given, only return servers whose namespace
matches (prefix before the last ``/``).
language: If given, only return servers that support this
language (case-insensitive).
Returns:
List of matching :class:`LspServerConfig` instances,
sorted by name.
"""
with self._lock:
results: list[LspServerConfig] = []
lang_lower = language.lower() if language else None
for config in self._servers.values():
if namespace is not None and config.namespace != namespace:
continue
if lang_lower is not None and lang_lower not in config.languages:
continue
results.append(config)
results.sort(key=lambda c: c.name)
return results
# -- Removal ---------------------------------------------------------------
def remove(self, name: str) -> bool:
"""Unregister a server by namespaced name.
Args:
name: The namespaced server name to remove.
Returns:
``True`` if the server was removed, ``False`` if it was
not registered.
Raises:
ValueError: If *name* is empty.
"""
if not name:
raise ValueError("name must be a non-empty string")
with self._lock:
if name in self._servers:
del self._servers[name]
logger.info("Removed LSP server: %s", name)
return True
return False
# -- Introspection ---------------------------------------------------------
def __len__(self) -> int:
"""Return the number of registered servers."""
with self._lock:
return len(self._servers)
def __contains__(self, name: str) -> bool:
"""Check whether a server name is registered."""
with self._lock:
return name in self._servers
__all__ = [
"LspRegistry",
]
+158
View File
@@ -0,0 +1,158 @@
"""Local-mode LSP Runtime stub.
In local mode the LSP Runtime does **not** start real language servers.
Every method raises :class:`LspNotAvailableError` with a clear message
directing the user to server mode. This stub ensures that the
public API surface exists and can be imported, type-checked, and
tested even when no actual LSP transport is available.
When server mode is implemented the concrete runtime will replace
these stub implementations while keeping the same interface.
"""
from __future__ import annotations
import logging
from typing import Any
from cleveragents.lsp.errors import LspNotAvailableError
logger = logging.getLogger(__name__)
_LOCAL_MODE_MSG = (
"LSP server start deferred to server mode - "
"language-server features are not available in local mode"
)
class LspRuntime:
"""Stub runtime for LSP servers in local mode.
All operations raise :class:`LspNotAvailableError`. The runtime
logs a warning on each call to aid debugging.
"""
def start_server(self, name: str, workspace_path: str) -> None:
"""Start an LSP server for *name* in *workspace_path*.
In local mode this is a no-op that raises immediately.
Args:
name: Namespaced server name.
workspace_path: Root directory for the language server.
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* or *workspace_path* is empty.
"""
if not name:
raise ValueError("name must be a non-empty string")
if not workspace_path:
raise ValueError("workspace_path must be a non-empty string")
logger.warning("%s (server=%s)", _LOCAL_MODE_MSG, name)
raise LspNotAvailableError(
_LOCAL_MODE_MSG,
details={"server": name, "workspace": workspace_path},
)
def stop_server(self, name: str) -> None:
"""Stop the LSP server identified by *name*.
Args:
name: Namespaced server name.
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* is empty.
"""
if not name:
raise ValueError("name must be a non-empty string")
logger.warning("%s (server=%s)", _LOCAL_MODE_MSG, name)
raise LspNotAvailableError(
_LOCAL_MODE_MSG,
details={"server": name},
)
def get_diagnostics(self, name: str, file_path: str) -> list[Any]:
"""Retrieve diagnostics for *file_path* from the named server.
In local mode this always raises; the return type annotation
is kept for interface compatibility.
Args:
name: Namespaced server name.
file_path: Path to the file being diagnosed.
Returns:
Empty list (never reached in local mode).
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* or *file_path* is empty.
"""
if not name:
raise ValueError("name must be a non-empty string")
if not file_path:
raise ValueError("file_path must be a non-empty string")
logger.warning("%s (server=%s, file=%s)", _LOCAL_MODE_MSG, name, file_path)
raise LspNotAvailableError(
_LOCAL_MODE_MSG,
details={"server": name, "file": file_path},
)
def get_completions(
self,
name: str,
file_path: str,
line: int,
column: int,
) -> list[Any]:
"""Retrieve completions for a position in *file_path*.
In local mode this always raises; the return type annotation
is kept for interface compatibility.
Args:
name: Namespaced server name.
file_path: Path to the file.
line: 1-based line number.
column: 1-based column number.
Returns:
Empty list (never reached in local mode).
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* or *file_path* is empty, or
*line*/*column* are not positive.
"""
if not name:
raise ValueError("name must be a non-empty string")
if not file_path:
raise ValueError("file_path must be a non-empty string")
if line < 1:
raise ValueError("line must be >= 1")
if column < 1:
raise ValueError("column must be >= 1")
logger.warning(
"%s (server=%s, file=%s, pos=%d:%d)",
_LOCAL_MODE_MSG,
name,
file_path,
line,
column,
)
raise LspNotAvailableError(
_LOCAL_MODE_MSG,
details={
"server": name,
"file": file_path,
"line": line,
"column": column,
},
)
__all__ = [
"LspRuntime",
]
+177
View File
@@ -0,0 +1,177 @@
"""LSP Tool Adapter stub for local mode.
The :class:`LspToolAdapter` translates LSP capabilities into
:class:`~cleveragents.tool.runtime.ToolSpec` entries so that actors
can discover and invoke language-server features through the standard
tool interface.
In **local mode** the adapter generates tool specs whose handlers
always raise :class:`LspNotAvailableError`. In server mode a
concrete adapter will replace the stub handlers with real LSP calls.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from cleveragents.lsp.errors import LspNotAvailableError
from cleveragents.lsp.models import LspCapability, LspServerConfig
logger = logging.getLogger(__name__)
# Maps each capability to the tool name suffix and description used
# when generating ToolSpec entries.
_CAPABILITY_TOOL_MAP: dict[LspCapability, tuple[str, str]] = {
LspCapability.DIAGNOSTICS: (
"diagnostics",
"Retrieve diagnostics (errors/warnings) for a file",
),
LspCapability.TYPE_INFO: (
"type_info",
"Get type information at a cursor position",
),
LspCapability.SYMBOLS: (
"symbols",
"List document and workspace symbols",
),
LspCapability.COMPLETIONS: (
"completions",
"Get code completion suggestions at a position",
),
LspCapability.REFERENCES: (
"references",
"Find all references to a symbol",
),
LspCapability.RENAME: (
"rename",
"Rename a symbol across the workspace",
),
LspCapability.CODE_ACTIONS: (
"code_actions",
"Retrieve available code actions for a range",
),
LspCapability.FORMAT: (
"format",
"Format a document or selection",
),
}
def _make_local_mode_handler(
server_name: str,
capability: LspCapability,
) -> Callable[..., Any]:
"""Create a handler that raises :class:`LspNotAvailableError`.
Args:
server_name: The namespaced LSP server name.
capability: The capability this handler represents.
Returns:
A callable that always raises.
"""
def _handler(**_kwargs: Any) -> Any:
raise LspNotAvailableError(
f"LSP capability '{capability.value}' for server "
f"'{server_name}' is not available in local mode",
details={"server": server_name, "capability": capability.value},
)
return _handler
class LspToolAdapter:
"""Generates tool specs from LSP server capabilities.
In local mode every tool handler raises
:class:`LspNotAvailableError`.
"""
def generate_tool_specs(
self,
config: LspServerConfig,
) -> list[dict[str, Any]]:
"""Generate tool-spec dicts from *config*'s capabilities.
Each returned dict has the shape expected by
:class:`~cleveragents.tool.runtime.ToolSpec`:
- ``name``: ``<server_name>/<capability_suffix>``
- ``description``: Human-readable description.
- ``handler``: Callable (raises in local mode).
- ``input_schema``: Minimal JSON Schema stub.
Args:
config: The LSP server configuration whose capabilities
should be turned into tool specs.
Returns:
List of tool-spec dicts, one per capability.
Raises:
ValueError: If *config* is ``None``.
"""
if config is None:
raise ValueError("config must not be None")
specs: list[dict[str, Any]] = []
for capability in config.capabilities:
suffix, description = _CAPABILITY_TOOL_MAP.get(
capability,
(capability.value, f"LSP {capability.value} operation"),
)
tool_name = f"{config.name}/{suffix}"
handler = _make_local_mode_handler(config.name, capability)
spec: dict[str, Any] = {
"name": tool_name,
"description": description,
"handler": handler,
"input_schema": _input_schema_for(capability),
}
specs.append(spec)
logger.debug("Generated tool spec: %s (local-mode stub)", tool_name)
return specs
def _input_schema_for(capability: LspCapability) -> dict[str, Any]:
"""Return a minimal JSON Schema for the given capability.
Args:
capability: The LSP capability.
Returns:
A JSON Schema dict.
"""
base: dict[str, Any] = {"type": "object", "properties": {}}
if capability in (
LspCapability.DIAGNOSTICS,
LspCapability.FORMAT,
LspCapability.SYMBOLS,
):
base["properties"] = {
"file_path": {"type": "string", "description": "Path to the file"},
}
base["required"] = ["file_path"]
elif capability in (
LspCapability.COMPLETIONS,
LspCapability.TYPE_INFO,
LspCapability.REFERENCES,
LspCapability.RENAME,
LspCapability.CODE_ACTIONS,
):
base["properties"] = {
"file_path": {"type": "string", "description": "Path to the file"},
"line": {"type": "integer", "description": "1-based line number"},
"column": {"type": "integer", "description": "1-based column number"},
}
base["required"] = ["file_path", "line", "column"]
return base
__all__ = [
"LspToolAdapter",
]