Merge latest master into invariant service seam
This commit is contained in:
145
packages/lsp/tool-lsp/src/index.ts
Normal file
145
packages/lsp/tool-lsp/src/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* 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}`)
|
||||
}
|
||||
}
|
||||
30
packages/lsp/tool-lsp/src/invariant.ts
Normal file
30
packages/lsp/tool-lsp/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lsp`.
|
||||
* @module @deepseek-ai/dsh-tool-lsp/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-lsp-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless adapter contributes one tool and prompt section, while query
|
||||
* lifecycle and result relations remain owned by the tool and LSP seams it composes.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
168
packages/lsp/tool-lsp/src/render.ts
Normal file
168
packages/lsp/tool-lsp/src/render.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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, complete-result
|
||||
* 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[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover']
|
||||
|
||||
/** Default cap on rendered locations before an omission marker is appended. */
|
||||
export const DEFAULT_MAX_LOCATIONS = 100
|
||||
|
||||
/** Default cap on the complete rendered tool result, including truncation metadata. */
|
||||
export const DEFAULT_MAX_RESULT_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 by count, then applies the complete result cap.
|
||||
* @param locations - the seam's locations (possibly empty).
|
||||
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
|
||||
* @param maxLocations - the cap before truncation.
|
||||
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
|
||||
* @returns the rendered text; a distinct no-result line when there are none.
|
||||
*/
|
||||
export function formatLocations(
|
||||
locations: readonly LspLocation[],
|
||||
workspaceRoot: string,
|
||||
maxLocations: number,
|
||||
maxResultChars: number,
|
||||
): string {
|
||||
if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations')
|
||||
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 boundResult(lines.join('\n'), maxResultChars, 'locations')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a hover result, applying `maxResultChars` last and keeping its marker within the cap.
|
||||
* @param hover - the normalized hover, or `null` for no hover.
|
||||
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
|
||||
* @returns the rendered hover text; a distinct no-result line for `null`.
|
||||
*/
|
||||
export function formatHover(hover: LspHover | null, maxResultChars: number): string {
|
||||
const text = hover === null ? 'No hover information.' : hover.contents
|
||||
return boundResult(text, maxResultChars, 'hover')
|
||||
}
|
||||
|
||||
/** Bound a complete rendered result, including the truncation notice itself. */
|
||||
function boundResult(text: string, maxChars: number, label: string): string {
|
||||
if (text.length <= maxChars) return text
|
||||
const notice = `\n… ${label} truncated (limit ${maxChars} characters).`
|
||||
if (notice.length >= maxChars) return notice.slice(0, maxChars)
|
||||
return `${text.slice(0, maxChars - notice.length)}${notice}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '.'
|
||||
// A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false
|
||||
// positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`).
|
||||
const outside = rel === '..' || rel.startsWith(`..${sep}`) || 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
packages/lsp/tool-lsp/src/session-cwd.ts
Normal file
19
packages/lsp/tool-lsp/src/session-cwd.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user