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
+130
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`)
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
* conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and
* ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it
* depends only on the tool arguments.
* @module @deepseek-ai/dsh-tool-lsp/render
*/
import { fileURLToPath } from 'node:url'
import { isAbsolute, relative, sep } from 'node:path'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover']
/** Default cap on rendered locations before an omission marker is appended. */
export const DEFAULT_MAX_LOCATIONS = 100
/** Default cap on hover characters (applied after normalization) before truncation is marked. */
export const DEFAULT_MAX_HOVER_CHARS = 16_000
/** Validated `lsp` arguments after coordinate checks. */
export interface LspToolInput {
readonly operation: LspOperation
readonly filePath: string
/** Zero-based UTF-16 position converted from the one-based model coordinates. */
readonly position: LspPosition
}
/** The raw, schema-typed argument shape. */
export interface LspToolArgs {
readonly operation: string
readonly file_path: string
readonly line: number
readonly character: number
}
/**
* Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are
* positive one-based integers converted to the seam's zero-based position.
* @param args - the schema-validated raw arguments.
* @returns the validated input with a zero-based position.
* @throws Error when the operation is unknown or a coordinate is not a positive integer.
*/
export function parseLspArgs(args: LspToolArgs): LspToolInput {
if (!isOperation(args.operation)) {
throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`)
}
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
const line = oneBased(args.line, 'line')
const character = oneBased(args.character, 'character')
return {
operation: args.operation,
filePath: args.file_path,
// The model counts from 1; the seam (and protocol) count from 0.
position: { line: line - 1, character: character - 1 },
}
}
/** Whether a string is one of the four operations. */
function isOperation(value: string): value is LspOperation {
return (LSP_OPERATIONS as readonly string[]).includes(value)
}
/** Validate a one-based coordinate is a positive integer. */
function oneBased(value: number, name: string): number {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`${name} must be a positive integer (one-based)`)
}
return value
}
/**
* Render a locations result grouped by file, converting each zero-based location back to a one-based
* `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path;
* outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
* appends an omission marker when it truncates.
* @param locations - the seam's locations (possibly empty).
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
* @param maxLocations - the cap before truncation.
* @returns the rendered text; a distinct no-result line when there are none.
*/
export function formatLocations(
locations: readonly LspLocation[],
workspaceRoot: string,
maxLocations: number,
): string {
if (locations.length === 0) return 'No results.'
const shown = locations.slice(0, maxLocations)
const omitted = locations.length - shown.length
const grouped = new Map<string, string[]>()
for (const location of shown) {
const path = renderUri(location.uri, workspaceRoot)
const line = location.range.start.line + 1
const character = location.range.start.character + 1
const entries = grouped.get(path) ?? []
entries.push(`${path}:${line}:${character}`)
grouped.set(path, entries)
}
const lines: string[] = []
for (const entries of grouped.values()) lines.push(...entries)
if (omitted > 0) {
lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`)
}
return lines.join('\n')
}
/**
* Render a hover result, applying `maxHoverChars` last and marking truncation.
* @param hover - the normalized hover, or `null` for no hover.
* @param maxHoverChars - the cap applied after normalization.
* @returns the rendered hover text; a distinct no-result line for `null`.
*/
export function formatHover(hover: LspHover | null, maxHoverChars: number): string {
if (hover === null) return 'No hover information.'
const contents = hover.contents
if (contents.length <= maxHoverChars) return contents
return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).`
}
/**
* Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative
* (inside) or absolute (outside); any other URI is returned verbatim.
* @param uri - the target URI from the seam.
* @param workspaceRoot - the canonical workspace root.
* @returns the display path or the verbatim URI.
*/
export function renderUri(uri: string, workspaceRoot: string): string {
if (!uri.startsWith('file:')) return uri
let absolute: string
try {
absolute = fileURLToPath(uri)
} catch {
// A malformed file: URI is not a path we can resolve; show it verbatim.
return uri
}
const rel = relative(workspaceRoot, absolute)
if (rel === '') return '.'
const outside = rel.startsWith('..') || isAbsolute(rel)
return outside ? absolute : rel.split(sep).join('/')
}
/**
* ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the
* operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has
* no character, so the title preserves the column).
* @param args - the raw tool arguments.
* @returns the generic call view.
*/
export function presentLspCall(args: LspToolArgs): GenericCallView {
return {
card: 'generic',
kind: 'search',
title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`,
locations: [{ path: args.file_path, line: args.line }],
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Derive the workspace root an `lsp` call resolves against: the calling agent's per-session
* workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths.
* Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as
* `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it
* can start a server.
* @module @deepseek-ai/dsh-tool-lsp/session-cwd
*/
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
/**
* The session workspace cwd for this call, or `undefined` when none applies.
* @param exec - the tool-execution context; only its optional `agent` is read.
* @returns the calling agent's session cwd, or undefined for a non-agent caller.
*/
export function sessionCwd(exec: ToolExecution): string | undefined {
return exec.agent?.session.header.cwd
}