search tools now support regex, reading line numbers, etc

This commit is contained in:
Andrew Pareles 2025-04-05 16:59:03 -07:00
parent 2aae01f3b6
commit b440c8732f
5 changed files with 111 additions and 37 deletions

View file

@ -1198,7 +1198,7 @@ We only need to do it for files that were edited since `from`, ie files between
// else search codebase for `target`
let uris: URI[] = []
try {
const { result } = await this._toolsService.callTool['search_pathnames_only']({ queryStr: target, pageNumber: 0 })
const { result } = await this._toolsService.callTool['search_pathnames_only']({ queryStr: target, include: null, pageNumber: 0 })
uris = result.uris
} catch (e) {
return null

View file

@ -541,7 +541,7 @@ export const SelectedFiles = (
}
return (
<div className='flex items-center flex-wrap text-left relative gap-x-0.5 gap-y-1'>
<div className='flex items-center flex-wrap text-left relative gap-x-0.5 gap-y-1 pb-0.5'>
{allSelections.map((selection, i) => {
@ -1178,7 +1178,7 @@ const loadingTitleWrapper = (item: React.ReactNode) => {
}
const folderFileStr = (isFolder: boolean) => isFolder ? 'folder' : 'file'
const titleOfToolName = {
'view_file_contents': { done: 'Read file', proposed: 'Read file', running: loadingTitleWrapper('Reading file') },
'read_file': { done: 'Read file', proposed: 'Read file', running: loadingTitleWrapper('Reading file') },
'ls_dir': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') },
'get_dir_structure': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') },
'search_pathnames_only': { done: 'Searched by file name', proposed: 'Search by file name', running: loadingTitleWrapper('Searching by file name') },
@ -1205,8 +1205,8 @@ const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName
return '';
}
if (toolName === 'view_file_contents') {
const toolParams = _toolParams as ToolCallParams['view_file_contents']
if (toolName === 'read_file') {
const toolParams = _toolParams as ToolCallParams['read_file']
return getBasename(toolParams.uri.fsPath);
} else if (toolName === 'ls_dir') {
const toolParams = _toolParams as ToolCallParams['ls_dir']
@ -1373,7 +1373,7 @@ type ToolComponent<T extends ToolName,> = {
}
const toolNameToComponent: { [T in ToolName]: ToolComponent<T> } = {
'view_file_contents': {
'read_file': {
requestWrapper: null,
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()

View file

@ -55,7 +55,9 @@ const validateJSON = (s: string): { [s: string]: unknown } => {
}
}
const isFalsy = (u: unknown) => {
return !u || u === 'null' || u === 'undefined'
}
const validateStr = (argName: string, value: unknown) => {
if (typeof value !== 'string') throw new Error(`Invalid LLM output format: ${argName} must be a string.`)
@ -64,13 +66,24 @@ const validateStr = (argName: string, value: unknown) => {
// We are NOT checking to make sure in workspace
// TODO!!!! check to make sure folder/file exists
const validateURI = (uriStr: unknown) => {
if (typeof uriStr !== 'string') throw new Error('Invalid LLM output format: Provided uri must be a string.')
const uri = URI.file(uriStr)
return uri
}
const validateOptionalURI = (uriStr: unknown) => {
if (isFalsy(uriStr)) return null
return validateURI(uriStr)
}
const validateOptionalStr = (argName: string, str: unknown) => {
if (isFalsy(str)) return null
return validateStr(argName, str)
}
const validatePageNum = (pageNumberUnknown: unknown) => {
if (!pageNumberUnknown) return 1
const parsedInt = Number.parseInt(pageNumberUnknown + '')
@ -79,6 +92,20 @@ const validatePageNum = (pageNumberUnknown: unknown) => {
return parsedInt
}
const validateNumber = (numStr: unknown, opts: { default: number | null }) => {
if (typeof numStr === 'number')
return numStr
if (isFalsy(numStr)) return opts.default
if (typeof numStr === 'string') {
const parsedInt = Number.parseInt(numStr + '')
if (!Number.isInteger(parsedInt)) return opts.default
return parsedInt
}
return opts.default
}
const validateRecursiveParamStr = (paramsUnknown: unknown) => {
if (typeof paramsUnknown !== 'string') throw new Error('Invalid LLM output format: Error calling tool: provided params must be a string.')
const params = paramsUnknown
@ -92,12 +119,15 @@ const validateProposedTerminalId = (terminalIdUnknown: unknown) => {
return terminalId
}
const validateWaitForCompletion = (b: unknown) => {
const validateBoolean = (b: unknown, opts: { default: boolean }) => {
if (typeof b === 'string') {
if (b === 'true') return true
if (b === 'false') return false
}
return true // default is true
if (typeof b === 'boolean') {
return b
}
return opts.default
}
@ -139,14 +169,17 @@ export class ToolsService implements IToolsService {
const queryBuilder = instantiationService.createInstance(QueryBuilder);
this.validateParams = {
view_file_contents: async (params: string) => {
read_file: async (params: string) => {
const o = validateJSON(params)
const { uri: uriStr, pageNumber: pageNumberUnknown } = o
const { uri: uriStr, startLine: startLineUnknown, endLine: endLineUnknown, pageNumber: pageNumberUnknown } = o
const uri = validateURI(uriStr)
const pageNumber = validatePageNum(pageNumberUnknown)
return { uri, pageNumber }
const startLine = validateNumber(startLineUnknown, { default: null })
const endLine = validateNumber(endLineUnknown, { default: null })
return { uri, startLine, endLine, pageNumber }
},
ls_dir: async (params: string) => {
const o = validateJSON(params)
@ -164,22 +197,35 @@ export class ToolsService implements IToolsService {
},
search_pathnames_only: async (params: string) => {
const o = validateJSON(params)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const {
query: queryUnknown,
include: includeUnknown,
pageNumber: pageNumberUnknown
} = o
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const include = validateOptionalStr('include', includeUnknown)
return { queryStr, pageNumber }
return { queryStr, include, pageNumber }
},
search_files: async (params: string) => {
const o = validateJSON(params)
const { query: queryUnknown, pageNumber: pageNumberUnknown } = o
const {
query: queryUnknown,
searchInFolder: searchInFolderUnknown,
isRegex: isRegexUnknown,
pageNumber: pageNumberUnknown
} = o
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
return { queryStr, pageNumber }
const searchInFolder = validateOptionalURI(searchInFolderUnknown)
const isRegex = validateBoolean(isRegexUnknown, { default: false })
return { queryStr, searchInFolder, isRegex, pageNumber }
},
// ---
@ -216,7 +262,7 @@ export class ToolsService implements IToolsService {
const { command: commandUnknown, terminalId: terminalIdUnknown, waitForCompletion: waitForCompletionUnknown } = o
const command = validateStr('command', commandUnknown)
const proposedTerminalId = validateProposedTerminalId(terminalIdUnknown)
const waitForCompletion = validateWaitForCompletion(waitForCompletionUnknown)
const waitForCompletion = validateBoolean(waitForCompletionUnknown, { default: true })
return { command, proposedTerminalId, waitForCompletion }
},
@ -224,16 +270,25 @@ export class ToolsService implements IToolsService {
this.callTool = {
view_file_contents: async ({ uri, pageNumber }) => {
read_file: async ({ uri, startLine, endLine, pageNumber }) => {
await voidModelService.initializeModel(uri)
const { model } = await voidModelService.getModelSafe(uri)
if (model === null) { throw new Error(`Contents were empty. There may have been an error, or the file may not exist.`) }
const readFileContents = model.getValue(EndOfLinePreference.LF)
let contents: string
if (startLine === null && endLine === null) {
contents = model.getValue(EndOfLinePreference.LF)
}
else {
const startLineNumber = startLine === null ? 1 : startLine
const endLineNumber = endLine === null ? model.getLineCount() : endLine
contents = model.getValueInRange({ startLineNumber, startColumn: 1, endLineNumber, endColumn: Number.MAX_SAFE_INTEGER }, EndOfLinePreference.LF)
}
const fromIdx = MAX_FILE_CHARS_PAGE * (pageNumber - 1)
const toIdx = MAX_FILE_CHARS_PAGE * pageNumber - 1
const fileContents = readFileContents.slice(fromIdx, toIdx + 1) // paginate
const hasNextPage = (readFileContents.length - 1) - toIdx >= 1
const fileContents = contents.slice(fromIdx, toIdx + 1) // paginate
const hasNextPage = (contents.length - 1) - toIdx >= 1
return { result: { fileContents, hasNextPage } }
},
@ -250,9 +305,11 @@ export class ToolsService implements IToolsService {
return { result: { str } }
},
search_pathnames_only: async ({ queryStr, pageNumber }) => {
search_pathnames_only: async ({ queryStr, include, pageNumber }) => {
const query = queryBuilder.file(workspaceContextService.getWorkspace().folders.map(f => f.uri), {
filePattern: queryStr,
includePattern: include ?? undefined,
})
const data = await searchService.fileSearch(query, CancellationToken.None)
@ -266,11 +323,15 @@ export class ToolsService implements IToolsService {
return { result: { uris, hasNextPage } }
},
search_files: async ({ queryStr, pageNumber }) => {
search_files: async ({ queryStr, isRegex, searchInFolder, pageNumber }) => {
const searchFolders = searchInFolder === null ?
workspaceContextService.getWorkspace().folders.map(f => f.uri)
: [searchInFolder]
const query = queryBuilder.text({
pattern: queryStr,
isRegExp: true,
}, workspaceContextService.getWorkspace().folders.map(f => f.uri))
isRegExp: isRegex,
}, searchFolders)
const data = await searchService.textSearch(query, CancellationToken.None)
@ -333,7 +394,7 @@ export class ToolsService implements IToolsService {
// given to the LLM after the call
this.stringOfResult = {
view_file_contents: (params, result) => {
read_file: (params, result) => {
return result.fileContents + nextPageStr(result.hasNextPage)
},
ls_dir: (params, result) => {

View file

@ -48,14 +48,23 @@ const uriParam = (object: string) => ({
uri: { type: 'string', description: `The FULL path to the ${object}.` }
})
const searchParams = {
searchInFolder: { type: 'string', description: 'Only search files in this given folder. Leave as empty to search all available files.' },
isRegex: { type: 'string', description: 'Whether to treat the query as a regular expression. Default is "false".' },
} as const
export const voidTools = {
// --- context-gathering (read/search/list) ---
view_file_contents: {
name: 'view_file_contents',
read_file: {
name: 'read_file',
description: `Returns file contents of a given URI. ${paginationHelper.desc}`,
params: {
...uriParam('file'),
startLine: { type: 'string', description: 'Line to start reading from. Default is "null", treated as 1.' },
endLine: { type: 'string', description: 'Line to stop reading from (inclusive). Default is "null", treated as Infinity.' },
...paginationHelper.param,
},
},
@ -71,7 +80,7 @@ export const voidTools = {
get_dir_structure: {
name: 'get_dir_structure',
description: `Returns a tree diagram of all the files and folders in the URI. If results are large, the given string will be truncated (this will be indicated). If truncated, you should use this tool on a more specific folder, or just use ls_dir which supports pagination but is not recursive.`,
description: `Returns a tree diagram of all the files and folders in the given folder URI. Call this to learn more about a folder. If results are large, the given string will be truncated (this will be indicated), in which case you might want to call this tool on a lower folder to get better results, or just use ls_dir which supports pagination.`,
params: {
...uriParam('folder')
}
@ -79,18 +88,20 @@ export const voidTools = {
search_pathnames_only: {
name: 'search_pathnames_only',
description: `Returns all pathnames that match a given \`find\`-style query (searches ONLY file names). You should use this when looking for a file with a specific name or path. ${paginationHelper.desc}`,
description: `Returns all pathnames that match a given query (searches ONLY file names). You should use this when looking for a file with a specific name or path. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...searchParams,
...paginationHelper.param,
},
},
search_files: {
name: 'search_files',
description: `Returns all pathnames that match a given \`grep\`-style query (searches ONLY file contents). The query can be any regex. This is often followed by the \`view_file_contents\` tool to view the full file contents of results. ${paginationHelper.desc}`,
description: `Returns all pathnames that match a given \`grep\`-style query (searches ONLY file contents). The query can be any regex. This is often followed by the \`read_file\` tool to view the full file contents of results. ${paginationHelper.desc}`,
params: {
query: { type: 'string', description: undefined },
...searchParams,
...paginationHelper.param,
},
},
@ -110,7 +121,7 @@ export const voidTools = {
description: `Delete a file or folder at the given path. Fails gracefully if the file or folder does not exist.`,
params: {
...uriParam('file or folder'),
params: { type: 'string', description: 'Return -r here to delete this URI and all descendants (if applicable). Default is the empty string.' }
params: { type: 'string', description: 'Return -r here to delete recursively (if applicable). Default is the empty string.' }
},
},

View file

@ -40,12 +40,13 @@ const toolNamesWithApproval = ['create_file_or_folder', 'delete_file_or_folder',
export type ToolNameWithApproval = typeof toolNamesWithApproval[number]
export const toolNamesThatRequireApproval = new Set<ToolName>(toolNamesWithApproval)
// PARAMS OF TOOL CALL
export type ToolCallParams = {
'view_file_contents': { uri: URI, pageNumber: number },
'read_file': { uri: URI, startLine: number | null, endLine: number | null, pageNumber: number },
'ls_dir': { rootURI: URI, pageNumber: number },
'get_dir_structure': { rootURI: URI },
'search_pathnames_only': { queryStr: string, pageNumber: number },
'search_files': { queryStr: string, pageNumber: number },
'search_pathnames_only': { queryStr: string, include: string | null, pageNumber: number },
'search_files': { queryStr: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number },
// ---
'edit_file': { uri: URI, changeDescription: string },
'create_file_or_folder': { uri: URI, isFolder: boolean },
@ -54,8 +55,9 @@ export type ToolCallParams = {
}
// RESULT OF TOOL CALL
export type ToolResultType = {
'view_file_contents': { fileContents: string, hasNextPage: boolean },
'read_file': { fileContents: string, hasNextPage: boolean },
'ls_dir': { children: ShallowDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number },
'get_dir_structure': { str: string, },
'search_pathnames_only': { uris: URI[], hasNextPage: boolean },