feat(lsp): implement functional LSP runtime

Replace local-mode stubs with real LSP protocol support:

- StdioTransport: subprocess management with JSON-RPC framing
- LspClient: LSP protocol (initialize/shutdown/diagnostics/completions)
- LspLifecycleManager: reference-counted instances, health checks, crash restart
- LspRuntime: registry-based server lookup, auto-restart on crash
- LspToolAdapter: runtime-delegating handlers with local-mode fallback
- LanguageDiscovery: 4-layer detection (extension, shebang, UKO, project)
- activate_bindings/deactivate_bindings: actor compiler LSP binding wiring

Tests: 27 Behave scenarios, 6 Robot integration tests, 250 existing pass.

ISSUES CLOSED: #826
This commit is contained in:
2026-03-18 04:36:57 +00:00
parent a854de7e1f
commit 4ff075e0da
16 changed files with 2369 additions and 100 deletions
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- Implemented functional LSP runtime replacing local-mode stubs with real
LSP protocol support. `StdioTransport` manages server subprocesses via
JSON-RPC over stdin/stdout. `LspClient` implements initialize/shutdown/
diagnostics/completions. `LspLifecycleManager` provides reference-counted
instances, health checks, and crash restart. `LanguageDiscovery` implements
4-layer detection (extension, shebang, UKO, project config). Includes
27 Behave BDD scenarios and 6 Robot integration tests. (#826)
- Aligned plan lifecycle model with specification: ERRORED is now
terminal in `is_terminal`, per-phase state validation enforces
APPLIED/CONSTRAINED to APPLY-only and COMPLETE to
+9 -10
View File
@@ -1335,41 +1335,40 @@ Feature: Consolidated Misc
Then the LSP removal result should be False
# ---------------------------------------------------------------------------
# Runtime stub raises LspNotAvailableError
# Runtime raises LspServerNotFoundError for unregistered servers
# ---------------------------------------------------------------------------
@lsp_runtime
Scenario: Runtime start_server raises LspNotAvailableError
Scenario: Runtime start_server raises error for unregistered server
Given a clean LSP registry
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"
Then an LSP error should be raised
@lsp_runtime
Scenario: Runtime stop_server raises LspNotAvailableError
Scenario: Runtime stop_server raises error for unregistered server
Given a clean LSP registry
Given a local-mode LSP runtime
When I call stop_server on the runtime with name "local/pyright"
Then an LspNotAvailableError should be raised
Then an LSP error should be raised
@lsp_runtime
Scenario: Runtime get_diagnostics raises LspNotAvailableError
Scenario: Runtime get_diagnostics raises error for unregistered server
Given a clean LSP registry
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
Then an LSP error should be raised
@lsp_runtime
Scenario: Runtime get_completions raises LspNotAvailableError
Scenario: Runtime get_completions raises error for unregistered server
Given a clean LSP registry
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
Then an LSP error should be raised
# ---------------------------------------------------------------------------
# Tool adapter generates tool specs from capabilities
+149
View File
@@ -0,0 +1,149 @@
Feature: Functional LSP Runtime
As an actor runtime
I need LSP servers to provide real code intelligence
So that actors can get diagnostics and completions
# ── StdioTransport ────────────────────────────────────────────────
Scenario: StdioTransport rejects empty command
When I try to create a StdioTransport with empty command
Then a ValueError should be raised for transport
Scenario: StdioTransport reports not alive before start
Given I create a StdioTransport for "echo"
Then the transport should not be alive
Scenario: StdioTransport start and stop with echo
Given I create a StdioTransport for "cat"
When I start the transport
Then the transport should be alive
When I stop the transport
Then the transport should not be alive
# ── LspClient ─────────────────────────────────────────────────────
Scenario: LspClient starts not initialized
Given I create an LspClient with a mock transport
Then the client should not be initialized
Scenario: LspClient tracks request IDs
Given I create an LspClient with a mock transport
When I get two request IDs from the client
Then the IDs should be sequential
# ── LspLifecycleManager ──────────────────────────────────────────
Scenario: LspLifecycleManager starts empty
Given I create an LspLifecycleManager
Then the manager should have no running servers
Scenario: LspLifecycleManager stop unknown server raises error
Given I create an LspLifecycleManager
When I try to stop server "local/unknown" on the manager
Then an LspServerNotFoundError should be raised for lifecycle
Scenario: LspLifecycleManager health check returns false for unknown
Given I create an LspLifecycleManager
Then health check for "local/unknown" should return false
# ── LspRuntime with registry ─────────────────────────────────────
Scenario: LspRuntime start_server rejects empty name
Given I create a functional LspRuntime
When I try to start server with empty name and workspace "/tmp"
Then a ValueError should be raised for runtime
Scenario: LspRuntime start_server rejects empty workspace
Given I create a functional LspRuntime
When I try to start server "local/test" with empty workspace
Then a ValueError should be raised for runtime
Scenario: LspRuntime start_server raises for unregistered server
Given I create a functional LspRuntime
When I try to start unregistered server "local/missing"
Then an LspServerNotFoundError should be raised for runtime
Scenario: LspRuntime get_diagnostics rejects empty name
Given I create a functional LspRuntime
When I try to get diagnostics with empty name
Then a ValueError should be raised for runtime
Scenario: LspRuntime get_completions validates line >= 1
Given I create a functional LspRuntime
When I try to get completions with line 0
Then a ValueError should be raised for runtime
Scenario: LspRuntime get_completions validates column >= 1
Given I create a functional LspRuntime
When I try to get completions with column 0
Then a ValueError should be raised for runtime
# ── LanguageDiscovery ────────────────────────────────────────────
Scenario: LanguageDiscovery detects Python from extension
Given I create a LanguageDiscovery instance
Then language for "test.py" should be "python"
And language for "test.pyi" should be "python"
Scenario: LanguageDiscovery detects TypeScript from extension
Given I create a LanguageDiscovery instance
Then language for "app.ts" should be "typescript"
And language for "app.tsx" should be "typescriptreact"
Scenario: LanguageDiscovery detects Rust from extension
Given I create a LanguageDiscovery instance
Then language for "main.rs" should be "rust"
Scenario: LanguageDiscovery returns plaintext for unknown
Given I create a LanguageDiscovery instance
Then language for "data.xyz" should be "plaintext"
Scenario: LanguageDiscovery detects Dockerfile by name
Given I create a LanguageDiscovery instance
Then language for "Dockerfile" should be "dockerfile"
Scenario: LanguageDiscovery uses project language as fallback
Given I create a LanguageDiscovery with project language "go"
Then language for "unknown_file" should be "go"
Scenario: LanguageDiscovery cache invalidation works
Given I create a LanguageDiscovery instance
When I detect language for "test.py"
And I invalidate "test.py" from discovery cache
Then the cache should not contain "test.py"
Scenario: LanguageDiscovery detects shebang for Python script
Given I create a LanguageDiscovery instance
And I create a temp file with shebang "#!/usr/bin/env python3"
Then the temp file language should be "python"
Scenario: LanguageDiscovery detects shebang for bash script
Given I create a LanguageDiscovery instance
And I create a temp file with shebang "#!/bin/bash"
Then the temp file language should be "shellscript"
# ── LspToolAdapter with runtime ──────────────────────────────────
Scenario: LspToolAdapter generates runtime-backed handlers when runtime provided
Given I create an LspToolAdapter with a functional runtime
And I have an LSP server config with diagnostics capability
When I generate functional tool specs from the adapter
Then the tool specs should have runtime handlers
Scenario: LspToolAdapter generates local-mode handlers without runtime
Given I create an LspToolAdapter without runtime
And I have an LSP server config with diagnostics capability
When I generate functional tool specs from the adapter
Then the tool specs should have local-mode handlers
# ── Binding activation ───────────────────────────────────────────
Scenario: LspRuntime activate_bindings with no bindings returns empty
Given I create a functional LspRuntime
When I activate bindings with empty list
Then the activated servers list should be empty
Scenario: LspRuntime activate_bindings skips unregistered servers
Given I create a functional LspRuntime
When I activate bindings with unregistered server binding
Then the activated servers list should be empty
@@ -0,0 +1,342 @@
"""Step definitions for the functional LSP runtime feature tests."""
from __future__ import annotations
import os
import tempfile
from unittest.mock import MagicMock, create_autospec
from behave import given, then, when
from behave.runner import Context
from cleveragents.lsp.client import LspClient
from cleveragents.lsp.discovery import LanguageDiscovery
from cleveragents.lsp.errors import LspNotAvailableError, LspServerNotFoundError
from cleveragents.lsp.lifecycle import LspLifecycleManager
from cleveragents.lsp.models import LspCapability, LspServerConfig
from cleveragents.lsp.runtime import LspRuntime
from cleveragents.lsp.tool_adapter import LspToolAdapter
from cleveragents.lsp.transport import StdioTransport
# ---------------------------------------------------------------------------
# StdioTransport steps
# ---------------------------------------------------------------------------
@when("I try to create a StdioTransport with empty command")
def step_transport_empty_command(context: Context) -> None:
try:
StdioTransport(command="")
context.transport_error = None
except ValueError as exc:
context.transport_error = exc
@then("a ValueError should be raised for transport")
def step_transport_value_error(context: Context) -> None:
assert context.transport_error is not None
assert isinstance(context.transport_error, ValueError)
@given('I create a StdioTransport for "{cmd}"')
def step_create_transport(context: Context, cmd: str) -> None:
context.transport = StdioTransport(command=cmd)
@then("the transport should not be alive")
def step_transport_not_alive(context: Context) -> None:
assert not context.transport.is_alive
@when("I start the transport")
def step_start_transport(context: Context) -> None:
context.transport.start()
@then("the transport should be alive")
def step_transport_alive(context: Context) -> None:
assert context.transport.is_alive
@when("I stop the transport")
def step_stop_transport(context: Context) -> None:
context.transport.stop()
# ---------------------------------------------------------------------------
# LspClient steps
# ---------------------------------------------------------------------------
@given("I create an LspClient with a mock transport")
def step_create_client_mock(context: Context) -> None:
mock_transport = create_autospec(StdioTransport, instance=True)
context.lsp_client = LspClient(mock_transport, server_name="test/mock")
@then("the client should not be initialized")
def step_client_not_initialized(context: Context) -> None:
assert not context.lsp_client.is_initialized
@when("I get two request IDs from the client")
def step_get_request_ids(context: Context) -> None:
context.id1 = context.lsp_client._next_id()
context.id2 = context.lsp_client._next_id()
@then("the IDs should be sequential")
def step_ids_sequential(context: Context) -> None:
assert context.id2 == context.id1 + 1
# ---------------------------------------------------------------------------
# LspLifecycleManager steps
# ---------------------------------------------------------------------------
@given("I create an LspLifecycleManager")
def step_create_lifecycle(context: Context) -> None:
context.lifecycle_mgr = LspLifecycleManager()
@then("the manager should have no running servers")
def step_lifecycle_empty(context: Context) -> None:
assert len(context.lifecycle_mgr.list_running()) == 0
@when('I try to stop server "{name}" on the manager')
def step_lifecycle_stop_unknown(context: Context, name: str) -> None:
try:
context.lifecycle_mgr.stop_server(name)
context.lifecycle_error = None
except LspServerNotFoundError as exc:
context.lifecycle_error = exc
@then("an LspServerNotFoundError should be raised for lifecycle")
def step_lifecycle_not_found(context: Context) -> None:
assert context.lifecycle_error is not None
assert isinstance(context.lifecycle_error, LspServerNotFoundError)
@then('health check for "{name}" should return false')
def step_health_false(context: Context, name: str) -> None:
assert not context.lifecycle_mgr.health_check(name)
# ---------------------------------------------------------------------------
# LspRuntime steps
# ---------------------------------------------------------------------------
@given("I create a functional LspRuntime")
def step_create_runtime(context: Context) -> None:
context.func_runtime = LspRuntime()
context.runtime_error = None
@when('I try to start server with empty name and workspace "{ws}"')
def step_runtime_empty_name(context: Context, ws: str) -> None:
try:
context.func_runtime.start_server("", ws)
context.runtime_error = None
except ValueError as exc:
context.runtime_error = exc
@when('I try to start server "{name}" with empty workspace')
def step_runtime_empty_workspace(context: Context, name: str) -> None:
try:
context.func_runtime.start_server(name, "")
context.runtime_error = None
except ValueError as exc:
context.runtime_error = exc
@when('I try to start unregistered server "{name}"')
def step_runtime_unregistered(context: Context, name: str) -> None:
try:
context.func_runtime.start_server(name, "/tmp")
context.runtime_error = None
except LspServerNotFoundError as exc:
context.runtime_error = exc
@then("a ValueError should be raised for runtime")
def step_runtime_value_error(context: Context) -> None:
assert context.runtime_error is not None
assert isinstance(context.runtime_error, ValueError)
@then("an LspServerNotFoundError should be raised for runtime")
def step_runtime_not_found(context: Context) -> None:
assert context.runtime_error is not None
assert isinstance(context.runtime_error, LspServerNotFoundError)
@when("I try to get diagnostics with empty name")
def step_runtime_diag_empty(context: Context) -> None:
try:
context.func_runtime.get_diagnostics("", "/tmp/test.py")
context.runtime_error = None
except ValueError as exc:
context.runtime_error = exc
@when("I try to get completions with line 0")
def step_runtime_completions_line0(context: Context) -> None:
try:
context.func_runtime.get_completions("local/test", "/tmp/test.py", 0, 1)
context.runtime_error = None
except ValueError as exc:
context.runtime_error = exc
@when("I try to get completions with column 0")
def step_runtime_completions_col0(context: Context) -> None:
try:
context.func_runtime.get_completions("local/test", "/tmp/test.py", 1, 0)
context.runtime_error = None
except ValueError as exc:
context.runtime_error = exc
# ---------------------------------------------------------------------------
# LanguageDiscovery steps
# ---------------------------------------------------------------------------
@given("I create a LanguageDiscovery instance")
def step_create_discovery(context: Context) -> None:
context.discovery = LanguageDiscovery()
@given('I create a LanguageDiscovery with project language "{lang}"')
def step_create_discovery_project(context: Context, lang: str) -> None:
context.discovery = LanguageDiscovery(project_languages=[lang])
@then('language for "{filename}" should be "{expected}"')
def step_check_language(context: Context, filename: str, expected: str) -> None:
result = context.discovery.detect_file_language(filename)
assert result == expected, f"Expected '{expected}', got '{result}'"
@when('I detect language for "{filename}"')
def step_detect_language(context: Context, filename: str) -> None:
context.detected_lang = context.discovery.detect_file_language(filename)
@when('I invalidate "{filename}" from discovery cache')
def step_invalidate_cache(context: Context, filename: str) -> None:
context.discovery.invalidate(filename)
@then('the cache should not contain "{filename}"')
def step_cache_not_contains(context: Context, filename: str) -> None:
assert filename not in context.discovery._cache
@given('I create a temp file with shebang "{shebang}"')
def step_create_shebang_file(context: Context, shebang: str) -> None:
fd, path = tempfile.mkstemp(suffix="")
with os.fdopen(fd, "w") as f:
f.write(f"{shebang}\nprint('hello')\n")
context.temp_shebang_file = path
@then('the temp file language should be "{lang}"')
def step_temp_file_lang(context: Context, lang: str) -> None:
result = context.discovery.detect_file_language(context.temp_shebang_file)
assert result == lang, f"Expected '{lang}', got '{result}'"
os.unlink(context.temp_shebang_file)
# ---------------------------------------------------------------------------
# LspToolAdapter steps
# ---------------------------------------------------------------------------
@given("I create an LspToolAdapter with a functional runtime")
def step_adapter_with_runtime(context: Context) -> None:
context.adapter_runtime = LspRuntime()
context.tool_adapter = LspToolAdapter(runtime=context.adapter_runtime)
@given("I create an LspToolAdapter without runtime")
def step_adapter_no_runtime(context: Context) -> None:
context.tool_adapter = LspToolAdapter()
@given("I have an LSP server config with diagnostics capability")
def step_server_config_diag(context: Context) -> None:
context.adapter_config = LspServerConfig(
name="local/test-server",
command="echo",
languages=["python"],
capabilities=[LspCapability.DIAGNOSTICS],
)
@when("I generate functional tool specs from the adapter")
def step_generate_specs(context: Context) -> None:
context.generated_specs = context.tool_adapter.generate_tool_specs(
context.adapter_config,
)
@then("the tool specs should have runtime handlers")
def step_specs_runtime_handlers(context: Context) -> None:
assert len(context.generated_specs) > 0
spec = context.generated_specs[0]
assert spec["name"] == "local/test-server/diagnostics"
# Runtime handler should not raise LspNotAvailableError
# (it would raise LspServerNotFoundError instead since server not started)
handler = spec["handler"]
try:
handler(file_path="/tmp/test.py")
except LspNotAvailableError as exc:
raise AssertionError(
"Expected runtime handler, got local-mode handler"
) from exc
except Exception:
pass # Expected: server not started
@then("the tool specs should have local-mode handlers")
def step_specs_local_handlers(context: Context) -> None:
assert len(context.generated_specs) > 0
spec = context.generated_specs[0]
handler = spec["handler"]
try:
handler(file_path="/tmp/test.py")
raise AssertionError("Expected LspNotAvailableError")
except LspNotAvailableError:
pass # Expected
# ---------------------------------------------------------------------------
# Binding activation steps
# ---------------------------------------------------------------------------
@when("I activate bindings with empty list")
def step_activate_empty(context: Context) -> None:
context.activated = context.func_runtime.activate_bindings([], "/tmp")
@then("the activated servers list should be empty")
def step_activated_empty(context: Context) -> None:
assert context.activated == []
@when("I activate bindings with unregistered server binding")
def step_activate_unregistered(context: Context) -> None:
mock_binding = MagicMock()
mock_binding.lsp_server_name = "local/nonexistent"
mock_binding.node_name = "test_node"
context.activated = context.func_runtime.activate_bindings(
[mock_binding],
"/tmp",
)
+12 -4
View File
@@ -185,7 +185,7 @@ 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:
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@@ -194,7 +194,7 @@ def step_runtime_stop(context: Context, name: str) -> None:
try:
context.lsp_runtime.stop_server(name)
context.lsp_error = None
except LspNotAvailableError as exc:
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@@ -203,7 +203,7 @@ def step_runtime_diagnostics(context: Context, name: str, file_path: str) -> Non
try:
context.lsp_result = context.lsp_runtime.get_diagnostics(name, file_path)
context.lsp_error = None
except LspNotAvailableError as exc:
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@@ -219,7 +219,7 @@ def step_runtime_completions(
name, file_path, line, col
)
context.lsp_error = None
except LspNotAvailableError as exc:
except (LspNotAvailableError, LspServerNotFoundError, LspError) as exc:
context.lsp_error = exc
@@ -379,6 +379,14 @@ def step_not_available_error(context: Context) -> None:
)
@then("an LSP error should be raised")
def step_any_lsp_error(context: Context) -> None:
assert context.lsp_error is not None, "Expected an LSP error"
assert isinstance(context.lsp_error, LspError), (
f"Expected LspError subclass, 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:
+144
View File
@@ -0,0 +1,144 @@
"""Robot Framework helper for LSP functional runtime integration tests."""
from __future__ import annotations
import importlib
import sys
from pathlib import Path
# Ensure local source tree is importable
_SRC = str(Path(__file__).resolve().parents[1] / "src")
if _SRC not in sys.path:
sys.path.insert(0, _SRC)
import cleveragents # noqa: E402
importlib.reload(cleveragents)
from cleveragents.lsp.discovery import LanguageDiscovery # noqa: E402
from cleveragents.lsp.errors import LspServerNotFoundError # noqa: E402
from cleveragents.lsp.lifecycle import LspLifecycleManager # noqa: E402
from cleveragents.lsp.models import LspCapability, LspServerConfig # noqa: E402
from cleveragents.lsp.runtime import LspRuntime # noqa: E402
from cleveragents.lsp.tool_adapter import LspToolAdapter # noqa: E402
from cleveragents.lsp.transport import StdioTransport # noqa: E402
def _run_transport_lifecycle() -> None:
"""Test transport create/alive check."""
t = StdioTransport(command="cat")
assert not t.is_alive
t.start()
assert t.is_alive
t.stop()
assert not t.is_alive
print("transport-lifecycle-ok")
def _run_transport_empty_command() -> None:
"""Test transport rejects empty command."""
try:
StdioTransport(command="")
print("FAIL: expected ValueError")
sys.exit(1)
except ValueError:
pass
print("transport-empty-command-ok")
def _run_lifecycle_manager_empty() -> None:
"""Test lifecycle manager starts empty."""
mgr = LspLifecycleManager()
assert len(mgr.list_running()) == 0
assert not mgr.health_check("local/test")
try:
mgr.stop_server("local/test")
print("FAIL: expected LspServerNotFoundError")
sys.exit(1)
except LspServerNotFoundError:
pass
print("lifecycle-manager-empty-ok")
def _run_runtime_validation() -> None:
"""Test runtime input validation."""
rt = LspRuntime()
errors = 0
try:
rt.start_server("", "/tmp")
except ValueError:
errors += 1
try:
rt.start_server("local/test", "")
except ValueError:
errors += 1
try:
rt.start_server("local/missing", "/tmp")
except LspServerNotFoundError:
errors += 1
assert errors == 3, f"Expected 3 validation errors, got {errors}"
print("runtime-validation-ok")
def _run_discovery() -> None:
"""Test language discovery."""
d = LanguageDiscovery()
assert d.detect_file_language("test.py") == "python"
assert d.detect_file_language("app.ts") == "typescript"
assert d.detect_file_language("main.rs") == "rust"
assert d.detect_file_language("unknown.xyz") == "plaintext"
assert d.detect_file_language("Dockerfile") == "dockerfile"
d2 = LanguageDiscovery(project_languages=["go"])
assert d2.detect_file_language("no_ext") == "go"
print("discovery-ok")
def _run_tool_adapter_modes() -> None:
"""Test tool adapter local vs runtime mode."""
config = LspServerConfig(
name="local/test",
command="echo",
languages=["python"],
capabilities=[LspCapability.DIAGNOSTICS, LspCapability.COMPLETIONS],
)
# Local mode
adapter_local = LspToolAdapter()
specs_local = adapter_local.generate_tool_specs(config)
assert len(specs_local) == 2
assert specs_local[0]["name"] == "local/test/diagnostics"
# Runtime mode
adapter_rt = LspToolAdapter(runtime=LspRuntime())
specs_rt = adapter_rt.generate_tool_specs(config)
assert len(specs_rt) == 2
assert specs_rt[0]["name"] == "local/test/diagnostics"
print("tool-adapter-modes-ok")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "all"
dispatch = {
"transport-lifecycle": _run_transport_lifecycle,
"transport-empty-command": _run_transport_empty_command,
"lifecycle-manager-empty": _run_lifecycle_manager_empty,
"runtime-validation": _run_runtime_validation,
"discovery": _run_discovery,
"tool-adapter-modes": _run_tool_adapter_modes,
}
if cmd == "all":
for fn in dispatch.values():
fn()
elif cmd in dispatch:
dispatch[cmd]()
else:
print(f"Unknown command: {cmd}")
sys.exit(1)
+9 -3
View File
@@ -21,7 +21,11 @@ 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.errors import ( # noqa: E402
LspError,
LspNotAvailableError,
LspServerNotFoundError,
)
from cleveragents.lsp.models import ( # noqa: E402
LspCapability,
LspServerConfig,
@@ -88,6 +92,8 @@ def cmd_remove_server() -> None:
def cmd_runtime_stub() -> None:
runtime = LspRuntime()
# The functional runtime raises LspServerNotFoundError for
# unregistered servers (not LspNotAvailableError like the old stub).
for fn_name, args in [
("start_server", ("local/pyright", "/tmp")),
("stop_server", ("local/pyright",)),
@@ -96,9 +102,9 @@ def cmd_runtime_stub() -> None:
]:
try:
getattr(runtime, fn_name)(*args)
print(f"FAIL: {fn_name} should have raised LspNotAvailableError")
print(f"FAIL: {fn_name} should have raised an error")
sys.exit(1)
except LspNotAvailableError:
except (LspNotAvailableError, LspServerNotFoundError, LspError):
pass
print("lsp-runtime-stub-ok")
+47
View File
@@ -0,0 +1,47 @@
*** Settings ***
Documentation Integration tests for LSP functional runtime
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER} ${CURDIR}/helper_lsp_functional_runtime.py
*** Test Cases ***
Transport Lifecycle Start And Stop
[Documentation] StdioTransport can start and stop a process
${result}= Run Process ${PYTHON} ${HELPER} transport-lifecycle cwd=${WORKSPACE} on_timeout=kill timeout=30s
Log ${result.stdout}
Log ${result.stderr}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} transport-lifecycle-ok
Transport Rejects Empty Command
[Documentation] StdioTransport rejects empty command
${result}= Run Process ${PYTHON} ${HELPER} transport-empty-command cwd=${WORKSPACE} on_timeout=kill timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} transport-empty-command-ok
Lifecycle Manager Empty State
[Documentation] LspLifecycleManager starts with no servers
${result}= Run Process ${PYTHON} ${HELPER} lifecycle-manager-empty cwd=${WORKSPACE} on_timeout=kill timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} lifecycle-manager-empty-ok
Runtime Input Validation
[Documentation] LspRuntime validates inputs correctly
${result}= Run Process ${PYTHON} ${HELPER} runtime-validation cwd=${WORKSPACE} on_timeout=kill timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} runtime-validation-ok
Language Discovery
[Documentation] LanguageDiscovery detects languages from extensions
${result}= Run Process ${PYTHON} ${HELPER} discovery cwd=${WORKSPACE} on_timeout=kill timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} discovery-ok
Tool Adapter Modes
[Documentation] LspToolAdapter generates specs in both modes
${result}= Run Process ${PYTHON} ${HELPER} tool-adapter-modes cwd=${WORKSPACE} on_timeout=kill timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tool-adapter-modes-ok
+18 -10
View File
@@ -1,16 +1,13 @@
"""LSP (Language Server Protocol) integration package.
Provides the registry, runtime stub, tool adapter, server stub,
and domain models for managing language servers within CleverAgents.
Provides the registry, runtime, tool adapter, server stub, protocol
client, transport, and lifecycle manager 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. The :class:`LspServer` provides a minimal
JSON-RPC server stub over stdin/stdout.
When server mode is implemented the concrete classes will replace
these stubs while preserving the public API.
The :class:`LspRuntime` manages LSP server processes and provides
code intelligence (diagnostics, completions) to actors. When no
runtime is supplied to :class:`LspToolAdapter`, tool handlers fall
back to raising :class:`LspNotAvailableError`.
Usage::
@@ -21,14 +18,20 @@ Usage::
LspToolAdapter,
LspServerConfig,
LspCapability,
LspClient,
LspLifecycleManager,
StdioTransport,
)
"""
from cleveragents.lsp.client import LspClient
from cleveragents.lsp.discovery import LanguageDiscovery
from cleveragents.lsp.errors import (
LspError,
LspNotAvailableError,
LspServerNotFoundError,
)
from cleveragents.lsp.lifecycle import LspLifecycleManager
from cleveragents.lsp.models import (
LspBinding,
LspCapability,
@@ -38,11 +41,15 @@ from cleveragents.lsp.registry import LspRegistry
from cleveragents.lsp.runtime import LspRuntime
from cleveragents.lsp.server import LspServer
from cleveragents.lsp.tool_adapter import LspToolAdapter
from cleveragents.lsp.transport import StdioTransport
__all__ = [
"LanguageDiscovery",
"LspBinding",
"LspCapability",
"LspClient",
"LspError",
"LspLifecycleManager",
"LspNotAvailableError",
"LspRegistry",
"LspRuntime",
@@ -50,4 +57,5 @@ __all__ = [
"LspServerConfig",
"LspServerNotFoundError",
"LspToolAdapter",
"StdioTransport",
]
+398
View File
@@ -0,0 +1,398 @@
"""LSP protocol client for communicating with language servers.
Implements the client side of the LSP protocol over a
:class:`StdioTransport`. Handles:
- ``initialize`` / ``initialized`` handshake.
- ``shutdown`` / ``exit`` lifecycle.
- ``textDocument/publishDiagnostics`` notification handling.
- ``textDocument/completion`` requests.
- ``textDocument/didOpen`` / ``textDocument/didClose`` notifications.
- Request ID tracking and response correlation.
The client is **synchronous** each request blocks until the response
arrives. Notifications received while waiting for a response are
queued and processed later.
Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758)
and issue #826.
"""
from __future__ import annotations
import contextlib
import threading
import time
from collections import deque
from typing import Any
import structlog
from cleveragents.lsp.errors import LspError
from cleveragents.lsp.transport import StdioTransport
logger = structlog.get_logger(__name__)
# Default timeout for a single JSON-RPC request/response cycle.
_REQUEST_TIMEOUT = 60.0
class LspClient:
"""Synchronous LSP protocol client.
Wraps a :class:`StdioTransport` and provides typed methods for
the LSP protocol operations needed by the runtime.
Args:
transport: The stdio transport connected to the server.
server_name: Human-readable name for logging.
"""
def __init__(
self,
transport: StdioTransport,
server_name: str = "",
) -> None:
self._transport = transport
self._server_name = server_name
self._request_id = 0
self._id_lock = threading.Lock()
self._initialized = False
self._server_capabilities: dict[str, Any] = {}
self._diagnostics: dict[str, list[dict[str, Any]]] = {}
self._pending_notifications: deque[dict[str, Any]] = deque(maxlen=1000)
@property
def is_initialized(self) -> bool:
"""Whether the initialize handshake has completed."""
return self._initialized
@property
def server_capabilities(self) -> dict[str, Any]:
"""Capabilities returned by the server during initialize."""
return self._server_capabilities
def _next_id(self) -> int:
with self._id_lock:
self._request_id += 1
return self._request_id
def _send_request(
self,
method: str,
params: dict[str, Any] | None = None,
*,
timeout: float = _REQUEST_TIMEOUT,
) -> dict[str, Any]:
"""Send a JSON-RPC request and wait for the response.
Args:
method: LSP method name.
params: Optional parameters.
timeout: Maximum seconds to wait for the response.
Returns:
The ``result`` field from the response.
Raises:
LspError: On JSON-RPC error, transport failure, or timeout.
"""
req_id = self._next_id()
message: dict[str, Any] = {
"jsonrpc": "2.0",
"id": req_id,
"method": method,
}
if params is not None:
message["params"] = params
self._transport.send_message(message)
deadline = time.monotonic() + timeout
# Read messages until we get our response or deadline expires
while time.monotonic() < deadline:
remaining = max(0.1, deadline - time.monotonic())
msg = self._transport.read_message(timeout=remaining)
if msg is None:
# Distinguish EOF (server died) from select timeout:
# if the transport process is still alive, select just
# timed out — retry until the outer deadline expires.
if self._transport.is_alive:
continue
raise LspError(
f"LSP server closed connection while waiting for "
f"response to '{method}' (id={req_id})",
details={"method": method, "id": req_id},
)
# Check if this is a response to our request
if "id" in msg and msg["id"] == req_id:
if "error" in msg:
err = msg["error"]
raise LspError(
f"LSP error for '{method}': {err.get('message', 'unknown')}",
details={
"method": method,
"code": err.get("code"),
"data": err.get("data"),
},
)
return msg.get("result", {})
# Not our response — queue as notification
self._handle_notification(msg)
raise LspError(
f"Timed out waiting for response to '{method}' "
f"(id={req_id}, timeout={timeout}s)",
details={"method": method, "id": req_id},
)
def _send_notification(
self,
method: str,
params: dict[str, Any] | None = None,
) -> None:
"""Send a JSON-RPC notification (no response expected).
Args:
method: LSP method name.
params: Optional parameters.
"""
message: dict[str, Any] = {
"jsonrpc": "2.0",
"method": method,
}
if params is not None:
message["params"] = params
self._transport.send_message(message)
def _handle_notification(self, msg: dict[str, Any]) -> None:
"""Process an incoming notification from the server."""
method = msg.get("method", "")
params = msg.get("params", {})
if method == "textDocument/publishDiagnostics":
uri = params.get("uri", "")
diagnostics = params.get("diagnostics", [])
self._diagnostics[uri] = diagnostics
logger.debug(
"lsp.client.diagnostics_received",
server=self._server_name,
uri=uri,
count=len(diagnostics),
)
else:
self._pending_notifications.append(msg)
# -- LSP Lifecycle -------------------------------------------------------
def initialize(
self,
workspace_path: str,
initialization_options: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Perform the LSP ``initialize`` handshake.
Args:
workspace_path: Root URI for the workspace.
initialization_options: Server-specific init options.
Returns:
The server's ``InitializeResult``.
Raises:
LspError: If already initialized or handshake fails.
"""
if self._initialized:
raise LspError(
"LSP client already initialized",
details={"server": self._server_name},
)
# Convert path to file URI
workspace_uri = workspace_path
if not workspace_uri.startswith("file://"):
workspace_uri = f"file://{workspace_path}"
params: dict[str, Any] = {
"processId": None,
"clientInfo": {
"name": "cleveragents",
"version": "0.1.0",
},
"rootUri": workspace_uri,
"workspaceFolders": [
{"uri": workspace_uri, "name": "workspace"},
],
"capabilities": {
"textDocument": {
"publishDiagnostics": {"relatedInformation": True},
"completion": {
"completionItem": {"snippetSupport": False},
},
"synchronization": {
"didSave": True,
"willSave": False,
},
},
"workspace": {
"workspaceFolders": True,
},
},
}
if initialization_options:
params["initializationOptions"] = initialization_options
logger.info(
"lsp.client.initializing",
server=self._server_name,
workspace=workspace_path,
)
result = self._send_request("initialize", params)
self._server_capabilities = result.get("capabilities", {})
self._initialized = True
# Send initialized notification
self._send_notification("initialized", {})
logger.info(
"lsp.client.initialized",
server=self._server_name,
capabilities=list(self._server_capabilities.keys()),
)
return result
def shutdown(self) -> None:
"""Send ``shutdown`` request followed by ``exit`` notification.
Raises:
LspError: If not initialized.
"""
if not self._initialized:
logger.warning(
"lsp.client.shutdown_not_initialized",
server=self._server_name,
)
return
logger.info("lsp.client.shutting_down", server=self._server_name)
try:
self._send_request("shutdown")
except LspError:
logger.warning(
"lsp.client.shutdown_error",
server=self._server_name,
exc_info=True,
)
with contextlib.suppress(BrokenPipeError, RuntimeError):
self._send_notification("exit")
self._initialized = False
# -- Text Document Operations --------------------------------------------
def did_open(
self,
uri: str,
language_id: str,
version: int,
text: str,
) -> None:
"""Notify the server that a document was opened.
Args:
uri: Document URI (``file://...``).
language_id: Language identifier (e.g., ``"python"``).
version: Document version number.
text: Full document text.
"""
self._send_notification(
"textDocument/didOpen",
{
"textDocument": {
"uri": uri,
"languageId": language_id,
"version": version,
"text": text,
},
},
)
def did_close(self, uri: str) -> None:
"""Notify the server that a document was closed.
Args:
uri: Document URI.
"""
self._send_notification(
"textDocument/didClose",
{"textDocument": {"uri": uri}},
)
# Clear cached diagnostics for this URI
self._diagnostics.pop(uri, None)
def get_diagnostics(self, uri: str) -> list[dict[str, Any]]:
"""Return cached diagnostics for *uri*.
Diagnostics are pushed by the server via
``textDocument/publishDiagnostics`` notifications and cached
by the client. This method returns the latest cached set.
Args:
uri: Document URI.
Returns:
List of LSP Diagnostic objects.
"""
# Drain pending messages (capped to prevent infinite loop if
# server is flooding notifications).
max_drain = 200
for _ in range(max_drain):
msg = self._transport.read_message(timeout=0.05)
if msg is None:
break
self._handle_notification(msg)
return list(self._diagnostics.get(uri, []))
def get_completions(
self,
uri: str,
line: int,
character: int,
) -> list[dict[str, Any]]:
"""Request completions at a position.
Args:
uri: Document URI.
line: 0-based line number.
character: 0-based character offset.
Returns:
List of CompletionItem dicts.
"""
result = self._send_request(
"textDocument/completion",
{
"textDocument": {"uri": uri},
"position": {"line": line, "character": character},
},
)
# Result may be a CompletionList or a list of CompletionItems
if isinstance(result, dict) and "items" in result:
return result["items"]
if isinstance(result, list):
return result
return []
__all__ = [
"LspClient",
]
+307
View File
@@ -0,0 +1,307 @@
"""4-layer language discovery for LSP server matching.
Determines which programming languages a resource (file, directory,
or project) contains, so the LSP runtime can start the right language
servers.
## Discovery Layers (priority order)
1. **File extension analysis** -- Map file extensions to language IDs
using a built-in extension table. Fastest and most common.
2. **Content analysis** -- Inspect shebang lines, magic comments, and
structural patterns for files without/ambiguous extensions.
3. **UKO classification** -- Use Universal Knowledge Ontology layer-3
classification when available (persisted across sessions).
4. **Explicit project declaration** -- Project-level language config
overrides or supplements automatic discovery.
Results are cached per file path and invalidated on content change.
Based on ``docs/specification.md`` Resource Language Discovery
(lines 20557-20566) and issue #826.
"""
from __future__ import annotations
import os
from typing import Any
import structlog
logger = structlog.get_logger(__name__)
# ---------------------------------------------------------------------------
# Layer 1: Extension-to-language mapping
# ---------------------------------------------------------------------------
_EXTENSION_MAP: dict[str, str] = {
".py": "python",
".pyi": "python",
".pyw": "python",
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
".rs": "rust",
".go": "go",
".java": "java",
".kt": "kotlin",
".kts": "kotlin",
".scala": "scala",
".c": "c",
".h": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".hpp": "cpp",
".hxx": "cpp",
".cs": "csharp",
".fs": "fsharp",
".rb": "ruby",
".php": "php",
".swift": "swift",
".lua": "lua",
".r": "r",
".R": "r",
".jl": "julia",
".zig": "zig",
".nim": "nim",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".hs": "haskell",
".ml": "ocaml",
".mli": "ocaml",
".pl": "perl",
".pm": "perl",
".dart": "dart",
".v": "v",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".fish": "shellscript",
".ps1": "powershell",
".psm1": "powershell",
".json": "json",
".jsonc": "jsonc",
".yaml": "yaml",
".yml": "yaml",
".toml": "toml",
".xml": "xml",
".html": "html",
".htm": "html",
".css": "css",
".scss": "scss",
".less": "less",
".sql": "sql",
".md": "markdown",
".rst": "restructuredtext",
".tex": "latex",
".dockerfile": "dockerfile",
".proto": "proto3",
".graphql": "graphql",
".gql": "graphql",
".vue": "vue",
".svelte": "svelte",
}
# ---------------------------------------------------------------------------
# Layer 2: Shebang / content analysis
# ---------------------------------------------------------------------------
_SHEBANG_MAP: dict[str, str] = {
"python": "python",
"python3": "python",
"node": "javascript",
"ruby": "ruby",
"perl": "perl",
"bash": "shellscript",
"sh": "shellscript",
"zsh": "shellscript",
"fish": "shellscript",
"lua": "lua",
"php": "php",
}
def _detect_from_shebang(first_line: str) -> str | None:
"""Parse a shebang line to determine language.
Handles both ``#!/usr/bin/env python3`` and ``#!/usr/bin/python3``.
Args:
first_line: The first line of the file.
Returns:
Language ID or ``None`` if not detectable.
"""
if not first_line.startswith("#!"):
return None
parts = first_line[2:].strip().split()
if not parts:
return None
# Handle /usr/bin/env <interpreter>
executable = parts[-1] if parts[0].endswith("/env") else parts[0]
basename = os.path.basename(executable)
# Strip version suffixes (python3.11 -> python3 -> python)
for key, lang in _SHEBANG_MAP.items():
if basename == key or basename.startswith(key):
return lang
return None
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
class LanguageDiscovery:
"""4-layer language discovery engine.
Detects programming languages for files and directories using
extension mapping, content analysis, UKO classification, and
explicit project declarations.
Args:
project_languages: Explicit language declarations from the
project configuration (layer 4).
uko_classifications: Pre-loaded UKO language classifications
keyed by file path (layer 3).
"""
def __init__(
self,
project_languages: list[str] | None = None,
uko_classifications: dict[str, str] | None = None,
) -> None:
self._project_languages = [lang.lower() for lang in (project_languages or [])]
self._uko: dict[str, str] = uko_classifications or {}
self._cache: dict[str, str] = {}
def detect_file_language(self, file_path: str) -> str:
"""Detect the language of a single file.
Applies the 4-layer process in priority order:
extension -> content -> UKO -> project default -> ``plaintext``.
Args:
file_path: Path to the file.
Returns:
Language identifier (e.g., ``"python"``).
"""
if file_path in self._cache:
return self._cache[file_path]
lang = self._detect_uncached(file_path)
self._cache[file_path] = lang
return lang
def detect_directory_languages(self, directory: str) -> list[str]:
"""Discover all languages present in a directory tree.
Walks the directory, detects each file's language, and returns
a deduplicated sorted list.
Args:
directory: Root directory to scan.
Returns:
Sorted list of unique language identifiers.
"""
languages: set[str] = set()
try:
for root, _dirs, files in os.walk(directory):
for fname in files:
fpath = os.path.join(root, fname)
lang = self.detect_file_language(fpath)
if lang != "plaintext":
languages.add(lang)
except OSError:
logger.warning(
"lsp.discovery.walk_error",
directory=directory,
exc_info=True,
)
return sorted(languages)
def invalidate(self, file_path: str) -> None:
"""Remove a file from the cache (e.g., after content change).
Args:
file_path: Path to invalidate.
"""
self._cache.pop(file_path, None)
def invalidate_all(self) -> None:
"""Clear the entire cache."""
self._cache.clear()
def _detect_uncached(self, file_path: str) -> str:
"""Run the 4-layer detection without cache."""
# Layer 1: Extension
ext = os.path.splitext(file_path)[1].lower()
# Handle Dockerfile (no extension but specific name)
basename = os.path.basename(file_path).lower()
if basename == "dockerfile" or basename.startswith("dockerfile."):
return "dockerfile"
if basename == "makefile":
return "makefile"
if basename == "cmakelists.txt":
return "cmake"
if ext in _EXTENSION_MAP:
return _EXTENSION_MAP[ext]
# Layer 2: Content analysis (shebang)
try:
with open(file_path, encoding="utf-8", errors="replace") as f:
first_line = f.readline(512)
lang = _detect_from_shebang(first_line)
if lang is not None:
return lang
except OSError:
pass
# Layer 3: UKO classification
if file_path in self._uko:
return self._uko[file_path]
# Layer 4: Project-level declaration (return first match if any)
if self._project_languages:
return self._project_languages[0]
return "plaintext"
def get_servers_for_language(
self,
language: str,
available_configs: list[Any],
) -> list[Any]:
"""Find LSP server configs that serve a given language.
Args:
language: Language identifier.
available_configs: List of ``LspServerConfig`` objects.
Returns:
Configs whose ``languages`` list includes *language*.
"""
return [
cfg
for cfg in available_configs
if language in getattr(cfg, "languages", [])
]
__all__ = [
"LanguageDiscovery",
]
+300
View File
@@ -0,0 +1,300 @@
"""LSP server lifecycle manager.
Manages running LSP server instances with:
- Reference-counted shared instances (multiple actors can share one server).
- Health checks via process liveness.
- Automatic restart on crash with re-initialization.
- Graceful shutdown when the last reference is released.
Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758)
and issue #826.
"""
from __future__ import annotations
import threading
from typing import Any
import structlog
from cleveragents.lsp.client import LspClient
from cleveragents.lsp.errors import LspServerNotFoundError
from cleveragents.lsp.models import LspServerConfig
from cleveragents.lsp.transport import StdioTransport
logger = structlog.get_logger(__name__)
class _ManagedServer:
"""Internal state for a running LSP server instance.
Attributes:
config: The server configuration.
transport: The stdio transport.
client: The LSP protocol client.
workspace_path: The workspace root path.
ref_count: Number of active references.
"""
__slots__ = ("client", "config", "ref_count", "transport", "workspace_path")
def __init__(
self,
config: LspServerConfig,
transport: StdioTransport,
client: LspClient,
workspace_path: str,
) -> None:
self.config = config
self.transport = transport
self.client = client
self.workspace_path = workspace_path
self.ref_count = 1
class LspLifecycleManager:
"""Manages running LSP server instances with reference counting.
Thread-safe. Each server is identified by its namespaced name.
Multiple callers can acquire/release the same server; the process
is only stopped when the last reference is released.
"""
def __init__(self) -> None:
self._servers: dict[str, _ManagedServer] = {}
self._lock = threading.Lock()
def start_server(
self,
config: LspServerConfig,
workspace_path: str,
) -> LspClient:
"""Start or acquire a reference to an LSP server.
If a server with the same name is already running for the same
workspace, its reference count is incremented and the existing
client is returned.
Args:
config: Server configuration.
workspace_path: Root directory for the workspace.
Returns:
An :class:`LspClient` connected to the running server.
Raises:
LspError: If the server process cannot be started.
"""
# Phase 1: Check for existing server (short lock)
with self._lock:
existing = self._servers.get(config.name)
if existing is not None and existing.transport.is_alive:
existing.ref_count += 1
logger.info(
"lsp.lifecycle.reused",
server=config.name,
ref_count=existing.ref_count,
)
return existing.client
# Phase 2: Start and initialize OUTSIDE the lock (may take seconds)
transport = StdioTransport(
command=config.command,
args=config.args,
env=config.env,
cwd=workspace_path,
)
transport.start()
client = LspClient(transport, server_name=config.name)
try:
client.initialize(workspace_path)
except Exception:
transport.stop()
raise
# Phase 3: Register the running server (short lock)
managed = _ManagedServer(
config=config,
transport=transport,
client=client,
workspace_path=workspace_path,
)
with self._lock:
# Another thread may have started the same server concurrently
race_winner = self._servers.get(config.name)
if race_winner is not None and race_winner.transport.is_alive:
# We lost the race — stop our instance, reuse theirs
transport.stop()
race_winner.ref_count += 1
return race_winner.client
self._servers[config.name] = managed
logger.info(
"lsp.lifecycle.started",
server=config.name,
workspace=workspace_path,
)
return client
def stop_server(self, name: str) -> None:
"""Release a reference to an LSP server.
Decrements the reference count. When it reaches zero, the
server is shut down gracefully.
Args:
name: Namespaced server name.
Raises:
LspServerNotFoundError: If no server with *name* is running.
"""
with self._lock:
managed = self._servers.get(name)
if managed is None:
raise LspServerNotFoundError(name)
managed.ref_count -= 1
if managed.ref_count > 0:
logger.info(
"lsp.lifecycle.released",
server=name,
ref_count=managed.ref_count,
)
return
# Last reference — shut down
self._shutdown_server(managed)
del self._servers[name]
def _shutdown_server(self, managed: _ManagedServer) -> None:
"""Gracefully shut down a managed server."""
name = managed.config.name
logger.info("lsp.lifecycle.shutting_down", server=name)
try:
managed.client.shutdown()
except Exception:
logger.warning(
"lsp.lifecycle.shutdown_error",
server=name,
exc_info=True,
)
managed.transport.stop()
def get_client(self, name: str) -> LspClient:
"""Get the client for a running server.
Args:
name: Namespaced server name.
Returns:
The :class:`LspClient` for the server.
Raises:
LspServerNotFoundError: If no server with *name* is running.
"""
with self._lock:
managed = self._servers.get(name)
if managed is None:
raise LspServerNotFoundError(name)
return managed.client
def health_check(self, name: str) -> bool:
"""Check whether the named server is alive.
Args:
name: Namespaced server name.
Returns:
``True`` if the server process is running.
"""
with self._lock:
managed = self._servers.get(name)
if managed is None:
return False
return managed.transport.is_alive
def restart_server(self, name: str) -> LspClient:
"""Restart a crashed or unresponsive server.
Stops the current process (if alive), starts a fresh one, and
re-initializes the LSP handshake.
Args:
name: Namespaced server name.
Returns:
A new :class:`LspClient` for the restarted server.
Raises:
LspServerNotFoundError: If the server was never started.
LspError: If the restart fails.
"""
with self._lock:
managed = self._servers.get(name)
if managed is None:
raise LspServerNotFoundError(name)
logger.warning("lsp.lifecycle.restarting", server=name)
# Stop old transport
managed.transport.stop()
# Start fresh
transport = StdioTransport(
command=managed.config.command,
args=managed.config.args,
env=managed.config.env,
cwd=managed.workspace_path,
)
transport.start()
client = LspClient(transport, server_name=name)
try:
client.initialize(managed.workspace_path)
except Exception:
transport.stop()
raise
managed.transport = transport
managed.client = client
logger.info("lsp.lifecycle.restarted", server=name)
return client
def stop_all(self) -> None:
"""Shut down all running servers. Used during cleanup."""
with self._lock:
for name, managed in list(self._servers.items()):
try:
self._shutdown_server(managed)
except Exception:
logger.warning(
"lsp.lifecycle.stop_all_error",
server=name,
exc_info=True,
)
self._servers.clear()
def list_running(self) -> list[dict[str, Any]]:
"""Return status info for all running servers."""
with self._lock:
result: list[dict[str, Any]] = []
for name, managed in self._servers.items():
result.append(
{
"name": name,
"workspace": managed.workspace_path,
"alive": managed.transport.is_alive,
"ref_count": managed.ref_count,
"initialized": managed.client.is_initialized,
}
)
return result
__all__ = [
"LspLifecycleManager",
]
+251 -62
View File
@@ -1,104 +1,175 @@
"""Local-mode LSP Runtime stub.
"""Functional LSP Runtime for managing language server processes.
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.
The ``LspRuntime`` manages LSP server processes and provides code
intelligence (diagnostics, completions) to actors. It delegates
process lifecycle to :class:`LspLifecycleManager` and protocol
communication to :class:`LspClient`.
When server mode is implemented the concrete runtime will replace
these stub implementations while keeping the same interface.
When a server is started, the runtime:
1. Looks up the :class:`LspServerConfig` from the :class:`LspRegistry`.
2. Delegates to the lifecycle manager to spawn (or reuse) the process.
3. Tracks running servers for query operations.
When a query (diagnostics, completions) is issued, the runtime:
1. Verifies the server is running and healthy.
2. If crashed, attempts automatic restart.
3. Delegates to the :class:`LspClient` for the protocol call.
Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758)
and issue #826.
"""
from __future__ import annotations
import logging
import contextlib
import os
from typing import Any
from cleveragents.lsp.errors import LspNotAvailableError
import structlog
logger = logging.getLogger(__name__)
from cleveragents.lsp.errors import LspError, LspServerNotFoundError
from cleveragents.lsp.lifecycle import LspLifecycleManager
from cleveragents.lsp.registry import LspRegistry
_LOCAL_MODE_MSG = (
"LSP server start deferred to server mode - "
"language-server features are not available in local mode"
)
logger = structlog.get_logger(__name__)
class LspRuntime:
"""Stub runtime for LSP servers in local mode.
"""Functional runtime for LSP servers.
All operations raise :class:`LspNotAvailableError`. The runtime
logs a warning on each call to aid debugging.
Manages the full lifecycle of language servers: start, query,
health-check, restart, and stop.
Args:
registry: The LSP server registry to look up configurations.
lifecycle_manager: Optional lifecycle manager (created if not
provided).
"""
def __init__(
self,
registry: LspRegistry | None = None,
lifecycle_manager: LspLifecycleManager | None = None,
) -> None:
self._registry = registry or LspRegistry()
self._lifecycle = lifecycle_manager or LspLifecycleManager()
@property
def registry(self) -> LspRegistry:
"""The underlying LSP server registry."""
return self._registry
@property
def lifecycle(self) -> LspLifecycleManager:
"""The underlying lifecycle manager."""
return self._lifecycle
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.
Looks up the server configuration from the registry and
delegates to the lifecycle manager to spawn (or reuse) the
process. The LSP ``initialize`` handshake is performed
automatically.
Args:
name: Namespaced server name.
name: Namespaced server name (must be registered).
workspace_path: Root directory for the language server.
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* or *workspace_path* is empty.
LspServerNotFoundError: If *name* is not in the registry.
LspError: If the server process cannot be started.
"""
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},
config = self._registry.get_or_raise(name)
logger.info(
"lsp.runtime.starting_server",
server=name,
workspace=workspace_path,
command=config.command,
)
self._lifecycle.start_server(config, workspace_path)
def stop_server(self, name: str) -> None:
"""Stop the LSP server identified by *name*.
Releases one reference to the server. The process is only
terminated when all references are released.
Args:
name: Namespaced server name.
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* is empty.
LspServerNotFoundError: If the server is not running.
"""
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},
)
logger.info("lsp.runtime.stopping_server", server=name)
self._lifecycle.stop_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.
Opens the file, sends ``textDocument/didOpen`` to the server,
reads cached diagnostics (pushed asynchronously by the server),
then closes the document.
Args:
name: Namespaced server name.
file_path: Path to the file being diagnosed.
Returns:
Empty list (never reached in local mode).
List of LSP Diagnostic dicts.
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* or *file_path* is empty.
LspServerNotFoundError: If the server is not running.
LspError: If the server has crashed.
"""
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},
client = self._get_healthy_client(name)
uri = self._path_to_uri(file_path)
# Open the file so the server analyses it
try:
text = self._read_file(file_path)
except OSError as exc:
raise LspError(
f"Cannot read file for diagnostics: {file_path}",
details={"file": file_path, "error": str(exc)},
) from exc
language_id = self._detect_language(file_path)
client.did_open(uri, language_id, version=1, text=text)
# Give the server time to process and push diagnostics
diagnostics = client.get_diagnostics(uri)
# Close the document
client.did_close(uri)
logger.info(
"lsp.runtime.diagnostics",
server=name,
file=file_path,
count=len(diagnostics),
)
return diagnostics
def get_completions(
self,
@@ -109,9 +180,6 @@ class LspRuntime:
) -> 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.
@@ -119,12 +187,12 @@ class LspRuntime:
column: 1-based column number.
Returns:
Empty list (never reached in local mode).
List of CompletionItem dicts.
Raises:
LspNotAvailableError: Always, in local mode.
ValueError: If *name* or *file_path* is empty, or
*line*/*column* are not positive.
ValueError: If inputs are invalid.
LspServerNotFoundError: If the server is not running.
LspError: If the server has crashed.
"""
if not name:
raise ValueError("name must be a non-empty string")
@@ -134,23 +202,144 @@ class LspRuntime:
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,
},
client = self._get_healthy_client(name)
uri = self._path_to_uri(file_path)
# Open the file
try:
text = self._read_file(file_path)
except OSError as exc:
raise LspError(
f"Cannot read file for completions: {file_path}",
details={"file": file_path, "error": str(exc)},
) from exc
language_id = self._detect_language(file_path)
client.did_open(uri, language_id, version=1, text=text)
# LSP uses 0-based line/character
completions = client.get_completions(uri, line - 1, column - 1)
client.did_close(uri)
logger.info(
"lsp.runtime.completions",
server=name,
file=file_path,
position=f"{line}:{column}",
count=len(completions),
)
return completions
def _get_healthy_client(self, name: str) -> Any:
"""Get the client for a server, restarting if crashed.
Args:
name: Server name.
Returns:
An :class:`LspClient` connected to a healthy server.
Raises:
LspServerNotFoundError: If the server was never started.
LspError: If restart fails.
"""
if not self._lifecycle.health_check(name):
logger.warning("lsp.runtime.server_crashed", server=name)
try:
return self._lifecycle.restart_server(name)
except (LspError, LspServerNotFoundError):
raise
return self._lifecycle.get_client(name)
@staticmethod
def _path_to_uri(file_path: str) -> str:
"""Convert a filesystem path to a ``file://`` URI."""
if file_path.startswith("file://"):
return file_path
return f"file://{file_path}"
@staticmethod
def _read_file(file_path: str) -> str:
"""Read file contents as UTF-8 text.
Raises:
LspError: If the resolved path is a directory or device.
"""
resolved = os.path.realpath(file_path)
if not os.path.isfile(resolved):
raise LspError(
f"Not a regular file: {file_path}",
details={"file": file_path, "resolved": resolved},
)
with open(resolved, encoding="utf-8") as f:
return f.read()
@staticmethod
def _detect_language(file_path: str) -> str:
"""Detect the language ID from a file extension.
Delegates to :class:`LanguageDiscovery` for the full 4-layer
detection process.
"""
from cleveragents.lsp.discovery import LanguageDiscovery
return LanguageDiscovery().detect_file_language(file_path)
def activate_bindings(
self,
bindings: list[Any],
workspace_path: str,
) -> list[str]:
"""Start LSP servers required by actor compilation bindings.
For each binding, looks up the server config in the registry
and starts it. Returns the names of servers that were
successfully started.
Args:
bindings: List of :class:`LspBinding` objects from the
actor compiler's :class:`CompilationMetadata`.
workspace_path: Workspace root for all servers.
Returns:
Names of servers that were started (or already running).
"""
started: list[str] = []
for binding in bindings:
server_name = getattr(binding, "lsp_server_name", "")
if not server_name:
continue
try:
self.start_server(server_name, workspace_path)
started.append(server_name)
except (LspError, LspServerNotFoundError) as exc:
logger.warning(
"lsp.runtime.binding_start_failed",
server=server_name,
node=getattr(binding, "node_name", ""),
error=str(exc),
)
return started
def deactivate_bindings(self, bindings: list[Any]) -> None:
"""Release LSP servers acquired by actor compilation bindings.
Args:
bindings: List of :class:`LspBinding` objects.
"""
for binding in bindings:
server_name = getattr(binding, "lsp_server_name", "")
if not server_name:
continue
with contextlib.suppress(LspError, LspServerNotFoundError):
self.stop_server(server_name)
def stop_all(self) -> None:
"""Shut down all running LSP servers."""
self._lifecycle.stop_all()
__all__ = [
+82 -11
View File
@@ -1,25 +1,32 @@
"""LSP Tool Adapter stub for local mode.
"""LSP Tool Adapter — bridges LSP capabilities to the tool interface.
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.
When an :class:`LspRuntime` is provided, generated handlers delegate
to the runtime for real LSP protocol calls. Without a runtime, handlers
raise :class:`LspNotAvailableError` (local-mode fallback).
Based on ``docs/specification.md`` LSP Capability Exposure (lines 20695-20741)
and issue #826.
"""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from typing import TYPE_CHECKING, Any
import structlog
from cleveragents.lsp.errors import LspNotAvailableError
from cleveragents.lsp.models import LspCapability, LspServerConfig
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from cleveragents.lsp.runtime import LspRuntime
logger = structlog.get_logger(__name__)
# Maps each capability to the tool name suffix and description used
# when generating ToolSpec entries.
@@ -65,6 +72,8 @@ def _make_local_mode_handler(
) -> Callable[..., Any]:
"""Create a handler that raises :class:`LspNotAvailableError`.
Used as fallback when no runtime is provided.
Args:
server_name: The namespaced LSP server name.
capability: The capability this handler represents.
@@ -83,13 +92,61 @@ def _make_local_mode_handler(
return _handler
def _make_runtime_handler(
runtime: LspRuntime,
server_name: str,
capability: LspCapability,
) -> Callable[..., Any]:
"""Create a handler that delegates to the LSP runtime.
Args:
runtime: The functional LSP runtime.
server_name: The namespaced LSP server name.
capability: The capability this handler represents.
Returns:
A callable that invokes the runtime method.
"""
def _handler(**kwargs: Any) -> Any:
file_path: str = kwargs.get("file_path", "")
if not file_path:
return {"error": "file_path is required"}
if capability == LspCapability.DIAGNOSTICS:
items = runtime.get_diagnostics(server_name, file_path)
return {"diagnostics": items}
if capability == LspCapability.COMPLETIONS:
line: int = kwargs.get("line", 1)
column: int = kwargs.get("column", 1)
items = runtime.get_completions(server_name, file_path, line, column)
return {"completions": items}
# Capabilities not yet implemented raise an explicit error
raise LspNotAvailableError(
f"LSP capability '{capability.value}' is not yet implemented "
f"for server '{server_name}'",
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
When *runtime* is provided, generated handlers delegate to the
runtime. Without a runtime, handlers raise
:class:`LspNotAvailableError`.
Args:
runtime: Optional functional LSP runtime for real protocol calls.
"""
def __init__(self, runtime: LspRuntime | None = None) -> None:
self._runtime = runtime
def generate_tool_specs(
self,
config: LspServerConfig,
@@ -101,7 +158,7 @@ class LspToolAdapter:
- ``name``: ``<server_name>/<capability_suffix>``
- ``description``: Human-readable description.
- ``handler``: Callable (raises in local mode).
- ``handler``: Callable (delegates to runtime or raises).
- ``input_schema``: Minimal JSON Schema stub.
Args:
@@ -124,7 +181,16 @@ class LspToolAdapter:
(capability.value, f"LSP {capability.value} operation"),
)
tool_name = f"{config.name}/{suffix}"
handler = _make_local_mode_handler(config.name, capability)
if self._runtime is not None:
handler = _make_runtime_handler(
self._runtime,
config.name,
capability,
)
else:
handler = _make_local_mode_handler(config.name, capability)
spec: dict[str, Any] = {
"name": tool_name,
"description": description,
@@ -132,7 +198,12 @@ class LspToolAdapter:
"input_schema": _input_schema_for(capability),
}
specs.append(spec)
logger.debug("Generated tool spec: %s (local-mode stub)", tool_name)
mode = "runtime" if self._runtime else "local-stub"
logger.debug(
"lsp.tool_adapter.generated",
tool=tool_name,
mode=mode,
)
return specs
+281
View File
@@ -0,0 +1,281 @@
"""LSP stdio transport for subprocess-based language servers.
Manages the lifecycle of an LSP server subprocess communicating via
JSON-RPC 2.0 over stdin/stdout. The transport handles:
- Spawning the server process with the configured command and args.
- Writing JSON-RPC messages with ``Content-Length`` headers to stdin.
- Reading JSON-RPC responses from stdout.
- Graceful and forced process termination.
The transport is **not** thread-safe by itself; callers (e.g.,
:class:`LspClient`) must serialise access when sharing a transport
across threads.
Based on ``docs/specification.md`` LSP Server Lifecycle (lines 20744-20758)
and issue #826.
"""
from __future__ import annotations
import json
import os
import select
import subprocess
import threading
from typing import Any
import structlog
logger = structlog.get_logger(__name__)
# Maximum time (seconds) to wait for a graceful process termination
# before sending SIGKILL.
_GRACEFUL_SHUTDOWN_TIMEOUT = 5.0
# Default read timeout in seconds. Prevents indefinite blocking on
# readline() when the server is slow or unresponsive.
_DEFAULT_READ_TIMEOUT = 30.0
# Maximum size of a single JSON-RPC message body (10 MiB, matching
# the existing ``server.py`` limit).
_MAX_CONTENT_LENGTH = 10 * 1024 * 1024
class StdioTransport:
"""Stdio transport for an LSP server subprocess.
Manages the full lifecycle of a language server process:
spawn, message I/O, and termination.
Note:
Uses ``select.select()`` for non-blocking reads, which requires
Unix-like pipe file descriptors. Not supported on Windows where
``select`` only works with sockets.
Args:
command: Executable command (e.g., ``"pyright-langserver"``).
args: Additional CLI arguments (e.g., ``["--stdio"]``).
env: Extra environment variables merged with the current env.
cwd: Working directory for the subprocess.
"""
def __init__(
self,
command: str,
args: list[str] | None = None,
env: dict[str, str] | None = None,
cwd: str | None = None,
) -> None:
if not command:
raise ValueError("command must be a non-empty string")
self._command = command
self._args = args or []
self._env = env or {}
self._cwd = cwd
self._process: subprocess.Popen[bytes] | None = None
self._read_lock = threading.Lock()
self._write_lock = threading.Lock()
@property
def is_alive(self) -> bool:
"""Return ``True`` if the subprocess is still running."""
return self._process is not None and self._process.poll() is None
def start(self) -> None:
"""Spawn the LSP server subprocess.
Raises:
LspError: If the process cannot be started.
RuntimeError: If already started.
"""
if self._process is not None and self.is_alive:
raise RuntimeError("Transport already started")
merged_env = {**os.environ, **self._env}
cmd = [self._command, *self._args]
logger.info(
"lsp.transport.starting",
command=self._command,
args=self._args,
cwd=self._cwd,
)
try:
self._process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=merged_env,
cwd=self._cwd,
)
except FileNotFoundError as exc:
from cleveragents.lsp.errors import LspError
raise LspError(
f"LSP server command not found: {self._command}",
details={"command": self._command, "args": self._args},
) from exc
except OSError as exc:
from cleveragents.lsp.errors import LspError
raise LspError(
f"Failed to start LSP server: {exc}",
details={"command": self._command, "error": str(exc)},
) from exc
logger.info(
"lsp.transport.started",
pid=self._process.pid,
command=self._command,
)
def stop(self, timeout: float = _GRACEFUL_SHUTDOWN_TIMEOUT) -> int | None:
"""Terminate the subprocess gracefully, then force-kill if needed.
Args:
timeout: Seconds to wait for graceful exit before SIGKILL.
Returns:
The process exit code, or ``None`` if not started.
"""
if self._process is None:
return None
if self._process.poll() is not None:
code = self._process.returncode
self._process = None
return code
logger.info(
"lsp.transport.stopping",
pid=self._process.pid,
)
self._process.terminate()
try:
self._process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
logger.warning(
"lsp.transport.force_killing",
pid=self._process.pid,
)
self._process.kill()
self._process.wait(timeout=2.0)
code = self._process.returncode
self._process = None
logger.info("lsp.transport.stopped", exit_code=code)
return code
def send_message(self, body: dict[str, Any]) -> None:
"""Write a JSON-RPC message to the server's stdin.
Args:
body: JSON-serialisable message body.
Raises:
RuntimeError: If the transport is not started.
BrokenPipeError: If the server process has exited.
"""
if self._process is None or self._process.stdin is None:
raise RuntimeError("Transport not started")
payload = json.dumps(body, separators=(",", ":")).encode("utf-8")
header = f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii")
with self._write_lock:
try:
self._process.stdin.write(header + payload)
self._process.stdin.flush()
except BrokenPipeError:
logger.error("lsp.transport.write_broken_pipe")
raise
def read_message(self, timeout: float | None = None) -> dict[str, Any] | None:
"""Read the next JSON-RPC message from the server's stdout.
Uses ``select`` to wait for data with a timeout. Returns
``None`` if no complete message arrives before *timeout*
seconds or the stream ends.
Args:
timeout: Maximum seconds to wait for data. ``None``
(default) waits up to ``_DEFAULT_READ_TIMEOUT``.
Returns:
Parsed JSON body, or ``None`` if the stream ended or
timed out.
Raises:
RuntimeError: If the transport is not started.
ValueError: If the message exceeds ``_MAX_CONTENT_LENGTH``.
"""
if self._process is None or self._process.stdout is None:
raise RuntimeError("Transport not started")
effective_timeout = timeout if timeout is not None else _DEFAULT_READ_TIMEOUT
with self._read_lock:
return self._read_one_message(effective_timeout)
def _read_one_message(self, timeout: float) -> dict[str, Any] | None:
"""Parse a single ``Content-Length`` framed JSON-RPC message."""
assert self._process is not None
assert self._process.stdout is not None
stdout = self._process.stdout
content_length: int | None = None
# Read headers (with select-based timeout)
while True:
ready, _, _ = select.select([stdout], [], [], timeout)
if not ready:
return None # Timed out waiting for data
line = stdout.readline()
if not line:
return None # EOF — server exited
decoded = line.decode("ascii", errors="replace").strip()
if not decoded:
break # Empty line = end of headers
if decoded.lower().startswith("content-length:"):
try:
content_length = int(decoded.split(":", 1)[1].strip())
except ValueError:
logger.warning(
"lsp.transport.invalid_content_length",
header=decoded,
)
return None
if content_length is None:
return None
if content_length > _MAX_CONTENT_LENGTH:
raise ValueError(
f"LSP message too large: {content_length} > {_MAX_CONTENT_LENGTH}"
)
# Read body (wait for remaining data)
ready, _, _ = select.select([stdout], [], [], timeout)
if not ready:
return None
body_bytes = stdout.read(content_length)
if len(body_bytes) < content_length:
return None # Truncated — server exited mid-message
try:
return json.loads(body_bytes)
except json.JSONDecodeError:
logger.warning(
"lsp.transport.invalid_json",
body_length=len(body_bytes),
)
return None
__all__ = [
"StdioTransport",
]
+13
View File
@@ -1177,3 +1177,16 @@ timeout_seconds # noqa: B018, F821
workspace_folder # noqa: B018, F821
execute_tool # noqa: B018, F821
wrap_service_method # noqa: B018, F821
# LSP functional runtime — public API (#826)
StdioTransport # noqa: B018, F821
LspClient # noqa: B018, F821
LspLifecycleManager # noqa: B018, F821
LanguageDiscovery # noqa: B018, F821
activate_bindings # noqa: B018, F821
deactivate_bindings # noqa: B018, F821
detect_file_language # noqa: B018, F821
detect_directory_languages # noqa: B018, F821
get_servers_for_language # noqa: B018, F821
restart_server # noqa: B018, F821
_make_runtime_handler # noqa: B018, F821