@sema-agent/core
Version:
Stateless, task-oriented AI agent core
372 lines (371 loc) • 18.8 kB
JavaScript
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;
};
}