summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
committerAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
commit062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch)
tree6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/core/src/tools
parentb3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff)
downloaddispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.tar.gz
dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.zip
feat(lsp): add config-driven LSP support (Roblox Luau via luau-lsp)
Add Language Server Protocol integration modeled on opencode's, wired for this codebase's plain-TypeScript tool/agent architecture. Core (@dispatch/core): - lsp/client.ts: LSP/JSON-RPC client over stdio (vscode-jsonrpc) with the initialize handshake, didOpen/didChange sync, push + pull diagnostics (textDocument/diagnostic, workspace/diagnostic), and a generic request() passthrough for hover/definition/references/documentSymbol. - lsp/server.ts: resolves dispatch.toml [lsp] entries into spawn specs. Config-driven only — no builtin registry, no auto-download. - lsp/manager.ts: process-wide LspManager owning client lifecycles, keyed by root+serverID, lazy spawn + reuse + graceful shutdown. - lsp/language.ts: extension->languageId map incl. .luau -> "luau". - lsp/diagnostic.ts: error-only <diagnostics> block formatting (1-based). - tools/lsp.ts: on-demand 'lsp' tool (1-based coords -> 0-based wire). - write-file.ts: optional onAfterWrite hook for diagnostics-on-write. - config schema: validate [lsp] block; DispatchConfig.lsp + LspServerConfig. API (@dispatch/api): - AgentManager owns one LspManager; per-working-directory server cache cleared on config reload; diagnostics appended to write_file results; 'lsp' tool gated by new perm_lsp setting; shutdownAll on destroy(). Config: - dispatch.toml: documented, commented [lsp.luau-lsp] Roblox example. Tests: fake-lsp-server fixture + client/manager/server/diagnostic/schema/ tool/write-hook suites, plus an opt-in real-binary luau-lsp smoke test (auto-skipped when luau-lsp is absent). 652 pass; biome + 3 typechecks green.
Diffstat (limited to 'packages/core/src/tools')
-rw-r--r--packages/core/src/tools/lsp.ts135
-rw-r--r--packages/core/src/tools/write-file.ts30
2 files changed, 163 insertions, 2 deletions
diff --git a/packages/core/src/tools/lsp.ts b/packages/core/src/tools/lsp.ts
new file mode 100644
index 0000000..3842cd4
--- /dev/null
+++ b/packages/core/src/tools/lsp.ts
@@ -0,0 +1,135 @@
+import { isAbsolute, resolve } from "node:path";
+import { pathToFileURL } from "node:url";
+import { z } from "zod";
+import { report as reportDiagnostics } from "../lsp/diagnostic.js";
+import type { LspManager } from "../lsp/manager.js";
+import type { ResolvedLspServer } from "../lsp/server.js";
+import type { ToolDefinition } from "../types/index.js";
+
+const OPERATIONS = ["diagnostics", "hover", "definition", "references", "documentSymbol"] as const;
+type Operation = (typeof OPERATIONS)[number];
+
+/**
+ * Context the LSP tool needs from the host: the live manager, the tab's
+ * effective working directory (used as the LSP `root`), and the servers
+ * resolved from that directory's `dispatch.toml`.
+ */
+export interface LspToolContext {
+ manager: LspManager;
+ workingDirectory: string;
+ servers: ResolvedLspServer[];
+}
+
+/**
+ * On-demand LSP query tool. Exposes diagnostics plus the navigation
+ * capabilities (hover/definition/references/documentSymbol) for a file at a
+ * position. Gated behind `perm_lsp` by the host.
+ *
+ * Coordinates are **1-based** in this tool's API (editor-style, matching what
+ * `read_file` shows); they are converted to the LSP wire's 0-based positions
+ * before the request.
+ */
+export function createLspTool(getContext: () => LspToolContext): ToolDefinition {
+ return {
+ name: "lsp",
+ description:
+ "Query the configured Language Server (e.g. luau-lsp for Roblox Luau) about a file. " +
+ "Operations: 'diagnostics' (type/lint errors for a file), 'hover' (type/docs at a position), " +
+ "'definition' (where a symbol is defined), 'references' (all uses of a symbol), " +
+ "'documentSymbol' (outline of a file). Line and character are 1-based (as shown in editors). " +
+ "Returns JSON. Requires an [lsp] server configured in dispatch.toml that matches the file's extension.",
+ parameters: z.object({
+ operation: z.enum(OPERATIONS).describe("The LSP operation to perform"),
+ path: z.string().describe("Path to the file, relative to the working directory"),
+ line: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ "Line number, 1-based (as shown in editors). Required for hover/definition/references.",
+ ),
+ character: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ "Character/column, 1-based (as shown in editors). Required for hover/definition/references.",
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const { manager, workingDirectory, servers } = getContext();
+ const operation = args.operation as Operation;
+ const pathArg = typeof args.path === "string" ? args.path : "";
+ if (!pathArg) return "Error: 'path' is required.";
+
+ const file = isAbsolute(pathArg) ? pathArg : resolve(workingDirectory, pathArg);
+
+ if (servers.length === 0) {
+ return "Error: no LSP servers are configured. Add an [lsp] entry to dispatch.toml.";
+ }
+ if (!manager.hasServerForFile(file, servers)) {
+ return `Error: no configured LSP server matches "${pathArg}" (check the server's extensions in dispatch.toml).`;
+ }
+
+ // Sync the file so the server has current content, then act.
+ await manager.touchFile({ file, root: workingDirectory, servers, mode: "document" });
+
+ if (operation === "diagnostics") {
+ const all = manager.getDiagnostics({ root: workingDirectory, servers, file });
+ const block = reportDiagnostics(file, all[file] ?? []);
+ return block || `No errors reported for ${pathArg}.`;
+ }
+
+ if (operation === "documentSymbol") {
+ const uri = pathToFileURL(file).href;
+ const results = await manager.request({
+ file,
+ root: workingDirectory,
+ servers,
+ method: "textDocument/documentSymbol",
+ params: { textDocument: { uri } },
+ });
+ const flat = results.flat().filter(Boolean);
+ return flat.length === 0
+ ? `No symbols found in ${pathArg}.`
+ : JSON.stringify(flat, null, 2);
+ }
+
+ // Positional operations need line + character.
+ const line = typeof args.line === "number" ? Math.floor(args.line) : undefined;
+ const character = typeof args.character === "number" ? Math.floor(args.character) : undefined;
+ if (line === undefined || character === undefined) {
+ return `Error: '${operation}' requires both 'line' and 'character' (1-based).`;
+ }
+
+ const uri = pathToFileURL(file).href;
+ // Convert editor 1-based → LSP wire 0-based.
+ const position = { line: line - 1, character: character - 1 };
+ const method =
+ operation === "hover"
+ ? "textDocument/hover"
+ : operation === "definition"
+ ? "textDocument/definition"
+ : "textDocument/references";
+ const params: Record<string, unknown> = {
+ textDocument: { uri },
+ position,
+ ...(operation === "references" ? { context: { includeDeclaration: true } } : {}),
+ };
+
+ const results = await manager.request({
+ file,
+ root: workingDirectory,
+ servers,
+ method,
+ params,
+ });
+ const flat = results.flat().filter(Boolean);
+ return flat.length === 0
+ ? `No results found for ${operation} at ${pathArg}:${line}:${character}.`
+ : JSON.stringify(flat, null, 2);
+ },
+ };
+}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
index aa69c86..8a73352 100644
--- a/packages/core/src/tools/write-file.ts
+++ b/packages/core/src/tools/write-file.ts
@@ -4,7 +4,21 @@ import { z } from "zod";
import type { ToolDefinition } from "../types/index.js";
import { canonicalize } from "./path-utils.js";
-export function createWriteFileTool(workingDirectory: string): ToolDefinition {
+/**
+ * Optional hook invoked AFTER a successful write, with the canonicalized
+ * absolute path of the file just written. Its returned string (when non-empty)
+ * is appended to the tool result. This is how LSP diagnostics are surfaced
+ * back to the model on write without coupling `@dispatch/core`'s tools to the
+ * API layer or the LSP manager — the host wires an implementation that touches
+ * the file through the LSP and formats any diagnostics. Errors thrown here are
+ * swallowed so a flaky LSP never fails the write itself.
+ */
+export type AfterWriteHook = (absolutePath: string) => Promise<string>;
+
+export function createWriteFileTool(
+ workingDirectory: string,
+ onAfterWrite?: AfterWriteHook,
+): ToolDefinition {
return {
name: "write_file",
description: "Write content to a file relative to the working directory.",
@@ -31,10 +45,22 @@ export function createWriteFileTool(workingDirectory: string): ToolDefinition {
try {
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content, "utf8");
- return `Successfully wrote to "${filePath}".`;
} catch (err) {
return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
}
+
+ let result = `Successfully wrote to "${filePath}".`;
+ // Post-write hook (e.g. LSP diagnostics). Best-effort: never let a
+ // hook failure turn a successful write into an error.
+ if (onAfterWrite) {
+ try {
+ const extra = await onAfterWrite(absolutePath);
+ if (extra) result += `\n\n${extra}`;
+ } catch {
+ /* ignore — diagnostics are advisory */
+ }
+ }
+ return result;
},
};
}