Skip to content

Commit 8193af6

Browse files
committed
Plugins: add LSP server runtime with stdio JSON-RPC client and agent tool bridge
1 parent 466510b commit 8193af6

2 files changed

Lines changed: 397 additions & 0 deletions

File tree

src/agents/embedded-pi-lsp.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { OpenClawConfig } from "../config/config.js";
2+
import type { BundleLspServerConfig } from "../plugins/bundle-lsp.js";
3+
import { loadEnabledBundleLspConfig } from "../plugins/bundle-lsp.js";
4+
5+
export type EmbeddedPiLspConfig = {
6+
lspServers: Record<string, BundleLspServerConfig>;
7+
diagnostics: Array<{ pluginId: string; message: string }>;
8+
};
9+
10+
export function loadEmbeddedPiLspConfig(params: {
11+
workspaceDir: string;
12+
cfg?: OpenClawConfig;
13+
}): EmbeddedPiLspConfig {
14+
const bundleLsp = loadEnabledBundleLspConfig({
15+
workspaceDir: params.workspaceDir,
16+
cfg: params.cfg,
17+
});
18+
// User-configured LSP servers could override bundle defaults here in the future.
19+
return {
20+
lspServers: { ...bundleLsp.config.lspServers },
21+
diagnostics: bundleLsp.diagnostics,
22+
};
23+
}
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
import { spawn, type ChildProcess } from "node:child_process";
2+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
3+
import type { OpenClawConfig } from "../config/config.js";
4+
import { logDebug, logWarn } from "../logger.js";
5+
import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js";
6+
import {
7+
resolveStdioMcpServerLaunchConfig,
8+
describeStdioMcpServerLaunchConfig,
9+
} from "./mcp-stdio.js";
10+
import type { AnyAgentTool } from "./tools/common.js";
11+
12+
// Minimal LSP JSON-RPC framing over stdio (Content-Length header + JSON body).
13+
14+
type LspSession = {
15+
serverName: string;
16+
process: ChildProcess;
17+
requestId: number;
18+
pendingRequests: Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>;
19+
buffer: string;
20+
initialized: boolean;
21+
capabilities: LspServerCapabilities;
22+
};
23+
24+
type LspServerCapabilities = {
25+
hoverProvider?: boolean;
26+
completionProvider?: boolean;
27+
definitionProvider?: boolean;
28+
referencesProvider?: boolean;
29+
diagnosticProvider?: boolean;
30+
[key: string]: unknown;
31+
};
32+
33+
export type BundleLspToolRuntime = {
34+
tools: AnyAgentTool[];
35+
sessions: Array<{ serverName: string; capabilities: LspServerCapabilities }>;
36+
dispose: () => Promise<void>;
37+
};
38+
39+
function encodeLspMessage(body: unknown): string {
40+
const json = JSON.stringify(body);
41+
return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`;
42+
}
43+
44+
function parseLspMessages(buffer: string): { messages: unknown[]; remaining: string } {
45+
const messages: unknown[] = [];
46+
let remaining = buffer;
47+
48+
while (true) {
49+
const headerEnd = remaining.indexOf("\r\n\r\n");
50+
if (headerEnd === -1) {
51+
break;
52+
}
53+
54+
const header = remaining.slice(0, headerEnd);
55+
const match = header.match(/Content-Length:\s*(\d+)/i);
56+
if (!match) {
57+
remaining = remaining.slice(headerEnd + 4);
58+
continue;
59+
}
60+
61+
const contentLength = parseInt(match[1], 10);
62+
const bodyStart = headerEnd + 4;
63+
const bodyEnd = bodyStart + contentLength;
64+
65+
if (Buffer.byteLength(remaining.slice(bodyStart), "utf-8") < contentLength) {
66+
break;
67+
}
68+
69+
try {
70+
const body = remaining.slice(bodyStart, bodyStart + contentLength);
71+
messages.push(JSON.parse(body));
72+
} catch {
73+
// skip malformed
74+
}
75+
remaining = remaining.slice(bodyEnd);
76+
}
77+
78+
return { messages, remaining };
79+
}
80+
81+
function sendRequest(session: LspSession, method: string, params?: unknown): Promise<unknown> {
82+
const id = ++session.requestId;
83+
return new Promise((resolve, reject) => {
84+
session.pendingRequests.set(id, { resolve, reject });
85+
const message = { jsonrpc: "2.0", id, method, params };
86+
const encoded = encodeLspMessage(message);
87+
session.process.stdin?.write(encoded, "utf-8");
88+
89+
// Timeout after 10 seconds
90+
setTimeout(() => {
91+
if (session.pendingRequests.has(id)) {
92+
session.pendingRequests.delete(id);
93+
reject(new Error(`LSP request ${method} timed out`));
94+
}
95+
}, 10_000);
96+
});
97+
}
98+
99+
function handleIncomingData(session: LspSession, chunk: string) {
100+
session.buffer += chunk;
101+
const { messages, remaining } = parseLspMessages(session.buffer);
102+
session.buffer = remaining;
103+
104+
for (const msg of messages) {
105+
if (typeof msg !== "object" || msg === null) {
106+
continue;
107+
}
108+
const record = msg as Record<string, unknown>;
109+
110+
if ("id" in record && typeof record.id === "number") {
111+
const pending = session.pendingRequests.get(record.id);
112+
if (pending) {
113+
session.pendingRequests.delete(record.id);
114+
if ("error" in record) {
115+
pending.reject(new Error(JSON.stringify(record.error)));
116+
} else {
117+
pending.resolve(record.result);
118+
}
119+
}
120+
}
121+
// Notifications (no id) are logged but not acted on
122+
if ("method" in record && !("id" in record)) {
123+
logDebug(`bundle-lsp:${session.serverName}: notification ${String(record.method)}`);
124+
}
125+
}
126+
}
127+
128+
async function initializeSession(session: LspSession): Promise<LspServerCapabilities> {
129+
const result = (await sendRequest(session, "initialize", {
130+
processId: process.pid,
131+
rootUri: null,
132+
capabilities: {
133+
textDocument: {
134+
hover: { contentFormat: ["plaintext", "markdown"] },
135+
completion: { completionItem: { snippetSupport: false } },
136+
definition: {},
137+
references: {},
138+
},
139+
},
140+
})) as { capabilities?: LspServerCapabilities } | undefined;
141+
142+
// Send initialized notification
143+
session.process.stdin?.write(
144+
encodeLspMessage({ jsonrpc: "2.0", method: "initialized", params: {} }),
145+
"utf-8",
146+
);
147+
148+
session.initialized = true;
149+
return result?.capabilities ?? {};
150+
}
151+
152+
async function disposeSession(session: LspSession) {
153+
if (session.initialized) {
154+
try {
155+
await sendRequest(session, "shutdown").catch(() => {});
156+
session.process.stdin?.write(
157+
encodeLspMessage({ jsonrpc: "2.0", method: "exit", params: null }),
158+
"utf-8",
159+
);
160+
} catch {
161+
// best-effort
162+
}
163+
}
164+
for (const [, pending] of session.pendingRequests) {
165+
pending.reject(new Error("LSP session disposed"));
166+
}
167+
session.pendingRequests.clear();
168+
session.process.kill();
169+
}
170+
171+
function buildLspTools(session: LspSession): AnyAgentTool[] {
172+
const tools: AnyAgentTool[] = [];
173+
const caps = session.capabilities;
174+
const serverLabel = session.serverName;
175+
176+
if (caps.hoverProvider) {
177+
tools.push({
178+
name: `lsp_hover_${serverLabel}`,
179+
label: `LSP Hover (${serverLabel})`,
180+
description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`,
181+
parameters: {
182+
type: "object",
183+
properties: {
184+
uri: { type: "string", description: "File URI (file:///path/to/file)" },
185+
line: { type: "number", description: "Zero-based line number" },
186+
character: { type: "number", description: "Zero-based character offset" },
187+
},
188+
required: ["uri", "line", "character"],
189+
},
190+
execute: async (_toolCallId, input) => {
191+
const params = input as { uri: string; line: number; character: number };
192+
const result = await sendRequest(session, "textDocument/hover", {
193+
textDocument: { uri: params.uri },
194+
position: { line: params.line, character: params.character },
195+
});
196+
return formatLspResult(serverLabel, "hover", result);
197+
},
198+
});
199+
}
200+
201+
if (caps.definitionProvider) {
202+
tools.push({
203+
name: `lsp_definition_${serverLabel}`,
204+
label: `LSP Go to Definition (${serverLabel})`,
205+
description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`,
206+
parameters: {
207+
type: "object",
208+
properties: {
209+
uri: { type: "string", description: "File URI (file:///path/to/file)" },
210+
line: { type: "number", description: "Zero-based line number" },
211+
character: { type: "number", description: "Zero-based character offset" },
212+
},
213+
required: ["uri", "line", "character"],
214+
},
215+
execute: async (_toolCallId, input) => {
216+
const params = input as { uri: string; line: number; character: number };
217+
const result = await sendRequest(session, "textDocument/definition", {
218+
textDocument: { uri: params.uri },
219+
position: { line: params.line, character: params.character },
220+
});
221+
return formatLspResult(serverLabel, "definition", result);
222+
},
223+
});
224+
}
225+
226+
if (caps.referencesProvider) {
227+
tools.push({
228+
name: `lsp_references_${serverLabel}`,
229+
label: `LSP Find References (${serverLabel})`,
230+
description: `Find all references to a symbol at a position in a file via the ${serverLabel} language server.`,
231+
parameters: {
232+
type: "object",
233+
properties: {
234+
uri: { type: "string", description: "File URI (file:///path/to/file)" },
235+
line: { type: "number", description: "Zero-based line number" },
236+
character: { type: "number", description: "Zero-based character offset" },
237+
includeDeclaration: {
238+
type: "boolean",
239+
description: "Include the declaration in results",
240+
},
241+
},
242+
required: ["uri", "line", "character"],
243+
},
244+
execute: async (_toolCallId, input) => {
245+
const params = input as {
246+
uri: string;
247+
line: number;
248+
character: number;
249+
includeDeclaration?: boolean;
250+
};
251+
const result = await sendRequest(session, "textDocument/references", {
252+
textDocument: { uri: params.uri },
253+
position: { line: params.line, character: params.character },
254+
context: { includeDeclaration: params.includeDeclaration ?? true },
255+
});
256+
return formatLspResult(serverLabel, "references", result);
257+
},
258+
});
259+
}
260+
261+
return tools;
262+
}
263+
264+
function formatLspResult(
265+
serverName: string,
266+
method: string,
267+
result: unknown,
268+
): AgentToolResult<unknown> {
269+
const text =
270+
result !== null && result !== undefined
271+
? JSON.stringify(result, null, 2)
272+
: `No ${method} result from ${serverName}`;
273+
return {
274+
content: [{ type: "text", text }],
275+
details: { lspServer: serverName, lspMethod: method },
276+
};
277+
}
278+
279+
export async function createBundleLspToolRuntime(params: {
280+
workspaceDir: string;
281+
cfg?: OpenClawConfig;
282+
reservedToolNames?: Iterable<string>;
283+
}): Promise<BundleLspToolRuntime> {
284+
const loaded = loadEmbeddedPiLspConfig({
285+
workspaceDir: params.workspaceDir,
286+
cfg: params.cfg,
287+
});
288+
for (const diagnostic of loaded.diagnostics) {
289+
logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`);
290+
}
291+
292+
const reservedNames = new Set(
293+
Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean),
294+
);
295+
const sessions: LspSession[] = [];
296+
const tools: AnyAgentTool[] = [];
297+
298+
try {
299+
for (const [serverName, rawServer] of Object.entries(loaded.lspServers)) {
300+
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
301+
if (!launch.ok) {
302+
logWarn(`bundle-lsp: skipped server "${serverName}" because ${launch.reason}.`);
303+
continue;
304+
}
305+
const launchConfig = launch.config;
306+
307+
try {
308+
const child = spawn(launchConfig.command, launchConfig.args ?? [], {
309+
stdio: ["pipe", "pipe", "pipe"],
310+
env: { ...process.env, ...launchConfig.env },
311+
cwd: launchConfig.cwd,
312+
});
313+
314+
const session: LspSession = {
315+
serverName,
316+
process: child,
317+
requestId: 0,
318+
pendingRequests: new Map(),
319+
buffer: "",
320+
initialized: false,
321+
capabilities: {},
322+
};
323+
324+
child.stdout?.setEncoding("utf-8");
325+
child.stdout?.on("data", (chunk: string) => handleIncomingData(session, chunk));
326+
child.stderr?.setEncoding("utf-8");
327+
child.stderr?.on("data", (chunk: string) => {
328+
for (const line of chunk.split(/\r?\n/).filter(Boolean)) {
329+
logDebug(`bundle-lsp:${serverName}: ${line.trim()}`);
330+
}
331+
});
332+
333+
const capabilities = await initializeSession(session);
334+
session.capabilities = capabilities;
335+
sessions.push(session);
336+
337+
const serverTools = buildLspTools(session);
338+
for (const tool of serverTools) {
339+
const normalizedName = tool.name.trim().toLowerCase();
340+
if (reservedNames.has(normalizedName)) {
341+
logWarn(
342+
`bundle-lsp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`,
343+
);
344+
continue;
345+
}
346+
reservedNames.add(normalizedName);
347+
tools.push(tool);
348+
}
349+
350+
logDebug(
351+
`bundle-lsp: started "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}) with ${serverTools.length} tools`,
352+
);
353+
} catch (error) {
354+
logWarn(
355+
`bundle-lsp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`,
356+
);
357+
}
358+
}
359+
360+
return {
361+
tools,
362+
sessions: sessions.map((s) => ({
363+
serverName: s.serverName,
364+
capabilities: s.capabilities,
365+
})),
366+
dispose: async () => {
367+
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
368+
},
369+
};
370+
} catch (error) {
371+
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
372+
throw error;
373+
}
374+
}

0 commit comments

Comments
 (0)