duplicate thread, retry, search_in_file

This commit is contained in:
Andrew Pareles 2025-04-20 04:22:03 -07:00
parent 902d419026
commit b02b2f0c89
7 changed files with 175 additions and 33 deletions

View file

@ -38,7 +38,6 @@ registerAction2(class extends Action2 {
});
}
async run(accessor: ServicesAccessor): Promise<void> {
console.log('hi')
const n = accessor.get(IDummyService)
console.log('Hi', n._serviceBrand)
}

View file

@ -33,8 +33,13 @@ import { truncate } from '../../../../base/common/strings.js';
import { THREAD_STORAGE_KEY } from '../common/storageKeys.js';
import { IConvertToLLMMessageService } from './convertToLLMMessageService.js';
import { timeout } from '../../../../base/common/async.js';
import { deepClone } from '../../../../base/common/objects.js';
// related to retrying when LLM message has error
const CHAT_RETRIES = 3
const RETRY_DELAY = 2500
export const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => {
if (!currentSelections) return null
@ -180,9 +185,12 @@ export interface IChatThreadService {
getCurrentThread(): ThreadType;
openNewThread(): void;
deleteThread(threadId: string): void;
switchToThread(threadId: string): void;
// thread selector
deleteThread(threadId: string): void;
duplicateThread(threadId: string): void;
// exposed getters/setters
// these all apply to current thread
getCurrentMessageState: (messageIdx: number) => UserMessageState
@ -345,7 +353,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
else throw new Error(`setStreamState`)
}
console.log('changeStreamState', threadId, state)
this._onDidChangeStreamState.fire({ threadId })
}
@ -369,7 +376,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (!messages) return false
const lastMsg = messages[messages.length - 1]
if (!lastMsg) return false
if (lastMsg.role === 'tool' && (lastMsg.type === 'running_now' || lastMsg.type === 'tool_request')) {
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
this._editMessageInThread(threadId, messages.length - 1, tool)
return true
}
@ -386,9 +394,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (!thread) return // should never happen
const lastMsg = thread.messages[thread.messages.length - 1]
if (!(
lastMsg.role === 'tool' && (lastMsg.type === 'tool_request')
)) return // should never happen
if (!(lastMsg.role === 'tool' && lastMsg.type === 'tool_request')) return // should never happen
const callThisToolFirst: ToolMessage<ToolName> = lastMsg
@ -404,7 +410,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const lastMsg = thread.messages[thread.messages.length - 1]
let params: ToolCallParams[ToolName]
if (lastMsg.role === 'tool' && (lastMsg.type === 'running_now' || lastMsg.type === 'tool_request')) {
if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') {
params = lastMsg.params
}
else return
@ -585,12 +591,6 @@ class ChatThreadService extends Disposable implements IChatThreadService {
isRunningWhenEnd = undefined
nMessagesSent += 1
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
// send llm message
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
const chatMessages = this.state.allThreads[threadId]?.messages ?? []
const { messages, separateSystemMessage } = await this._convertToLLMMessagesService.prepareLLMChatMessages({
chatMessages,
@ -598,15 +598,18 @@ class ChatThreadService extends Disposable implements IChatThreadService {
chatMode
})
let aborted = false
let shouldRetry = true
let nAttempts = 0
while (shouldRetry) {
shouldRetry = false
let resMessageIsDonePromise: (toolCall?: RawToolCallObj | undefined) => void // resolves when user approves this tool use (or if tool doesn't require approval)
const messageIsDonePromise = new Promise<RawToolCallObj | undefined>((res, rej) => { resMessageIsDonePromise = res })
// send llm message
this._setStreamState(threadId, { isRunning: 'LLM' }, 'merge')
const llmCancelToken = this._llmMessageService.sendLLMMessage({
messagesType: 'chatMessages',
chatMode,
@ -632,7 +635,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
nAttempts += 1
shouldRetry = true
this._setStreamState(threadId, { displayContentSoFar: undefined, reasoningSoFar: undefined, streamingToken: undefined, toolCallSoFar: undefined }, 'merge')
timeout(2500).then(() => { resMessageIsDonePromise() })
timeout(RETRY_DELAY).then(() => { resMessageIsDonePromise() })
}
else {
// const toolCallSoFar = this.streamState[threadId]?.toolCallSoFar
@ -659,13 +662,14 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
this._setStreamState(threadId, { streamingToken: llmCancelToken }, 'merge') // new stream token for the new message
const toolCall = await messageIsDonePromise // wait for message to complete
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
if (shouldRetry) {
continue
}
if (aborted) {
return
}
this._setStreamState(threadId, { streamingToken: undefined }, 'merge') // streaming message is done
// call tool if there is one
const tool: RawToolCallObj | undefined = toolCall
@ -884,7 +888,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const [_, toIdx] = c
if (toIdx === fromIdx) return
console.log(`going from ${fromIdx} to ${toIdx}`)
// console.log(`going from ${fromIdx} to ${toIdx}`)
// update the user's checkpoint
this._addUserModificationsToCurrCheckpoint({ threadId })
@ -1433,6 +1437,22 @@ We only need to do it for files that were edited since `from`, ie files between
this._setState({ ...this.state, allThreads: newThreads }, true)
}
duplicateThread(threadId: string) {
const { allThreads: currentThreads } = this.state
const threadToDuplicate = currentThreads[threadId]
if (!threadToDuplicate) return
const newThread = {
...deepClone(threadToDuplicate),
id: generateUuid(),
}
const newThreads = {
...currentThreads,
[newThread.id]: newThread,
}
this._storeAllThreads(newThreads)
this._setState({ allThreads: newThreads }, true)
}
private _addMessageToThread(threadId: string, message: ChatMessage) {
const { allThreads } = this.state

View file

@ -1232,6 +1232,7 @@ const titleOfToolName = {
'open_bg_terminal': { done: `Opened terminal`, proposed: 'Open terminal', running: loadingTitleWrapper('Opening terminal') },
'kill_bg_terminal': { done: `Killed terminal`, proposed: 'Kill terminal', running: loadingTitleWrapper('Killing terminal') },
'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') },
} as const satisfies Record<ToolName, { done: any, proposed: any, running: any }>
const getTitle = (toolMessage: Pick<ChatMessage & { role: 'tool' }, 'name' | 'type'>): React.ReactNode => {
@ -1281,6 +1282,13 @@ const toolNameToDesc = (toolName: ToolName, _toolParams: ToolCallParams[ToolName
desc1: `"${toolParams.query}"`,
}
},
'search_in_file': () => {
const toolParams = _toolParams as ToolCallParams['search_in_file'];
return {
desc1: `"${toolParams.query}"`,
desc1Info: getRelative(toolParams.uri, accessor),
};
},
'create_file_or_folder': () => {
const toolParams = _toolParams as ToolCallParams['create_file_or_folder']
return {
@ -1692,8 +1700,9 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
const { rawParams, params } = toolMessage
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, }
if (params.includePattern)
if (params.includePattern) {
componentParams.info = `Only search in ${params.includePattern}`
}
if (toolMessage.type === 'success') {
const { result, rawParams } = toolMessage
@ -1740,9 +1749,16 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
const { rawParams, params } = toolMessage
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, }
if (params.searchInFolder) {
const rel = getRelative(params.searchInFolder, accessor)
if (rel) componentParams.info = `Only search in ${rel}`
if (params.searchInFolder || params.isRegex) {
let info: string[] = []
if (params.searchInFolder) {
const rel = getRelative(params.searchInFolder, accessor)
if (rel) info.push(`Only search in ${rel}`)
}
if (params.isRegex) {
info.push(`Treat as regex`)
}
componentParams.info = info.join('; ')
}
if (toolMessage.type === 'success') {
@ -1774,6 +1790,46 @@ const toolNameToComponent: { [T in ToolName]: { resultWrapper: ResultWrapper<T>,
}
},
'search_in_file': {
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor();
const toolsService = accessor.get('IToolsService');
const title = getTitle(toolMessage);
const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor);
const icon = null;
if (toolMessage.type === 'tool_request' || toolMessage.type === 'rejected' || toolMessage.type === 'running_now') return null;
const isError = toolMessage.type === 'tool_error';
const { rawParams, params } = toolMessage;
const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon };
if (params.isRegex) componentParams.info = 'Treat as regex'
if (toolMessage.type === 'success') {
const { result } = toolMessage; // result is array of snippets
componentParams.numResults = result.lines.length;
componentParams.children = result.lines.length === 0 ? undefined :
<ToolChildrenWrapper>
<CodeChildren>
<pre className='font-mono whitespace-pre'>
{toolsService.stringOfResult['search_in_file'](params, result)}
</pre>
</CodeChildren>
</ToolChildrenWrapper>
}
else {
const { result } = toolMessage;
componentParams.children = <ToolChildrenWrapper>
<CodeChildren>
{result}
</CodeChildren>
</ToolChildrenWrapper>;
}
return <ToolHeaderWrapper {...componentParams} />;
}
},
'read_lint_errors': {
resultWrapper: ({ toolMessage }) => {
const accessor = useAccessor()
@ -2163,7 +2219,7 @@ const Checkpoint = ({ message, threadId, messageIdx, isCheckpointGhost, threadIs
{...isDisabled ? {
'data-tooltip-id': 'void-tooltip',
'data-tooltip-content': `Disabled ${isRunning ? 'when running' : 'because another thread is running'}`,
'data-tooltip-place': 'left',
'data-tooltip-place': 'top',
} : {}}
>
Checkpoint

View file

@ -7,7 +7,7 @@ import { useMemo, useState } from 'react';
import { CopyButton, IconShell1 } from '../markdown/ApplyBlockHoverButtons.js';
import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useFullChatThreadsStreamState, useSettingsState } from '../util/services.js';
import { IconX } from './SidebarChat.js';
import { Check, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react';
import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react';
import { IsRunningType, ThreadType } from '../../../chatThreadService.js';
@ -247,6 +247,21 @@ const formatTime = (date: Date) => {
};
const DuplicateButton = ({ threadId }: { threadId: string }) => {
const accessor = useAccessor()
const chatThreadsService = accessor.get('IChatThreadService')
return <IconShell1
Icon={Copy}
className='size-[11px]'
onClick={() => { chatThreadsService.duplicateThread(threadId); }}
data-tooltip-id='void-tooltip'
data-tooltip-place='top'
data-tooltip-content='Duplicate thread'
>
</IconShell1>
}
const TrashButton = ({ threadId }: { threadId: string }) => {
const accessor = useAccessor()
@ -374,6 +389,9 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni
<div className="flex items-center gap-2 opacity-60">
{idx === hoveredIdx ?
<>
{/* trash icon */}
<DuplicateButton threadId={pastThread.id} />
{/* trash icon */}
<TrashButton threadId={pastThread.id} />
</>

View file

@ -195,14 +195,23 @@ export class ToolsService implements IToolsService {
is_regex: isRegexUnknown,
page_number: pageNumberUnknown
} = params
const queryStr = validateStr('query', queryUnknown)
const pageNumber = validatePageNum(pageNumberUnknown)
const searchInFolder = validateOptionalURI(searchInFolderUnknown)
const isRegex = validateBoolean(isRegexUnknown, { default: false })
return { query: queryStr, searchInFolder, isRegex, pageNumber }
return {
query: queryStr,
isRegex,
searchInFolder,
pageNumber
}
},
search_in_file: (params: RawToolParamsObj) => {
const { uri: uriStr, query: queryUnknown, is_regex: isRegexUnknown } = params;
const uri = validateURI(uriStr);
const query = validateStr('query', queryUnknown);
const isRegex = validateBoolean(isRegexUnknown, { default: false });
return { uri, query, isRegex };
},
read_lint_errors: (params: RawToolParamsObj) => {
@ -333,6 +342,24 @@ export class ToolsService implements IToolsService {
const hasNextPage = (data.results.length - 1) - toIdx >= 1
return { result: { queryStr, uris, hasNextPage } }
},
search_in_file: async ({ uri, query, isRegex }) => {
await voidModelService.initializeModel(uri);
const { model } = await voidModelService.getModelSafe(uri);
if (model === null) { throw new Error(`No contents; File does not exist.`); }
const contents = model.getValue(EndOfLinePreference.LF);
const contentOfLine = contents.split('\n');
const totalLines = contentOfLine.length;
const regex = isRegex ? new RegExp(query) : null;
const lines: number[] = []
for (let i = 0; i < totalLines; i++) {
const line = contentOfLine[i];
if ((isRegex && regex!.test(line)) || (!isRegex && line.includes(query))) {
const matchLine = i + 1;
lines.push(matchLine);
}
}
return { result: { lines } };
},
read_lint_errors: async ({ uri }) => {
await timeout(1000)
@ -435,6 +462,15 @@ export class ToolsService implements IToolsService {
search_for_files: (params, result) => {
return result.uris.map(uri => uri.fsPath).join('\n') + nextPageStr(result.hasNextPage)
},
search_in_file: (params, result) => {
const { model } = voidModelService.getModel(params.uri)
if (!model) return '<Error getting string of result>'
const lines = result.lines.map(n => {
const lineContent = model.getValueInRange({ startLineNumber: n, startColumn: 1, endLineNumber: n, endColumn: Number.MAX_SAFE_INTEGER }, EndOfLinePreference.LF)
return `Line ${n}:\n\`\`\`\n${lineContent}\n\`\`\``
}).join('\n\n');
return lines;
},
read_lint_errors: (params, result) => {
return result.lintErrors ?
stringifyLintErrors(result.lintErrors)

View file

@ -111,8 +111,8 @@ export const voidTools = {
description: `Returns full contents of a given file.`,
params: {
...uriParam('file'),
start_line: { description: 'Optional. Only fill this in if you already know the line numbers you need to search. Defaults to 1.' },
end_line: { description: 'Optional. Only fill this in if you already know the line numbers you need to search. Defaults to Infinity.' },
start_line: { description: 'Optional. Do NOT fill this in unless you already know the line numbers you need to search. Defaults to 1.' },
end_line: { description: 'Optional. Do NOT fill this in unless you already know the line numbers you need to search. Defaults to Infinity.' },
...paginationParam,
},
},
@ -156,11 +156,22 @@ export const voidTools = {
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.' },
is_regex: { description: 'Optional. Default is false. Whether query is a regex.' },
is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' },
...paginationParam,
},
},
// add new search_in_file tool
search_in_file: {
name: 'search_in_file',
description: `Returns an array of all the start line numbers where the content appears in the file.`,
params: {
...uriParam('file'),
query: { description: 'The string or regex to search for in the file.' },
is_regex: { description: 'Optional. Default is false. Whether the query is a regex.' }
}
},
read_lint_errors: {
name: 'read_lint_errors',
description: `Returns all lint errors on a given file.`,

View file

@ -39,6 +39,7 @@ export type ToolCallParams = {
'get_dir_tree': { uri: URI },
'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 },
'read_lint_errors': { uri: URI },
// ---
'edit_file': { uri: URI, changeDescription: string },
@ -57,6 +58,7 @@ export type ToolResultType = {
'get_dir_tree': { str: string, },
'search_pathnames_only': { uris: URI[], hasNextPage: boolean },
'search_for_files': { uris: URI[], hasNextPage: boolean },
'search_in_file': { lines: number[]; },
'read_lint_errors': { lintErrors: LintErrorItem[] | null },
// ---
'edit_file': Promise<{ lintErrors: LintErrorItem[] | null }>,