feat(lsp): LSP capability seam, generic stdio provider, and lsp tool

Implements the LSP capability seam RFC as three packages: dsh-lsp (the
ctx.lsp interface — provider registry by branded id + exclusive extension
mapping, per-query order-independent selection, closed request/result
vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server
provider — Content-Length JSON-RPC framing, per-(provider, workspace) process
single-flight, transient didOpen/query/didClose, an abortable per-instance
queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and
bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool —
four operations, one-based UTF-16 cursor conversion, workspace-grouped location
rendering, hover capping, a required session workspace, and a timeout budget).

Why: an agent had text search and file reads but no way to identify a program
symbol — follow an alias, connect an interface to implementations, or read an
inferred type — before changing code. Splitting model contract, seam, and local
subprocess behavior keeps the four semantic queries stable across future remote
or sandbox-native providers without leaking a JSON-RPC escape hatch.
This commit is contained in:
Dudu-0223
2026-07-16 12:05:35 +08:00
parent 66b2cfc609
commit d0029d8d60
56 changed files with 4527 additions and 30 deletions

View File

@@ -0,0 +1,130 @@
/**
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
* (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor
* coordinates to the seam's zero-based positions, requires the session workspace with no fallback,
* caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to
* enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider.
*
* Namespace plugin (named exports, no default export).
* @module @deepseek-ai/dsh-tool-lsp
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {} from '@deepseek-ai/dsh-lsp'
import type {} from '@deepseek-ai/dsh-system-prompt'
import {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
} from './render.ts'
import { sessionCwd } from './session-cwd.ts'
export {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
renderUri,
} from './render.ts'
export { sessionCwd } from './session-cwd.ts'
/** Cordis plugin name for loader diagnostics. */
export const name = 'tool-lsp'
/** Services required by this plugin. */
export const inject = ['tools', 'lsp', 'systemPrompt']
/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */
export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
/** The stable system-prompt guidance positioning LSP as a precision aid. */
export const LSP_PROMPT_TEXT =
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.'
/** Plugin configuration: result caps and the timeout budget. */
export interface Config {
/** Largest number of rendered locations before an omission marker (default 100). */
maxLocations?: number
/** Largest hover length in characters after normalization (default 16000). */
maxHoverChars?: number
/** Tool-call timeout budget in ms (default 60000). */
timeoutMs?: number
}
export const Config: z<Config> = z.object({
maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS),
timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
})
type ResolvedConfig = Required<Config>
/**
* Register the `lsp` tool and its system-prompt guidance.
* @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`).
* @param config - the resolved plugin configuration.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveInteger('maxLocations', resolved.maxLocations)
assertPositiveInteger('maxHoverChars', resolved.maxHoverChars)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
ctx.tools.register(defineTool({
name: 'lsp',
description:
'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.',
parameters: {
operation: {
type: 'string',
required: true,
enum: [...LSP_OPERATIONS],
description: 'definition, references, implementation, or hover.',
},
file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' },
},
timeoutMs: resolved.timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseLspArgs(args)
const workspaceRoot = sessionCwd(exec)
if (workspaceRoot === undefined) {
throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
}
const result = await ctx.lsp.query({
operation: input.operation,
filePath: input.filePath,
position: input.position,
workspaceRoot,
}, exec.signal)
switch (result.kind) {
case 'locations':
return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }]
case 'hover':
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
}
},
presentCall: presentLspCall,
}))
}
/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`tool-lsp: ${name} must be a positive integer`)
}
}