UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

218 lines (217 loc) • 9.82 kB
import { uriToPath, buildRequest, callHierarchyMethod, parseResult, pathToUri } from "./lsp-protocol.js"; import { lspFailureOf, SharedAbortScope, settleOnAbort } from "./lsp.js"; import { thrownText } from "./thrown-value.js"; const WARMUP_MAX_ATTEMPTS = 3; export class TransportLspSession { transport; languageId; readText; log; warmup; opened = new Map(); openedUris = new Map(); syncing = new Map(); createdAt = Date.now(); warmupBarrier; warmupDone = false; warmupAttempts = 0; constructor(transport, languageId, readText, log = () => { }, warmup = { windowMs: 10_000, retryMs: 1_000 }, diagnostics) { this.transport = transport; this.languageId = languageId; this.readText = readText; this.log = log; this.warmup = warmup; if (diagnostics && transport.onNotification) { transport.onNotification((method, params) => { if (method !== "textDocument/publishDiagnostics") return; const p = params; if (!p || typeof p.uri !== "string" || !Array.isArray(p.diagnostics)) return; if (typeof p.version === "number") { const filePath = this.openedUris.get(p.uri) ?? uriToPath(p.uri); const known = this.opened.get(filePath); if (known && p.version < known.version) { this.log("lsp_diagnostics_stale_dropped", { uri: p.uri, version: p.version, current: known.version }); return; } } diagnostics.publish(p.uri, p.diagnostics); }); } } get inFlight() { return this.activeRequests; } activeRequests = 0; async request(op, params, signal) { this.activeRequests += 1; try { return await this.requestOnce(op, params, signal); } finally { this.activeRequests -= 1; } } async requestOnce(op, params, signal) { const built = buildRequest(op, params); if (!built) return { kind: "none", reason: "unsupported_operation" }; const sync = await this.syncOpenedFiles(params.filePath, signal); if (!sync.ok) { const reason = signal?.aborted ? "cancelled" : (sync.reason ?? "file_sync_failed"); this.log("lsp_op_degraded", { op, file: params.filePath, reason, err: sync.error }); return { kind: "none", reason, ...(reason === "file_sync_failed" ? { detail: sync.error } : {}) }; } if (this.warmup.windowMs > 0 && !this.warmupDone) { if (op === "goToDefinition") this.warmupDone = true; else await this.warmupBarrierOnce(params.filePath, signal); } for (let attempt = 0;; attempt++) { let raw; let noHierarchyItem = false; try { raw = await this.transport.request(built.method, built.params, signal); if (op === "incomingCalls" || op === "outgoingCalls") { const items = Array.isArray(raw) ? raw : raw != null ? [raw] : []; noHierarchyItem = items.length === 0; raw = noHierarchyItem ? null : await this.transport.request(callHierarchyMethod(op), { item: items[0] }, signal); } } catch (e) { const f = lspFailureOf(e) ?? TransportLspSession.classifyUnbranded(signal, this.transport); this.log("lsp_op_degraded", { op, file: params.filePath, reason: f.reason, err: thrownText(e) }); return { kind: "none", reason: f.reason, ...(f.detail !== undefined ? { detail: f.detail } : {}) }; } const result = noHierarchyItem ? { kind: "none", reason: "no_call_hierarchy_item" } : parseResult(op, raw, pathToUri(params.filePath)); if (result.kind === "none" && this.warmup.windowMs > 0 && Date.now() - this.createdAt < this.warmup.windowMs && !signal?.aborted) { await new Promise((r) => setTimeout(r, this.warmup.retryMs)); continue; } this.log("lsp_op", { op, file: params.filePath, ...(params.line !== undefined ? { line: params.line } : {}), ...(params.character !== undefined ? { character: params.character } : {}), result: result.kind, n: result.kind === "locations" ? result.locations.length : result.kind === "symbols" ? result.symbols.length : undefined, rawNull: raw == null, ...(attempt > 0 ? { warmupRetries: attempt } : {}), }); return result; } } async warmupBarrierOnce(filePath, signal) { let entry = this.warmupBarrier; if (entry && entry.scope.signal.aborted) entry = undefined; if (!entry) { if (this.warmupAttempts >= WARMUP_MAX_ATTEMPTS) return; this.warmupAttempts += 1; const scope = new SharedAbortScope(); const started = {}; const job = this.transport .request("textDocument/definition", { textDocument: { uri: pathToUri(filePath) }, position: { line: 0, character: 0 } }, scope.signal) .then(() => { this.warmupDone = true; }) .catch(() => { if (this.warmupBarrier === started.self) this.warmupBarrier = undefined; }); entry = { job, scope }; started.self = entry; this.warmupBarrier = entry; } entry.scope.join(signal); await settleOnAbort(entry.job, signal, () => undefined); } async syncOpenedFiles(queried, signal) { const files = new Set(this.opened.keys()); files.add(queried); const outcomes = await Promise.all([...files].map(async (fp) => [fp, await this.syncOne(fp, signal)])); return outcomes.find(([fp]) => fp === queried)?.[1] ?? { ok: true }; } syncOne(filePath, signal) { const inflight = this.syncing.get(filePath); if (inflight && !inflight.scope.signal.aborted) { inflight.scope.join(signal); return settleOnAbort(inflight.job, signal, () => ({ ok: false, error: "aborted by the caller" })); } const scope = new SharedAbortScope(); scope.join(signal); const entry = { scope, job: (async () => { let text; try { text = await this.readText(filePath, scope.signal); } catch (e) { const error = thrownText(e); this.log("lsp_sync_skipped", { file: filePath, error, opened: this.opened.has(filePath) }); return { ok: false, error }; } const cur = this.opened.get(filePath); const uri = pathToUri(filePath); if (!cur) { this.transport.notify("textDocument/didOpen", { textDocument: { uri, languageId: this.languageId, version: 1, text }, }); if (this.transport.closed === true) { this.log("lsp_sync_notify_dropped", { file: filePath, kind: "didOpen" }); return { ok: false, error: "didOpen was not delivered (transport closed)", reason: "server_terminated" }; } this.opened.set(filePath, { version: 1, text }); this.openedUris.set(uri, filePath); } else if (cur.text !== text) { const version = cur.version + 1; this.transport.notify("textDocument/didChange", { textDocument: { uri, version }, contentChanges: [{ text }] }); if (this.transport.closed === true) { this.log("lsp_sync_notify_dropped", { file: filePath, kind: "didChange", version }); return { ok: false, error: "didChange was not delivered (transport closed)", reason: "server_terminated" }; } this.opened.set(filePath, { version, text }); this.log("lsp_file_resync", { file: filePath, version }); } return { ok: true }; })(), }; this.syncing.set(filePath, entry); const settled = entry.job.finally(() => { if (this.syncing.get(filePath) === entry) this.syncing.delete(filePath); }); return settleOnAbort(settled, signal, () => ({ ok: false, error: "aborted by the caller" })); } static classifyUnbranded(signal, transport) { if (signal?.aborted) return { reason: "cancelled" }; if (transport.closed === true) return { reason: "server_terminated" }; return { reason: "server_error" }; } get closed() { return this.transport.closed === true; } openedFiles() { return [...this.opened.keys()]; } async warmOpen(filePaths, signal) { const outcomes = await Promise.all(filePaths.map((fp) => this.syncOne(fp, signal))); const failed = outcomes.filter((o) => !o.ok).length; return { opened: outcomes.length - failed, failed }; } notifyFileChanged(filePath, signal) { return this.syncOne(filePath, signal).then(() => undefined); } close() { return this.transport.close(); } }