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