support get usage and get definition tool call

This commit is contained in:
David Halim 2026-04-23 18:24:27 +08:00
parent 4822bfacca
commit 5dea442f27
5 changed files with 321 additions and 17 deletions

31
note.md
View file

@ -523,6 +523,27 @@ Resp Nemotron: 3 tool calls, search + read convertToLLMMessageService.ts + read
- **C4 deliberately deferred**: WARNING framing on `rewrite_file` / `delete_file_or_folder` was drafted but not applied. Rationale: the write-tool silent-failure fix earlier in this session (toolsService `fileService.exists` check) handles the missing-file case, and we haven't observed `rewrite_file` misuse on *existing* files since A3+A4 shipped. Adding preventive hardening for a pathology we haven't seen dilutes attention across the prompt without a confirmed win. Re-open if `rewrite_file` misuse surfaces in daily use post-fork-switch — the drafts are preserved in the eval-section commit for quick reintroduction.
- **Accepted residual quirk**: C6 (parallel-reads example) **did not move Gemma or Nemotron on natural prompts** (tested post-ship). Both models still read serially after a search unless the user prompt explicitly says "in parallel" (Perf 4 pattern). Conclusion: post-search parallel-batching is a model-capability issue, not a prompt-words issue — adding further instructions is unlikely to help. Left the C6 bullet in place (harmless, ~1 sentence, may help on future stronger models) but stopped iterating.
- Files: `prompts.ts` — one helper const (`terminalDescHelper`), five tool descriptions, two param descriptions (`uriParam` helper + one inline), one new bullet in agent `importantDetails`, one extension to the existing parallel-tool rule. ~15 line-level edits. No other files touched, fully revertable.
- **Phase E1 — `go_to_definition` / `go_to_usages` LSP tools** — two new read-only builtin tools that bridge monaco's `getDefinitionsAtPosition` / `getReferencesAtPosition` into the agent's tool call surface. Target: stop weaker models from "fishing with grep" (35 `search_in_file` calls to locate where a named symbol is defined or used) and give the agent precise LSP semantics that handle aliased imports, re-exports, and overloaded references correctly in one call.
- **Wiring**: new entries in `BuiltinToolCallParams` / `BuiltinToolResultType` (`toolsServiceTypes.ts`); full validator/body/stringifier triple in `toolsService.ts` with `ILanguageFeaturesService` captured in the constructor; title + description + `resultWrapper` entries in `SidebarChat.tsx` — clicking a result row opens the file at the target range. Pagination shared with `search_for_files` (`MAX_CHILDREN_URIs_PAGE`). Pre-initializes target models in the body so the stringifier can sync-read preview lines per location.
- **`line` is optional, not required** (this was an iteration during the session — initial strict-line version shipped first, then relaxed after the first eval showed Gemma hallucinating `line=1`). Resolution strategy: if `line` is given AND in-range AND the symbol is on that line (word-boundary match), use it; else fall back to scanning the file for the first whole-word occurrence. Errors only when the symbol doesn't appear anywhere in the file. Uses `\b${escaped}\b` instead of `lineContent.indexOf(symbolName)` to prevent `foo` matching inside `fooBar` (seen with `validateNumber` inside `validateNumberAbs` style substrings). Two helpers at module scope: `findFirstSymbolOccurrence` and `resolveSymbolPosition` (wrapper that tries the hint, falls back to scan).
- **Error-path UX**: if no LSP definition/reference provider is registered for the file's language (e.g. `.md`, random config files), body throws an actionable error message naming the fallback tool — `"No LSP definition provider is registered for markdown files. Use search_in_file or search_for_files with \`X\` as the query instead."` — so the agent recovers cleanly in one turn instead of stalling.
- **Auto-gen parallel-tool list** (small Phase C follow-on while editing the prompt anyway): replaced the hand-enumerated "example with `read_file`" in the C6 bullet with a list computed at prompt-build time — `builtinToolNames.filter(n => approvalTypeOfBuiltinToolName[n] === undefined)` enumerates everything that isn't `edits` / `delete` / `terminal` / `MCP tools`. Single source of truth: absence from the approval map already means "no approval needed, i.e. read-only", so new read-only tools show up in the parallel guidance automatically. ~20 extra tokens per request vs. the prior vague wording, future-proof.
- **Prompt push (three surfaces, same Phase C multi-surface pattern)**:
1. Tool descriptions: enumerate the scenarios where LSP wins ("answer a question, inspect a function before calling it, follow an import to its real source, resolve what a re-export actually points to"), name the alternative tools that are noisier, and call out the no-LSP fallback.
2. `line` param description explicitly recommends passing it when known and marks it REQUIRED for disambiguation (shadowing, overloads, re-assignment), while documenting when omit-is-safe (distinctive names only; risky for `i`, `x`, `result`, `run`).
3. New C5 `importantDetails` bullet — task-centric not user-centric (learned during session that "when the user asks" framing was too narrow; agent-initiated navigation is the more common case): *"To locate a NAMED function / class / variable / type — whether to inspect its definition or find all its usages — always use `go_to_definition` / `go_to_usages`; use `search_in_file` / `search_for_files` only for free-text or conceptual queries (…'find TODOs', 'any references to auth cookies') where there is no specific identifier to resolve."* Plus redirect sentence appended to `search_in_file` and `search_for_files` descriptions: *"For locating where a NAMED … is defined or used, use `go_to_definition` / `go_to_usages` instead — LSP is precise where text search is noisy."*
- **Measured deltas** (natural-prompt test: *"Where is `validateNumber` defined? It's used inside `src/vs/workbench/contrib/void/browser/toolsService.ts`."* — no explicit "use go_to_definition" hint, same prompt across all three models):
- **MiniMax**: 1 LSP call, 3 steps total (reasoning → `go_to_definition` with `line` omitted → answer). Fallback scan resolved the position; agent picked up the tool description cleanly. **Ideal flow.**
- **Gemma**: 0 LSP calls, 3 steps total (reasoning → `read_file` → answer). Ignored the LSP tools entirely, read the file and eyeball-located the definition. Not broken, just not improved.
- **Nemotron**: 0 LSP calls, 5+ steps, 4 tool calls (`read_file` → `search_for_files``search_in_file``read_file` ranged). Worst case — went full grep-party, never reached for LSP.
- **Accepted residual quirk** (same capability ceiling as C6): prescriptive tool-selection rules move strong models (MiniMax) but do **not** move Gemma or Nemotron on natural prompts, even with three coordinated prompt surfaces pushing toward the tool. Conclusion: this is a base-model capability issue (tool-selection is one of the hardest things smaller models do — pretraining bias toward `grep`/search dominates), not a prompt-words issue. Further prompt weight would have diminishing returns and risks over-correction (agent calling `go_to_definition` for free-text queries like "find TODOs" where there's no identifier to resolve). Stopped iterating. Net effect is still a clear win: MiniMax users get 1-call LSP flows where they previously had 4-call grep flows; Gemma / Nemotron users get the tool as an **escape hatch** when they explicitly mention it in prompts (no regression, baseline unchanged). Tool is genuinely useful for re-export chains, overloaded declarations, and aliased imports — cases where lexical search is wrong, not just noisier.
- **Design decisions and rejections during session**:
- Rejected making `line` required with a "suggested line" error message (would still cost a round-trip per hallucination; silent fallback strictly better UX).
- Rejected adding few-shot examples of `go_to_definition` calls in the prompt (models over-fit to example symbol names, prompt bloat).
- Rejected auto-routing `search_in_file` calls for exact symbol names to LSP internally (violates agent control, hides behavior, fragile).
- Rejected adding an explicit `kind: 'read' | 'edit' | 'terminal'` field on `InternalToolInfo` in favor of reusing `approvalTypeOfBuiltinToolName` for read-only inference (no duplication, no drift risk).
- Skipped `go_to_implementation` and `go_to_type_definition` for now — revisit if daily use surfaces need. `includeDeclaration: true` hardcoded for `go_to_usages` (matches VS Code `Shift+F12` default), no param.
- Files: `toolsServiceTypes.ts` (2 type entries), `toolsService.ts` (2 validators + 2 bodies + 2 stringifiers + `ILanguageFeaturesService` injection + 2 helper funcs + 1 import line), `prompts.ts` (2 tool definitions, 1 C5 bullet rewrite, 2 description redirects, 1 auto-gen parallel list), `SidebarChat.tsx` (2 title entries, 2 desc entries, 2 resultWrapper renderers). One commit, ~300 lines of diff, ~150 of which are the new React tool-renderers.
- **Prompt Phase A1+A2 (Option 1: persona shift + anti-hedging)**`prompts.ts` `chat_systemMessage`: header reframed from "expert coding agent" to "senior software engineer working as the user's pair-programmer" with explicit end-to-end ownership clause; three new directives near top of `importantDetails` apply across all modes: (1) commit to one solution / don't list alternatives unless trade-offs are non-obvious, (2) act don't describe, (3) brief completion summary, no padded "let me know if you'd like me to..." offers. Empirically validated against 3 models (Nemotron, Gemma 4 26B, MiniMax 2.5) on benchmark Tasks 0/3/5 — see "Prompt evaluation logs" section. Headline wins: Nemotron Task 0 trail-off eliminated; Gemma 4 search-loop on Task 5 dropped from 5+ self-doubting iterations to 2 clean reads (27% total chat tokens). Headline cost: capable models (Nemotron, MiniMax) read the persona shift as license to be more thorough → +18% to +90% token totals on those models. Net positive (correctness held; stylistic wins real), kept as new baseline. Token-volume side-effect motivates A4 (re-balance over-iteration rules) as the immediate next prompt step.
- **Perf 4 — Parallel tool calls per turn (end-to-end)** ✅ DONE. Root cause: `sendLLMMessage.impl.ts` had hardcoded `if (index !== 0) continue` on the OAI-compat tool_calls aggregator, `tools[0]` on the Anthropic path, and `functionCalls[0]` on the Gemini path — silently dropping every tool after the first when a model emitted a batch. Downstream types and the agent loop all assumed a single tool per assistant turn, so fixing the aggregators alone wasn't enough: the full pipeline had to be arrayified. Scope of the change: (1) `OnText` + `OnFinalMessage` now carry `toolCalls?: RawToolCallObj[]` instead of `toolCall?: RawToolCallObj`. (2) All three provider paths rewritten to aggregate tools by index/id into a `Map<index, {name, argsStr, id}>` (OAI-compat, Anthropic) or a deduped append-list (Gemini), with `onText` streaming the in-progress array and `onFinalMessage` emitting the parsed final array. (3) `streamState.toolCallsSoFar` became an array; the agent loop pre-adds every tool in the batch to the thread as a `tool_request` with `batchIndex`/`batchSize` (UI numbering metadata), then serially drains via `_tryDrainPendingBatch`. Approval / reject pause between tools — `approveLatestToolRequest` advances to the first pending row; `rejectLatestToolRequest` gained a `resumeAgent: boolean` flag: the reject button marks ALL pending tools in the batch as rejected and resumes the loop so the model can react, while `abortRunning` uses `resumeAgent: false` to terminate cleanly. (4) `convertToLLMMessageService.ts` was still assuming one-tool-per-assistant on replay — fixed: OpenAI path appends to the assistant's `tool_calls` instead of overwriting; Anthropic path appends `tool_use` blocks walking back to the assistant (not just `prevMsg`); Gemini path tracks tool names by id (`Map<id, name>`) so `functionResponse.name` always matches the paired `functionCall.name` (the previous single-variable approach mislabeled all but the last tool in a batch); XML fallback concatenates all consecutive tool messages, not just `next`. (5) UI: `SidebarChat.tsx` `getTitle` prefixes `(i/N)` for batched tools; `ChatBubble` takes `firstPendingToolRequestIdx` so only the earliest pending tool_request in the trailing batch shows approve/reject buttons — the rest render as stacked progress rows. (6) Prompt: replaced `Only use ONE tool call at a time.` in `chat_systemMessage` with an explicit invitation to batch independent operations ("You can call multiple tools in a single turn when the operations are independent... use separate turns when a later tool's arguments depend on an earlier tool's result"). XML-fallback path keeps its one-tool rule — grammar extraction only parses one tag at a time, the constraint is technical. (7) Telemetry: `captureLLMEvent` now emits `toolCallCount` and comma-joined `toolCallNames` instead of a single `toolCallName`. Empirically validated across 3 models on the "read prompts.ts, sendLLMMessage.impl.ts, and chatThreadService.ts in parallel" prompt: **Gemma 4 batches cleanly** (3-tool `search_pathnames_only` → 3-tool `read_file`, 2 round-trips total); **MiniMax 2.5 partially batches** (one mixed search+read batch in turn 1, clean 2-tool read batch in turn 2); **Nemotron never batches** — 8+ solo tool calls on the same task, confirming it's a small-model capability ceiling rather than a prompt issue. The UI's `(i/N)` prefix + stacked progress rows + single-active-approve-button all render correctly across all three models. Files touched: `sendLLMMessageTypes.ts`, `sendLLMMessage.impl.ts`, `sendLLMMessage.ts`, `extractGrammar.ts`, `chatThreadServiceTypes.ts`, `chatThreadService.ts`, `convertToLLMMessageService.ts`, `SidebarChat.tsx`, `prompts.ts`.
@ -641,13 +662,9 @@ Path decision from the audit: **do the small, concrete wins first, then decide o
**Phase E — Three small daily-use wins, independently shippable.** Ordered by ROI per hour (highest first).
**E1 — Revive `go_to_definition` / `go_to_usages` as LLM tools** (highest ROI, ~half day)
- Add two new entries to `builtinTools` in `prompts.ts`. Params: `{ uri, line, column }` for definition; same for usages (plus optional `include_declaration: boolean`). Return: a small list of `{ uri, range, preview }` structs so the agent gets the same shape it does from `search_in_file` but via LSP semantics.
- Wire into `toolsService.ts` using monaco's existing LSP bridge — the same APIs that power Ctrl+F12 / Ctrl+shift+F12 in the editor. The abandonment markers on lines 342343 suggest this was scoped before; need to understand why it was dropped (possibly LSP-provider-availability concerns — fallback to `search_in_file` if no provider).
- Impact: every "where is `foo` defined", "who calls `bar`" task stops being a lexical search (which misses aliased imports, re-exports, dynamic calls) and becomes a one-tool-call LSP query. Weaker models (Nemotron, Gemma) currently do 3-5 lexical searches for this; with LSP tools, 1 call.
- Risk: low. If LSP providers aren't available for a language, tool returns "no LSP provider for this language, fall back to search tools" — agent self-corrects.
~~**E1 — Revive `go_to_definition` / `go_to_usages` as LLM tools**~~ ✅ DONE — see entry in Done section above. Shipped with optional-`line` fallback scan (word-boundary match), three-surface prompt push (tool descriptions + C5 `importantDetails` bullet + redirect lines in `search_in_file` / `search_for_files`), and auto-gen parallel-tool list derived from `approvalTypeOfBuiltinToolName`. Validated empirically on a natural "where is `validateNumber` defined" prompt: **MiniMax** uses the tool in 1 call (ideal), **Gemma / Nemotron** ignore it and fall back to read/grep (same capability ceiling as C6 parallel-reads — strong models adopt prescriptive tool rules, weaker ones don't, regardless of prompt weight). Net: clear win for MiniMax users, zero regression for others, tool remains as explicit escape hatch. Stopped iterating on the prompt — further weight risks over-correction (calling LSP for free-text queries where there's no identifier to resolve).
**E2 — `.cursor/rules` / `AGENTS.md` auto-loading** (~1 day)
**E2 — `.cursor/rules` / `AGENTS.md` auto-loading** (~1 day, next up)
- New service `rulesIngestionService.ts` (or fold into `convertToLLMMessageService.ts` if simpler): watches `.cursor/rules/*.mdc` and `AGENTS.md` at workspace root + per-folder. On change, parses frontmatter (`alwaysApply`, `globs`, `description`).
- On each `chat_systemMessage` assembly, inject a new `[Workspace Rules]` section right after the per-mode block but before `importantDetails` (keeps the standing rules authoritative over workspace overrides). Rules with `alwaysApply: true` always inject; rules with `globs:` inject only when at least one attached file matches the glob.
- Mirror Cursor's rule selection semantics: `alwaysApply` / `agentRequestable` / `glob-matched` / `manual-only`. Ignore `manual-only` for now (requires UI surface, defer).
@ -663,7 +680,7 @@ Path decision from the audit: **do the small, concrete wins first, then decide o
- Risk: low. Pure context-plumbing, no model-behavior changes. Main work is the UI picker to surface the new types.
**Execution order & commit strategy**
- Each of E1 / E2 / E3 is its own commit — they're fully independent. Recommended order: E1 → E2 → E3 (highest-ROI first, and E1's LSP-tool infrastructure won't conflict with the others).
- E1 ✅ done (own commit). E2 and E3 remain — fully independent. Recommended order: E2 → E3 (E2 is higher ROI; it also unblocks per-project memory which will be useful when dog-fooding E3).
- After each phase, dog-food with a one-day daily-use window before committing the next. If a phase's real-world impact is smaller than projected (or reveals a different problem), the later phases can be resequenced / dropped.
- Phase D deferred below — no observed pain to justify it.
- Phase F (indexing) held pending Phase E daily-use data.

View file

@ -1722,6 +1722,8 @@ const titleOfBuiltinToolName = {
'read_lint_errors': { done: `Read lint errors`, proposed: 'Read lint errors', running: loadingTitleWrapper('Reading lint errors') },
'search_in_file': { done: 'Searched in file', proposed: 'Search in file', running: loadingTitleWrapper('Searching in file') },
'go_to_definition': { done: 'Found definition', proposed: 'Go to definition', running: loadingTitleWrapper('Finding definition') },
'go_to_usages': { done: 'Found usages', proposed: 'Go to usages', running: loadingTitleWrapper('Finding usages') },
} as const satisfies Record<BuiltinToolName, { done: any, proposed: any, running: any }>
@ -1875,7 +1877,23 @@ const toolNameToDesc = (toolName: BuiltinToolName, _toolParams: BuiltinToolCallP
desc1: getBasename(toolParams.uri.fsPath),
desc1Info: getRelative(toolParams.uri, accessor),
}
}
},
'go_to_definition': () => {
const toolParams = _toolParams as BuiltinToolCallParams['go_to_definition']
const filePart = getRelative(toolParams.uri, accessor) ?? getBasename(toolParams.uri.fsPath)
return {
desc1: `"${toolParams.symbolName}"`,
desc1Info: toolParams.line !== null ? `${filePart}:${toolParams.line}` : filePart,
}
},
'go_to_usages': () => {
const toolParams = _toolParams as BuiltinToolCallParams['go_to_usages']
const filePart = getRelative(toolParams.uri, accessor) ?? getBasename(toolParams.uri.fsPath)
return {
desc1: `"${toolParams.symbolName}"`,
desc1Info: toolParams.line !== null ? `${filePart}:${toolParams.line}` : filePart,
}
},
}
try {
@ -2532,6 +2550,86 @@ const builtinToolNameToComponent: { [T in BuiltinToolName]: { resultWrapper: Res
}
},
'go_to_definition': {
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
const title = getTitle(toolMessage)
const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor)
const icon = null
if (toolMessage.type === 'tool_request') return null
if (toolMessage.type === 'running_now') return null
const isError = false
const isRejected = toolMessage.type === 'rejected'
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected }
if (toolMessage.type === 'success') {
const { result } = toolMessage
componentParams.numResults = result.locations.length
componentParams.children = result.locations.length === 0 ? undefined
: <ToolChildrenWrapper>
{result.locations.map((loc, i) => (<ListableToolItem key={i}
name={`${getBasename(loc.uri.fsPath)}:${loc.line}:${loc.column}`}
className='w-full overflow-auto'
onClick={() => { voidOpenFileFn(loc.uri, accessor, [loc.line, loc.line]) }}
/>))}
</ToolChildrenWrapper>
}
else if (toolMessage.type === 'tool_error') {
const { result } = toolMessage
componentParams.bottomChildren = <BottomChildren title='Error'>
<CodeChildren>
{result}
</CodeChildren>
</BottomChildren>
}
return <ToolHeaderWrapper {...componentParams} />
}
},
'go_to_usages': {
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
const title = getTitle(toolMessage)
const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor)
const icon = null
if (toolMessage.type === 'tool_request') return null
if (toolMessage.type === 'running_now') return null
const isError = false
const isRejected = toolMessage.type === 'rejected'
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected }
if (toolMessage.type === 'success') {
const { result } = toolMessage
componentParams.numResults = result.locations.length
componentParams.hasNextPage = result.hasNextPage
componentParams.children = result.locations.length === 0 ? undefined
: <ToolChildrenWrapper>
{result.locations.map((loc, i) => (<ListableToolItem key={i}
name={`${getBasename(loc.uri.fsPath)}:${loc.line}:${loc.column}`}
className='w-full overflow-auto'
onClick={() => { voidOpenFileFn(loc.uri, accessor, [loc.line, loc.line]) }}
/>))}
{result.hasNextPage &&
<ListableToolItem name={`Results truncated. Call again with page_number+1 to see more.`} isSmall={true} className='w-full overflow-auto' />
}
</ToolChildrenWrapper>
}
else if (toolMessage.type === 'tool_error') {
const { result } = toolMessage
componentParams.bottomChildren = <BottomChildren title='Error'>
<CodeChildren>
{result}
</CodeChildren>
</BottomChildren>
}
return <ToolHeaderWrapper {...componentParams} />
}
},
'read_lint_errors': {
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()

View file

@ -10,7 +10,10 @@ import { IEditCodeService } from './editCodeServiceInterface.js'
import { ITerminalToolService } from './terminalToolService.js'
import { LintErrorItem, BuiltinToolCallParams, BuiltinToolResultType, BuiltinToolName } from '../common/toolsServiceTypes.js'
import { IVoidModelService } from '../common/voidModelService.js'
import { EndOfLinePreference } from '../../../../editor/common/model.js'
import { EndOfLinePreference, ITextModel } from '../../../../editor/common/model.js'
import { Position } from '../../../../editor/common/core/position.js'
import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'
import { getDefinitionsAtPosition, getReferencesAtPosition } from '../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'
import { IVoidCommandBarService } from './voidCommandBarService.js'
import { computeDirectoryTree1Deep, IDirectoryStrService, stringifyDirectoryTree1Deep } from '../common/directoryStrService.js'
import { IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'
@ -146,6 +149,36 @@ const checkIfIsFolder = (uriStr: string) => {
return false
}
// Scan a model for the first whole-word occurrence of `symbolName`. Whole-word
// matching via \b prevents false positives like `validateNumber` matching inside
// `validateNumberAbs`. Returns 1-indexed line and column, or null when the symbol
// does not appear anywhere in the file.
const findFirstSymbolOccurrence = (model: ITextModel, symbolName: string): { line: number, column: number } | null => {
const escaped = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`\\b${escaped}\\b`)
const lineCount = model.getLineCount()
for (let ln = 1; ln <= lineCount; ln++) {
const content = model.getLineContent(ln)
const m = regex.exec(content)
if (m) return { line: ln, column: m.index + 1 }
}
return null
}
// Resolve where to point the LSP for `symbolName` in `model`.
// Priority: explicit lineHint if the symbol is actually on that line (word-boundary);
// otherwise fall back to first whole-word occurrence anywhere in the file.
// Returns null only when the symbol does not appear in the file at all.
const resolveSymbolPosition = (model: ITextModel, symbolName: string, lineHint: number | null): { line: number, column: number } | null => {
const lineCount = model.getLineCount()
if (lineHint !== null && lineHint >= 1 && lineHint <= lineCount) {
const escaped = symbolName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const m = new RegExp(`\\b${escaped}\\b`).exec(model.getLineContent(lineHint))
if (m) return { line: lineHint, column: m.index + 1 }
}
return findFirstSymbolOccurrence(model, symbolName)
}
export interface IToolsService {
readonly _serviceBrand: undefined;
validateParams: ValidateBuiltinParams;
@ -175,6 +208,7 @@ export class ToolsService implements IToolsService {
@IDirectoryStrService private readonly directoryStrService: IDirectoryStrService,
@IMarkerService private readonly markerService: IMarkerService,
@IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService,
@ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService,
) {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
@ -254,6 +288,25 @@ export class ToolsService implements IToolsService {
return { uri, query, isRegex };
},
go_to_definition: (params: RawToolParamsObj) => {
const { uri: uriStr, symbol_name: symbolNameUnknown, line: lineUnknown } = params
const uri = validateURI(uriStr)
const symbolName = validateStr('symbol_name', symbolNameUnknown)
const line = validateNumber(lineUnknown, { default: null })
if (line !== null && line < 1) throw new Error(`\`line\` must be 1 or greater, got ${line}.`)
return { uri, symbolName, line }
},
go_to_usages: (params: RawToolParamsObj) => {
const { uri: uriStr, symbol_name: symbolNameUnknown, line: lineUnknown, page_number: pageNumberUnknown } = params
const uri = validateURI(uriStr)
const symbolName = validateStr('symbol_name', symbolNameUnknown)
const line = validateNumber(lineUnknown, { default: null })
if (line !== null && line < 1) throw new Error(`\`line\` must be 1 or greater, got ${line}.`)
const pageNumber = validatePageNum(pageNumberUnknown)
return { uri, symbolName, line, pageNumber }
},
read_lint_errors: (params: RawToolParamsObj) => {
const {
uri: uriUnknown,
@ -420,6 +473,86 @@ export class ToolsService implements IToolsService {
return { result: { lines } };
},
go_to_definition: async ({ uri, symbolName, line }) => {
await voidModelService.initializeModel(uri)
const { model } = await voidModelService.getModelSafe(uri)
if (model === null) throw new Error(`File does not exist: ${uri.fsPath}.`)
// Position resolution:
// 1. If `line` is given AND in range AND the symbol is on that line (word-boundary
// match), use it. This is the most reliable mode — the agent has seen the symbol.
// 2. Otherwise (null / out of range / symbol not on that line), fall back to scanning
// the file for the first whole-word occurrence. Safe for unique names; documented
// in the tool description so agents know when this is safe.
// 3. If the symbol doesn't appear anywhere in the file, error.
const position = resolveSymbolPosition(model, symbolName, line)
if (position === null) throw new Error(`Symbol \`${symbolName}\` not found anywhere in ${uri.fsPath}. Check the spelling of the symbol or the file path.`)
const providers = languageFeaturesService.definitionProvider.ordered(model)
if (providers.length === 0) throw new Error(`No LSP definition provider is registered for ${model.getLanguageId()} files. Use \`search_in_file\` or \`search_for_files\` with \`${symbolName}\` as the query instead.`)
const links = await getDefinitionsAtPosition(
languageFeaturesService.definitionProvider,
model,
new Position(position.line, position.column),
false,
CancellationToken.None,
)
// Pre-initialize target models so the stringifier can synchronously read
// a preview line for each location (matching the search_in_file pattern).
await Promise.all(
[...new Set(links.map(l => l.uri.toString()))].map(s => voidModelService.initializeModel(URI.parse(s)))
)
const locations = links.map(link => ({
uri: link.uri,
line: link.range.startLineNumber,
column: link.range.startColumn,
}))
return { result: { locations } }
},
go_to_usages: async ({ uri, symbolName, line, pageNumber }) => {
await voidModelService.initializeModel(uri)
const { model } = await voidModelService.getModelSafe(uri)
if (model === null) throw new Error(`File does not exist: ${uri.fsPath}.`)
const position = resolveSymbolPosition(model, symbolName, line)
if (position === null) throw new Error(`Symbol \`${symbolName}\` not found anywhere in ${uri.fsPath}. Check the spelling of the symbol or the file path.`)
const providers = languageFeaturesService.referenceProvider.ordered(model)
if (providers.length === 0) throw new Error(`No LSP reference provider is registered for ${model.getLanguageId()} files. Use \`search_for_files\` with \`${symbolName}\` as the query instead.`)
// `compact: false` keeps declaration in the result list (matches Shift+F12);
// `recursive: false` matches the default VS Code "Find All References" command.
const links = await getReferencesAtPosition(
languageFeaturesService.referenceProvider,
model,
new Position(position.line, position.column),
false,
false,
CancellationToken.None,
)
// Paginate at the same page size as search_for_files / ls_dir.
const fromIdx = MAX_CHILDREN_URIs_PAGE * (pageNumber - 1)
const toIdx = MAX_CHILDREN_URIs_PAGE * pageNumber - 1
const pageLinks = links.slice(fromIdx, toIdx + 1)
const hasNextPage = (links.length - 1) - toIdx >= 1
await Promise.all(
[...new Set(pageLinks.map(l => l.uri.toString()))].map(s => voidModelService.initializeModel(URI.parse(s)))
)
const locations = pageLinks.map(link => ({
uri: link.uri,
line: link.range.startLineNumber,
column: link.range.startColumn,
}))
return { result: { locations, hasNextPage } }
},
read_lint_errors: async ({ uri }) => {
await timeout(1000)
const { lintErrors } = this._getLintErrors(uri)
@ -556,6 +689,33 @@ export class ToolsService implements IToolsService {
}).join('\n\n');
return lines;
},
go_to_definition: (params, result) => {
if (result.locations.length === 0) {
return `No definition found for \`${params.symbolName}\` on ${params.uri.fsPath}:${params.line}. This can happen for built-in or primitive types. If you believe this is wrong, try \`search_in_file\` or \`search_for_files\` with \`${params.symbolName}\` as the query.`
}
const header = result.locations.length === 1
? `Found 1 definition of \`${params.symbolName}\`:`
: `Found ${result.locations.length} definitions of \`${params.symbolName}\`:`
const lines = result.locations.map((loc, i) => {
const { model } = voidModelService.getModel(loc.uri)
const preview = model ? model.getLineContent(loc.line).trim() : '<preview unavailable>'
return `${i + 1}. ${loc.uri.fsPath}:${loc.line}:${loc.column} ${preview}`
})
return [header, ...lines].join('\n')
},
go_to_usages: (params, result) => {
if (result.locations.length === 0) {
return `No usages found for \`${params.symbolName}\` on ${params.uri.fsPath}:${params.line}. If you believe this is wrong, try \`search_for_files\` with \`${params.symbolName}\` as the query.`
}
const header = `Found ${result.locations.length} ${result.locations.length === 1 ? 'usage' : 'usages'} of \`${params.symbolName}\`${result.hasNextPage ? ' (more on next page)' : ''}:`
const lines = result.locations.map((loc, i) => {
const { model } = voidModelService.getModel(loc.uri)
const preview = model ? model.getLineContent(loc.line).trim() : '<preview unavailable>'
return `${i + 1}. ${loc.uri.fsPath}:${loc.line}:${loc.column} ${preview}`
})
const footer = result.hasNextPage ? '\n\n(More usages available. Call again with `page_number` incremented by 1 to see them.)' : ''
return [header, ...lines].join('\n') + footer
},
read_lint_errors: (params, result) => {
return result.lintErrors ?
stringifyLintErrors(result.lintErrors)

View file

@ -239,7 +239,7 @@ export const builtinTools: {
search_for_files: {
name: 'search_for_files',
description: `Use this to find which files contain a given string or regex pattern across the workspace. Returns a list of matching file names (not line numbers). For line-number positions within a specific file, use \`search_in_file\`. Never use \`run_command\` with \`grep\` — this tool is the correct choice.`,
description: `Use this to find which files contain a given string or regex pattern across the workspace. Returns a list of matching file names (not line numbers). For line-number positions within a specific file, use \`search_in_file\`. For locating where a NAMED function / class / variable / type is defined or used, use \`go_to_definition\` / \`go_to_usages\` instead — LSP is precise where text search is noisy. Never use \`run_command\` with \`grep\` — this tool is the correct choice.`,
params: {
query: { description: `Your query for the search.` },
search_in_folder: { description: 'Optional. Leave as blank by default. ONLY fill this in if your previous search with the same query was truncated. Searches descendants of this folder only.' },
@ -251,7 +251,7 @@ export const builtinTools: {
// add new search_in_file tool
search_in_file: {
name: 'search_in_file',
description: `Use this to find where a pattern appears inside a specific file. Returns the start line numbers of matches. For cross-file content search, use \`search_for_files\`. Never use \`run_command\` with \`grep\` — this tool is the correct choice.`,
description: `Use this to find where a pattern appears inside a specific file. Returns the start line numbers of matches. For cross-file content search, use \`search_for_files\`. For locating where a NAMED function / class / variable / type is defined or used, use \`go_to_definition\` / \`go_to_usages\` instead — LSP is precise where text search is noisy. Never use \`run_command\` with \`grep\` — this tool is the correct choice.`,
params: {
...uriParam('file'),
query: { description: 'The string or regex to search for in the file.' },
@ -259,6 +259,27 @@ export const builtinTools: {
}
},
go_to_definition: {
name: 'go_to_definition',
description: `Use this to find where a symbol (function, class, variable, type) is defined, using the language's LSP (same mechanism as VS Code's "Go to Definition" / F12). Returns precise source locations — NOT text matches. This is the correct tool whenever you need to locate the source of a named identifier, whether to answer a question, inspect a function before calling it, follow an import to its real source, or resolve what a re-export actually points to. Prefer this over \`search_in_file\` or \`search_for_files\` whenever you know the symbol name: LSP resolves aliases, re-exports, and overloaded references that lexical search conflates or misses entirely. If no LSP provider is registered for the file's language, this tool returns an error telling you to fall back to \`search_in_file\` / \`search_for_files\`.`,
params: {
...uriParam('file'),
symbol_name: { description: `The name of the symbol you want to locate (e.g. \`validateToken\`, \`MyClass\`). Case-sensitive. Must appear somewhere in the file; the tool matches whole words only (e.g., \`foo\` will not match inside \`fooBar\`).` },
line: { description: `Optional — strongly recommended when you know it. The 1-indexed line number in the file where \`symbol_name\` appears. If you have just read the file or run \`search_in_file\`, pass the line you saw — this is the most reliable mode and is REQUIRED to disambiguate when the same name has multiple meanings in the same file (shadowing, re-assignment, overloaded declarations, local-vs-outer bindings). If omitted, the tool scans the file for the first whole-word occurrence of \`symbol_name\` — this is safe only when the name is distinctive enough to have a single meaning in the file (typical for unique function/class names, risky for short/common names like \`i\`, \`x\`, \`result\`, \`run\`).` },
},
},
go_to_usages: {
name: 'go_to_usages',
description: `Use this to find everywhere a symbol is referenced across the workspace, using the language's LSP (same mechanism as VS Code's "Find All References" / Shift+F12). Returns precise call sites and reference locations including the declaration itself — NOT text matches. This is the correct tool whenever you need to find who calls/uses a named identifier: before refactoring, before deleting, or to understand impact. Prefer this over \`search_for_files\` whenever you know the symbol name: LSP handles aliased imports, re-exports, and dynamic references that text search cannot disambiguate. If no LSP provider is registered for the file's language, this tool returns an error telling you to fall back to \`search_for_files\`.`,
params: {
...uriParam('file'),
symbol_name: { description: `The name of the symbol whose usages you want to find (e.g. \`validateToken\`, \`MyClass\`). Case-sensitive. Must appear somewhere in the file; the tool matches whole words only (e.g., \`foo\` will not match inside \`fooBar\`).` },
line: { description: `Optional — strongly recommended when you know it. The 1-indexed line number in the file where \`symbol_name\` appears. If you have just read the file or run \`search_in_file\`, pass the line you saw — this is the most reliable mode and is REQUIRED to disambiguate when the same name has multiple meanings in the same file (shadowing, re-assignment, overloaded declarations). If omitted, the tool scans the file for the first whole-word occurrence of \`symbol_name\` — safe for distinctive names, risky for common names.` },
...paginationParam,
},
},
read_lint_errors: {
name: 'read_lint_errors',
description: `Use this tool to view all the lint errors on a file.`,
@ -338,10 +359,6 @@ export const builtinTools: {
params: { persistent_terminal_id: { description: `The ID of the persistent terminal.` } }
}
// go_to_definition
// go_to_usages
} satisfies { [T in keyof BuiltinToolResultType]: InternalToolInfo }
@ -512,7 +529,15 @@ You will be given instructions from the user, and may also receive a list of fil
// turn that batches N reads costs one round-trip instead of N, and prefix caching
// stays warm across the whole batch. Keep sequential tools for dependent steps
// where later arguments require earlier results.
details.push(`You can call multiple tools in a single turn when the operations are independent (e.g. reading several files, searching several patterns). Prefer batching reads/searches together rather than issuing them one-at-a-time across turns. Use separate turns when a later tool's arguments depend on an earlier tool's result. Concrete example: when a search or list returns multiple files you want to inspect, read ALL of them in ONE turn (one assistant message with multiple \`read_file\` tool calls) — NOT one per turn. Per-turn reads compound input tokens for every subsequent call.`)
// Auto-generate the read-only tool list from approvalTypeOfBuiltinToolName so this
// stays in sync when tools are added/removed. A tool is read-only iff it is NOT in
// approvalTypeOfBuiltinToolName (absence from that map is already how Void decides
// what's safe to auto-allow), which is the exact semantic we want here.
const readOnlyToolNames = builtinToolNames
.filter(n => approvalTypeOfBuiltinToolName[n] === undefined)
.map(n => `\`${n}\``)
.join(', ')
details.push(`Read-only tools (${readOnlyToolNames}) can be called in parallel in one turn when their arguments are independent — prefer batching them over issuing them one-at-a-time. Concrete example: when a search or list returns multiple files you want to inspect, call ALL the reads/lookups in ONE turn (one assistant message with multiple tool calls) — NOT one per turn. Per-turn reads compound input tokens for every subsequent call. Use separate turns only when a later tool's arguments depend on an earlier tool's result.`)
// Perf 2 — trimmed tool results hint. Older data-fetching tool outputs in the
// conversation history may have their bodies replaced with a short marker
// (starting with "[trimmed — ...]"). The model needs to know this is expected
@ -545,7 +570,7 @@ You will be given instructions from the user, and may also receive a list of fil
// eval; Gemma's persistent `find`-fallback after C1-only change). Rules
// that appear in tool descriptions AND importantDetails get followed
// more reliably than rules that appear in one surface only.
details.push(`For file and directory operations, always use the dedicated tool — never shell out via \`run_command\`: use \`read_file\` (not \`cat\`), \`ls_dir\` or \`get_dir_tree\` (not \`ls\` / \`tree\`), \`search_pathnames_only\` (not \`find\`), \`search_in_file\` or \`search_for_files\` (not \`grep\`), and \`edit_file\` or \`rewrite_file\` (not \`sed\` / \`echo >\`). \`run_command\` is for things the dedicated tools don't do — installing packages, running tests, git operations, build commands.`)
details.push(`For file and directory operations, always use the dedicated tool — never shell out via \`run_command\`: use \`read_file\` (not \`cat\`), \`ls_dir\` or \`get_dir_tree\` (not \`ls\` / \`tree\`), \`search_pathnames_only\` (not \`find\`), \`search_in_file\` or \`search_for_files\` (not \`grep\`), and \`edit_file\` or \`rewrite_file\` (not \`sed\` / \`echo >\`). To locate a NAMED function / class / variable / type — whether to inspect its definition or find all its usages — always use \`go_to_definition\` / \`go_to_usages\`; use \`search_in_file\` / \`search_for_files\` only for free-text or conceptual queries (e.g., 'where is error handling done', 'find TODOs', 'any references to auth cookies') where there is no specific identifier to resolve. \`run_command\` is for things the dedicated tools don't do — installing packages, running tests, git operations, build commands.`)
// A4 — Rebalance over-iteration. Replaces three compounding rules
// ("maximal certainty BEFORE" + "OFTEN need to gather context" +

View file

@ -84,6 +84,8 @@ export type BuiltinToolCallParams = {
'search_pathnames_only': { query: string, includePattern: string | null, pageNumber: number },
'search_for_files': { query: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number },
'search_in_file': { uri: URI, query: string, isRegex: boolean },
'go_to_definition': { uri: URI, symbolName: string, line: number | null },
'go_to_usages': { uri: URI, symbolName: string, line: number | null, pageNumber: number },
'read_lint_errors': { uri: URI },
// ---
'rewrite_file': { uri: URI, newContent: string },
@ -105,6 +107,8 @@ export type BuiltinToolResultType = {
'search_pathnames_only': { uris: URI[], hasNextPage: boolean },
'search_for_files': { uris: URI[], hasNextPage: boolean },
'search_in_file': { lines: number[]; },
'go_to_definition': { locations: { uri: URI, line: number, column: number }[] },
'go_to_usages': { locations: { uri: URI, line: number, column: number }[], hasNextPage: boolean },
'read_lint_errors': { lintErrors: LintErrorItem[] | null },
// ---
'rewrite_file': Promise<{ lintErrors: LintErrorItem[] | null }>,