feat(lsp): activate LSP by default, add call hierarchy/format/signature, sync edits (#639)
LSP was never activated in interactive sessions because the default active tools list hardcoded only read/bash/edit/write. This adds lsp to that list and ships four new capabilities alongside edit sync and stronger prompt guidance. - Add "lsp" to default active tools in agent-session.ts - New actions: incoming_calls, outgoing_calls, format, signature - Wire edit/write tools to notify LSP clients on file changes - Strengthen system prompt and GSD prompt with full LSP operation catalog Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1ea9163dea
commit
fd29c02c81
10 changed files with 313 additions and 5 deletions
|
|
@ -2331,7 +2331,7 @@ export class AgentSession {
|
|||
|
||||
const defaultActiveToolNames = this._baseToolsOverride
|
||||
? Object.keys(this._baseToolsOverride)
|
||||
: ["read", "bash", "edit", "write"];
|
||||
: ["read", "bash", "edit", "write", "lsp"];
|
||||
const baseActiveToolNames = options.activeToolNames ?? defaultActiveToolNames;
|
||||
this._refreshToolRegistry({
|
||||
activeToolNames: baseActiveToolNames,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,18 @@ const CLIENT_CAPABILITIES = {
|
|||
properties: ["edit"],
|
||||
},
|
||||
},
|
||||
callHierarchy: {
|
||||
dynamicRegistration: false,
|
||||
},
|
||||
signatureHelp: {
|
||||
dynamicRegistration: false,
|
||||
signatureInformation: {
|
||||
documentationFormat: ["markdown", "plaintext"],
|
||||
parameterInformation: {
|
||||
labelOffsetSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
formatting: {
|
||||
dynamicRegistration: false,
|
||||
},
|
||||
|
|
@ -701,6 +713,20 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all LSP clients that have the file open that it changed on disk.
|
||||
* Synchronous entry point — async refresh runs in background.
|
||||
* Swallows errors so editing never fails because of LSP.
|
||||
*/
|
||||
export function notifyFileChanged(filePath: string): void {
|
||||
const uri = fileToUri(filePath);
|
||||
for (const client of clients.values()) {
|
||||
if (client.openFiles.has(uri)) {
|
||||
refreshFile(client, filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown a specific client by key.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -15,10 +15,13 @@ import {
|
|||
WARMUP_TIMEOUT_MS,
|
||||
} from "./client.js";
|
||||
import { getServersForFile, type LspConfig, loadConfig } from "./config.js";
|
||||
import { applyWorkspaceEdit } from "./edits.js";
|
||||
import { applyTextEdits, applyWorkspaceEdit } from "./edits.js";
|
||||
import { ToolAbortError, clampTimeout, throwIfAborted } from "./helpers.js";
|
||||
import { detectLspmux } from "./lspmux.js";
|
||||
import {
|
||||
type CallHierarchyIncomingCall,
|
||||
type CallHierarchyItem,
|
||||
type CallHierarchyOutgoingCall,
|
||||
type CodeAction,
|
||||
type CodeActionContext,
|
||||
type Command,
|
||||
|
|
@ -32,7 +35,9 @@ import {
|
|||
type LspToolDetails,
|
||||
lspSchema,
|
||||
type ServerConfig,
|
||||
type SignatureHelp,
|
||||
type SymbolInformation,
|
||||
type TextEdit,
|
||||
type WorkspaceEdit,
|
||||
} from "./types.js";
|
||||
import {
|
||||
|
|
@ -42,12 +47,14 @@ import {
|
|||
extractHoverText,
|
||||
fileToUri,
|
||||
filterWorkspaceSymbols,
|
||||
formatCallHierarchyItem,
|
||||
formatCodeAction,
|
||||
formatDiagnostic,
|
||||
formatDiagnosticsSummary,
|
||||
formatDocumentSymbol,
|
||||
formatGroupedDiagnosticMessages,
|
||||
formatLocation,
|
||||
formatSignatureHelp,
|
||||
formatSymbolInformation,
|
||||
formatWorkspaceEdit,
|
||||
hasGlobPattern,
|
||||
|
|
@ -338,7 +345,7 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
|
|||
signal?: AbortSignal,
|
||||
_onUpdate?: AgentToolUpdateCallback<LspToolDetails>,
|
||||
): Promise<AgentToolResult<LspToolDetails>> {
|
||||
const { action, file, line, symbol, occurrence, query, new_name, apply, timeout } = params;
|
||||
const { action, file, line, symbol, occurrence, query, new_name, apply, tab_size, insert_spaces, timeout } = params;
|
||||
const timeoutSec = clampTimeout(timeout);
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
|
||||
signal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
||||
|
|
@ -876,6 +883,154 @@ export function createLspTool(cwd: string): AgentTool<typeof lspSchema, LspToolD
|
|||
break;
|
||||
}
|
||||
|
||||
case "incoming_calls": {
|
||||
const prepareResult = (await sendRequest(
|
||||
client,
|
||||
"textDocument/prepareCallHierarchy",
|
||||
{
|
||||
textDocument: { uri },
|
||||
position,
|
||||
},
|
||||
signal,
|
||||
)) as CallHierarchyItem[] | null;
|
||||
|
||||
if (!prepareResult || prepareResult.length === 0) {
|
||||
output = "No call hierarchy item found at this position";
|
||||
break;
|
||||
}
|
||||
|
||||
const incomingResult = (await sendRequest(
|
||||
client,
|
||||
"callHierarchy/incomingCalls",
|
||||
{ item: prepareResult[0] },
|
||||
signal,
|
||||
)) as CallHierarchyIncomingCall[] | null;
|
||||
|
||||
if (!incomingResult || incomingResult.length === 0) {
|
||||
output = `No incoming calls found for ${prepareResult[0].name}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const incomingLines: string[] = [];
|
||||
const limitedIncoming = incomingResult.slice(0, REFERENCE_CONTEXT_LIMIT);
|
||||
for (const call of limitedIncoming) {
|
||||
const header = formatCallHierarchyItem(call.from, cwd);
|
||||
const filePath = uriToFile(call.from.uri);
|
||||
const callLine = call.fromRanges[0]?.start.line ?? call.from.selectionRange.start.line;
|
||||
const context = await readLocationContext(filePath, callLine + 1, LOCATION_CONTEXT_LINES);
|
||||
if (context.length > 0) {
|
||||
incomingLines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`);
|
||||
} else {
|
||||
incomingLines.push(` ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const truncation = incomingResult.length > REFERENCE_CONTEXT_LIMIT
|
||||
? `\n ... ${incomingResult.length - REFERENCE_CONTEXT_LIMIT} additional caller(s) omitted`
|
||||
: "";
|
||||
output = `${incomingResult.length} caller(s) of ${prepareResult[0].name}:\n${incomingLines.join("\n")}${truncation}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "outgoing_calls": {
|
||||
const prepareResult = (await sendRequest(
|
||||
client,
|
||||
"textDocument/prepareCallHierarchy",
|
||||
{
|
||||
textDocument: { uri },
|
||||
position,
|
||||
},
|
||||
signal,
|
||||
)) as CallHierarchyItem[] | null;
|
||||
|
||||
if (!prepareResult || prepareResult.length === 0) {
|
||||
output = "No call hierarchy item found at this position";
|
||||
break;
|
||||
}
|
||||
|
||||
const outgoingResult = (await sendRequest(
|
||||
client,
|
||||
"callHierarchy/outgoingCalls",
|
||||
{ item: prepareResult[0] },
|
||||
signal,
|
||||
)) as CallHierarchyOutgoingCall[] | null;
|
||||
|
||||
if (!outgoingResult || outgoingResult.length === 0) {
|
||||
output = `No outgoing calls found from ${prepareResult[0].name}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const outgoingLines: string[] = [];
|
||||
const limitedOutgoing = outgoingResult.slice(0, REFERENCE_CONTEXT_LIMIT);
|
||||
for (const call of limitedOutgoing) {
|
||||
const header = formatCallHierarchyItem(call.to, cwd);
|
||||
const filePath = uriToFile(call.to.uri);
|
||||
const callLine = call.to.selectionRange.start.line;
|
||||
const context = await readLocationContext(filePath, callLine + 1, LOCATION_CONTEXT_LINES);
|
||||
if (context.length > 0) {
|
||||
outgoingLines.push(` ${header}\n${context.map(l => ` ${l}`).join("\n")}`);
|
||||
} else {
|
||||
outgoingLines.push(` ${header}`);
|
||||
}
|
||||
}
|
||||
|
||||
const outTruncation = outgoingResult.length > REFERENCE_CONTEXT_LIMIT
|
||||
? `\n ... ${outgoingResult.length - REFERENCE_CONTEXT_LIMIT} additional callee(s) omitted`
|
||||
: "";
|
||||
output = `${outgoingResult.length} callee(s) from ${prepareResult[0].name}:\n${outgoingLines.join("\n")}${outTruncation}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "format": {
|
||||
if (!targetFile) {
|
||||
output = "Error: file parameter required for format";
|
||||
break;
|
||||
}
|
||||
|
||||
const formatResult = (await sendRequest(
|
||||
client,
|
||||
"textDocument/formatting",
|
||||
{
|
||||
textDocument: { uri },
|
||||
options: {
|
||||
tabSize: tab_size ?? 4,
|
||||
insertSpaces: insert_spaces ?? true,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
)) as TextEdit[] | null;
|
||||
|
||||
if (!formatResult || formatResult.length === 0) {
|
||||
const relPath = path.relative(cwd, targetFile);
|
||||
output = `${relPath}: already formatted (no changes)`;
|
||||
break;
|
||||
}
|
||||
|
||||
await applyTextEdits(targetFile, formatResult);
|
||||
const relPath = path.relative(cwd, targetFile);
|
||||
output = `Formatted ${relPath}: ${formatResult.length} edit(s) applied`;
|
||||
break;
|
||||
}
|
||||
|
||||
case "signature": {
|
||||
const sigResult = (await sendRequest(
|
||||
client,
|
||||
"textDocument/signatureHelp",
|
||||
{
|
||||
textDocument: { uri },
|
||||
position,
|
||||
},
|
||||
signal,
|
||||
)) as SignatureHelp | null;
|
||||
|
||||
if (!sigResult || !sigResult.signatures || sigResult.signatures.length === 0) {
|
||||
output = "No signature information at this position";
|
||||
} else {
|
||||
output = formatSignatureHelp(sigResult);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "rename": {
|
||||
if (!new_name) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@ Interacts with Language Server Protocol servers for code intelligence.
|
|||
- `references`: Find references → locations with 3-line source context (first 50), remaining location-only
|
||||
- `hover`: Get type info and documentation → type signature + docs
|
||||
- `symbols`: List symbols in file, or search workspace (with query, no file)
|
||||
- `incoming_calls`: Find all callers of a function → call sites with context
|
||||
- `outgoing_calls`: Find all functions called by a function → callees with context
|
||||
- `rename`: Rename symbol across codebase → preview or apply edits
|
||||
- `code_actions`: List available quick-fixes/refactors/import actions; apply one when `apply: true` and `query` matches title or index
|
||||
- `format`: Format file using language server formatter → applies edits in-place
|
||||
- `signature`: Get function signature and parameter info at cursor position
|
||||
- `status`: Show active language servers
|
||||
- `reload`: Restart the language server
|
||||
</operations>
|
||||
|
|
@ -22,6 +26,8 @@ Interacts with Language Server Protocol servers for code intelligence.
|
|||
- `query`: Symbol search query, code-action kind filter (list mode), or code-action selector (apply mode)
|
||||
- `new_name`: Required for rename
|
||||
- `apply`: Apply edits for rename/code_actions (default true for rename, list mode for code_actions unless explicitly true)
|
||||
- `tab_size`: Tab size for formatting (default: 4)
|
||||
- `insert_spaces`: Use spaces for formatting (default: true)
|
||||
- `timeout`: Request timeout in seconds (clamped to 5-60, default 20)
|
||||
</parameters>
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ export const lspSchema = Type.Object({
|
|||
"code_actions",
|
||||
"type_definition",
|
||||
"implementation",
|
||||
"incoming_calls",
|
||||
"outgoing_calls",
|
||||
"format",
|
||||
"signature",
|
||||
"status",
|
||||
"reload",
|
||||
],
|
||||
|
|
@ -43,6 +47,8 @@ export const lspSchema = Type.Object({
|
|||
query: Type.Optional(Type.String({ description: "Search query or SSR pattern" })),
|
||||
new_name: Type.Optional(Type.String({ description: "New name for rename" })),
|
||||
apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true)" })),
|
||||
tab_size: Type.Optional(Type.Number({ description: "Tab size for formatting (default: 4)" })),
|
||||
insert_spaces: Type.Optional(Type.Boolean({ description: "Use spaces for formatting (default: true)" })),
|
||||
timeout: Type.Optional(Type.Number({ description: "Request timeout in seconds" })),
|
||||
});
|
||||
|
||||
|
|
@ -419,3 +425,50 @@ export interface LspJsonRpcNotification {
|
|||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Call Hierarchy
|
||||
// =============================================================================
|
||||
|
||||
export interface CallHierarchyItem {
|
||||
name: string;
|
||||
kind: SymbolKind;
|
||||
tags?: number[];
|
||||
detail?: string;
|
||||
uri: string;
|
||||
range: Range;
|
||||
selectionRange: Range;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface CallHierarchyIncomingCall {
|
||||
from: CallHierarchyItem;
|
||||
fromRanges: Range[];
|
||||
}
|
||||
|
||||
export interface CallHierarchyOutgoingCall {
|
||||
to: CallHierarchyItem;
|
||||
fromRanges: Range[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signature Help
|
||||
// =============================================================================
|
||||
|
||||
export interface ParameterInformation {
|
||||
label: string | [number, number];
|
||||
documentation?: string | MarkupContent;
|
||||
}
|
||||
|
||||
export interface SignatureInformation {
|
||||
label: string;
|
||||
documentation?: string | MarkupContent;
|
||||
parameters?: ParameterInformation[];
|
||||
activeParameter?: number;
|
||||
}
|
||||
|
||||
export interface SignatureHelp {
|
||||
signatures: SignatureInformation[];
|
||||
activeSignature?: number;
|
||||
activeParameter?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ import path from "node:path";
|
|||
import { glob } from "glob";
|
||||
import { isEnoent } from "./helpers.js";
|
||||
import type {
|
||||
CallHierarchyItem,
|
||||
CodeAction,
|
||||
Command,
|
||||
Diagnostic,
|
||||
DiagnosticSeverity,
|
||||
DocumentSymbol,
|
||||
Location,
|
||||
MarkupContent,
|
||||
SignatureHelp,
|
||||
SymbolInformation,
|
||||
SymbolKind,
|
||||
TextEdit,
|
||||
|
|
@ -680,3 +683,56 @@ export async function readLocationContext(filePath: string, line: number, contex
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Call Hierarchy Formatting
|
||||
// =============================================================================
|
||||
|
||||
export function formatCallHierarchyItem(item: CallHierarchyItem, cwd: string): string {
|
||||
const icon = symbolKindToIcon(item.kind);
|
||||
const detail = item.detail ? ` ${item.detail}` : "";
|
||||
const relPath = path.relative(cwd, uriToFile(item.uri));
|
||||
const line = item.selectionRange.start.line + 1;
|
||||
return `${icon} ${item.name}${detail} @ ${relPath}:${line}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signature Help Formatting
|
||||
// =============================================================================
|
||||
|
||||
function extractDocText(doc: string | MarkupContent | undefined): string {
|
||||
if (!doc) return "";
|
||||
if (typeof doc === "string") return doc;
|
||||
return doc.value;
|
||||
}
|
||||
|
||||
export function formatSignatureHelp(result: SignatureHelp): string {
|
||||
if (!result.signatures || result.signatures.length === 0) {
|
||||
return "No signature information";
|
||||
}
|
||||
|
||||
const activeIdx = result.activeSignature ?? 0;
|
||||
const sig = result.signatures[activeIdx] ?? result.signatures[0];
|
||||
const activeParam = result.activeParameter ?? sig.activeParameter;
|
||||
|
||||
const lines: string[] = [sig.label];
|
||||
|
||||
const sigDoc = extractDocText(sig.documentation);
|
||||
if (sigDoc) {
|
||||
lines.push("", sigDoc);
|
||||
}
|
||||
|
||||
if (sig.parameters && sig.parameters.length > 0) {
|
||||
lines.push("", "Parameters:");
|
||||
for (let i = 0; i < sig.parameters.length; i++) {
|
||||
const p = sig.parameters[i];
|
||||
const label = typeof p.label === "string" ? p.label : sig.label.slice(p.label[0], p.label[1]);
|
||||
const active = i === activeParam ? " <-- active" : "";
|
||||
const doc = extractDocText(p.documentation);
|
||||
const docSuffix = doc ? ` — ${doc}` : "";
|
||||
lines.push(` ${label}${docSuffix}${active}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,13 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
|
|||
// LSP guideline
|
||||
if (hasLsp) {
|
||||
addGuideline(
|
||||
"Use lsp for go-to-definition, find-references, hover, rename, and diagnostics when working in typed codebases. Prefer lsp over grep for semantic navigation (finding call sites, implementations, type info). Falls back gracefully if no language server is available for the file type.",
|
||||
`Use lsp as the primary tool for code navigation in typed codebases:
|
||||
- Navigation: definition, type_definition, implementation, references, incoming_calls, outgoing_calls
|
||||
- Understanding: hover (types + docs), signature (parameter info), symbols (file/workspace search)
|
||||
- Refactoring: rename (project-wide), code_actions (quick-fixes, imports, refactors), format (formatter)
|
||||
- Verification: diagnostics after edits to catch type errors immediately
|
||||
- Never grep for a symbol definition when lsp can resolve it semantically
|
||||
- Never shell out to a formatter when lsp format is available`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
restoreLineEndings,
|
||||
stripBom,
|
||||
} from "./edit-diff.js";
|
||||
import { notifyFileChanged } from "../lsp/client.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
||||
const editSchema = Type.Object({
|
||||
|
|
@ -187,6 +188,8 @@ export function createEditTool(cwd: string, options?: EditToolOptions): AgentToo
|
|||
const finalContent = bom + restoreLineEndings(newContent, originalEnding);
|
||||
await ops.writeFile(absolutePath, finalContent);
|
||||
|
||||
try { notifyFileChanged(absolutePath); } catch { /* best-effort */ }
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { AgentTool } from "@gsd/pi-agent-core";
|
|||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
import { notifyFileChanged } from "../lsp/client.js";
|
||||
import { resolveToCwd } from "./path-utils.js";
|
||||
|
||||
const writeSchema = Type.Object({
|
||||
|
|
@ -83,6 +84,8 @@ export function createWriteTool(cwd: string, options?: WriteToolOptions): AgentT
|
|||
// Write the file
|
||||
await ops.writeFile(absolutePath, content);
|
||||
|
||||
try { notifyFileChanged(absolutePath); } catch { /* best-effort */ }
|
||||
|
||||
// Check if aborted after writing
|
||||
if (aborted) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ Templates showing the expected format for each artifact type are in:
|
|||
|
||||
**File editing:** Always `read` a file before using `edit`. The `edit` tool requires exact text match — you need the real content, not a guess. Use `write` only for new files or complete rewrites.
|
||||
|
||||
**Code navigation:** Use `lsp` for go-to-definition, find-references, and type info. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically.
|
||||
**Code navigation:** Use `lsp` for definition, type_definition, implementation, references, incoming_calls, outgoing_calls, hover, signature, symbols, rename, code_actions, format, and diagnostics. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically. Never shell out to prettier/rustfmt/gofmt when `lsp format` is available. After editing code, use `lsp diagnostics` to verify no type errors were introduced.
|
||||
|
||||
**Codebase exploration:** Use `subagent` with `scout` for broad unfamiliar subsystem mapping. Use `rg` for text search across files. Use `lsp` for structural navigation. Never read files one-by-one to "explore" — search first, then read what's relevant.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue