docs(rfc): propose LSP capability seam
This commit is contained in:
@@ -28,6 +28,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
|---|---|
|
||||
| [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 |
|
||||
| [Extract a generic long-running tool runtime](proposed/architecture/2026-06-20-generic-long-running-tool-runtime.md) | 2026-06-20 |
|
||||
| [LSP capability seam and model-facing query tool](proposed/architecture/2026-07-15-lsp-capability-seam.md) | 2026-07-15 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-15-lsp-capability-seam.md: 89e58c3ae0ba9f49ed0164a76a230b141296eee5
|
||||
2026-07-15-lsp-capability-seam.zh.md: c1c2448b1e980fc17a347f6c434e8553639304f3
|
||||
198
docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md
Normal file
198
docs/rfc/proposed/architecture/2026-07-15-lsp-capability-seam.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# RFC: LSP capability seam and model-facing query tool
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-15-lsp-capability-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness has text search and file reads, but neither identifies a program symbol. A textual match cannot reliably distinguish two same-named functions, follow an import alias, connect an interface to its implementations, or report an inferred type. Before changing code, an agent therefore lacks the semantic navigation that a human gets from an editor's language server.
|
||||
|
||||
LSP support has three owners: the model needs a stable query schema, the harness needs provider selection and normalized results, and the local implementation needs process, JSON-RPC, workspace, synchronization, and filesystem behavior. Combining them would bind the model contract to local subprocesses and obstruct remote or sandbox-native providers.
|
||||
|
||||
Many language servers behave best when the queried document is opened with current text. A compatible agent client must bound that state, define whether its source read is a model observation, and keep the document snapshot in the same filesystem namespace as the server's workspace index.
|
||||
|
||||
## Proposal
|
||||
|
||||
Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation:
|
||||
|
||||
1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors.
|
||||
2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. Multiple plugin instances may register different server commands and extension-to-language-id mappings.
|
||||
3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation.
|
||||
|
||||
`dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays.
|
||||
|
||||
The model and seam expose exactly `definition`, `references`, `implementation`, and `hover`; no arbitrary JSON-RPC method escapes through `ctx.lsp`.
|
||||
|
||||
The prompt positions LSP as a precision aid: `Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.`
|
||||
|
||||
## Package and ownership boundaries
|
||||
|
||||
`dsh-lsp` registers providers by branded id and extension-to-language-id mapping. `registerProvider()` atomically reserves the id and every normalized extension: invalid input or any conflict publishes nothing, and its disposer releases all reservations. Provider plugins register through `ctx.effect()`. Selection is per query and order-independent; no match returns a structured unavailable error. The first version has no glob, language-id, or explicit route selector and no statically declared operation capabilities.
|
||||
|
||||
The seam exposes one `query(request, signal?)` operation because no fields need implementation defaulting: `workspaceRoot` is required, `languageId` comes from the registration, and consumers own timeouts and result limits. `query()` validates, selects, and derives without hidden `??` fallbacks, leaving no executable spec to resolve. `dsh-tool-lsp` passes only `exec.signal` as a bare `AbortSignal`, matching web and keeping `dsh-lsp` independent of `dsh-tools`. Removal before selection fails as unavailable; later disposal follows the selected provider's cancellation lifecycle without rerouting.
|
||||
|
||||
The intended contract shape is:
|
||||
|
||||
```ts
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
type LspOperation = 'definition' | 'references' | 'implementation' | 'hover'
|
||||
type LspProviderId = Branded<'LspProviderId'>
|
||||
|
||||
interface LspPosition {
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
readonly start: LspPosition
|
||||
readonly end: LspPosition
|
||||
}
|
||||
|
||||
interface LspQueryRequest {
|
||||
readonly operation: LspOperation
|
||||
readonly filePath: string
|
||||
readonly position: LspPosition
|
||||
readonly workspaceRoot: string
|
||||
}
|
||||
|
||||
interface LspProviderQuery extends LspQueryRequest {
|
||||
readonly languageId: string
|
||||
}
|
||||
|
||||
type LspQueryResult =
|
||||
| { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] }
|
||||
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
|
||||
|
||||
interface LspProvider {
|
||||
readonly id: LspProviderId
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
|
||||
interface LspService {
|
||||
registerProvider(provider: LspProvider): () => void
|
||||
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
```
|
||||
|
||||
Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`. The seam exposes no protocol types, process or document controls, or generic request escape hatch.
|
||||
|
||||
`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.
|
||||
|
||||
## Model-facing contract
|
||||
|
||||
The single `lsp` tool accepts:
|
||||
|
||||
```ts
|
||||
interface LspToolInput {
|
||||
readonly operation: 'definition' | 'references' | 'implementation' | 'hover'
|
||||
readonly file_path: string
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
```
|
||||
|
||||
`line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based `LspPosition` and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable remain outside model input.
|
||||
|
||||
The tool requires `workspaceRoot` from session `header.cwd`, with no fallback; absence fails as `LSP_WORKSPACE_REQUIRED` before querying or startup. The local provider resolves relative paths against that root and accepts absolute paths directly; both forms are canonicalized and rejected before startup when the target is outside the canonical workspace.
|
||||
|
||||
Locations render as stable, file-grouped `path:line:character` entries. A `file:` URI accepted by Node `fileURLToPath()` becomes a relative path inside the workspace or an absolute path outside it; other URIs remain verbatim. `maxLocations` defaults to `100`, and `maxHoverChars` defaults to `16_000` after hover normalization; both report omissions. Empty locations and `null` hover are successful no-result responses; malformed payloads remain structured errors.
|
||||
|
||||
ACP uses `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }` with an args-derived operation/cursor `title`. Because `FileLocation` has no character, follow-along focuses the input line while the title preserves the cursor; presentation remains pure.
|
||||
|
||||
## Timeout ownership
|
||||
|
||||
`dsh-tool-lsp` attaches one configurable `timeoutMs` budget, default `60_000`, to the tool definition. `dsh-timeout-policy` enforces it and supplies `exec.signal`, which reaches `ctx.lsp.query`; the budget covers the complete queued open/query/close lifecycle and is not model-configurable.
|
||||
|
||||
The seam and provider add no startup or request deadline. Non-tool callers therefore receive no hidden timeout and must supply an `AbortSignal`, using `deadline()` when they need a budget.
|
||||
|
||||
Provider disposal occurs outside tool execution, so `dsh-lsp-local` keeps `shutdownTimeoutMs` (default `5_000`) for `shutdown`/`exit` and `killGraceMs` (default `2_000`) before hard kill; the same bounds govern failed-instance cleanup. It uses `deadline()` and `timeoutOf()` but owns request cancellation, process signals, and awaiting close because timeout notification does not terminate work.
|
||||
|
||||
## Workspace, filesystem, and document synchronization
|
||||
|
||||
`dsh-lsp-local` canonicalizes and reads through Node APIs in the subprocess's host namespace. It rejects missing, non-regular, non-UTF-8, oversized, or canonical out-of-workspace sources and keeps one handle through validation and reading. It does not consume `ctx.fs` or emit `fs/observed`: only the LSP result is model-visible, so the query does not satisfy read-before-write policy.
|
||||
|
||||
The `read` tool is unsuitable source because its output is windowed, numbered, transcript-visible, and observed. Reading in `tool-lsp` would also assign provider-specific synchronization to the consumer and preclude non-local providers.
|
||||
|
||||
The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`.
|
||||
|
||||
1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs.
|
||||
2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id.
|
||||
3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request.
|
||||
4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination.
|
||||
|
||||
Documents close after each call, so the first version needs no `didChange`, `didSave`, content cache, mutation listener, or document LRU. One abortable per-instance queue serializes complete lifecycles; distinct instances may run in parallel. The server's workspace index remains responsible for closed files reached from the source.
|
||||
|
||||
The canonical workspace `realpath` must be a directory and supplies process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity; symlink aliases therefore share an instance. Result locations may be external, but an external path cannot become a query source. Remote, virtual, or independently sandboxed filesystems require another provider.
|
||||
|
||||
## Local server lifecycle and protocol behavior
|
||||
|
||||
`dsh-lsp-local` lazily single-flights one server per `(provider id, canonical workspace realpath)`. At load it resolves the executable after credential scrubbing and environment overrides, failing before registration if unavailable; resolution stays lazy and launch uses no shell. `maxMessageBytes` defaults to `16_000_000`, `maxStderrBytes` to `1_000_000`, and `maxDocumentBytes` to `4_000_000`. A crash fails the active query without replay; a later query may replace the process. Each query starts at most one process, so the MVP has no cross-request restart counter.
|
||||
|
||||
Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. Returned operation and synchronization capabilities are authoritative. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. Configuration may supply initialization options and `workspace/configuration` responses, but the client rejects `workspace/applyEdit` and never executes commands or edits.
|
||||
|
||||
Navigation maps `Location` directly and `LocationLink` from `targetUri` plus `targetSelectionRange`. Hover normalization takes `MarkupContent.value`, preserves string `MarkedString` values, renders language-tagged values as fenced code, joins arrays with one blank line, and applies `maxHoverChars` last.
|
||||
|
||||
Abort reaches every query phase and sends `$/cancelRequest` once an id exists. An unresponsive server is terminated and awaited without collateral active work because the instance is serialized. Disposal rejects and cancels work, attempts graceful shutdown, escalates through bounded termination, and awaits quiescence.
|
||||
|
||||
## Deliberately deferred surface
|
||||
|
||||
Symbols are deferred because they need different schemas and overlap read/search; a future workspace-symbol tool must accept a search query. Call hierarchy is deferred because support is uneven, and `prepareCallHierarchy` remains an internal prerequisite rather than a model operation.
|
||||
|
||||
Diagnostics need separate freshness, accumulation, and transcript rules. Mutations such as rename, code actions, and formatting require separate tools with preview, permission, and write-policy integration.
|
||||
|
||||
The local provider trusts its configured server and claims no sandbox confinement. Supporting untrusted binaries requires a later process/filesystem contract for workspace reads plus private cache and temporary writes; restricted, remote, or virtual workspaces require another provider.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Copy Claude Code's unified schema.** Its cursor operations validate the core use case, but symbols and call hierarchy need different arguments. Copying all nine operations would freeze speculative surface, so the proposal aligns only on the four semantic queries.
|
||||
|
||||
**Let providers register tools.** Loaded servers would then control model schema and prompts, preventing one stable contract across local and remote providers.
|
||||
|
||||
**Expose arbitrary LSP methods.** A JSON-RPC escape hatch would leak protocol payloads and admit unreviewed mutation or command execution; the operation union stays closed.
|
||||
|
||||
**Expose `resolve(request)` / `query(spec)`.** With no defaulted fields, resolution would only expose provider selection, and a public spec could outlive provider disposal or replacement. One operation keeps selection and invocation atomic to the registration lifetime.
|
||||
|
||||
**Wrap the signal in a per-seam execution-context object.** Web passes a bare `AbortSignal`; wrapping this single field would add unexplained asymmetry. `query()` gains a context object only when another field requires it.
|
||||
|
||||
**Read through `ctx.fs` or the `read` tool.** This could mix the document with a server index from another filesystem namespace; tool output is also windowed, numbered, and observed. The host-local provider reads unobserved full text beside its subprocess.
|
||||
|
||||
**Keep documents open.** Mirroring edits requires version ownership, all-path `didChange`, HMR recovery, eviction, and stale-state rules. Transient opens avoid that MVP state machine.
|
||||
|
||||
**Configure phase timeouts.** Nested timers create competing classifications and fresh budgets. One caller-owned deadline covers query work; only out-of-call teardown keeps local bounds.
|
||||
|
||||
**Query without `didOpen`.** Although permitted, support is inconsistent and may use stale server state. Transient open supplies an explicit current snapshot.
|
||||
|
||||
**Add routes or select the first match.** Registration order and HMR timing are not product semantics, while a route table duplicates unique extension ownership. Overlaps therefore fail registration.
|
||||
|
||||
**Run concurrent queries in one instance.** If cancellation fails, terminating the shared process would kill unrelated work. Per-instance serialization limits that blast radius; instances remain parallel.
|
||||
|
||||
**Ship presets or PATH discovery.** A catalog would make the generic host own language policy, while discovery cannot infer arguments, language ids, or initialization. Deployments configure providers explicitly; composition plugins may package presets.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Package tests pin the three-package dependency direction, runtime injections, and `ctx.lsp`-only communication.
|
||||
- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation.
|
||||
- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors.
|
||||
- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `references.includeDeclaration`.
|
||||
- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection.
|
||||
- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown.
|
||||
- Lifecycle tests pin startup single-flight, per-instance serialization, cross-instance parallelism, abortable queues, crash replacement without replay, and quiescent disposal.
|
||||
- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event.
|
||||
- A keyless pinned TypeScript real-server e2e exercises all four operations; runnable configuration uses the same explicit provider mapping.
|
||||
- Snapshots cover model-visible schema, prompt, results, omissions, and ACP rendering; a built-artifact smoke test covers framing and cleanup.
|
||||
- Package and architecture docs cover configuration, security boundaries, and search/read guidance; the new `packages/lsp/` group is added to the AGENTS.md repository-layout block, the packages/README.md group table, and architecture.md in the same change.
|
||||
|
||||
## Risks
|
||||
|
||||
Language servers vary in method support, capability interpretation, and indexing readiness; LSP has no universal “index complete” signal. Servers without compatible transient-open synchronization are unsupported even if closed-document queries work. Supported servers may still return empty or partial results, so the tool promises no cross-server completeness. The pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
|
||||
|
||||
Transient opens repeat parsing and notifications. Per-instance serialization increases latency under parallel agents, and long-lived workspace processes consume memory until disposal.
|
||||
|
||||
Extension ownership is exclusive within one runtime. Two providers cannot both claim `.ts`, even with different language ids; this is a conscious MVP limit. The intended extension is a deployment-configured selector above registrations that can relax exclusive reservations without adding provider choice to model input or changing `LspProvider.query`.
|
||||
|
||||
UTF-16 cursor columns are exact for the protocol but difficult for a model to count around non-BMP characters. Invalid or off-symbol positions may produce empty results, so error text and prompt examples must explain the coordinate convention without encouraging broad LSP use.
|
||||
|
||||
Direct Node access aligns the query snapshot with the server index but bypasses `ctx.fs` and its policy. Canonical containment rejects source files outside the workspace; a trusted server may still read the workspace and use caches. The first implementation therefore requires trusted host-local deployment and provides no sandbox guarantee.
|
||||
@@ -0,0 +1,198 @@
|
||||
# RFC: LSP 能力服务边界与面向模型的查询工具
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-15-lsp-capability-seam.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
harness 已具备文本搜索与文件读取能力,但二者都无法识别程序符号。文本匹配无法可靠地区分同名函数、跟踪导入别名、关联接口与具体实现,也无法报告推断类型。因此,agent(智能体)在修改代码前缺少人类通过编辑器语言服务器获得的语义导航能力。
|
||||
|
||||
语言服务器协议(Language Server Protocol,LSP)支持分属三个职责方:模型需要稳定的查询 schema,harness 需要提供方选择与规范化结果,本地实现则负责进程、JSON-RPC、工作区、同步与文件系统行为。将三者合并会使模型契约绑定本地子进程,并阻碍远程或沙箱原生提供方。
|
||||
|
||||
许多语言服务器只有在查询文档已按当前文本打开时才能稳定工作。兼容的 agent 客户端必须限制这项状态、定义内部读取是否算作模型观察,并确保文档快照与服务器工作区索引位于同一文件系统命名空间。
|
||||
|
||||
## 提案
|
||||
|
||||
将 LSP 建成由三个 package 组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现:
|
||||
|
||||
1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。
|
||||
2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。
|
||||
3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。
|
||||
|
||||
`dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。
|
||||
|
||||
模型与服务边界仅公开 `definition`、`references`、`implementation` 和 `hover`;`ctx.lsp` 不提供任意 JSON-RPC 方法。
|
||||
|
||||
提示词将 LSP 定位为精确查询手段:`Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references.`
|
||||
|
||||
## Package 与职责边界
|
||||
|
||||
`dsh-lsp` 按带品牌类型的 id 和扩展名到语言 id 的映射注册提供方。`registerProvider()` 以原子方式占用 id 与所有规范化扩展名:输入无效或存在冲突时不发布任何状态,清理函数释放全部占用。提供方插件通过 `ctx.effect()` 注册。系统按查询且不受顺序影响地选择提供方;没有匹配项时返回结构化不可用错误。第一版不提供 glob、language-id 或显式路由选择器,也不静态声明操作能力。
|
||||
|
||||
服务边界只公开 `query(request, signal?)`,因为没有字段需要实现层填充默认值:`workspaceRoot` 是必填项,`languageId` 来自注册映射,超时与结果限制由消费方负责。`query()` 执行校验、选择与推导时不使用隐藏的 `??` 后备逻辑,因此没有需要 resolve 的可执行 spec。`dsh-tool-lsp` 只把 `exec.signal` 作为裸 `AbortSignal` 传递,与 web 一致,并使 `dsh-lsp` 不依赖 `dsh-tools`。提供方在选择前被移除时按不可用失败;之后的释放遵循已选提供方的取消生命周期,不改路由。
|
||||
|
||||
预期契约如下:
|
||||
|
||||
```ts
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
type LspOperation = 'definition' | 'references' | 'implementation' | 'hover'
|
||||
type LspProviderId = Branded<'LspProviderId'>
|
||||
|
||||
interface LspPosition {
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
readonly start: LspPosition
|
||||
readonly end: LspPosition
|
||||
}
|
||||
|
||||
interface LspQueryRequest {
|
||||
readonly operation: LspOperation
|
||||
readonly filePath: string
|
||||
readonly position: LspPosition
|
||||
readonly workspaceRoot: string
|
||||
}
|
||||
|
||||
interface LspProviderQuery extends LspQueryRequest {
|
||||
readonly languageId: string
|
||||
}
|
||||
|
||||
type LspQueryResult =
|
||||
| { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] }
|
||||
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
|
||||
|
||||
interface LspProvider {
|
||||
readonly id: LspProviderId
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
|
||||
interface LspService {
|
||||
registerProvider(provider: LspProvider): () => void
|
||||
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
```
|
||||
|
||||
映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。
|
||||
|
||||
`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
|
||||
|
||||
## 面向模型的契约
|
||||
|
||||
单一 `lsp` 工具接受以下参数:
|
||||
|
||||
```ts
|
||||
interface LspToolInput {
|
||||
readonly operation: 'definition' | 'references' | 'implementation' | 'hover'
|
||||
readonly file_path: string
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
```
|
||||
|
||||
`line` 和 `character` 是从一开始计数的正数 UTF-16 光标坐标;工具将其转换为服务边界中从零开始的 `LspPosition`,并将渲染位置转回。`references` 包含声明,避免影响分析漏掉定义位置。提供方、语言 id、工作区根目录、限制、超时、初始化和可执行文件均不进入模型输入。
|
||||
|
||||
工具必须从会话 `header.cwd` 取得 `workspaceRoot`,没有后备值;缺失时在查询或启动前以 `LSP_WORKSPACE_REQUIRED` 失败。本地提供方基于根目录解析相对路径并直接接受绝对路径;两种路径都会进行规范化,如果目标位于规范工作区外,则在启动前拒绝。
|
||||
|
||||
位置按文件稳定分组并渲染为 `path:line:character`。Node `fileURLToPath()` 可接受的 `file:` URI 在工作区内转换为相对路径,在工作区外转换为绝对路径;其他 URI 保持原样。`maxLocations` 默认值为 `100`,`maxHoverChars` 在 `hover` 归一化后应用,默认值为 `16_000`;两者都会报告省略数量。空位置与 `null` hover 是成功的无结果响应;格式错误的载荷保持为结构化错误。
|
||||
|
||||
ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_path, line }] }`,`title` 由参数推导并标明操作与光标。由于 `FileLocation` 没有 character,跟随位置聚焦输入行,标题保留完整光标;展示保持纯函数。
|
||||
|
||||
## 超时归属
|
||||
|
||||
`dsh-tool-lsp` 将一个可配置的 `timeoutMs` 预算附加到工具定义,默认值为 `60_000`。`dsh-timeout-policy` 执行预算并提供传入 `ctx.lsp.query` 的 `exec.signal`;该预算覆盖排队、打开、查询和关闭的完整生命周期,模型不可配置。
|
||||
|
||||
服务边界和提供方不增加启动或请求截止时间。非工具调用方不会获得隐藏超时,必须自行提供 `AbortSignal`,并在需要预算时使用 `deadline()`。
|
||||
|
||||
提供方释放发生在工具执行之外,因此 `dsh-lsp-local` 保留 `shutdownTimeoutMs`(默认 `5_000`)限制 `shutdown`/`exit`,以及 `killGraceMs`(默认 `2_000`)限制强制终止前的宽限期;失败实例的清理也使用相同边界。它使用 `deadline()` 和 `timeoutOf()`,但仍负责请求取消、进程信号和等待关闭,因为超时通知不会终止工作。
|
||||
|
||||
## 工作区、文件系统与文档同步
|
||||
|
||||
`dsh-lsp-local` 通过 Node API 在子进程所在的主机命名空间中规范化并读取文件。它拒绝缺失、非普通、非 UTF-8、超大或规范路径越出工作区的源文件,并在校验与读取期间保持同一句柄。它不使用 `ctx.fs` 或发送 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足写前读取策略。
|
||||
|
||||
`read` 工具的输出带窗口与行号,进入 transcript 且已被观察,不适合作为源文件。在 `tool-lsp` 内读取还会把提供方专用同步职责交给消费方,并排除非本地提供方。
|
||||
|
||||
本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。
|
||||
|
||||
1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。
|
||||
2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。
|
||||
3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。
|
||||
4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。
|
||||
|
||||
每次调用后都关闭文档,因此第一版不需要 `didChange`、`didSave`、内容缓存、变更监听器或文档 LRU。每个实例使用一个可取消队列串行执行完整生命周期;不同实例可以并行。服务器工作区索引仍负责从源文件跳转到的已关闭文件。
|
||||
|
||||
规范工作区 `realpath` 必须是目录,并用于进程 cwd、`rootUri`、唯一的 `workspaceFolders` 条目和进程池 identity;符号链接别名因此共享实例。结果位置可以在工作区外,但外部路径不能成为查询源。远程、虚拟或独立沙箱化文件系统需要另一种提供方。
|
||||
|
||||
## 本地服务器生命周期与协议行为
|
||||
|
||||
`dsh-lsp-local` 按 `(provider id, canonical workspace realpath)` 懒启动一个服务器,并通过 single-flight 合并启动。插件加载时,它在清除凭据并应用环境变量覆盖后解析可执行文件;命令不可用时在注册前失败,解析保持懒执行,启动不经过 shell。`maxMessageBytes` 默认值为 `16_000_000`,`maxStderrBytes` 默认值为 `1_000_000`,`maxDocumentBytes` 默认值为 `4_000_000`。崩溃使当前查询失败且不重放;后续查询可以替换进程。每次查询最多启动一个进程,因此 MVP 不设置跨请求重启计数器。
|
||||
|
||||
初始化声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及 definition 与 implementation 的 `linkSupport: true`,但不支持动态注册。服务器返回的操作与同步能力均为真源。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值均属于协议错误。配置可以提供初始化选项和 `workspace/configuration` 响应,但客户端拒绝 `workspace/applyEdit`,绝不执行命令或编辑。
|
||||
|
||||
导航结果直接映射 `Location`,并将 `LocationLink` 的 `targetUri` 与 `targetSelectionRange` 映射为统一位置。`hover` 归一化直接采用 `MarkupContent.value`,保留字符串 `MarkedString`,把带语言标签的值渲染为围栏代码块,以一个空行连接数组,并在最后应用 `maxHoverChars`。
|
||||
|
||||
取消信号传递到查询的所有阶段,请求 id 创建后还会发送 `$/cancelRequest`。无响应的服务器会被终止并等待关闭;实例串行化保证没有其他正在执行的工作被连带中断。资源释放会拒绝并取消工作、尝试优雅关闭、通过有界终止流程升级处理,并等待完全停稳。
|
||||
|
||||
## 明确延后的接口
|
||||
|
||||
符号操作因需要不同 schema 且与读取或搜索重叠而延后;未来的工作区符号工具必须接收搜索词。调用层级因支持度不一而延后,`prepareCallHierarchy` 仍是内部准备步骤,不是模型操作。
|
||||
|
||||
诊断需要独立的新鲜度、累积与 transcript 规则。重命名、代码操作和格式化等变更能力需要单独工具,并集成预览、权限和写入策略。
|
||||
|
||||
本地提供方信任配置的服务器,不声称具备沙箱隔离。支持不受信任的二进制文件需要后续补充允许读取工作区并写入私有缓存与临时目录的进程/文件系统契约;受限、远程或虚拟工作区需要另一种提供方。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**照搬 Claude Code 的统一 schema。** 它的光标操作验证了核心场景,但符号与调用层级需要不同参数。照搬九种操作会固化尚未验证的接口,因此本提案只对齐四种语义查询。
|
||||
|
||||
**允许提供方注册工具。** 已加载服务器会控制模型 schema 和提示词,无法在本地与远程提供方之间维持统一契约。
|
||||
|
||||
**公开任意 LSP 方法。** JSON-RPC 逃生口会泄露协议载荷,并允许未经评审的变更或命令执行;操作联合保持封闭。
|
||||
|
||||
**公开 `resolve(request)` / `query(spec)`。** 没有需要填充默认值的字段时,resolve 只会暴露提供方选择,而公开 spec 可能活过提供方释放或替换。单一操作让选择与调用共用注册生命周期。
|
||||
|
||||
**将信号包装为每服务边界的执行上下文对象。** Web 传递裸 `AbortSignal`;仅包装这一个字段会造成无谓的不对称。只有另一个字段确有需要时,`query()` 才引入上下文对象。
|
||||
|
||||
**通过 `ctx.fs` 或 `read` 工具读取。** 这可能把文档与另一文件系统命名空间中的服务器索引混合;工具输出还带窗口、行号且已被观察。host-local 提供方在子进程旁读取未观察的完整文本。
|
||||
|
||||
**保持文档打开。** 镜像编辑需要版本归属、覆盖所有路径的 `didChange`、HMR 恢复、淘汰和陈旧状态规则。临时打开避免在 MVP 引入这套状态机。
|
||||
|
||||
**配置分阶段超时。** 嵌套定时器会产生相互竞争的分类与新预算。一个由调用方负责的截止时间覆盖查询;只有调用外清理保留本地限制。
|
||||
|
||||
**不发送 `didOpen`。** 协议虽允许,但支持不一致且可能使用陈旧服务器状态。临时打开提供明确的当前快照。
|
||||
|
||||
**增加路由或选择首个匹配项。** 注册顺序与 HMR 时机不是产品语义,路由表又会重复唯一扩展名所有权。因此,扩展名重叠时注册失败。
|
||||
|
||||
**在一个实例中并发查询。** 取消失败时,终止共享进程会杀死无关工作。实例内串行可限制影响范围;不同实例仍可并行。
|
||||
|
||||
**内置 preset 或 PATH 发现。** 目录会让通用 host 承担语言策略,而发现机制无法推断参数、语言 id 或初始化配置。部署显式配置提供方,组合插件可以封装 preset。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Package 测试固定三个 package 的依赖方向、运行时注入和仅通过 `ctx.lsp` 通信的边界。
|
||||
- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。
|
||||
- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。
|
||||
- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `references.includeDeclaration`。
|
||||
- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。
|
||||
- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。
|
||||
- 生命周期测试固定启动 single-flight、实例内串行、跨实例并行、可取消队列、崩溃后不重放的替换,以及释放后完全停稳。
|
||||
- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。
|
||||
- 无密钥且固定版本的 TypeScript 真实服务器 e2e 覆盖四种操作;可运行配置使用同一项显式提供方映射。
|
||||
- 快照覆盖模型可见 schema、提示词、结果、省略提示和 ACP 渲染;构建产物冒烟测试覆盖分帧与清理。
|
||||
- Package 与架构文档覆盖配置、安全边界和搜索/读取指导;同一改动中,新的 `packages/lsp/` package 组要加入 AGENTS.md 的仓库布局块、packages/README.md 的分组表和 architecture.md。
|
||||
|
||||
## 风险
|
||||
|
||||
各语言服务器对方法支持、能力解释和索引就绪时机的处理不同;LSP 没有统一的“索引完成”信号。无法声明兼容临时打开同步能力的服务器不受支持,即使它能查询已关闭文档。受支持的服务器仍可能返回空结果或不完整结果,因此工具不承诺跨服务器完整性。固定的 TypeScript e2e 只建立一条兼容性基线,不代表跨语言承诺。
|
||||
|
||||
临时打开会重复解析并产生通知。实例内串行会增加并发 agent 的延迟,长期运行的工作区进程则持续占用内存直到释放。
|
||||
|
||||
同一运行时内的扩展名所有权互斥。即使 language id 不同,两个提供方也不能同时占用 `.ts`;这是有意接受的 MVP 限制。预期扩展方式是在注册之上增加由部署配置的 selector,允许放宽互斥占用,同时不向模型输入增加提供方选择,也不改变 `LspProvider.query`。
|
||||
|
||||
UTF-16 光标列与协议完全一致,但模型难以在包含非 BMP 字符的文本中准确计数。无效位置或不在符号上的位置可能返回空结果,因此错误文本和提示词示例必须说明坐标约定,同时避免鼓励模型广泛使用 LSP。
|
||||
|
||||
直接访问 Node 文件系统会对齐查询快照与服务器索引,但绕过 `ctx.fs` 及其策略。规范路径 containment 会拒绝工作区外的源文件;受信任的服务器仍可读取工作区并使用缓存。因此,第一版要求受信任的 host-local 部署,不提供沙箱保证。
|
||||
Reference in New Issue
Block a user