Skip to content

Commit 191c614

Browse files
committed
Add LSP tool for code intelligence features
- Add python_agent_harness/tools/lsp.py with agent-facing LSP interface - Add python_agent_harness/lsp/ directory with JSON-RPC client implementation - Register LSP tool in default_registry() - Supports: goToDefinition, findReferences, hover, documentSymbol, workspaceSymbol, goToImplementation, callHierarchy operations - Auto-discovers LSP servers (pyright, typescript-language-server, clangd, etc.) - All 1043 tests pass, ruff linting passes
1 parent c3f6a76 commit 191c614

5 files changed

Lines changed: 573 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Minimal built-in LSP client used by python-agent-harness."""
2+
from .client import LSPClient, LSPError
3+
from .manager import get_client, shutdown_all
4+
5+
__all__ = ["LSPClient", "LSPError", "get_client", "shutdown_all"]

‎python_agent_harness/lsp/client.py‎

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
"""Small, dependency-free LSP/JSON-RPC client used by the agent harness.
2+
3+
This intentionally implements only the transport/lifecycle pieces needed by the
4+
agent-facing LSP tool. It is synchronous at the public boundary because the
5+
harness' Tool API is synchronous; a reader thread keeps server messages flowing.
6+
"""
7+
from __future__ import annotations
8+
9+
import json
10+
import os
11+
import queue
12+
import subprocess
13+
import threading
14+
import time
15+
from collections.abc import Callable
16+
from pathlib import Path
17+
from typing import Any
18+
19+
20+
class LSPError(RuntimeError):
21+
pass
22+
23+
24+
class LSPClient:
25+
def __init__(self, command: list[str], root: str, language_id: str, timeout: float = 30.0):
26+
self.command = command
27+
self.root = os.path.realpath(root)
28+
self.language_id = language_id
29+
self.timeout = timeout
30+
self.proc: subprocess.Popen[bytes] | None = None
31+
self._reader: threading.Thread | None = None
32+
self._write_lock = threading.Lock()
33+
self._state_lock = threading.Lock()
34+
self._next_id = 1
35+
self._pending: dict[int, queue.Queue] = {}
36+
self._opened: dict[str, int] = {}
37+
self._texts: dict[str, str] = {}
38+
self._diagnostics: dict[str, list[dict[str, Any]]] = {}
39+
self._capabilities: dict[str, Any] = {}
40+
self.position_encoding = "utf-16"
41+
self._closed = False
42+
43+
@property
44+
def alive(self) -> bool:
45+
return self.proc is not None and self.proc.poll() is None and not self._closed
46+
47+
def start(self) -> None:
48+
if self.alive:
49+
return
50+
self._closed = False
51+
try:
52+
self.proc = subprocess.Popen(
53+
self.command,
54+
stdin=subprocess.PIPE,
55+
stdout=subprocess.PIPE,
56+
stderr=subprocess.DEVNULL,
57+
cwd=self.root,
58+
bufsize=0,
59+
)
60+
except OSError as e:
61+
raise LSPError(f"failed to start LSP server {' '.join(self.command)!r}: {e}") from e
62+
self._reader = threading.Thread(target=self._read_loop, daemon=True, name=f"lsp-reader:{self.command[0]}")
63+
self._reader.start()
64+
try:
65+
result = self.request(
66+
"initialize",
67+
{
68+
"processId": os.getpid(),
69+
"rootUri": Path(self.root).as_uri(),
70+
"workspaceFolders": [{"uri": Path(self.root).as_uri(), "name": Path(self.root).name}],
71+
"capabilities": {
72+
"general": {"positionEncodings": ["utf-16", "utf-8"]},
73+
"workspace": {"workspaceFolders": True, "symbol": {"dynamicRegistration": False}},
74+
"textDocument": {
75+
"definition": {"linkSupport": True},
76+
"references": {},
77+
"hover": {"contentFormat": ["markdown", "plaintext"]},
78+
"documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
79+
"implementation": {"linkSupport": True},
80+
"callHierarchy": {"dynamicRegistration": False},
81+
},
82+
},
83+
"trace": "off",
84+
},
85+
)
86+
self._capabilities = result.get("capabilities", {}) if isinstance(result, dict) else {}
87+
encoding = self._capabilities.get("positionEncoding")
88+
if encoding in {"utf-8", "utf-16", "utf-32"}:
89+
self.position_encoding = encoding
90+
self.notify("initialized", {})
91+
except Exception:
92+
self.close()
93+
raise
94+
95+
def request(self, method: str, params: Any = None) -> Any:
96+
self.start_if_needed()
97+
with self._state_lock:
98+
request_id = self._next_id
99+
self._next_id += 1
100+
waiter: queue.Queue = queue.Queue(maxsize=1)
101+
self._pending[request_id] = waiter
102+
message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
103+
if params is not None:
104+
message["params"] = params
105+
try:
106+
self._send(message)
107+
kind, value = waiter.get(timeout=self.timeout)
108+
except queue.Empty as e:
109+
with self._state_lock:
110+
self._pending.pop(request_id, None)
111+
raise LSPError(f"LSP request timed out: {method}") from e
112+
if kind == "error":
113+
code = value.get("code") if isinstance(value, dict) else None
114+
msg = value.get("message", "unknown LSP error") if isinstance(value, dict) else str(value)
115+
raise LSPError(f"LSP {method} failed ({code}): {msg}")
116+
return value
117+
118+
def notify(self, method: str, params: Any = None) -> None:
119+
self.start_if_needed()
120+
message: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
121+
if params is not None:
122+
message["params"] = params
123+
self._send(message)
124+
125+
def start_if_needed(self) -> None:
126+
if not self.alive:
127+
self.start()
128+
129+
def open_document(self, uri: str, text: str) -> None:
130+
self.start_if_needed()
131+
with self._state_lock:
132+
version = self._opened.get(uri, 0)
133+
if version:
134+
if self._texts.get(uri) == text:
135+
return
136+
version += 1
137+
self._opened[uri] = version
138+
self._texts[uri] = text
139+
self.notify("textDocument/didChange", {
140+
"textDocument": {"uri": uri, "version": version},
141+
"contentChanges": [{"text": text}],
142+
})
143+
return
144+
self._opened[uri] = 1
145+
self._texts[uri] = text
146+
self.notify("textDocument/didOpen", {
147+
"textDocument": {"uri": uri, "languageId": self.language_id, "version": 1, "text": text}
148+
})
149+
150+
def close_document(self, uri: str) -> None:
151+
with self._state_lock:
152+
if uri not in self._opened:
153+
return
154+
self._opened.pop(uri, None)
155+
self._texts.pop(uri, None)
156+
if self.alive:
157+
self.notify("textDocument/didClose", {"textDocument": {"uri": uri}})
158+
159+
def diagnostics(self, uri: str) -> list[dict[str, Any]]:
160+
with self._state_lock:
161+
return list(self._diagnostics.get(uri, []))
162+
163+
def close(self) -> None:
164+
if self._closed:
165+
return
166+
proc = self.proc
167+
was_alive = proc is not None and proc.poll() is None
168+
if was_alive:
169+
try:
170+
self.request("shutdown", None)
171+
except Exception:
172+
pass
173+
try:
174+
self.notify("exit", None)
175+
except Exception:
176+
pass
177+
self._closed = True
178+
proc = self.proc
179+
if proc is not None and proc.poll() is None:
180+
try:
181+
proc.terminate()
182+
proc.wait(timeout=2)
183+
except Exception:
184+
try:
185+
proc.kill()
186+
except Exception:
187+
pass
188+
with self._state_lock:
189+
for waiter in self._pending.values():
190+
try:
191+
waiter.put_nowait(("error", {"message": "LSP client closed"}))
192+
except queue.Full:
193+
pass
194+
self._pending.clear()
195+
self._opened.clear()
196+
self._texts.clear()
197+
198+
def _send(self, message: dict[str, Any]) -> None:
199+
data = json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
200+
packet = b"Content-Length: " + str(len(data)).encode("ascii") + b"\r\n\r\n" + data
201+
proc = self.proc
202+
if proc is None or proc.stdin is None or proc.poll() is not None:
203+
raise LSPError("LSP server is not running")
204+
with self._write_lock:
205+
try:
206+
proc.stdin.write(packet)
207+
proc.stdin.flush()
208+
except OSError as e:
209+
raise LSPError(f"failed writing to LSP server: {e}") from e
210+
211+
def _read_loop(self) -> None:
212+
proc = self.proc
213+
if proc is None or proc.stdout is None:
214+
return
215+
stream = proc.stdout
216+
try:
217+
while not self._closed:
218+
headers: dict[str, str] = {}
219+
while True:
220+
line = stream.readline()
221+
if not line:
222+
return
223+
line = line.rstrip(b"\r\n")
224+
if not line:
225+
break
226+
if b":" in line:
227+
key, value = line.split(b":", 1)
228+
headers[key.decode("ascii", "replace").lower()] = value.strip().decode("ascii", "replace")
229+
try:
230+
length = int(headers.get("content-length", "0"))
231+
except ValueError:
232+
continue
233+
payload = stream.read(length)
234+
if len(payload) != length:
235+
return
236+
try:
237+
message = json.loads(payload.decode("utf-8"))
238+
except (UnicodeDecodeError, json.JSONDecodeError):
239+
continue
240+
self._handle_message(message)
241+
finally:
242+
with self._state_lock:
243+
pending = list(self._pending.values())
244+
self._pending.clear()
245+
for waiter in pending:
246+
try:
247+
waiter.put_nowait(("error", {"message": "LSP server exited"}))
248+
except queue.Full:
249+
pass
250+
251+
def _handle_message(self, message: dict[str, Any]) -> None:
252+
if "id" in message and ("result" in message or "error" in message):
253+
request_id = message.get("id")
254+
if isinstance(request_id, int):
255+
with self._state_lock:
256+
waiter = self._pending.pop(request_id, None)
257+
if waiter is not None:
258+
waiter.put(("error", message["error"]) if "error" in message else ("result", message.get("result")))
259+
return
260+
261+
method = message.get("method")
262+
params = message.get("params")
263+
if method == "textDocument/publishDiagnostics" and isinstance(params, dict):
264+
uri = params.get("uri")
265+
if isinstance(uri, str):
266+
with self._state_lock:
267+
self._diagnostics[uri] = params.get("diagnostics", []) or []
268+
return
269+
270+
# Servers may send requests/notifications to the client. Respond to
271+
# common client-side requests so a server does not stall waiting for
272+
# configuration/progress/UI handling that the coding agent does not need.
273+
if "id" in message and isinstance(message.get("id"), int):
274+
method = str(method or "")
275+
if method == "workspace/configuration":
276+
items = params if isinstance(params, list) else []
277+
result = [None for _ in items]
278+
elif method == "workspace/applyEdit":
279+
result = {"applied": False}
280+
else:
281+
result = None
282+
try:
283+
self._send({"jsonrpc": "2.0", "id": message["id"], "result": result})
284+
except LSPError:
285+
pass
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Workspace/server management for the built-in LSP tool."""
2+
from __future__ import annotations
3+
4+
import atexit
5+
import json
6+
import os
7+
import shutil
8+
import threading
9+
from pathlib import Path
10+
from typing import Any
11+
12+
from .client import LSPClient, LSPError
13+
14+
# command, language id. Users can override/add servers with
15+
# PYTHON_AGENT_HARNESS_LSP_SERVERS as a JSON object keyed by language/extension.
16+
DEFAULT_SERVERS: dict[str, tuple[list[str], str]] = {
17+
".py": (["pyright-langserver", "--stdio"], "python"),
18+
".pyi": (["pyright-langserver", "--stdio"], "python"),
19+
".js": (["typescript-language-server", "--stdio"], "javascript"),
20+
".jsx": (["typescript-language-server", "--stdio"], "javascriptreact"),
21+
".ts": (["typescript-language-server", "--stdio"], "typescript"),
22+
".tsx": (["typescript-language-server", "--stdio"], "typescriptreact"),
23+
".c": (["clangd"], "c"),
24+
".h": (["clangd"], "c"),
25+
".cc": (["clangd"], "cpp"),
26+
".cpp": (["clangd"], "cpp"),
27+
".cxx": (["clangd"], "cpp"),
28+
".hpp": (["clangd"], "cpp"),
29+
".rs": (["rust-analyzer"], "rust"),
30+
".go": (["gopls"], "go"),
31+
}
32+
33+
_SERVERS: dict[str, LSPClient] = {}
34+
_LOCK = threading.RLock()
35+
36+
37+
def _load_server_config() -> dict[str, tuple[list[str], str]]:
38+
result = dict(DEFAULT_SERVERS)
39+
raw = os.environ.get("PYTHON_AGENT_HARNESS_LSP_SERVERS")
40+
if not raw:
41+
return result
42+
try:
43+
data = json.loads(raw)
44+
except json.JSONDecodeError:
45+
return result
46+
if not isinstance(data, dict):
47+
return result
48+
for key, value in data.items():
49+
if isinstance(value, dict) and isinstance(value.get("command"), list):
50+
command = [str(x) for x in value["command"]]
51+
language_id = str(value.get("language_id", key.lstrip(".")))
52+
result[str(key)] = (command, language_id)
53+
return result
54+
55+
56+
def _find_root(path: str, project_dir: str) -> str:
57+
p = Path(path).resolve()
58+
current = p.parent if p.is_file() else p
59+
project = Path(project_dir).resolve()
60+
markers = {".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", "compile_commands.json"}
61+
while True:
62+
if any((current / marker).exists() for marker in markers):
63+
return str(current)
64+
if current == project or current.parent == current:
65+
return str(project)
66+
current = current.parent
67+
68+
69+
def _server_for(path: str) -> tuple[str, list[str], str] | None:
70+
ext = Path(path).suffix.lower()
71+
config = _load_server_config()
72+
spec = config.get(ext)
73+
if spec is None:
74+
return None
75+
command, language_id = spec
76+
if not command or shutil.which(command[0]) is None:
77+
return None
78+
return ext, command, language_id
79+
80+
81+
def get_client(path: str, project_dir: str) -> tuple[LSPClient, str]:
82+
spec = _server_for(path)
83+
if spec is None:
84+
raise LSPError(f"No LSP server configured or installed for {Path(path).suffix or 'this'} file type.")
85+
ext, command, language_id = spec
86+
root = _find_root(path, project_dir)
87+
key = os.path.normcase(os.path.realpath(root)) + "\0" + "\0".join(command)
88+
with _LOCK:
89+
client = _SERVERS.get(key)
90+
if client is None or not client.alive:
91+
if client is not None:
92+
client.close()
93+
client = LSPClient(command, root, language_id)
94+
client.start()
95+
_SERVERS[key] = client
96+
return client, key
97+
98+
99+
def shutdown_all() -> None:
100+
with _LOCK:
101+
clients = list(_SERVERS.values())
102+
_SERVERS.clear()
103+
for client in clients:
104+
client.close()
105+
106+
107+
atexit.register(shutdown_all)

0 commit comments

Comments
 (0)