diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
| commit | 062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch) | |
| tree | 6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/core/src/tools | |
| parent | b3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff) | |
| download | dispatch-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.ts | 135 | ||||
| -rw-r--r-- | packages/core/src/tools/write-file.ts | 30 |
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; }, }; } |
