Skip to content
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
{
"name": "lsp_java_getFileStructure",
"toolReferenceName": "javaFileStructure",
"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.",
"modelDescription": "Get a known Java file's outline: classes, interfaces, methods, fields, symbol kinds, and read_file ranges, to pick a precise source range instead of reading the whole file.\n\nUse when the user gave a Java file path or lsp_java_findSymbol returned a file and broader file context is needed; do not guess paths. Results include a top-level file plus per-symbol startLine, endLine, and readFileRange ({ offset, limit }); call read_file with filePath=file and the selected symbol's readFileRange. Use limit to cap returned outline items (default 20, max 60). Not for workspace-wide search\u2014use lsp_java_findSymbol for that.",
"displayName": "Java: Get File Structure",
"userDescription": "Get a Java file outline with classes, methods, fields, and line ranges.",
"tags": [
Expand All @@ -69,7 +69,11 @@
"properties": {
"uri": {
"type": "string",
"description": "Workspace-relative path to a Java file. Must be a known path from prior tool results or user input — do not guess."
"description": "Workspace-relative path to a Java file. Prefer file returned by lsp_java_findSymbol. Must be a known path from prior tool results or user input — do not guess."
},
"limit": {
"type": "number",
"description": "Maximum outline items to return (default: 20, max: 60). Use a smaller value when only top-level context is needed."
}
},
"required": [
Expand All @@ -80,7 +84,7 @@
{
"name": "lsp_java_findSymbol",
"toolReferenceName": "javaFindSymbol",
"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.",
"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\nResults include file, startLine, endLine, range, and readFileInput ({ filePath, offset, limit }) compatible with read_file. On relevant results, use read_file with readFileInput when source is needed, or lsp_java_getFileStructure with the returned file when broader file context is needed. 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.",
"displayName": "Java: Find Symbol",
"userDescription": "Find Java class, method, field, or interface definitions by name.",
"tags": [
Expand Down
4 changes: 2 additions & 2 deletions resources/instruments/javaLspContext.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ For Java symbol navigation, two compiler-accurate `lsp_java_*` tools are availab

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`.

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.
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.

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.
Use `lsp_java_getFileStructure` only with a path confirmed by the user or a previous tool result. Prefer `file` from `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. `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.
12 changes: 6 additions & 6 deletions resources/skills/java-lsp-tools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ Two compiler-accurate tools backed by the Java Language Server (jdtls). They ret
### `lsp_java_findSymbol`
Search for Java symbol definitions (classes, methods, fields) by name across the workspace. Supports partial matching.
- Input: `{ query, limit? }` — limit defaults to 20, max 50
- Output: `{ results: [{ name, kind, container?, location, range }], total }` (~60 tokens); `range` is `L start-end`
- 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`
- **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
- Do not repeat with the same or similar query after relevant results are returned
- When source is needed for a returned symbol, use its `readFileInput` directly

### `lsp_java_getFileStructure`
Get hierarchical outline of a Java file (classes, methods, fields) with line ranges.
- Input: `{ uri }` — workspace-relative path. Must be a known path from prior tool results or user input — do not guess
- Output: symbol tree with `L start-end` ranges (~100 tokens)
- 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
- Output: `{ file, symbols: [{ name, kind, startLine, endLine, readFileRange, range, detail?, children? }], truncated? }`; call `read_file` with `filePath=file` and the selected symbol's `readFileRange`
- **Use before** `read_file` when you need to choose a precise line range in a known Java file

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

## Typical Workflow

**findSymbolgetFileStructure → read_file (specific lines only)**
**lsp_java_findSymbollsp_java_getFileStructure → read_file (specific lines only)**

If `findSymbol` returns relevant symbols, move forward to `getFileStructure` or `read_file`; do not call `findSymbol` again with the same or similar identifier.
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.

## Fallback

Expand Down
114 changes: 91 additions & 23 deletions src/copilot/tools/javaContextTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import { sendInfo } from "vscode-extension-telemetry-wrapper";

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

interface ReadFileInput {
filePath: string;
offset: number;
limit: number;
}

interface ReadFileRange {
offset: number;
limit: number;
}

function toInclusiveLineRange(range: vscode.Range): { startLine: number; endLine: number } {
const startLine = range.start.line + 1;
const endLine = Math.max(startLine, range.end.character === 0 && range.end.line > range.start.line
? range.end.line
: range.end.line + 1);
return { startLine, endLine };
}

function toReadFileRange(startLine: number, endLine: number): ReadFileRange {
return {
offset: startLine,
limit: endLine - startLine + 1,
};
}

function toReadFileInput(filePath: string, startLine: number, endLine: number): ReadFileInput {
return {
filePath,
...toReadFileRange(startLine, endLine),
};
}

/**
* Normalize a workspace-symbol query for a single fallback retry.
* Strips a fully-qualified package prefix (com.foo.Bar -> Bar), generic parameters
Expand Down Expand Up @@ -86,7 +120,8 @@ function getToolErrorCode(error: unknown): string {
* - Relative path: "src/main/java/Main.java"
* - Absolute path: "/home/user/project/src/Main.java" or "C:\\Users\\...\\Main.java"
*
* Relative paths are resolved against the first workspace folder.
* Relative paths are resolved against the first workspace folder unless they
* start with a workspace folder name in a multi-root workspace.
* The resolved URI must use the file: scheme and fall under a workspace folder.
*/
function resolveFileUri(input: string): vscode.Uri {
Expand All @@ -96,19 +131,31 @@ function resolveFileUri(input: string): vscode.Uri {
}

let uri: vscode.Uri;
const normalizedInput = input.trim();

if (input.includes("://")) {
if (normalizedInput.includes("://")) {
// URI string (e.g. "file:///home/user/project/src/Main.java")
uri = vscode.Uri.parse(input);
uri = vscode.Uri.parse(normalizedInput);
Comment on lines 133 to +138

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 467198e by removing the ambiguous location: file:line field from lsp_java_findSymbol; the intended lsp_java_getFileStructure handoff now uses the structured file field instead.

if (uri.scheme !== "file") {
throw new Error(`Unsupported URI scheme "${uri.scheme}". Only file: URIs are allowed.`);
}
} else if (path.isAbsolute(input)) {
} else if (path.isAbsolute(normalizedInput)) {
// Absolute filesystem path (Unix or Windows)
uri = vscode.Uri.file(input);
uri = vscode.Uri.file(normalizedInput);
} else {
// Relative path — resolve against first workspace folder
uri = vscode.Uri.joinPath(folders[0].uri, input);
// Relative path — resolve against a matching workspace folder when
// asRelativePath included the folder name, otherwise use the first root.
const normalizedRelativePath = normalizedInput.replace(/\\/g, "/");
const matchingFolder = folders.find(folder =>
normalizedRelativePath === folder.name || normalizedRelativePath.startsWith(`${folder.name}/`));
if (matchingFolder) {
const pathInFolder = normalizedRelativePath === matchingFolder.name
? ""
: normalizedRelativePath.substring(matchingFolder.name.length + 1);
uri = vscode.Uri.joinPath(matchingFolder.uri, pathInFolder);
} else {
uri = vscode.Uri.joinPath(folders[0].uri, normalizedRelativePath);
}
Comment thread
Copilot marked this conversation as resolved.
}

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

interface FileStructureInput {
uri: string;
limit?: number;
}

const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
async invoke(options, _token) {
const startTime = Date.now();
const limit = Math.min(Math.max(options.input.limit || DEFAULT_FILE_STRUCTURE_SYMBOL_NODES, 1), MAX_FILE_STRUCTURE_SYMBOL_NODES);
let resultCount = 0;
let status = "success";
let errorCode = "";
let emptyReason = "";
let responseCharCount = 0;
let truncated = false;
try {
const uri = resolveFileUri(options.input.uri);
try {
Expand Down Expand Up @@ -171,11 +221,12 @@ const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
responseCharCount = getResponseCharCount(noSymbolsPayload);
return toResult(noSymbolsPayload);
}
const counter = { count: 0 };
const result = symbolsToJson(symbols, 0, counter);
const counter = { count: 0, truncated: false };
const result = symbolsToJson(symbols, 0, counter, limit);
resultCount = counter.count;
const truncated = counter.count >= MAX_SYMBOL_NODES;
const fileStructurePayload = { symbols: result, ...(truncated && { truncated: true }) };
truncated = counter.truncated;
const file = vscode.workspace.asRelativePath(uri);
const fileStructurePayload = { file, symbols: result, ...(truncated && { truncated: true }) };
responseCharCount = getResponseCharCount(fileStructurePayload);
return toResult(fileStructurePayload);
} catch (e) {
Expand All @@ -188,6 +239,8 @@ const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
status,
...(errorCode && { errorCode }),
...(emptyReason && { emptyReason }),
truncated: truncated ? "true" : "false",
limit,
resultCount,
responseCharCount,
durationMs: Date.now() - startTime,
Expand All @@ -199,28 +252,36 @@ const fileStructureTool: vscode.LanguageModelTool<FileStructureInput> = {
interface SymbolNode {
name: string;
kind: string;
startLine: number;
endLine: number;
readFileRange: ReadFileRange;
range: string;
detail?: string;
children?: SymbolNode[];
}

function symbolsToJson(symbols: vscode.DocumentSymbol[], depth: number, counter: { count: number }): SymbolNode[] {
function symbolsToJson(symbols: vscode.DocumentSymbol[], depth: number, counter: { count: number; truncated: boolean }, limit: number): SymbolNode[] {
const result: SymbolNode[] = [];
for (const s of symbols) {
if (counter.count >= MAX_SYMBOL_NODES) {
if (counter.count >= limit) {
counter.truncated = true;
break;
}
counter.count++;
const { startLine, endLine } = toInclusiveLineRange(s.range);
const node: SymbolNode = {
name: s.name,
kind: vscode.SymbolKind[s.kind],
range: `L${s.range.start.line + 1}-${s.range.end.line + 1}`,
startLine,
endLine,
readFileRange: toReadFileRange(startLine, endLine),
range: `L${startLine}-${endLine}`,
};
if (s.detail) {
node.detail = s.detail;
}
if (s.children?.length && depth < MAX_SYMBOL_DEPTH) {
node.children = symbolsToJson(s.children, depth + 1, counter);
node.children = symbolsToJson(s.children, depth + 1, counter, limit);
}
result.push(node);
}
Expand Down Expand Up @@ -288,13 +349,20 @@ const findSymbolTool: vscode.LanguageModelTool<FindSymbolInput> = {
return toResult(noMatchesPayload);
}
totalResults = symbols.length;
const results = symbols.slice(0, limit).map(s => ({
name: s.name,
kind: vscode.SymbolKind[s.kind],
container: s.containerName || undefined,
location: `${vscode.workspace.asRelativePath(s.location.uri)}:${s.location.range.start.line + 1}`,
range: `L${s.location.range.start.line + 1}-${s.location.range.end.line + 1}`,
}));
const results = symbols.slice(0, limit).map(s => {
const file = vscode.workspace.asRelativePath(s.location.uri);
const { startLine, endLine } = toInclusiveLineRange(s.location.range);
return {
name: s.name,
kind: vscode.SymbolKind[s.kind],
container: s.containerName || undefined,
file,
startLine,
endLine,
readFileInput: toReadFileInput(file, startLine, endLine),
range: `L${startLine}-${endLine}`,
};
});
resultCount = results.length;
const findSymbolPayload = { results, total: symbols.length };
responseCharCount = getResponseCharCount(findSymbolPayload);
Expand Down
Loading