Integrating LSP into Hermes Agent: Async I/O, Delta Filtering, and Multi-Language Support
The article explains how Hermes Agent gains real‑time code intelligence by directly connecting to Language Server Protocol servers via async stdin/stdout, handling noisy diagnostics with delta filtering and line‑number shifting, and managing over 30 languages through a unified ServerDef abstraction.
Background: Agents Write Code Blindly
Agents can open files and run commands, but without language intelligence they generate code that may compile but contain type or logic errors that only surface at runtime. LSP (Language Server Protocol) solves this by providing diagnostics, hover information, definitions, and references.
What LSP Gives an Agent
diagnostics : type errors, undefined references, syntax problems.
hover : function signatures and type hints.
definition : precise symbol locations.
references : all usages of a symbol.
For code‑writing agents, diagnostics are the most critical capability because they tell the agent exactly where a mistake lies.
Research: Claude Code vs. OpenCode
Claude Code integrates LSP through the MCP (Model Context Protocol) IDE plugin, delegating server management to the IDE. Advantages: no need for the agent to manage server processes, reuse of already‑started servers, and out‑of‑the‑box IDE ecosystem support. Drawbacks: dependence on a running IDE, extra latency (Agent → MCP → IDE → LSP), and incompatibility with headless environments.
OpenCode implements a low‑level LSPClient that talks to the language server directly over stdin/stdout. Key design decisions borrowed by Hermes:
One‑to‑one client keyed by (server_id, workspace_root).
Always send a full‑document replacement even when the server declares incremental sync.
Retry -32801 ContentModified errors with exponential back‑off.
Skip the first TypeScript server push (global scan) and debounce subsequent pushes.
Design Conclusions: Three Core Decisions
Decision 1 – Direct LSP Connection (No IDE)
Hermes runs headless on servers, so it must spawn language‑server processes itself and communicate via JSON‑RPC over stdin/stdout.
Spawn Language Server on demand.
Communicate with JSON‑RPC.
Manage the server lifecycle (spawn, shutdown, crash recovery).
Decision 2 – Git Workspace Gating
LSP is activated only inside a Git worktree. Hermes walks up from the edited file to locate a .git marker (or a linked worktree file) and starts the server only if the file resides within that worktree. This prevents unnecessary server launches for temporary files, ensures project‑root context (e.g., pyproject.toml, go.mod), and controls resource usage.
Decision 3 – Delta Filtering with Line‑Number Shift
Before an edit, Hermes snapshots the current diagnostics (baseline). After the edit, it obtains a new diagnostic list and returns only the differences. Because inserted lines shift existing warnings, Hermes builds a line‑number mapping using difflib.SequenceMatcher and applies the shift to the baseline before computing the delta.
Core Implementation: LSPClient – Async stdin/stdout Client
Handshake with 45‑second Timeout
The client first sends an initialize request, receives server capabilities, then sends an initialized notification. Hermes sets a 45‑second timeout because language servers can be slow to start.
# client.py - _initialize()
async def _initialize(self) -> None:
params = {
"rootUri": file_uri(self.workspace_root),
"capabilities": {
"textDocument": {
"synchronization": {"didOpen": True, "didChange": True, "didSave": True},
"diagnostic": {"dynamicRegistration": True, "relatedDocumentSupport": True},
"publishDiagnostics": {"versionSupport": True, "tagSupport": {"valueSet": [1, 2]}}
}
},
"initializationOptions": self._init_options,
}
result = await asyncio.wait_for(self._send_request("initialize", params), timeout=45.0)
await self._send_notification("initialized", {})
await self._send_notification("workspace/didChangeConfiguration", {"settings": self._init_options})Full‑Document Replacement Trick
Even when a server declares incremental sync, Hermes always sends a full‑document didChange notification with a range that covers the entire file. This avoids the complexity of UTF‑16 range calculations and works with major servers (pyright, tsserver, gopls).
# client.py - open_file()
async def open_file(self, path: str, *, language_id: str = "plaintext") -> int:
text = Path(abs_path).read_text(encoding="utf-8")
if existing is not None:
if self._sync_kind == 2: # server declared Incremental
content_changes = [{"range": {"start": {"line": 0, "character": 0}, "end": _end_position(old_text)}, "text": text}]
else:
content_changes = [{"text": text}] # Full mode
await self._send_notification("textDocument/didChange", {"textDocument": {"uri": uri, "version": new_version}, "contentChanges": content_changes})ContentModified Retry
When the server returns -32801 ContentModified, Hermes retries the request up to three times with exponential back‑off (0.5 s, 1 s, 2 s).
# client.py - _send_request_with_retry()
async def _send_request_with_retry(self, method: str, params: Any, *, timeout: float) -> Any:
for attempt in range(4): # up to 3 retries
try:
return await asyncio.wait_for(self._send_request(method, params), timeout=timeout)
except LSPRequestError as e:
if e.code == -32801 and attempt < 3:
await asyncio.sleep(0.5 * (2 ** attempt))
continue
raiseDiagnostics Denoising: Push + Pull, Delta Filtering, Line‑Shift
Two Diagnostic Paths
Push: server sends textDocument/publishDiagnostics spontaneously. Pull: client requests textDocument/diagnostic (LSP 3.17). Hermes merges both streams, dedupes by a content key, and applies delta filtering.
# client.py - diagnostics_for()
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
push = self._push_diagnostics.get(abs_path) or []
pull = self._pull_diagnostics.get(abs_path) or []
return _dedupe(push, pull)
def _dedupe(*lists):
seen: Set[str] = set()
out = []
for lst in lists:
for d in lst:
key = _diagnostic_key(d) # (severity, code, source, message, range)
if key not in seen:
seen.add(key)
out.append(d)
return outTypeScript Double‑Push Debounce
TypeScript servers often push diagnostics twice (quick syntax then slower semantic). Hermes waits 150 ms after the first push; if a second push arrives, it uses the later result.
# client.py - _wait_for_fresh_push()
async def _wait_for_fresh_push(self, path, version, timeout):
deadline = asyncio.get_event_loop().time() + timeout
baseline = self._push_counter
while True:
if path in self._published and published_version >= version:
debounce_baseline = self._push_counter
debounce_deadline = now() + 0.15
while self._push_counter == debounce_baseline:
remaining = debounce_deadline - now()
if remaining <= 0:
break
await asyncio.wait_for(self._push_event.wait(), timeout=remaining)
returnDelta Filtering + Line‑Shift
Hermes snapshots a baseline before writing, builds a line‑shift function with build_line_shift, then filters out diagnostics whose keys appear in the shifted baseline.
# manager.py - get_diagnostics_sync()
def get_diagnostics_sync(self, file_path, *, delta=True, line_shift=None) -> List[Dict]:
diags = self._loop.run(self._open_and_wait_async(file_path))
if delta:
baseline = self._delta_baseline.get(abs_path) or []
if baseline and line_shift is not None:
baseline = shift_baseline(baseline, line_shift)
seen = {_diag_key(d) for d in baseline}
diags = [d for d in diags if _diag_key(d) not in seen]
return diagsServer Registry: Unified Management of 30+ Languages
Hermes describes each language server with a ServerDef containing four fields: server_id, extensions, resolve_root, and build_spawn. This abstraction lets the same code handle Python, Go, TypeScript, etc., and includes exclusion rules (e.g., Deno projects).
# servers.py - ServerDef (Python)
@dataclass
class ServerDef:
server_id: str
extensions: Tuple[str, ...]
resolve_root: Callable
build_spawn: Callable
ServerDef(
server_id="pyright",
extensions=(".py", ".pyi"),
resolve_root=_root_python, # find pyproject.toml / setup.py
build_spawn=_spawn_pyright,
)Project‑Root Resolution
Each language has its own marker files ( pyproject.toml, go.mod, package.json). The helper _root_or_workspace() walks upward to locate a marker; if none is found, it falls back to the workspace root. TypeScript additionally excludes deno.json to avoid starting the generic server for Deno projects.
Engineering Details
BackgroundLoop – Bridging Sync and Async
File‑operation code runs synchronously, while LSP communication is asynchronous. Hermes starts a dedicated thread with its own asyncio event loop; synchronous callers submit coroutines via run() and block until the result is ready.
# manager.py - _BackgroundLoop
class _BackgroundLoop:
def start(self):
self._thread = threading.Thread(target=self._run_forever, daemon=True, name="hermes-lsp-loop")
self._thread.start()
def _run_forever(self):
loop = asyncio.new_event_loop()
self._loop = loop
loop.run_forever()
def run(self, coro, *, timeout=None):
fut = asyncio.run_coroutine_threadsafe(coro, self._loop)
return fut.result(timeout=timeout)Lazy Spawn
Language servers are heavy; Hermes spawns them only on first use via _get_or_spawn(). The method caches running clients, awaits ongoing spawns, and records permanently failed servers in a broken set to avoid repeated attempts.
Broken‑Set Protection
If a server fails to start (binary missing, permission error, OOM), its key is added to broken. Subsequent requests for that (server_id, workspace_root) return None instantly. Restarting Hermes clears the set, allowing recovery after manual fixes.
Auto‑Install Strategy
When which pyright-langserver returns None, Hermes can automatically install the server according to the install_strategy configuration: "auto" (run npm install -g or pip install), "manual" (search PATH only), or "off" (disable LSP).
Conclusion
By integrating LSP, Hermes gives its Agent real‑time code awareness: precise diagnostics, hover information, and symbol navigation across more than thirty languages. The design balances headless operation, resource efficiency, and noise reduction through Git gating, delta filtering, and robust process management, turning the Agent from a blind typer into an intelligent coder.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
James' Growth Diary
I am James, focusing on AI Agent learning and growth. I continuously update two series: “AI Agent Mastery Path,” which systematically outlines core theories and practices of agents, and “Claude Code Design Philosophy,” which deeply analyzes the design thinking behind top AI tools. Helping you build a solid foundation in the AI era.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
