Skip to content

Commit 395d5cc

Browse files
authored
perf: improve Java LSP tool result handoff (#1031)
* Improve Java LSP tool result handoff Return structured file and line fields from findSymbol so the model can consume precise ranges without repeating symbol search. Accept line-suffixed Java locations in file structure resolution and document outlineInput handoff. * Address Java LSP handoff review comments Use normalized relative paths consistently when resolving file structure inputs and classify truncated findSymbol responses before small exact-result guidance. * Remove speculative Java location suffix handling Keep the PR focused on the evidence-backed findSymbol handoff contract. Do not claim getFileStructure accepts path:line inputs without telemetry proving that case. * Remove prescriptive findSymbol next step Keep findSymbol results as structured location data and leave next-tool choice to the model. The payload still exposes file, line, range, and outlineInput for downstream consumption without embedding result-level routing guidance. * Use file field for Java LSP handoff Remove the redundant outlineInput field and keep findSymbol output focused on factual location data: file, startLine, endLine, and range. getFileStructure can consume the returned file when broader file outline context is needed. * Return read_file input from Java symbol lookup * Add read ranges to Java file structure * Tighten Java file structure limit * Remove ambiguous Java symbol location field * perf: trim Java LSP tool descriptions to reduce token cost modelDescription is sent to the model on every request, so condense both tool descriptions and the uri param description while keeping the read_file (offset/limit) handoff and no-re-search guidance intact. * fix: address review nits on getFileStructure limit and instruction tool name - Use nullish coalescing + Math.floor for getFileStructure limit so an explicit 0 and fractional values are handled correctly. - Use the full lsp_java_findSymbol tool name in the Java LSP instructions.
1 parent 1996083 commit 395d5cc

4 files changed

Lines changed: 106 additions & 34 deletions

File tree

package.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
{
5353
"name": "lsp_java_getFileStructure",
5454
"toolReferenceName": "javaFileStructure",
55-
"modelDescription": "Get a known Java file's outline: classes, interfaces, methods, fields, symbol kinds, and line ranges, to pick a precise read_file range instead of reading the whole file.\n\nUse after lsp_java_findSymbol returns a file, or when the user gave a Java file path; do not guess paths. Not for workspace-wide search\u2014use lsp_java_findSymbol for that. Do not re-call for the same file unless the first result was empty.",
55+
"modelDescription": "Outline a known Java file (classes, methods, fields with line ranges) to pick a precise read_file range instead of reading the whole file. Needs a path from lsp_java_findSymbol or the user do not guess. Returns file plus per-symbol readFileRange ({ offset, limit }) for read_file. Use limit to cap outline items (default 20, max 60). Not for workspace search (use lsp_java_findSymbol).",
5656
"displayName": "Java: Get File Structure",
5757
"userDescription": "Get a Java file outline with classes, methods, fields, and line ranges.",
5858
"tags": [
@@ -69,7 +69,11 @@
6969
"properties": {
7070
"uri": {
7171
"type": "string",
72-
"description": "Workspace-relative path to a Java file. Must be a known path from prior tool results or user input — do not guess."
72+
"description": "Workspace-relative path to a Java file, from lsp_java_findSymbol or user input — do not guess."
73+
},
74+
"limit": {
75+
"type": "number",
76+
"description": "Maximum outline items to return (default: 20, max: 60). Use a smaller value when only top-level context is needed."
7377
}
7478
},
7579
"required": [
@@ -80,7 +84,7 @@
8084
{
8185
"name": "lsp_java_findSymbol",
8286
"toolReferenceName": "javaFindSymbol",
83-
"modelDescription": "Find Java class, interface, method, or field definitions across the workspace by name or partial identifier. Prefer over grep_search, file_search, semantic_search, or search subagents for Java symbol lookup.\n\nOn relevant results, do not repeat with a similar query; continue with lsp_java_getFileStructure or read_file on the returned line range. The tool retries internally, so on an empty result do not re-search\u2014retry once only if it reports indexing in progress, otherwise use generic search.\n\nDo not use for non-Java files, literals, comments, build/XML files, or conceptual exploration.",
87+
"modelDescription": "Find Java class/interface/method/field definitions across the workspace by name or partial identifier. Prefer over grep_search, file_search, or semantic_search for Java symbol lookup. Each result has file and readFileInput ({ filePath, offset, limit }) for read_file; use it when source is needed, or lsp_java_getFileStructure with file for broader context. On empty results don't re-search (it retries internally); retry once only if it reports indexing in progress, else use generic search. Not for non-Java files, literals, comments, or build/XML files.",
8488
"displayName": "Java: Find Symbol",
8589
"userDescription": "Find Java class, method, field, or interface definitions by name.",
8690
"tags": [

resources/instruments/javaLspContext.instructions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ For Java symbol navigation, two compiler-accurate `lsp_java_*` tools are availab
1010

1111
If these tools are not already available in the current tool list, load them with `tool_search` using a query such as `Java LSP symbol navigation lsp_java`.
1212

13-
Use `lsp_java_findSymbol` before `grep_search`, `search_subagent`, `semantic_search`, or `file_search` only when the task is to locate Java symbols by name or partial identifier. If it returns relevant symbols, do not call it again with the same or similar query; next use `lsp_java_getFileStructure` for the returned file or `read_file` on the smallest useful line range.
13+
Use `lsp_java_findSymbol` before `grep_search`, `search_subagent`, `semantic_search`, or `file_search` only when the task is to locate Java symbols by name or partial identifier. If it returns relevant symbols and source is needed, call `read_file` with the returned `readFileInput`, or call `lsp_java_getFileStructure` with the returned `file` when broader file context is needed.
1414

15-
Use `lsp_java_getFileStructure` only with a path confirmed by the user or a previous tool result. Do not guess paths. Use generic search for string literals, comments, XML, Gradle/Maven files, non-Java files, or broad conceptual exploration. `findSymbol` already retries internally with a normalized identifier, so do not re-issue the same search on an empty result: if it reports indexing in progress, retry once after a short pause; otherwise fall back to generic search.
15+
Use `lsp_java_getFileStructure` only with a path confirmed by the user or a previous tool result. Prefer `file` from `lsp_java_findSymbol`; do not guess paths. Its output includes a top-level `file` and per-symbol `readFileRange`; to read a selected symbol, call `read_file` with `filePath=file` and that `readFileRange`. Use `limit` to keep large outlines small. Use generic search for string literals, comments, XML, Gradle/Maven files, non-Java files, or broad conceptual exploration. `lsp_java_findSymbol` already retries internally with a normalized identifier, so do not re-issue the same search on an empty result: if it reports indexing in progress, retry once after a short pause; otherwise fall back to generic search.

resources/skills/java-lsp-tools/SKILL.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,14 @@ Two compiler-accurate tools backed by the Java Language Server (jdtls). They ret
1212
### `lsp_java_findSymbol`
1313
Search for Java symbol definitions (classes, methods, fields) by name across the workspace. Supports partial matching.
1414
- Input: `{ query, limit? }` — limit defaults to 20, max 50
15-
- Output: `{ results: [{ name, kind, container?, location, range }], total }` (~60 tokens); `range` is `L start-end`
15+
- Output: `{ results: [{ name, kind, container?, file, startLine, endLine, readFileInput, range }], total }`; `readFileInput` is `{ filePath, offset, limit }` for `read_file`, and `file` can be passed to `lsp_java_getFileStructure`
1616
- **Use instead of** `grep_search`, `file_search`, `semantic_search`, or `search_subagent` when looking for where a Java class/method/field is defined by identifier
17-
- Do not repeat with the same or similar query after relevant results are returned
17+
- When source is needed for a returned symbol, use its `readFileInput` directly
1818

1919
### `lsp_java_getFileStructure`
2020
Get hierarchical outline of a Java file (classes, methods, fields) with line ranges.
21-
- Input: `{ uri }` — workspace-relative path. Must be a known path from prior tool results or user input — do not guess
22-
- Output: symbol tree with `L start-end` ranges (~100 tokens)
21+
- Input: `{ uri, limit? }` — workspace-relative path plus max outline items. Prefer `file` from `lsp_java_findSymbol`; limit defaults to 20, max 60. Must be a known path from prior tool results or user input — do not guess
22+
- Output: `{ file, symbols: [{ name, kind, startLine, endLine, readFileRange, range, detail?, children? }], truncated? }`; call `read_file` with `filePath=file` and the selected symbol's `readFileRange`
2323
- **Use before** `read_file` when you need to choose a precise line range in a known Java file
2424

2525
## When to Use
@@ -34,9 +34,9 @@ Get hierarchical outline of a Java file (classes, methods, fields) with line ran
3434

3535
## Typical Workflow
3636

37-
**findSymbolgetFileStructure → read_file (specific lines only)**
37+
**lsp_java_findSymbollsp_java_getFileStructure → read_file (specific lines only)**
3838

39-
If `findSymbol` returns relevant symbols, move forward to `getFileStructure` or `read_file`; do not call `findSymbol` again with the same or similar identifier.
39+
If `lsp_java_findSymbol` returns relevant symbols and source is needed, call `read_file` with the returned `readFileInput`, or call `lsp_java_getFileStructure` with the returned `file` when broader file context is needed.
4040

4141
## Fallback
4242

src/copilot/tools/javaContextTools.ts

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import { sendInfo } from "vscode-extension-telemetry-wrapper";
2828

2929
// Hard caps to keep tool responses within the < 200 token budget.
3030
const MAX_SYMBOL_DEPTH = 3;
31-
const MAX_SYMBOL_NODES = 80;
31+
const MAX_FILE_STRUCTURE_SYMBOL_NODES = 60;
32+
const DEFAULT_FILE_STRUCTURE_SYMBOL_NODES = 20;
3233
const MAX_CALL_RESULTS = 50;
3334
const MAX_TYPE_RESULTS = 50;
3435
const MAX_IMPORTS = 50;
@@ -44,6 +45,39 @@ function getResponseCharCount(data: unknown): number {
4445
return typeof data === "string" ? data.length : JSON.stringify(data, null, 2).length;
4546
}
4647

48+
interface ReadFileInput {
49+
filePath: string;
50+
offset: number;
51+
limit: number;
52+
}
53+
54+
interface ReadFileRange {
55+
offset: number;
56+
limit: number;
57+
}
58+
59+
function toInclusiveLineRange(range: vscode.Range): { startLine: number; endLine: number } {
60+
const startLine = range.start.line + 1;
61+
const endLine = Math.max(startLine, range.end.character === 0 && range.end.line > range.start.line
62+
? range.end.line
63+
: range.end.line + 1);
64+
return { startLine, endLine };
65+
}
66+
67+
function toReadFileRange(startLine: number, endLine: number): ReadFileRange {
68+
return {
69+
offset: startLine,
70+
limit: endLine - startLine + 1,
71+
};
72+
}
73+
74+
function toReadFileInput(filePath: string, startLine: number, endLine: number): ReadFileInput {
75+
return {
76+
filePath,
77+
...toReadFileRange(startLine, endLine),
78+
};
79+
}
80+
4781
/**
4882
* Normalize a workspace-symbol query for a single fallback retry.
4983
* Strips a fully-qualified package prefix (com.foo.Bar -> Bar), generic parameters
@@ -86,7 +120,8 @@ function getToolErrorCode(error: unknown): string {
86120
* - Relative path: "src/main/java/Main.java"
87121
* - Absolute path: "/home/user/project/src/Main.java" or "C:\\Users\\...\\Main.java"
88122
*
89-
* Relative paths are resolved against the first workspace folder.
123+
* Relative paths are resolved against the first workspace folder unless they
124+
* start with a workspace folder name in a multi-root workspace.
90125
* The resolved URI must use the file: scheme and fall under a workspace folder.
91126
*/
92127
function resolveFileUri(input: string): vscode.Uri {
@@ -96,19 +131,31 @@ function resolveFileUri(input: string): vscode.Uri {
96131
}
97132

98133
let uri: vscode.Uri;
134+
const normalizedInput = input.trim();
99135

100-
if (input.includes("://")) {
136+
if (normalizedInput.includes("://")) {
101137
// URI string (e.g. "file:///home/user/project/src/Main.java")
102-
uri = vscode.Uri.parse(input);
138+
uri = vscode.Uri.parse(normalizedInput);
103139
if (uri.scheme !== "file") {
104140
throw new Error(`Unsupported URI scheme "${uri.scheme}". Only file: URIs are allowed.`);
105141
}
106-
} else if (path.isAbsolute(input)) {
142+
} else if (path.isAbsolute(normalizedInput)) {
107143
// Absolute filesystem path (Unix or Windows)
108-
uri = vscode.Uri.file(input);
144+
uri = vscode.Uri.file(normalizedInput);
109145
} else {
110-
// Relative path — resolve against first workspace folder
111-
uri = vscode.Uri.joinPath(folders[0].uri, input);
146+
// Relative path — resolve against a matching workspace folder when
147+
// asRelativePath included the folder name, otherwise use the first root.
148+
const normalizedRelativePath = normalizedInput.replace(/\\/g, "/");
149+
const matchingFolder = folders.find(folder =>
150+
normalizedRelativePath === folder.name || normalizedRelativePath.startsWith(`${folder.name}/`));
151+
if (matchingFolder) {
152+
const pathInFolder = normalizedRelativePath === matchingFolder.name
153+
? ""
154+
: normalizedRelativePath.substring(matchingFolder.name.length + 1);
155+
uri = vscode.Uri.joinPath(matchingFolder.uri, pathInFolder);
156+
} else {
157+
uri = vscode.Uri.joinPath(folders[0].uri, normalizedRelativePath);
158+
}
112159
}
113160

114161
// Ensure the resolved path is under a workspace folder
@@ -130,16 +177,19 @@ function resolveFileUri(input: string): vscode.Uri {
130177

131178
interface FileStructureInput {
132179
uri: string;
180+
limit?: number;
133181
}
134182

135183
const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
136184
async invoke(options, _token) {
137185
const startTime = Date.now();
186+
const limit = Math.min(Math.max(Math.floor(options.input.limit ?? DEFAULT_FILE_STRUCTURE_SYMBOL_NODES), 1), MAX_FILE_STRUCTURE_SYMBOL_NODES);
138187
let resultCount = 0;
139188
let status = "success";
140189
let errorCode = "";
141190
let emptyReason = "";
142191
let responseCharCount = 0;
192+
let truncated = false;
143193
try {
144194
const uri = resolveFileUri(options.input.uri);
145195
try {
@@ -171,11 +221,12 @@ const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
171221
responseCharCount = getResponseCharCount(noSymbolsPayload);
172222
return toResult(noSymbolsPayload);
173223
}
174-
const counter = { count: 0 };
175-
const result = symbolsToJson(symbols, 0, counter);
224+
const counter = { count: 0, truncated: false };
225+
const result = symbolsToJson(symbols, 0, counter, limit);
176226
resultCount = counter.count;
177-
const truncated = counter.count >= MAX_SYMBOL_NODES;
178-
const fileStructurePayload = { symbols: result, ...(truncated && { truncated: true }) };
227+
truncated = counter.truncated;
228+
const file = vscode.workspace.asRelativePath(uri);
229+
const fileStructurePayload = { file, symbols: result, ...(truncated && { truncated: true }) };
179230
responseCharCount = getResponseCharCount(fileStructurePayload);
180231
return toResult(fileStructurePayload);
181232
} catch (e) {
@@ -188,6 +239,8 @@ const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
188239
status,
189240
...(errorCode && { errorCode }),
190241
...(emptyReason && { emptyReason }),
242+
truncated: truncated ? "true" : "false",
243+
limit,
191244
resultCount,
192245
responseCharCount,
193246
durationMs: Date.now() - startTime,
@@ -199,28 +252,36 @@ const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
199252
interface SymbolNode {
200253
name: string;
201254
kind: string;
255+
startLine: number;
256+
endLine: number;
257+
readFileRange: ReadFileRange;
202258
range: string;
203259
detail?: string;
204260
children?: SymbolNode[];
205261
}
206262

207-
function symbolsToJson(symbols: vscode.DocumentSymbol[], depth: number, counter: { count: number }): SymbolNode[] {
263+
function symbolsToJson(symbols: vscode.DocumentSymbol[], depth: number, counter: { count: number; truncated: boolean }, limit: number): SymbolNode[] {
208264
const result: SymbolNode[] = [];
209265
for (const s of symbols) {
210-
if (counter.count >= MAX_SYMBOL_NODES) {
266+
if (counter.count >= limit) {
267+
counter.truncated = true;
211268
break;
212269
}
213270
counter.count++;
271+
const { startLine, endLine } = toInclusiveLineRange(s.range);
214272
const node: SymbolNode = {
215273
name: s.name,
216274
kind: vscode.SymbolKind[s.kind],
217-
range: `L${s.range.start.line + 1}-${s.range.end.line + 1}`,
275+
startLine,
276+
endLine,
277+
readFileRange: toReadFileRange(startLine, endLine),
278+
range: `L${startLine}-${endLine}`,
218279
};
219280
if (s.detail) {
220281
node.detail = s.detail;
221282
}
222283
if (s.children?.length && depth < MAX_SYMBOL_DEPTH) {
223-
node.children = symbolsToJson(s.children, depth + 1, counter);
284+
node.children = symbolsToJson(s.children, depth + 1, counter, limit);
224285
}
225286
result.push(node);
226287
}
@@ -288,13 +349,20 @@ const findSymbolTool: vscode.LanguageModelTool<FindSymbolInput> = {
288349
return toResult(noMatchesPayload);
289350
}
290351
totalResults = symbols.length;
291-
const results = symbols.slice(0, limit).map(s => ({
292-
name: s.name,
293-
kind: vscode.SymbolKind[s.kind],
294-
container: s.containerName || undefined,
295-
location: `${vscode.workspace.asRelativePath(s.location.uri)}:${s.location.range.start.line + 1}`,
296-
range: `L${s.location.range.start.line + 1}-${s.location.range.end.line + 1}`,
297-
}));
352+
const results = symbols.slice(0, limit).map(s => {
353+
const file = vscode.workspace.asRelativePath(s.location.uri);
354+
const { startLine, endLine } = toInclusiveLineRange(s.location.range);
355+
return {
356+
name: s.name,
357+
kind: vscode.SymbolKind[s.kind],
358+
container: s.containerName || undefined,
359+
file,
360+
startLine,
361+
endLine,
362+
readFileInput: toReadFileInput(file, startLine, endLine),
363+
range: `L${startLine}-${endLine}`,
364+
};
365+
});
298366
resultCount = results.length;
299367
const findSymbolPayload = { results, total: symbols.length };
300368
responseCharCount = getResponseCharCount(findSymbolPayload);

0 commit comments

Comments
 (0)