openclaw/src/agents/pi-bundle-lsp-runtime.ts

379 lines
12 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { OpenClawConfig } from "../config/config.js";
import { logDebug, logWarn } from "../logger.js";
import { loadEmbeddedPiLspConfig } from "./embedded-pi-lsp.js";
import {
resolveStdioMcpServerLaunchConfig,
describeStdioMcpServerLaunchConfig,
} from "./mcp-stdio.js";
import type { AnyAgentTool } from "./tools/common.js";
// Minimal LSP JSON-RPC framing over stdio (Content-Length header + JSON body).
type LspSession = {
serverName: string;
process: ChildProcess;
requestId: number;
pendingRequests: Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>;
buffer: string;
initialized: boolean;
capabilities: LspServerCapabilities;
};
type LspServerCapabilities = {
hoverProvider?: boolean;
completionProvider?: boolean;
definitionProvider?: boolean;
referencesProvider?: boolean;
diagnosticProvider?: boolean;
[key: string]: unknown;
};
export type BundleLspToolRuntime = {
tools: AnyAgentTool[];
sessions: Array<{ serverName: string; capabilities: LspServerCapabilities }>;
dispose: () => Promise<void>;
};
function encodeLspMessage(body: unknown): string {
const json = JSON.stringify(body);
return `Content-Length: ${Buffer.byteLength(json, "utf-8")}\r\n\r\n${json}`;
}
function parseLspMessages(buffer: string): { messages: unknown[]; remaining: string } {
const messages: unknown[] = [];
let remaining = buffer;
while (true) {
const headerEnd = remaining.indexOf("\r\n\r\n");
if (headerEnd === -1) {
break;
}
const header = remaining.slice(0, headerEnd);
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
remaining = remaining.slice(headerEnd + 4);
continue;
}
const contentLength = parseInt(match[1], 10);
const bodyStart = headerEnd + 4;
const bodyEnd = bodyStart + contentLength;
if (Buffer.byteLength(remaining.slice(bodyStart), "utf-8") < contentLength) {
break;
}
try {
const body = remaining.slice(bodyStart, bodyStart + contentLength);
messages.push(JSON.parse(body));
} catch {
// skip malformed
}
remaining = remaining.slice(bodyEnd);
}
return { messages, remaining };
}
function sendRequest(session: LspSession, method: string, params?: unknown): Promise<unknown> {
const id = ++session.requestId;
return new Promise((resolve, reject) => {
session.pendingRequests.set(id, { resolve, reject });
const message = { jsonrpc: "2.0", id, method, params };
const encoded = encodeLspMessage(message);
session.process.stdin?.write(encoded, "utf-8");
// Timeout after 10 seconds
setTimeout(() => {
if (session.pendingRequests.has(id)) {
session.pendingRequests.delete(id);
reject(new Error(`LSP request ${method} timed out`));
}
}, 10_000);
});
}
function handleIncomingData(session: LspSession, chunk: string) {
session.buffer += chunk;
const { messages, remaining } = parseLspMessages(session.buffer);
session.buffer = remaining;
for (const msg of messages) {
if (typeof msg !== "object" || msg === null) {
continue;
}
const record = msg as Record<string, unknown>;
if ("id" in record && typeof record.id === "number") {
const pending = session.pendingRequests.get(record.id);
if (pending) {
session.pendingRequests.delete(record.id);
if ("error" in record) {
pending.reject(new Error(JSON.stringify(record.error)));
} else {
pending.resolve(record.result);
}
}
}
// Notifications (no id) are logged but not acted on
if ("method" in record && !("id" in record)) {
logDebug(`bundle-lsp:${session.serverName}: notification ${String(record.method)}`);
}
}
}
async function initializeSession(session: LspSession): Promise<LspServerCapabilities> {
const result = (await sendRequest(session, "initialize", {
processId: process.pid,
rootUri: null,
capabilities: {
textDocument: {
hover: { contentFormat: ["plaintext", "markdown"] },
completion: { completionItem: { snippetSupport: false } },
definition: {},
references: {},
},
},
})) as { capabilities?: LspServerCapabilities } | undefined;
// Send initialized notification
session.process.stdin?.write(
encodeLspMessage({ jsonrpc: "2.0", method: "initialized", params: {} }),
"utf-8",
);
session.initialized = true;
return result?.capabilities ?? {};
}
async function disposeSession(session: LspSession) {
if (session.initialized) {
try {
await sendRequest(session, "shutdown").catch(() => {});
session.process.stdin?.write(
encodeLspMessage({ jsonrpc: "2.0", method: "exit", params: null }),
"utf-8",
);
} catch {
// best-effort
}
}
for (const [, pending] of session.pendingRequests) {
pending.reject(new Error("LSP session disposed"));
}
session.pendingRequests.clear();
session.process.kill();
}
function buildLspTools(session: LspSession): AnyAgentTool[] {
const tools: AnyAgentTool[] = [];
const caps = session.capabilities;
const serverLabel = session.serverName;
if (caps.hoverProvider) {
tools.push({
name: `lsp_hover_${serverLabel}`,
label: `LSP Hover (${serverLabel})`,
description: `Get hover information for a symbol at a position in a file via the ${serverLabel} language server.`,
parameters: {
type: "object",
properties: {
uri: { type: "string", description: "File URI (file:///path/to/file)" },
line: { type: "number", description: "Zero-based line number" },
character: { type: "number", description: "Zero-based character offset" },
},
required: ["uri", "line", "character"],
},
execute: async (_toolCallId, input) => {
const params = input as { uri: string; line: number; character: number };
const result = await sendRequest(session, "textDocument/hover", {
textDocument: { uri: params.uri },
position: { line: params.line, character: params.character },
});
return formatLspResult(serverLabel, "hover", result);
},
});
}
if (caps.definitionProvider) {
tools.push({
name: `lsp_definition_${serverLabel}`,
label: `LSP Go to Definition (${serverLabel})`,
description: `Find the definition of a symbol at a position in a file via the ${serverLabel} language server.`,
parameters: {
type: "object",
properties: {
uri: { type: "string", description: "File URI (file:///path/to/file)" },
line: { type: "number", description: "Zero-based line number" },
character: { type: "number", description: "Zero-based character offset" },
},
required: ["uri", "line", "character"],
},
execute: async (_toolCallId, input) => {
const params = input as { uri: string; line: number; character: number };
const result = await sendRequest(session, "textDocument/definition", {
textDocument: { uri: params.uri },
position: { line: params.line, character: params.character },
});
return formatLspResult(serverLabel, "definition", result);
},
});
}
if (caps.referencesProvider) {
tools.push({
name: `lsp_references_${serverLabel}`,
label: `LSP Find References (${serverLabel})`,
description: `Find all references to a symbol at a position in a file via the ${serverLabel} language server.`,
parameters: {
type: "object",
properties: {
uri: { type: "string", description: "File URI (file:///path/to/file)" },
line: { type: "number", description: "Zero-based line number" },
character: { type: "number", description: "Zero-based character offset" },
includeDeclaration: {
type: "boolean",
description: "Include the declaration in results",
},
},
required: ["uri", "line", "character"],
},
execute: async (_toolCallId, input) => {
const params = input as {
uri: string;
line: number;
character: number;
includeDeclaration?: boolean;
};
const result = await sendRequest(session, "textDocument/references", {
textDocument: { uri: params.uri },
position: { line: params.line, character: params.character },
context: { includeDeclaration: params.includeDeclaration ?? true },
});
return formatLspResult(serverLabel, "references", result);
},
});
}
return tools;
}
function formatLspResult(
serverName: string,
method: string,
result: unknown,
): AgentToolResult<unknown> {
const text =
result !== null && result !== undefined
? JSON.stringify(result, null, 2)
: `No ${method} result from ${serverName}`;
return {
content: [{ type: "text", text }],
details: { lspServer: serverName, lspMethod: method },
};
}
export async function createBundleLspToolRuntime(params: {
workspaceDir: string;
cfg?: OpenClawConfig;
reservedToolNames?: Iterable<string>;
}): Promise<BundleLspToolRuntime> {
const loaded = loadEmbeddedPiLspConfig({
workspaceDir: params.workspaceDir,
cfg: params.cfg,
});
for (const diagnostic of loaded.diagnostics) {
logWarn(`bundle-lsp: ${diagnostic.pluginId}: ${diagnostic.message}`);
}
// Skip spawning when no LSP servers are configured.
if (Object.keys(loaded.lspServers).length === 0) {
return { tools: [], sessions: [], dispose: async () => {} };
}
const reservedNames = new Set(
Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean),
);
const sessions: LspSession[] = [];
const tools: AnyAgentTool[] = [];
try {
for (const [serverName, rawServer] of Object.entries(loaded.lspServers)) {
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
if (!launch.ok) {
logWarn(`bundle-lsp: skipped server "${serverName}" because ${launch.reason}.`);
continue;
}
const launchConfig = launch.config;
try {
const child = spawn(launchConfig.command, launchConfig.args ?? [], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, ...launchConfig.env },
cwd: launchConfig.cwd,
});
const session: LspSession = {
serverName,
process: child,
requestId: 0,
pendingRequests: new Map(),
buffer: "",
initialized: false,
capabilities: {},
};
child.stdout?.setEncoding("utf-8");
child.stdout?.on("data", (chunk: string) => handleIncomingData(session, chunk));
child.stderr?.setEncoding("utf-8");
child.stderr?.on("data", (chunk: string) => {
for (const line of chunk.split(/\r?\n/).filter(Boolean)) {
logDebug(`bundle-lsp:${serverName}: ${line.trim()}`);
}
});
const capabilities = await initializeSession(session);
session.capabilities = capabilities;
sessions.push(session);
const serverTools = buildLspTools(session);
for (const tool of serverTools) {
const normalizedName = tool.name.trim().toLowerCase();
if (reservedNames.has(normalizedName)) {
logWarn(
`bundle-lsp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`,
);
continue;
}
reservedNames.add(normalizedName);
tools.push(tool);
}
logDebug(
`bundle-lsp: started "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}) with ${serverTools.length} tools`,
);
} catch (error) {
logWarn(
`bundle-lsp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`,
);
}
}
return {
tools,
sessions: sessions.map((s) => ({
serverName: s.serverName,
capabilities: s.capabilities,
})),
dispose: async () => {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
},
};
} catch (error) {
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
throw error;
}
}