UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

386 lines (385 loc) • 15.2 kB
import { spawn } from "node:child_process"; import { killProcessTree } from "../execution-env/kill-tree.js"; import { open, readFile, stat } from "node:fs/promises"; import { basename, dirname, extname, resolve as resolvePath } from "node:path"; import { constants as fsConstants, existsSync } from "node:fs"; import { pathToUri } from "../../core/lsp-protocol.js"; import { LspDiagnosticsRegistry } from "../../core/lsp-diagnostics.js"; import { TransportLspSession } from "../../core/lsp-session.js"; import { StdioLspTransport } from "./stdio-lsp-transport.js"; import { MAX_LSP_FILE_BYTES, SharedAbortScope, settleOnAbort } from "../../core/lsp.js"; import { asThrownError, thrownText } from "../../core/thrown-value.js"; import { announceSecretEnvScrub, inheritedHostEnv } from "../../core/secret-env.js"; export const DEFAULT_LSP_SERVERS = { typescript: "typescript-language-server --stdio", javascript: "typescript-language-server --stdio", python: "pyright-langserver --stdio", go: "gopls", rust: "rust-analyzer", dart: "dart language-server", kotlin: "kotlin-language-server", java: "jdtls", swift: "sourcekit-lsp", c: "clangd", cpp: "clangd", }; const EXT_LANG = { ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript", ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust", ".dart": "dart", ".kt": "kotlin", ".kts": "kotlin", ".java": "java", ".swift": "swift", ".c": "c", ".h": "c", ".cc": "cpp", ".cpp": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hh": "cpp", ".hxx": "cpp", }; const ROOT_MARKERS = [ "package.json", "tsconfig.json", "jsconfig.json", "go.mod", "Cargo.toml", "pubspec.yaml", "build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts", "pom.xml", "Package.swift", "pyproject.toml", "setup.py", "requirements.txt", "compile_commands.json", ".git", ]; const CLIENT_CAPABILITIES = { textDocument: { definition: {}, references: {}, hover: {}, documentSymbol: {}, implementation: {}, callHierarchy: {}, publishDiagnostics: { versionSupport: true }, }, workspace: { symbol: {}, workspaceFolders: false }, }; const INIT_FAILURE_LATCH = 2; export class NodeLspManager { servers; spawnFn; readTextFn; resolveRootFn; log; warmup; diagnostics; maxSessions; idleTtlMs; sweepTimer; cache = new Map(); opening = new Map(); failed = new Map(); initFailures = new Map(); disposed = false; constructor(opts = {}) { this.servers = { ...DEFAULT_LSP_SERVERS, ...opts.servers }; this.spawnFn = opts.spawn ?? lspSpawnWithEnv(opts.env, opts.onNotice); this.readTextFn = opts.readText ?? defaultLspReadText; this.resolveRootFn = opts.resolveRoot ?? defaultResolveRoot; this.log = opts.log ?? (() => { }); this.warmup = opts.warmup; this.diagnostics = opts.diagnostics ?? new LspDiagnosticsRegistry(); this.maxSessions = Math.max(1, opts.maxSessions ?? 16); this.idleTtlMs = Math.max(0, opts.idleTtlMs ?? 600_000); if (this.idleTtlMs > 0) { this.sweepTimer = setInterval(() => void this.sweepIdle(), Math.min(this.idleTtlMs, 60_000)); this.sweepTimer.unref?.(); } } sweepIdle() { const cutoff = Date.now() - this.idleTtlMs; for (const [key, e] of [...this.cache]) { if (e.session.inFlight > 0) { e.lastUsed = Date.now(); continue; } if (e.lastUsed < cutoff) { this.cache.delete(key); this.log("lsp_evict_idle", { key }); void e.session.close().catch(() => undefined); } } } async sessionFor(filePath, signal, env) { const language = languageFor(filePath); if (!language || !this.serverCmd(language)) return undefined; const root = this.resolveRootFn(filePath, env); const key = `${language} ${root}`; const cached = this.cache.get(key); if (cached && !cached.session.closed) { cached.lastUsed = Date.now(); return cached.session; } const neg = this.failed.get(key); if (neg !== undefined) { neg.attempts += 1; return undefined; } if (cached) { this.cache.delete(key); const carry = cached.session.openedFiles(); this.log("lsp_heal", { language, root, carried: carry.length }); return this.open(key, language, root, signal, env, carry); } return this.open(key, language, root, signal, env); } open(key, language, root, signal, env, carry = []) { const inflight = this.opening.get(key); if (inflight && !inflight.scope.signal.aborted) { inflight.scope.join(signal); return settleOnAbort(inflight.job, signal, () => undefined); } const scope = new SharedAbortScope(); scope.join(signal); const entry = { root, cancelled: false, scope, job: undefined }; entry.job = this.openSession(language, root, scope.signal, env) .then(async (session) => { if (!session) return undefined; if (this.disposed || entry.cancelled) { await session.close().catch(() => undefined); return undefined; } if (carry.length) { const carried = await session.warmOpen(carry, scope.signal).catch(() => ({ opened: 0, failed: carry.length })); this.log("lsp_heal_carried", { language, root, ...carried }); if (this.disposed || entry.cancelled) { await session.close().catch(() => undefined); return undefined; } } this.evictOverCap(); this.cache.set(key, { session, root, lastUsed: Date.now() }); return session; }) .finally(() => { if (this.opening.get(key) === entry) this.opening.delete(key); }); this.opening.set(key, entry); return settleOnAbort(entry.job, signal, () => undefined); } evictOverCap() { while (this.cache.size >= this.maxSessions) { let lruKey; let lruTime = Infinity; for (const [k, e] of this.cache) { if (e.session.inFlight > 0) continue; if (e.lastUsed < lruTime) { lruTime = e.lastUsed; lruKey = k; } } if (lruKey === undefined) { this.log("lsp_evict_deferred", { size: this.cache.size, maxSessions: this.maxSessions }); break; } const evicted = this.cache.get(lruKey); this.cache.delete(lruKey); this.log("lsp_evict_lru", { key: lruKey }); void evicted.session.close().catch(() => undefined); } } async evict(root) { const closing = []; this.clearFailed(root); for (const [key, e] of [...this.cache]) { if (e.root === root) { this.cache.delete(key); this.log("lsp_evict", { root }); closing.push(e.session.close().catch(() => undefined)); } } for (const o of this.opening.values()) { if (o.root === root) { o.cancelled = true; closing.push(o.job.then(() => undefined).catch(() => undefined)); } } await Promise.all(closing); } clearFailed(root) { for (const [key, e] of [...this.failed]) { if (root !== undefined && e.root !== root) continue; this.failed.delete(key); this.initFailures.delete(key); this.log("lsp_negative_cache_cleared", { key, attempts: e.attempts, kind: e.kind }); } for (const [key, _n] of [...this.initFailures]) { if (root === undefined || key.endsWith(` ${root}`)) this.initFailures.delete(key); } } unavailableReason(filePath, env) { const language = languageFor(filePath); if (!language) return undefined; const cmd = this.serverCmd(language); if (!cmd) return `no ${language} language server is configured in this environment`; const e = this.failed.get(`${language} ${this.resolveRootFn(filePath, env)}`); if (e?.kind === "missing_binary") return `the ${language} language server ("${e.command}") is not installed on this host`; if (e?.kind === "initialize") return `the ${language} language server ("${e.command}") is installed but failed to initialize`; return undefined; } async openSession(language, root, signal, _env) { const cmd = this.serverCmd(language); if (!cmd) return undefined; const tokens = (cmd.match(/"[^"]*"|\S+/g) ?? []).map((t) => (t.startsWith('"') && t.endsWith('"') && t.length >= 2 ? t.slice(1, -1) : t)); const [command, ...args] = tokens.length > 0 ? tokens : [""]; const key = `${language} ${root}`; let child; try { child = await this.spawnFn(command, args, root, signal); } catch (e) { this.log("lsp_spawn_error", { language, root, command, err: thrownText(e) }); return undefined; } if (!child) { if (signal?.aborted !== true) this.failed.set(key, { root, attempts: 1, kind: "missing_binary", command, language }); this.log("lsp_spawn_degraded", { language, root, command }); return undefined; } const transport = new StdioLspTransport(child); try { const rootUri = pathToUri(root); await transport.request("initialize", { processId: null, rootPath: root, rootUri, workspaceFolders: [{ uri: rootUri, name: basename(root) || root }], capabilities: CLIENT_CAPABILITIES, }, signal); transport.notify("initialized", {}); } catch (e) { await transport.close(); const attempt = (this.initFailures.get(key) ?? 0) + 1; const latched = attempt >= INIT_FAILURE_LATCH && signal?.aborted !== true; if (latched) { this.failed.set(key, { root, attempts: 1, kind: "initialize", command, language }); this.initFailures.delete(key); } else this.initFailures.set(key, attempt); this.log("lsp_init_degraded", { language, root, err: thrownText(e), attempt, latched }); return undefined; } this.initFailures.delete(key); this.log("lsp_open", { language, root }); return new TransportLspSession(transport, language, this.readTextFn, this.log, this.warmup, this.diagnostics); } serverCmd(language) { const cmd = this.servers[language]; return cmd ? cmd : undefined; } async dispose() { this.disposed = true; if (this.sweepTimer) clearInterval(this.sweepTimer); const opening = [...this.opening.values()].map((o) => o.job.catch(() => undefined)); const sessions = [...this.cache.values()].map((e) => e.session); this.cache.clear(); await Promise.all([...sessions.map((s) => s.close().catch(() => undefined)), ...opening]); } } export function languageFor(filePath) { return EXT_LANG[extname(filePath).toLowerCase()]; } function defaultResolveRoot(filePath, env) { if (env?.cwd) return env.cwd; let dir = dirname(resolvePath(filePath)); for (;;) { for (const m of ROOT_MARKERS) if (existsSync(resolvePath(dir, m))) return dir; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return dirname(resolvePath(filePath)); } export { MAX_LSP_FILE_BYTES } from "../../core/lsp.js"; export const defaultLspReadText = async (fp, signal) => { const st = await stat(fp); if (!st.isFile()) throw new Error(`not a regular file: ${fp}`); if (st.size > MAX_LSP_FILE_BYTES) throw new Error(`file too large for LSP analysis (${st.size} bytes exceeds ${MAX_LSP_FILE_BYTES})`); const flags = fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0); const fh = await open(fp, flags); try { const fst = await fh.stat(); if (!fst.isFile()) throw new Error(`not a regular file: ${fp}`); if (fst.size > MAX_LSP_FILE_BYTES) throw new Error(`file too large for LSP analysis (${fst.size} bytes exceeds ${MAX_LSP_FILE_BYTES})`); return await readFile(fh, { encoding: "utf8", signal }); } finally { await fh.close().catch(() => undefined); } }; export function lspSpawnOptions(cwd) { return process.platform === "win32" ? { cwd, stdio: ["pipe", "pipe", "ignore"], shell: true } : { cwd, stdio: ["pipe", "pipe", "ignore"] }; } export const defaultLspSpawn = (command, args, cwd, signal) => lspSpawnWithEnv(undefined)(command, args, cwd, signal); const lspSpawnWithEnv = (env, onNotice) => (command, args, cwd, signal) => new Promise((resolve, reject) => { let child; try { let scrubbedEnv = env; if (scrubbedEnv == null) { const findings = []; scrubbedEnv = inheritedHostEnv("scrub", findings); announceSecretEnvScrub(onNotice, findings); } child = spawn(command, args, { ...lspSpawnOptions(cwd), env: scrubbedEnv }); } catch (e) { return reject(asThrownError(e)); } child.on("error", () => { }); const onSpawn = () => { cleanup(); resolve(child); }; const onError = (err) => { cleanup(); if (err?.code === "ENOENT") resolve(undefined); else reject(asThrownError(err)); }; const onAbort = () => { cleanup(); try { if (child.pid === undefined) return resolve(undefined); if (process.platform === "win32") killProcessTree(child.pid, { force: true }); else child.kill(); } catch { } resolve(undefined); }; const cleanup = () => { child.removeListener("spawn", onSpawn); child.removeListener("error", onError); signal?.removeEventListener("abort", onAbort); }; child.once("spawn", onSpawn); child.once("error", onError); if (signal) { if (signal.aborted) return onAbort(); signal.addEventListener("abort", onAbort, { once: true }); } });