Files
deepseek-harness/packages/lsp/tool-lsp/src/index.ts
2026-07-21 13:29:40 +08:00

146 lines
6.2 KiB
TypeScript

/**
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
* (`goToDefinition`/`findReferences`/`goToImplementation`/`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 { assertNever, 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 { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
formatHover,
formatLocations,
LSP_OPERATIONS,
parseLspArgs,
presentLspCall,
} from './render.ts'
import { sessionCwd } from './session-cwd.ts'
export {
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
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. findReferences 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 complete rendered result in characters, including truncation metadata (default 16000). */
maxResultChars?: 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),
maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS),
timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).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('maxResultChars', resolved.maxResultChars)
assertTimer('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 goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.',
parameters: {
operation: {
type: 'string',
required: true,
enum: [...LSP_OPERATIONS],
description: 'goToDefinition, findReferences, goToImplementation, 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':
// Relativize against the provider's canonical workspace root (which its file: URIs are
// relative to), not the session cwd: a symlinked cwd would otherwise misclassify every
// in-workspace location as external and render it as an absolute path.
return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }]
case 'hover':
return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }]
/* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */
default:
return assertNever(result, 'tool-lsp result')
}
},
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`)
}
}
/** Reject a timer value Node would clamp instead of scheduling as configured. */
function assertTimer(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}