UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

372 lines (371 loc) • 18.8 kB
import { uriToPath } from "./lsp-protocol.js"; import { anchorSpelling, foldRedundantPathSegments, pathFamilyOf, resolveKey, violationDetails, violationText } from "../tools/fs/safety.js"; import { seatForCall, sensitivePathDenyList } from "../tools/fs/read-deny.js"; import { pathTargetBaseOf } from "./effective-path-target.js"; import { DEFAULT_GATE_AWAIT_TIMEOUT_MS, withGateTimeout } from "./gate-bounded-await.js"; import { pathTargetOf } from "./tool-registry.js"; import { Type } from "typebox"; import { defineTool, errorResult } from "./tools.js"; import { toolFace } from "./tool-catalog-entries.js"; import { thrownText } from "./thrown-value.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) { if (!cwd) return foldRedundantPathSegments(filePath); return foldRedundantPathSegments(anchorSpelling(pathFamilyOf({ root: cwd }), cwd, filePath)); } const LSP_PATH_TARGET = pathTargetOf("LSP"); export function lspBaseOf(readFace, env) { return pathTargetBaseOf(LSP_PATH_TARGET, readFace !== undefined ? { root: readFace.rootCanonical, cwd: readFace.cwdRef?.current } : { cwd: env?.cwd }); } function judgeLspRead(env, face, spelling, signal, admitted) { return resolveKey(env, face.rootCanonical, spelling, signal, lspBaseOf(face), face.roots, admitted, face.readDeny, face.face); } const LSP_UNBINDABLE = "this environment cannot bind a read to the file that was checked"; export function lspReadGateOf(env, face, authorizedKey) { const exempt = authorizedKey === undefined ? undefined : (key) => key === authorizedKey; const keyOf = async (filePath, signal) => { const r = await judgeLspRead(env, face, filePath, signal, exempt).catch(() => undefined); return r?.ok === true ? r.key : undefined; }; return async (filePath, signal) => { await face.ready?.(undefined, signal); const key = await keyOf(filePath, signal); if (key === undefined) return { ok: false, reason: "the read boundary refuses this file" }; if (env.openBound === undefined) return { ok: false, reason: LSP_UNBINDABLE }; let held; try { held = await env.openBound(key, signal); } catch (e) { return { ok: false, reason: thrownText(e) }; } if (!held.ok) return { ok: false, reason: held.error.message }; if ((await keyOf(filePath, signal)) !== key) { await held.value.close(); return { ok: false, reason: "it resolved to a different file while it was being checked" }; } return { ok: true, key, object: held.value }; }; } export async function lspListedWithheld(env, face, uris, signal) { const out = new Map(); if (face.readDeny === undefined || uris.length === 0) return out; await face.ready?.(undefined, signal); const distinct = [...new Set(uris)]; const unreadable = { unreadable: true }; const judgeOne = async (uri) => { const r = await resolveKey(env, face.rootCanonical, uriToPath(uri), signal, undefined, face.roots, undefined, face.readDeny, "open"); if (r.ok) return undefined; return r.violation.code === "read_path_denied" && r.violation.pattern !== undefined ? { pattern: r.violation.pattern } : unreadable; }; const batch = Promise.all(distinct.map(async (uri) => [uri, await judgeOne(uri).catch(() => unreadable)])); const judged = await withGateTimeout(batch, DEFAULT_GATE_AWAIT_TIMEOUT_MS, signal, "lspListedPaths").catch(() => undefined); for (const [uri, withholding] of judged ?? distinct.map((uri) => [uri, unreadable])) if (withholding !== undefined) out.set(uri, withholding); return out; } 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; } async function withholdDenied(result, withheldOf) { if (result.kind !== "locations" && result.kind !== "symbols") return { result, note: "" }; const entries = result.kind === "locations" ? result.locations : result.symbols; const withheld = await withheldOf(entries.map((e) => e.uri)); const patterns = []; let denied = 0; let unreadable = 0; const keeps = (uri) => { const w = withheld.get(uri); if (w === undefined) return true; if ("pattern" in w) { denied += 1; if (!patterns.includes(w.pattern)) patterns.push(w.pattern); } else unreadable += 1; return false; }; const kept = result.kind === "locations" ? { kind: "locations", locations: result.locations.filter((l) => keeps(l.uri)) } : { kind: "symbols", symbols: result.symbols.filter((s) => keeps(s.uri)) }; const n = (k) => `${k} result${k === 1 ? "" : "s"}`; const note = (denied > 0 ? `\n[note: ${n(denied)} in files matching ${sensitivePathDenyList(patterns)} ${denied === 1 ? "was" : "were"} withheld]` : "") + (unreadable > 0 ? `\n[note: ${n(unreadable)} withheld: the file${unreadable === 1 ? "" : "s"} could not be resolved to be judged against the read deny list]` : ""); return { result: kept, note }; } const factsOfFileError = (code) => (code === "not_found" ? "missing" : code === "invalid" ? "not_a_file" : undefined); async function heldTargetFacts(env, key, signal) { if (env.openBound === undefined) return undefined; const held = await env.openBound(key, signal).catch(() => undefined); if (held === undefined) return undefined; if (!held.ok) return factsOfFileError(held.error.code); try { if (held.value.kind === "directory") return "not_a_file"; const size = await held.value.size(); return size.ok ? { size: size.value } : undefined; } finally { await held.value.close(); } } async function namedTargetFacts(env, target, signal) { const info = await env.fileInfo(target, signal); if (!info.ok) return factsOfFileError(info.error.code); if (info.value.kind === "directory") return "not_a_file"; return info.value.kind === "file" ? { size: info.value.size } : undefined; } const POSITION_OPS = new Set([ "goToDefinition", "findReferences", "hover", "goToImplementation", "prepareCallHierarchy", "incomingCalls", "outgoingCalls", ]); export function createLspTool(manager, opts = {}) { const faceSeat = opts.readFace; if (faceSeat !== undefined && opts.env === undefined) throw new TypeError("createLspTool: LspToolOptions.readFace needs the env it reads through (LspToolOptions.env)"); 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) => { await faceSeat?.ready?.(ctx.toolCallId, ctx.signal); const readFace = faceSeat === undefined ? undefined : { ...faceSeat, readDeny: seatForCall(faceSeat.readDeny, ctx.toolCallId), roots: seatForCall(faceSeat.roots, ctx.toolCallId), ready: undefined }; 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, lspBaseOf(readFace, opts.env)); let gate; let judgedKey; if (readFace !== undefined && opts.env !== undefined) { const admitted = readFace.admitted; const r = await judgeLspRead(opts.env, readFace, filePath, ctx.signal, admitted && ((key) => admitted(ctx.toolCallId, key))); if (!r.ok) return errorResult(violationText("LSP", r.violation), violationDetails(r.violation)); judgedKey = r.key; gate = lspReadGateOf(opts.env, readFace, r.key); } const facts = opts.env === undefined ? undefined : judgedKey !== undefined ? await heldTargetFacts(opts.env, judgedKey, ctx.signal) : await namedTargetFacts(opts.env, target, ctx.signal); if (facts === "missing") return errorResult(`Error (LSP): File does not exist: ${filePath}`); if (facts === "not_a_file") return errorResult(`Error (LSP): Path is not a file: ${filePath}`); if (facts !== undefined && facts.size > MAX_LSP_FILE_BYTES) { return `File too large for LSP analysis (${Math.ceil(facts.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, gate); const ignored = await filterIgnored(result, opts.isPathIgnored); const env = opts.env; const kept = readFace !== undefined && env !== undefined ? await withholdDenied(ignored, (uris) => lspListedWithheld(env, readFace, uris, ctx.signal)) : { result: ignored, note: "" }; const final = toModelBase(kept.result); const text = fmt(final, { operation, filePath }) + kept.note; 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; }; }