feat: add LSP support (TypeScript and Python) (#474)

Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Charles Packer <cpacker@users.noreply.github.com>
Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
Shubham Naik
2026-01-07 11:41:09 -08:00
committed by GitHub
parent f783070660
commit 3ed7a05370
13 changed files with 1107 additions and 0 deletions

View File

@@ -300,6 +300,16 @@ async function main(): Promise<void> {
await settingsManager.initialize();
const settings = await settingsManager.getSettingsWithSecureTokens();
// Initialize LSP infrastructure for type checking
if (process.env.LETTA_ENABLE_LSP) {
try {
const { lspManager } = await import("./lsp/manager.js");
await lspManager.initialize(process.cwd());
} catch (error) {
console.error("[LSP] Failed to initialize:", error);
}
}
// Initialize telemetry (enabled by default, opt-out via LETTA_CODE_TELEM=0)
telemetry.init();

260
src/lsp/client.ts Normal file
View File

@@ -0,0 +1,260 @@
/**
* LSP Client - Handles JSON-RPC communication with LSP servers
*/
import { EventEmitter } from "node:events";
import type { Readable, Writable } from "node:stream";
import type {
Diagnostic,
InitializeParams,
InitializeResult,
JsonRpcNotification,
JsonRpcRequest,
JsonRpcResponse,
LSPServerProcess,
} from "./types.js";
export interface LSPClientOptions {
serverID: string;
server: LSPServerProcess;
rootUri: string;
}
/**
* LSP Client that communicates with an LSP server via JSON-RPC over STDIO
*/
export class LSPClient extends EventEmitter {
private serverID: string;
private process: LSPServerProcess;
private rootUri: string;
private stdin: Writable;
private stdout: Readable;
private requestId = 0;
private pendingRequests = new Map<
number | string,
{
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}
>();
private buffer = "";
private initialized = false;
constructor(options: LSPClientOptions) {
super();
this.serverID = options.serverID;
this.process = options.server;
this.rootUri = options.rootUri;
if (!this.process.process.stdin || !this.process.process.stdout) {
throw new Error("LSP server process must have stdin/stdout");
}
this.stdin = this.process.process.stdin;
this.stdout = this.process.process.stdout;
this.setupListeners();
}
private setupListeners(): void {
// Read from stdout and parse JSON-RPC messages
this.stdout.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString();
this.processBuffer();
});
this.stdout.on("error", (err) => {
this.emit("error", err);
});
this.process.process.on("exit", (code) => {
this.emit("exit", code);
});
}
private processBuffer(): void {
while (true) {
// Find Content-Length header
const headerMatch = this.buffer.match(/Content-Length: (\d+)\r\n/);
if (!headerMatch?.[1]) break;
const contentLength = Number.parseInt(headerMatch[1], 10);
const headerEnd = this.buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const messageStart = headerEnd + 4;
if (this.buffer.length < messageStart + contentLength) break;
// Extract message
const messageText = this.buffer.substring(
messageStart,
messageStart + contentLength,
);
this.buffer = this.buffer.substring(messageStart + contentLength);
try {
const message = JSON.parse(messageText);
this.handleMessage(message);
} catch (error) {
this.emit("error", new Error(`Failed to parse LSP message: ${error}`));
}
}
}
private handleMessage(message: JsonRpcResponse | JsonRpcNotification): void {
// Handle responses to our requests
if ("id" in message && message.id !== undefined) {
const response = message as JsonRpcResponse;
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
if (response.error) {
pending.reject(new Error(`LSP Error: ${response.error.message}`));
} else {
pending.resolve(response.result);
}
}
return;
}
// Handle notifications from server
const notification = message as JsonRpcNotification;
if (notification.method === "textDocument/publishDiagnostics") {
const params = notification.params as {
uri: string;
diagnostics: Diagnostic[];
};
this.emit("diagnostics", params.uri, params.diagnostics);
}
this.emit("notification", notification);
}
private sendRequest<T>(method: string, params?: unknown): Promise<T> {
return new Promise((resolve, reject) => {
const id = ++this.requestId;
const request: JsonRpcRequest = {
jsonrpc: "2.0",
id,
method,
params,
};
this.pendingRequests.set(id, {
resolve: resolve as (result: unknown) => void,
reject,
});
this.sendMessage(request);
});
}
private sendNotification(method: string, params?: unknown): void {
const notification: JsonRpcNotification = {
jsonrpc: "2.0",
method,
params,
};
this.sendMessage(notification);
}
private sendMessage(message: JsonRpcRequest | JsonRpcNotification): void {
const content = JSON.stringify(message);
const header = `Content-Length: ${content.length}\r\n\r\n`;
this.stdin.write(header + content);
}
/**
* Initialize the LSP server
*/
async initialize(): Promise<InitializeResult> {
const params: InitializeParams = {
processId: process.pid,
rootUri: `file://${this.rootUri}`,
capabilities: {
textDocument: {
publishDiagnostics: {
relatedInformation: true,
versionSupport: true,
},
},
},
initializationOptions: this.process.initialization,
};
const result = await this.sendRequest<InitializeResult>(
"initialize",
params,
);
// Send initialized notification
this.sendNotification("initialized", {});
this.initialized = true;
return result;
}
/**
* Notify server that a document was opened
*/
didOpen(
uri: string,
languageId: string,
version: number,
text: string,
): void {
if (!this.initialized) return;
this.sendNotification("textDocument/didOpen", {
textDocument: {
uri,
languageId,
version,
text,
},
});
}
/**
* Notify server that a document was changed
*/
didChange(uri: string, version: number, text: string): void {
if (!this.initialized) return;
this.sendNotification("textDocument/didChange", {
textDocument: {
uri,
version,
},
contentChanges: [
{
text,
},
],
});
}
/**
* Notify server that a document was closed
*/
didClose(uri: string): void {
if (!this.initialized) return;
this.sendNotification("textDocument/didClose", {
textDocument: {
uri,
},
});
}
/**
* Shutdown the LSP server gracefully
*/
async shutdown(): Promise<void> {
if (this.initialized) {
await this.sendRequest("shutdown");
this.sendNotification("exit");
}
this.process.process.kill();
}
}

257
src/lsp/manager.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* LSP Manager - Orchestrates multiple LSP servers and maintains diagnostics
*/
import * as path from "node:path";
import { LSPClient } from "./client.js";
import type { Diagnostic, LSPServerInfo } from "./types.js";
interface ActiveServer {
client: LSPClient;
rootUri: string;
extensions: string[];
}
/**
* Global LSP Manager singleton
* Manages LSP servers and aggregates diagnostics
*/
export class LSPManager {
private static instance: LSPManager | null = null;
private servers = new Map<string, ActiveServer>();
private diagnostics = new Map<string, Diagnostic[]>();
private openDocuments = new Map<string, { version: number; uri: string }>();
private serverDefinitions: LSPServerInfo[] = [];
private enabled = false;
private constructor() {
// Private constructor for singleton
}
static getInstance(): LSPManager {
if (!LSPManager.instance) {
LSPManager.instance = new LSPManager();
}
return LSPManager.instance;
}
/**
* Initialize LSP system for a project
*/
async initialize(projectRoot: string): Promise<void> {
// Check if LSP is enabled
if (!process.env.LETTA_ENABLE_LSP) {
return;
}
this.enabled = true;
// Load server definitions
const { SERVERS } = await import("./servers/index.js");
this.serverDefinitions = SERVERS;
console.log(`[LSP] Initialized for project: ${projectRoot}`);
}
/**
* Get or start LSP server for a file
*/
private async getOrStartServer(filePath: string): Promise<LSPClient | null> {
if (!this.enabled) return null;
const ext = path.extname(filePath).toLowerCase();
// Find server definition for this file extension
const serverDef = this.serverDefinitions.find((s) =>
s.extensions.includes(ext),
);
if (!serverDef) {
return null;
}
// Check if server is already running
const existing = this.servers.get(serverDef.id);
if (existing) {
return existing.client;
}
// Start new server
try {
const { spawn } = await import("node:child_process");
const rootUri = process.cwd();
// Check if server binary is available
if (serverDef.autoInstall) {
const isAvailable = await serverDef.autoInstall.check();
if (!isAvailable) {
console.log(
`[LSP] ${serverDef.id} not found, attempting auto-install...`,
);
await serverDef.autoInstall.install();
}
}
const command = serverDef.command[0];
if (!command) {
console.error(`[LSP] ${serverDef.id} has no command configured`);
return null;
}
const proc = spawn(command, serverDef.command.slice(1), {
cwd: rootUri,
env: {
...process.env,
...serverDef.env,
},
});
const client = new LSPClient({
serverID: serverDef.id,
server: {
process: proc,
initialization: serverDef.initialization,
},
rootUri,
});
// Listen for diagnostics
client.on("diagnostics", (uri: string, diagnostics: Diagnostic[]) => {
this.updateDiagnostics(uri, diagnostics);
});
client.on("error", (error: Error) => {
console.error(`[LSP] ${serverDef.id} error:`, error);
});
client.on("exit", (code: number | null) => {
console.log(`[LSP] ${serverDef.id} exited with code ${code}`);
this.servers.delete(serverDef.id);
});
// Initialize the server
await client.initialize();
this.servers.set(serverDef.id, {
client,
rootUri,
extensions: serverDef.extensions,
});
console.log(`[LSP] Started ${serverDef.id}`);
return client;
} catch (error) {
console.error(`[LSP] Failed to start ${serverDef.id}:`, error);
return null;
}
}
/**
* Notify LSP that a file was opened or touched
*/
async touchFile(filePath: string, changed: boolean): Promise<void> {
if (!this.enabled) return;
const client = await this.getOrStartServer(filePath);
if (!client) return;
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(process.cwd(), filePath);
const uri = `file://${absolutePath}`;
const existing = this.openDocuments.get(absolutePath);
if (!existing) {
// Open document for the first time
const { promises: fs } = await import("node:fs");
const text = await fs.readFile(absolutePath, "utf-8");
const languageId = this.getLanguageId(filePath);
client.didOpen(uri, languageId, 1, text);
this.openDocuments.set(absolutePath, { version: 1, uri });
} else if (changed) {
// Document was changed
const { promises: fs } = await import("node:fs");
const text = await fs.readFile(absolutePath, "utf-8");
const newVersion = existing.version + 1;
client.didChange(uri, newVersion, text);
this.openDocuments.set(absolutePath, {
version: newVersion,
uri,
});
}
}
/**
* Update diagnostics for a file
*/
private updateDiagnostics(uri: string, diagnostics: Diagnostic[]): void {
// Convert file:// URI to absolute path
const filePath = uri.replace("file://", "");
this.diagnostics.set(filePath, diagnostics);
}
/**
* Get diagnostics for a specific file
*/
getDiagnostics(filePath?: string): Diagnostic[] {
if (!this.enabled) return [];
if (filePath) {
const absolutePath = path.isAbsolute(filePath)
? filePath
: path.resolve(process.cwd(), filePath);
return this.diagnostics.get(absolutePath) || [];
}
// Return all diagnostics
const all: Diagnostic[] = [];
for (const diagnostics of this.diagnostics.values()) {
all.push(...diagnostics);
}
return all;
}
/**
* Get language ID for a file
*/
private getLanguageId(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
const languageMap: Record<string, string> = {
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".py": "python",
".pyi": "python",
".go": "go",
".rs": "rust",
".java": "java",
".c": "c",
".cpp": "cpp",
".h": "c",
".hpp": "cpp",
};
return languageMap[ext] || "plaintext";
}
/**
* Shutdown all LSP servers
*/
async shutdown(): Promise<void> {
const promises: Promise<void>[] = [];
for (const server of this.servers.values()) {
promises.push(server.client.shutdown());
}
await Promise.all(promises);
this.servers.clear();
this.diagnostics.clear();
this.openDocuments.clear();
}
}
// Export singleton instance
export const lspManager = LSPManager.getInstance();

13
src/lsp/servers/index.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* LSP Server Registry
*/
import type { LSPServerInfo } from "../types.js";
import { PythonServer } from "./python.js";
import { TypeScriptServer } from "./typescript.js";
/**
* All available LSP servers
* Add new servers here as they are implemented
*/
export const SERVERS: LSPServerInfo[] = [TypeScriptServer, PythonServer];

64
src/lsp/servers/python.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Python LSP Server Definition
* Uses Pyright language server
*/
import type { LSPServerInfo } from "../types.js";
/**
* Python Language Server (Pyright)
* High-performance static type checker for Python
*/
export const PythonServer: LSPServerInfo = {
id: "python",
extensions: [".py", ".pyi"],
command: ["pyright-langserver", "--stdio"],
initialization: {
python: {
analysis: {
typeCheckingMode: "basic", // basic, standard, or strict
autoSearchPaths: true,
useLibraryCodeForTypes: true,
},
},
},
autoInstall: {
async check(): Promise<boolean> {
try {
const { execSync } = await import("node:child_process");
execSync("pyright-langserver --version", {
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
async install(): Promise<void> {
if (process.env.LETTA_DISABLE_LSP_DOWNLOAD) {
throw new Error(
"LSP auto-download is disabled. Please install pyright manually: npm install -g pyright",
);
}
console.log("[LSP] Installing pyright...");
const { spawn } = await import("node:child_process");
return new Promise((resolve, reject) => {
const proc = spawn("npm", ["install", "-g", "pyright"], {
stdio: "inherit",
});
proc.on("exit", (code) => {
if (code === 0) {
console.log("[LSP] Successfully installed pyright");
resolve();
} else {
reject(new Error(`npm install failed with code ${code}`));
}
});
});
},
},
};

View File

@@ -0,0 +1,68 @@
/**
* TypeScript/JavaScript LSP Server Definition
*/
import type { LSPServerInfo } from "../types.js";
/**
* TypeScript Language Server
* Uses typescript-language-server + typescript
*/
export const TypeScriptServer: LSPServerInfo = {
id: "typescript",
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
command: ["typescript-language-server", "--stdio"],
initialization: {
preferences: {
includeInlayParameterNameHints: "all",
includeInlayFunctionParameterTypeHints: true,
},
},
autoInstall: {
async check(): Promise<boolean> {
try {
const { execSync } = await import("node:child_process");
execSync("typescript-language-server --version", {
stdio: "ignore",
});
return true;
} catch {
return false;
}
},
async install(): Promise<void> {
if (process.env.LETTA_DISABLE_LSP_DOWNLOAD) {
throw new Error(
"LSP auto-download is disabled. Please install typescript-language-server manually: npm install -g typescript-language-server typescript",
);
}
console.log(
"[LSP] Installing typescript-language-server and typescript...",
);
const { spawn } = await import("node:child_process");
return new Promise((resolve, reject) => {
const proc = spawn(
"npm",
["install", "-g", "typescript-language-server", "typescript"],
{
stdio: "inherit",
},
);
proc.on("exit", (code) => {
if (code === 0) {
console.log(
"[LSP] Successfully installed typescript-language-server",
);
resolve();
} else {
reject(new Error(`npm install failed with code ${code}`));
}
});
});
},
},
};

161
src/lsp/types.ts Normal file
View File

@@ -0,0 +1,161 @@
/**
* LSP Infrastructure Types
* Based on Language Server Protocol specification
*/
import type { ChildProcess } from "node:child_process";
/**
* LSP Diagnostic severity levels
*/
export enum DiagnosticSeverity {
Error = 1,
Warning = 2,
Information = 3,
Hint = 4,
}
/**
* Position in a text document (0-based)
*/
export interface Position {
line: number;
character: number;
}
/**
* Range in a text document
*/
export interface Range {
start: Position;
end: Position;
}
/**
* LSP Diagnostic
*/
export interface Diagnostic {
range: Range;
severity?: DiagnosticSeverity;
code?: string | number;
source?: string;
message: string;
relatedInformation?: DiagnosticRelatedInformation[];
}
export interface DiagnosticRelatedInformation {
location: Location;
message: string;
}
export interface Location {
uri: string;
range: Range;
}
/**
* LSP Server process handle
*/
export interface LSPServerProcess {
process: ChildProcess;
initialization?: Record<string, unknown>;
}
/**
* LSP Server definition
*/
export interface LSPServerInfo {
id: string;
extensions: string[];
command: string[];
env?: Record<string, string>;
initialization?: Record<string, unknown>;
autoInstall?: {
check: () => Promise<boolean>;
install: () => Promise<void>;
};
}
/**
* Text document for LSP
*/
export interface TextDocument {
uri: string;
languageId: string;
version: number;
text: string;
}
/**
* JSON-RPC Request
*/
export interface JsonRpcRequest {
jsonrpc: "2.0";
id: number | string;
method: string;
params?: unknown;
}
/**
* JSON-RPC Response
*/
export interface JsonRpcResponse {
jsonrpc: "2.0";
id: number | string;
result?: unknown;
error?: {
code: number;
message: string;
data?: unknown;
};
}
/**
* JSON-RPC Notification
*/
export interface JsonRpcNotification {
jsonrpc: "2.0";
method: string;
params?: unknown;
}
/**
* LSP Initialize params
*/
export interface InitializeParams {
processId: number | null;
rootUri: string | null;
capabilities: ClientCapabilities;
initializationOptions?: unknown;
}
export interface ClientCapabilities {
textDocument?: {
publishDiagnostics?: {
relatedInformation?: boolean;
tagSupport?: { valueSet: number[] };
versionSupport?: boolean;
};
};
}
/**
* LSP Initialize result
*/
export interface InitializeResult {
capabilities: ServerCapabilities;
serverInfo?: {
name: string;
version?: string;
};
}
export interface ServerCapabilities {
textDocumentSync?: number | TextDocumentSyncOptions;
// Add more as needed
}
export interface TextDocumentSyncOptions {
openClose?: boolean;
change?: number;
}

85
src/tests/lsp.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { lspManager } from "../lsp/manager";
// Enable LSP for tests
process.env.LETTA_ENABLE_LSP = "true";
beforeAll(async () => {
// Initialize LSP for the project
await lspManager.initialize(process.cwd());
});
afterAll(async () => {
// Cleanup LSP servers
await lspManager.shutdown();
});
test("LSP Manager: initializes successfully", () => {
// Just verify it doesn't throw
expect(true).toBe(true);
});
test("LSP Manager: touchFile opens a TypeScript file", async () => {
const filePath = "./src/lsp/types.ts";
// Touch the file (should open it in LSP)
await lspManager.touchFile(filePath, false);
// Wait for LSP to process
await new Promise((resolve) => setTimeout(resolve, 200));
// File should be opened (no error thrown)
expect(true).toBe(true);
});
test("LSP Manager: getDiagnostics returns empty for valid file", async () => {
const filePath = "./src/lsp/types.ts";
// Touch the file
await lspManager.touchFile(filePath, false);
// Wait for diagnostics
await new Promise((resolve) => setTimeout(resolve, 200));
// Get diagnostics - should be empty for a valid file
const diagnostics = lspManager.getDiagnostics(filePath);
// types.ts should have no errors
expect(diagnostics.length).toBe(0);
});
test("LSP Manager: handles file changes", async () => {
const { promises: fs } = await import("node:fs");
const testFile = "./test-lsp-file.ts";
try {
// Create a valid file
await fs.writeFile(testFile, "const x: number = 42;");
// Touch file
await lspManager.touchFile(testFile, false);
await new Promise((resolve) => setTimeout(resolve, 300));
const diagnostics1 = lspManager.getDiagnostics(testFile);
expect(diagnostics1.length).toBe(0);
// Modify file with an error
await fs.writeFile(testFile, "const x: number = 'string';"); // Type error!
// Notify LSP of change
await lspManager.touchFile(testFile, true);
await new Promise((resolve) => setTimeout(resolve, 500));
// LSP should process the change (diagnostics may or may not arrive depending on timing)
// Just verify getDiagnostics doesn't crash
const diagnostics2 = lspManager.getDiagnostics(testFile);
expect(diagnostics2).toBeDefined();
} finally {
// Cleanup
try {
await fs.unlink(testFile);
} catch {
// Ignore
}
}
});

View File

@@ -0,0 +1,39 @@
# Read
Reads a file from the local filesystem. You can access any file directly by using this tool.
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
Usage:
- The file_path parameter must be an absolute path, not a relative path
- By default, it reads up to 2000 lines starting from the beginning of the file
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
- Any lines longer than 2000 characters will be truncated
- Results are returned using cat -n format, with line numbers starting at 1
- This tool can only read files, not directories. To read a directory, use the ls command via Bash.
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
## Diagnostics (TypeScript/JavaScript/Python)
When reading supported files, the Read tool automatically includes LSP diagnostics (type errors, syntax errors, etc.):
**Supported languages:**
- TypeScript/JavaScript: `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`
- Python: `.py`, `.pyi`
**Behavior:**
- **Auto-included** for files under 500 lines
- **Manually controlled** via `include_types` parameter:
- `include_types: true` - Force include diagnostics (even for large files)
- `include_types: false` - Skip diagnostics (even for small files)
When errors are found, they appear at the end of the file content:
```
This file has errors, please fix
<file_diagnostics>
ERROR [10:5] Cannot find name 'foo'
ERROR [15:3] Type 'string' is not assignable to type 'number'
</file_diagnostics>
```
This helps you catch type errors before making edits.

100
src/tools/impl/ReadLSP.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* LSP-enhanced Read tool - wraps the base Read tool and adds LSP diagnostics
* This is used when LETTA_ENABLE_LSP is set
*/
import { read as baseRead } from "./Read.js";
// Format a single diagnostic in opencode style: "ERROR [line:col] message"
function formatDiagnostic(diag: {
severity?: number;
range: { start: { line: number; character: number } };
message: string;
}): string {
const severityMap: Record<number, string> = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
};
const severity = severityMap[diag.severity || 1] || "ERROR";
const line = diag.range.start.line + 1; // Convert to 1-based
const col = diag.range.start.character + 1;
return `${severity} [${line}:${col}] ${diag.message}`;
}
interface ReadLSPArgs {
file_path: string;
offset?: number;
limit?: number;
include_types?: boolean;
}
interface ReadLSPResult {
content: string;
}
export async function read_lsp(args: ReadLSPArgs): Promise<ReadLSPResult> {
// First, call the base read function
const result = await baseRead(args);
// Skip LSP if not enabled (shouldn't happen since we only load this when enabled)
if (!process.env.LETTA_ENABLE_LSP) {
return result;
}
// Determine if we should include diagnostics
const lineCount = result.content.split("\n").length;
const shouldInclude =
args.include_types === true ||
(args.include_types !== false && lineCount < 500);
if (!shouldInclude) {
return result;
}
try {
// Import LSP manager dynamically
const { lspManager } = await import("../../lsp/manager.js");
const path = await import("node:path");
// Resolve the path
const userCwd = process.env.USER_CWD || process.cwd();
const resolvedPath = path.default.isAbsolute(args.file_path)
? args.file_path
: path.default.resolve(userCwd, args.file_path);
// Touch the file (opens it in LSP if not already open)
await lspManager.touchFile(resolvedPath, false);
// Wait briefly for diagnostics (LSP servers are async)
await new Promise((resolve) => setTimeout(resolve, 100));
// Get diagnostics
const diagnostics = lspManager.getDiagnostics(resolvedPath);
if (diagnostics.length > 0) {
// Only show errors (severity 1) like opencode does
const errors = diagnostics.filter((d) => d.severity === 1);
if (errors.length === 0) {
return result;
}
const maxDiagnostics = 10;
const displayed = errors.slice(0, maxDiagnostics);
const suffix =
errors.length > maxDiagnostics
? `\n... and ${errors.length - maxDiagnostics} more`
: "";
return {
content: `${result.content}\n\nThis file has errors, please fix\n<file_diagnostics>\n${displayed.map(formatDiagnostic).join("\n")}${suffix}\n</file_diagnostics>`,
};
}
// No errors - return as-is
return result;
} catch (_error) {
// If LSP fails, silently return the base result
return result;
}
}

View File

@@ -140,6 +140,7 @@ const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
LS: { requiresApproval: false },
MultiEdit: { requiresApproval: true },
Read: { requiresApproval: false },
ReadLSP: { requiresApproval: false },
Skill: { requiresApproval: false },
Task: { requiresApproval: true },
TodoWrite: { requiresApproval: false },
@@ -478,6 +479,22 @@ export async function loadTools(modelIdentifier?: string): Promise<void> {
);
}
}
// If LSP is enabled, swap Read with LSP-enhanced version
if (process.env.LETTA_ENABLE_LSP && toolRegistry.has("Read")) {
const lspDefinition = TOOL_DEFINITIONS.ReadLSP;
if (lspDefinition) {
// Replace Read with ReadLSP (but keep the name "Read" for the agent)
toolRegistry.set("Read", {
schema: {
name: "Read", // Keep the tool name as "Read" for the agent
description: lspDefinition.description,
input_schema: lspDefinition.schema,
},
fn: lspDefinition.impl,
});
}
}
}
export function isOpenAIModel(modelIdentifier: string): boolean {

View File

@@ -0,0 +1,24 @@
{
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The absolute path to the file to read"
},
"offset": {
"type": "number",
"description": "The line number to start reading from. Only provide if the file is too large to read at once"
},
"limit": {
"type": "number",
"description": "The number of lines to read. Only provide if the file is too large to read at once."
},
"include_types": {
"type": "boolean",
"description": "Optional: Include LSP diagnostics (type errors, syntax errors) for supported files (TypeScript, JavaScript, Python). Defaults to true for files under 500 lines."
}
},
"required": ["file_path"],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@@ -18,6 +18,7 @@ import MultiEditDescription from "./descriptions/MultiEdit.md";
import ReadDescription from "./descriptions/Read.md";
import ReadFileCodexDescription from "./descriptions/ReadFileCodex.md";
import ReadFileGeminiDescription from "./descriptions/ReadFileGemini.md";
import ReadLSPDescription from "./descriptions/ReadLSP.md";
import ReadManyFilesGeminiDescription from "./descriptions/ReadManyFilesGemini.md";
import ReplaceGeminiDescription from "./descriptions/ReplaceGemini.md";
import RunShellCommandGeminiDescription from "./descriptions/RunShellCommandGemini.md";
@@ -51,6 +52,7 @@ import { multi_edit } from "./impl/MultiEdit";
import { read } from "./impl/Read";
import { read_file } from "./impl/ReadFileCodex";
import { read_file_gemini } from "./impl/ReadFileGemini";
import { read_lsp } from "./impl/ReadLSP";
import { read_many_files } from "./impl/ReadManyFilesGemini";
import { replace } from "./impl/ReplaceGemini";
import { run_shell_command } from "./impl/RunShellCommandGemini";
@@ -84,6 +86,7 @@ import MultiEditSchema from "./schemas/MultiEdit.json";
import ReadSchema from "./schemas/Read.json";
import ReadFileCodexSchema from "./schemas/ReadFileCodex.json";
import ReadFileGeminiSchema from "./schemas/ReadFileGemini.json";
import ReadLSPSchema from "./schemas/ReadLSP.json";
import ReadManyFilesGeminiSchema from "./schemas/ReadManyFilesGemini.json";
import ReplaceGeminiSchema from "./schemas/ReplaceGemini.json";
import RunShellCommandGeminiSchema from "./schemas/RunShellCommandGemini.json";
@@ -167,6 +170,12 @@ const toolDefinitions = {
description: ReadDescription.trim(),
impl: read as unknown as ToolImplementation,
},
// LSP-enhanced Read - used when LETTA_ENABLE_LSP is set
ReadLSP: {
schema: ReadLSPSchema,
description: ReadLSPDescription.trim(),
impl: read_lsp as unknown as ToolImplementation,
},
Skill: {
schema: SkillSchema,
description: SkillDescription.trim(),