Files
hermes-agent/agent/lsp/protocol.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

139 lines
5.6 KiB
Python

"""Minimal LSP JSON-RPC 2.0 framer over async streams.
Wire format: ``Content-Length: <bytes>\\r\\n\\r\\n<utf-8 JSON body>`` where the
body is a JSON-RPC 2.0 request, response, or notification. Just the framer
plus envelope helpers, so :class:`agent.lsp.client.LSPClient` can focus on
protocol semantics.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Optional, Tuple
logger = logging.getLogger("agent.lsp.protocol")
# LSP error codes we care about (spec 3.17 #errorCodes).
ERROR_CONTENT_MODIFIED = -32801
ERROR_METHOD_NOT_FOUND = -32601
_MAX_HEADER_BYTES = 8192 # a well-behaved server fits in well under 200 bytes
_MAX_BODY_BYTES = 64 * 1024 * 1024
class LSPProtocolError(Exception):
"""The framing or envelope itself is broken (vs. :class:`LSPRequestError`, a conformant error response)."""
class LSPRequestError(Exception):
"""An LSP request returned a JSON-RPC error response; carries ``code``, ``message``, ``data``."""
def __init__(self, code: int, message: str, data: Any = None) -> None:
super().__init__(f"LSP error {code}: {message}")
self.code, self.message, self.data = code, message, data
def encode_message(obj: dict) -> bytes:
"""Encode an envelope as compact UTF-8 JSON with an exact Content-Length header."""
body = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
return f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body
async def _read_headers(reader: asyncio.StreamReader) -> Optional[dict]:
"""Read the header block; ``None`` on clean EOF before any header started."""
headers: dict = {}
header_bytes = 0
while True:
try:
line = await reader.readuntil(b"\r\n")
except asyncio.IncompleteReadError as e:
# EOF before any header started is a clean close; mid-block is bad framing.
if not e.partial and not headers:
return None
raise LSPProtocolError(f"unexpected EOF while reading LSP headers (partial={e.partial!r})") from e
# Cap against a server streaming headers without ever emitting CRLF-CRLF.
header_bytes += len(line)
if header_bytes > _MAX_HEADER_BYTES:
raise LSPProtocolError("LSP header block exceeded 8 KiB without terminator")
line = line[:-2] # strip CRLF
if not line:
return headers # blank line ends header block
try:
key, _, value = line.decode("ascii").partition(":")
except UnicodeDecodeError as e:
raise LSPProtocolError(f"non-ASCII LSP header: {line!r}") from e
if not key:
raise LSPProtocolError(f"malformed LSP header line: {line!r}")
headers[key.strip().lower()] = value.strip()
async def read_message(reader: asyncio.StreamReader) -> Optional[dict]:
"""Read one framed message.
``None`` on clean EOF between messages (typical shutdown); :class:`LSPProtocolError` on malformed framing.
"""
headers = await _read_headers(reader)
if headers is None:
return None
cl = headers.get("content-length")
if cl is None:
raise LSPProtocolError(f"LSP message missing Content-Length: {headers!r}")
try:
n = int(cl)
except ValueError as e:
raise LSPProtocolError(f"non-integer Content-Length: {cl!r}") from e
if n < 0 or n > _MAX_BODY_BYTES:
raise LSPProtocolError(f"unreasonable Content-Length: {n}")
try:
body = await reader.readexactly(n)
except asyncio.IncompleteReadError as e:
raise LSPProtocolError(f"truncated LSP body: expected {n} bytes, got {len(e.partial)}") from e
try:
return json.loads(body.decode("utf-8"))
except json.JSONDecodeError as e:
raise LSPProtocolError(f"invalid JSON in LSP body: {e}") from e
except UnicodeDecodeError as e:
raise LSPProtocolError(f"non-UTF-8 LSP body: {e}") from e
def make_notification(method: str, params: Any) -> dict:
"""Build a JSON-RPC 2.0 notification envelope (no ``id``)."""
return {"jsonrpc": "2.0", "method": method, **({} if params is None else {"params": params})}
def make_request(req_id: int, method: str, params: Any) -> dict:
"""Build a JSON-RPC 2.0 request envelope."""
return {"jsonrpc": "2.0", "id": req_id, "method": method, **({} if params is None else {"params": params})}
def make_response(req_id: Any, result: Any) -> dict:
"""Build a JSON-RPC 2.0 success response envelope."""
return {"jsonrpc": "2.0", "id": req_id, "result": result}
def make_error_response(req_id: Any, code: int, message: str, data: Any = None) -> dict:
"""Build a JSON-RPC 2.0 error response envelope."""
err = {"code": code, "message": message, **({} if data is None else {"data": data})}
return {"jsonrpc": "2.0", "id": req_id, "error": err}
def classify_message(msg: dict) -> Tuple[str, Any]:
"""Return ``(kind, key)``: kind ∈ request/response/notification/invalid; key is the id (request/response),
the method (notification) or ``None`` (invalid)."""
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
return "invalid", None
if "id" in msg:
if "method" in msg:
return "request", msg["id"]
return ("response", msg["id"]) if ("result" in msg or "error" in msg) else ("invalid", None)
return ("notification", msg["method"]) if "method" in msg else ("invalid", None)
__all__ = [
"ERROR_CONTENT_MODIFIED", "ERROR_METHOD_NOT_FOUND", "LSPProtocolError", "LSPRequestError",
"encode_message", "read_message", "make_request", "make_notification", "make_response",
"make_error_response", "classify_message",
]