index.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. /**
  2. * Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
  3. * (`goToDefinition`/`findReferences`/`goToImplementation`/`hover`); it converts one-based UTF-16
  4. * cursor coordinates to the seam's zero-based positions, requires the session workspace with no
  5. * fallback, caps and renders results, and attaches a configurable timeout budget for
  6. * `dsh-timeout-policy` to enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and
  7. * imports no provider.
  8. *
  9. * Namespace plugin (named exports, no default export).
  10. * @module @deepseek-ai/dsh-tool-lsp
  11. */
  12. import type { Context } from 'cordis'
  13. import z from 'schemastery'
  14. import { defineTool } from '@deepseek-ai/dsh-tools'
  15. import { assertNever } from '@deepseek-ai/dsh-llm'
  16. import { LspError } from '@deepseek-ai/dsh-lsp'
  17. import type {} from '@deepseek-ai/dsh-lsp'
  18. import type {} from '@deepseek-ai/dsh-system-prompt'
  19. import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  20. import {
  21. DEFAULT_MAX_LOCATIONS,
  22. DEFAULT_MAX_RESULT_CHARS,
  23. formatHover,
  24. formatLocations,
  25. LSP_OPERATIONS,
  26. parseLspArgs,
  27. presentLspCall,
  28. } from './render.ts'
  29. import { sessionCwd } from './session-cwd.ts'
  30. export {
  31. DEFAULT_MAX_LOCATIONS,
  32. DEFAULT_MAX_RESULT_CHARS,
  33. formatHover,
  34. formatLocations,
  35. LSP_OPERATIONS,
  36. parseLspArgs,
  37. presentLspCall,
  38. renderUri,
  39. } from './render.ts'
  40. export { sessionCwd } from './session-cwd.ts'
  41. /** Cordis plugin name for loader diagnostics. */
  42. export const name = 'tool-lsp'
  43. /** Services required by this plugin. */
  44. export const inject = ['tools', 'lsp', 'systemPrompt']
  45. /** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */
  46. export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
  47. /** The stable system-prompt guidance positioning LSP as a precision aid. */
  48. export const LSP_PROMPT_TEXT =
  49. '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.'
  50. /** Plugin configuration: result caps and the timeout budget. */
  51. export interface Config {
  52. /** Largest number of rendered locations before an omission marker (default 100). */
  53. maxLocations?: number
  54. /** Largest complete rendered result in characters, including truncation metadata (default 16000). */
  55. maxResultChars?: number
  56. /** Tool-call timeout budget in ms (default 60000). */
  57. timeoutMs?: number
  58. }
  59. export const Config: z<Config> = z.object({
  60. maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
  61. maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS),
  62. timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
  63. })
  64. type ResolvedConfig = Required<Config>
  65. const LSP_POSITION_OUTPUT_SCHEMA = {
  66. type: 'object',
  67. additionalProperties: false,
  68. properties: {
  69. line: { type: 'integer', required: true },
  70. character: { type: 'integer', required: true },
  71. },
  72. } as const
  73. const LSP_RANGE_OUTPUT_SCHEMA = {
  74. type: 'object',
  75. additionalProperties: false,
  76. properties: {
  77. start: { ...LSP_POSITION_OUTPUT_SCHEMA, required: true },
  78. end: { ...LSP_POSITION_OUTPUT_SCHEMA, required: true },
  79. },
  80. } as const
  81. /**
  82. * Register the `lsp` tool and its system-prompt guidance.
  83. * @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`).
  84. * @param config - the resolved plugin configuration.
  85. */
  86. export function apply(ctx: Context, config: Config): void {
  87. const resolved = config as ResolvedConfig
  88. assertPositiveInteger('maxLocations', resolved.maxLocations)
  89. assertPositiveInteger('maxResultChars', resolved.maxResultChars)
  90. assertTimer('timeoutMs', resolved.timeoutMs)
  91. ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
  92. ctx.tools.register(defineTool({
  93. name: 'lsp',
  94. description:
  95. '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.',
  96. parameters: {
  97. operation: {
  98. type: 'string',
  99. required: true,
  100. enum: [...LSP_OPERATIONS],
  101. description: 'goToDefinition, findReferences, goToImplementation, or hover.',
  102. },
  103. file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
  104. line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
  105. character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' },
  106. },
  107. output: {
  108. schema: {
  109. oneOf: [
  110. {
  111. type: 'object',
  112. additionalProperties: false,
  113. properties: {
  114. kind: { type: 'string', required: true, const: 'locations' },
  115. locations: {
  116. type: 'array',
  117. required: true,
  118. items: {
  119. type: 'object',
  120. additionalProperties: false,
  121. properties: {
  122. uri: { type: 'string', required: true },
  123. range: { ...LSP_RANGE_OUTPUT_SCHEMA, required: true },
  124. },
  125. },
  126. },
  127. resolvedWorkspaceRoot: { type: 'string', required: true },
  128. },
  129. },
  130. {
  131. type: 'object',
  132. additionalProperties: false,
  133. properties: {
  134. kind: { type: 'string', required: true, const: 'hover' },
  135. hover: {
  136. required: true,
  137. oneOf: [
  138. { type: 'null' },
  139. {
  140. type: 'object',
  141. additionalProperties: false,
  142. properties: {
  143. contents: { type: 'string', required: true },
  144. range: LSP_RANGE_OUTPUT_SCHEMA,
  145. },
  146. },
  147. ],
  148. },
  149. },
  150. },
  151. ],
  152. },
  153. render: (_args, value) => {
  154. switch (value.kind) {
  155. case 'locations':
  156. return [{ type: 'text', text: formatLocations(value.locations, value.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }]
  157. case 'hover':
  158. return [{ type: 'text', text: formatHover(value.hover, resolved.maxResultChars) }]
  159. /* v8 ignore next -- exhaustive over the output schema's closed union; unreachable. */
  160. default:
  161. return assertNever(value, 'tool-lsp output')
  162. }
  163. },
  164. },
  165. timeoutMs: resolved.timeoutMs,
  166. async execute(args, exec) {
  167. const input = parseLspArgs(args)
  168. const workspaceRoot = sessionCwd(exec)
  169. if (workspaceRoot === undefined) {
  170. throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
  171. }
  172. const result = await ctx.lsp.query({
  173. operation: input.operation,
  174. filePath: input.filePath,
  175. position: input.position,
  176. workspaceRoot,
  177. }, exec.signal)
  178. switch (result.kind) {
  179. case 'locations':
  180. return {
  181. kind: 'locations' as const,
  182. locations: result.locations.map(location => ({
  183. uri: location.uri,
  184. range: {
  185. start: { line: location.range.start.line, character: location.range.start.character },
  186. end: { line: location.range.end.line, character: location.range.end.character },
  187. },
  188. })),
  189. resolvedWorkspaceRoot: result.resolvedWorkspaceRoot,
  190. }
  191. case 'hover':
  192. return {
  193. kind: 'hover' as const,
  194. hover: result.hover === null
  195. ? null
  196. : {
  197. contents: result.hover.contents,
  198. ...result.hover.range === undefined
  199. ? {}
  200. : {
  201. range: {
  202. start: { line: result.hover.range.start.line, character: result.hover.range.start.character },
  203. end: { line: result.hover.range.end.line, character: result.hover.range.end.character },
  204. },
  205. },
  206. },
  207. }
  208. /* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */
  209. default:
  210. return assertNever(result, 'tool-lsp result')
  211. }
  212. },
  213. presentCall: presentLspCall,
  214. }))
  215. }
  216. /** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
  217. function assertPositiveInteger(name: string, value: number): void {
  218. if (!Number.isInteger(value) || value < 1) {
  219. throw new Error(`tool-lsp: ${name} must be a positive integer`)
  220. }
  221. }
  222. /** Reject a timer value Node would clamp instead of scheduling as configured. */
  223. function assertTimer(name: string, value: number): void {
  224. if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
  225. throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
  226. }
  227. }