UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

244 lines (243 loc) • 12.2 kB
import { uriToPath } from "./lsp-protocol.js"; import { foldRedundantPathSegments, isAbsoluteForFamily, joinForFamily, pathFamilyOf } from "../tools/fs/safety.js"; import { Type } from "typebox"; import { defineTool, errorResult } from "./tools.js"; import { toolFace } from "./tool-catalog-entries.js"; export const LSP_OPERATIONS = [ "goToDefinition", "findReferences", "hover", "documentSymbol", "workspaceSymbol", "goToImplementation", "prepareCallHierarchy", "incomingCalls", "outgoingCalls", ]; export const LSP_FAILURE_BRAND = "lspFailureReason"; const LSP_NONE_REASONS = new Set([ "unsupported_operation", "server_error", "timeout", "cancelled", "server_terminated", "no_call_hierarchy_item", "file_sync_failed", ]); export function brandLspFailure(e, reason, detail) { const carrier = e; carrier[LSP_FAILURE_BRAND] = reason; if (detail !== undefined) carrier.lspFailureDetail = detail; return e; } export function lspFailureOf(e) { const r = e?.[LSP_FAILURE_BRAND]; if (typeof r !== "string" || !LSP_NONE_REASONS.has(r)) return undefined; const d = e.lspFailureDetail; return { reason: r, ...(typeof d === "string" ? { detail: d } : {}) }; } export class SharedAbortScope { controller = new AbortController(); joiners = 0; abandoned = 0; get signal() { return this.controller.signal; } join(signal) { this.joiners += 1; if (!signal) return; if (signal.aborted) { this.abandon(); return; } signal.addEventListener("abort", () => this.abandon(), { once: true }); } abandon() { this.abandoned += 1; if (this.abandoned >= this.joiners) this.controller.abort(); } } export function settleOnAbort(job, signal, onAbort) { if (!signal) return job; if (signal.aborted) { void job.catch(() => undefined); return Promise.resolve(onAbort()); } return new Promise((resolve, reject) => { const onAbortEvent = () => resolve(onAbort()); signal.addEventListener("abort", onAbortEvent, { once: true }); job.then((v) => { signal.removeEventListener("abort", onAbortEvent); resolve(v); }, (e) => { signal.removeEventListener("abort", onAbortEvent); reject(e); }); }); } export function resolveLspPath(filePath, cwd) { const family = cwd === undefined ? undefined : pathFamilyOf({ root: cwd }); const isAbsForm = family !== undefined && isAbsoluteForFamily(family, filePath); if (isAbsForm || !cwd || family === undefined) return foldRedundantPathSegments(filePath); return foldRedundantPathSegments(joinForFamily(family, cwd, filePath)); } export const MAX_LSP_FILE_BYTES = 10 * 1000 * 1000; function fmt(result, ctx) { switch (result.kind) { case "hover": return result.contents.trim() || "(no hover information)"; case "locations": return result.locations.length === 0 ? "No locations found." : `Found ${result.locations.length} location(s):\n` + result.locations.map((l) => `- ${l.uri}:${l.line}:${l.character}${l.preview ? ` ${l.preview.trim()}` : ""}`).join("\n"); case "symbols": return result.symbols.length === 0 ? "No symbols found." : `Found ${result.symbols.length} symbol(s):\n` + result.symbols .map((s) => `- ${s.name}${s.kind ? ` (${s.kind})` : ""} — ${s.uri}${s.positionUnknown ? " (no position reported by the language server)" : `:${s.line}:${s.character}`}`) .join("\n"); case "none": return fmtNone(result, ctx); } } function fmtNone(result, ctx) { const { operation, filePath } = ctx; const detail = result.detail; switch (result.reason) { case undefined: return "No results."; case "no_call_hierarchy_item": return "No call hierarchy item found at this position"; case "unsupported_operation": return `The language server for "${filePath}" does not support ${operation}${detail ? ` (${detail})` : ""}. Fall back to Grep/Read for this lookup.`; case "timeout": return `Error performing ${operation}: the language server did not respond in time. This is NOT an empty result — do not conclude the symbol has no ${operation === "findReferences" ? "references" : "results"}; retry or fall back to Grep/Read.`; case "server_terminated": return `Error performing ${operation}: the language server exited before answering. This is NOT an empty result — retry (a fresh server is spawned on the next call) or fall back to Grep/Read.`; case "cancelled": return `Error performing ${operation}: the request was cancelled before the language server answered. This is NOT an empty result.`; case "server_error": return `Error performing ${operation}: ${detail ?? "the language server returned an error"}. This is NOT an empty result — fall back to Grep/Read.`; case "file_sync_failed": return `Error performing ${operation}: "${filePath}" could not be read to synchronize it with the language server${detail ? ` (${detail})` : ""}. This is NOT an empty result — the server was never given the file's current text; re-check the path or fall back to Grep/Read.`; } } const LSP_ERROR_REASONS = new Set([ "server_error", "timeout", "cancelled", "server_terminated", "file_sync_failed", ]); async function filterIgnored(result, isIgnored) { if (!isIgnored) return result; if (result.kind === "locations") { const keep = []; for (const l of result.locations) if (!(await isIgnored(l.uri))) keep.push(l); return { kind: "locations", locations: keep }; } if (result.kind === "symbols") { const keep = []; for (const s of result.symbols) if (!(await isIgnored(s.uri))) keep.push(s); return { kind: "symbols", symbols: keep }; } return result; } const POSITION_OPS = new Set([ "goToDefinition", "findReferences", "hover", "goToImplementation", "prepareCallHierarchy", "incomingCalls", "outgoingCalls", ]); export function createLspTool(manager, opts = {}) { return defineTool({ name: "LSP", ...toolFace("lsp"), description: "Interact with Language Server Protocol (LSP) servers to get code intelligence features.\n" + "\n" + "Supported operations:\n" + "- goToDefinition: Find where a symbol is defined\n" + "- findReferences: Find all references to a symbol\n" + "- hover: Get hover information (documentation, type info) for a symbol\n" + "- documentSymbol: Get all symbols (functions, classes, variables) in a document\n" + "- workspaceSymbol: Search for symbols across the entire workspace (uses `query`, not a position)\n" + "- goToImplementation: Find implementations of an interface or abstract method\n" + "- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)\n" + "- incomingCalls: Find all functions/methods that call the function at a position\n" + "- outgoingCalls: Find all functions/methods called by the function at a position\n" + "\n" + "Position-based operations require:\n" + "- filePath: The file to operate on\n" + "- line: The line number (1-based, as shown in editors)\n" + "- character: The character offset (1-based, as shown in editors)\n" + "\n" + "If no language server is available for the file, you'll be told to fall back to Grep/Read.", parameters: Type.Object({ operation: Type.Union(LSP_OPERATIONS.map((o) => Type.Literal(o)), { description: "The LSP operation." }), filePath: Type.String({ description: "The file to operate on." }), line: Type.Optional(Type.Number({ description: "The line number (1-based, as shown in editors)." })), character: Type.Optional(Type.Number({ description: "The character offset (1-based, as shown in editors)." })), query: Type.Optional(Type.String({ description: "Symbol query for workspaceSymbol (may be empty to browse broadly)." })), }), execute: async (args, ctx) => { const { operation, filePath, line, character, query } = args; if (POSITION_OPS.has(operation) && (line === undefined || character === undefined)) { return errorResult(`Error (LSP): the "${operation}" operation needs both line and character.`); } if (POSITION_OPS.has(operation) && (!Number.isInteger(line) || line < 1 || !Number.isInteger(character) || character < 1)) { return errorResult(`Error (LSP): line and character are 1-based integers (as shown in editors); got line=${line}, character=${character}.`); } const wsQuery = query; if (operation === "workspaceSymbol" && wsQuery === undefined) { return errorResult(`Error (LSP): the "workspaceSymbol" operation needs a query.`); } const target = resolveLspPath(filePath, opts.env?.cwd); if (opts.env) { const info = await opts.env.fileInfo(target, ctx.signal); if (!info.ok && info.error.code === "not_found") return errorResult(`Error (LSP): File does not exist: ${filePath}`); if (!info.ok && info.error.code === "invalid") return errorResult(`Error (LSP): Path is not a file: ${filePath}`); if (info.ok && info.value.kind === "directory") return errorResult(`Error (LSP): Path is not a file: ${filePath}`); if (info.ok && info.value.kind === "file" && info.value.size > MAX_LSP_FILE_BYTES) { return `File too large for LSP analysis (${Math.ceil(info.value.size / 1e6)}MB exceeds 10MB limit)`; } } const session = await manager.sessionFor(target, ctx.signal, opts.env); if (!session) { const why = manager.unavailableReason?.(target, opts.env) ?? "no language server for its language in this environment"; return `LSP is unavailable for "${filePath}" (${why}). Fall back to Grep/Read for this lookup.`; } const seamCharacter = character !== undefined ? character - 1 : undefined; const result = await session.request(operation, { filePath: target, line, character: seamCharacter, symbol: wsQuery }, ctx.signal); const final = toModelBase(await filterIgnored(result, opts.isPathIgnored)); const text = fmt(final, { operation, filePath }); return final.kind === "none" && final.reason !== undefined && LSP_ERROR_REASONS.has(final.reason) ? errorResult(text) : text; }, }); } function toModelBase(result) { if (result.kind === "locations") { return { ...result, locations: result.locations.map((l) => ({ ...l, character: l.character + 1 })) }; } if (result.kind === "symbols") { return { ...result, symbols: result.symbols.map((s) => ({ ...s, character: s.character + 1 })) }; } return result; } export function gitCheckIgnoreFilter(env, root) { const cache = new Map(); return async (uri) => { const path = uriToPath(uri); const hit = cache.get(path); if (hit !== undefined) return hit; let ignored = false; try { const q = `'${path.replace(/'/g, `'\\''`)}'`; const res = await env.exec(`git check-ignore ${q}`, { cwd: root, timeout: 10 }); ignored = res.ok && res.value.exitCode === 0; } catch { ignored = false; } cache.set(path, ignored); return ignored; }; }