diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 1246fd06..76b06723 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -12,11 +12,10 @@ import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; import { chat_userMessageContent, chat_systemMessage, chat_lastUserMessageWithFilesAdded, chat_selectionsString } from '../common/prompt/prompts.js'; -import { LLMChatMessage, ToolCallType } from '../common/sendLLMMessageTypes.js'; +import { getErrorMessage, LLMChatMessage, ToolCallType } from '../common/sendLLMMessageTypes.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IVoidFileService } from '../common/voidFileService.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { getErrorMessage } from '../../../../base/common/errors.js'; import { ChatMode, FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; import { IVoidSettingsService } from '../common/voidSettingsService.js'; import { ToolName, ToolCallParams, ToolResultType, InternalToolInfo, voidTools, toolNamesThatRequireApproval } from '../common/toolsServiceTypes.js'; @@ -24,7 +23,7 @@ import { IToolsService } from './toolsService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { ITextModelService } from '../../../../editor/common/services/resolverService.js'; -import { ChatMessage, CodespanLocationLink, StagingSelectionItem } from '../common/chatThreadServiceTypes.js'; +import { ChatMessage, CodespanLocationLink, StagingSelectionItem, ToolRequestApproval } from '../common/chatThreadServiceTypes.js'; import { Position } from '../../../../editor/common/core/position.js'; import { ITerminalToolService } from './terminalToolService.js'; @@ -168,15 +167,16 @@ export interface IChatThreadService { closeStagingSelectionsInMessage(messageIdx: number): void; - // call to edit a message - editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise; - - // call to add a message - addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise; - cancelStreaming(threadId: string): void; dismissStreamError(threadId: string): void; + // call to edit a message - CAN THROW + editUserMessageAndStreamResponse({ userMessage, chatMode, messageIdx }: { userMessage: string, chatMode: ChatMode, messageIdx: number }): Promise; + + // call to add a message - CAN THROW + addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise; + + // approve/reject - CAN THROW approveTool(toolId: string): void; rejectTool(toolId: string): void; } @@ -316,24 +316,39 @@ class ChatThreadService extends Disposable implements IChatThreadService { private resRejOfToolAwaitingApproval: { [toolId: string]: { res: () => void, rej: () => void } } = {} - approveTool(toolId: string) { - // TODO!!! if not streaming, approveToolAndStreamResponse - // if streaming, do below - const resRej = this.resRejOfToolAwaitingApproval[toolId] - delete this.resRejOfToolAwaitingApproval[toolId] - resRej?.res() + // CAN THROW ERRORS + approveTool(toolId: string) { + // if not streaming, approveToolAndStreamResponse + const threadId = this.getCurrentThread().id + const isStreaming = !!this.streamState[threadId]?.streamingToken + if (!isStreaming) { + this._approveToolAndStreamResponse_NotStreamingNow({ chatMode: 'agent' }) + } + else { + const resRej = this.resRejOfToolAwaitingApproval[toolId] + delete this.resRejOfToolAwaitingApproval[toolId] + resRej?.res() + } } rejectTool(toolId: string) { - const resRej = this.resRejOfToolAwaitingApproval[toolId] - delete this.resRejOfToolAwaitingApproval[toolId] - resRej?.rej() + // if not streaming, rejecttool + const threadId = this.getCurrentThread().id + const isStreaming = !!this.streamState[threadId]?.streamingToken + if (!isStreaming) { + this._rejectTool_NotStreamingNow({}) + } + else { + const resRej = this.resRejOfToolAwaitingApproval[toolId] + delete this.resRejOfToolAwaitingApproval[toolId] + resRej?.rej() + } } - private _staticAgentLoopsProps = () => { + private _currentModelSelectionProps = () => { // these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools) const featureName: FeatureName = 'Chat' const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] @@ -341,9 +356,26 @@ class ChatThreadService extends Disposable implements IChatThreadService { return { modelSelection, modelSelectionOptions } } + private _tools = (chatMode: ChatMode) => { + const toolNames: ToolName[] | undefined = chatMode === 'chat' ? undefined + : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName)) + : chatMode === 'agent' ? Object.keys(voidTools) as ToolName[] + : undefined + + const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName]) + return tools + } - private async _agentLoop({ threadId, tools, prevSelns, currSelns, modelSelection, modelSelectionOptions, chatMode, userMessageContent }: { + + private readonly errMsgs = { + rejected: 'Tool call was rejected by the user.', + errWhenStringifying: (error: any) => `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` + } + + + // CAN THROW ERRORS + private async _agentLoop({ threadId, tools, prevSelns, currSelns, modelSelection, modelSelectionOptions, chatMode, userMessageContent, callThisTool }: { tools: InternalToolInfo[] | undefined, threadId: string, prevSelns: StagingSelectionItem[], @@ -352,27 +384,18 @@ class ChatThreadService extends Disposable implements IChatThreadService { modelSelectionOptions: ModelSelectionOptions | undefined, chatMode: ChatMode, userMessageContent: string, // content of LATEST user message + + callThisTool?: ToolRequestApproval }) { - this._setStreamState(threadId, { error: undefined }) // clear any previous error - let nMessagesSent = 0 - let shouldSendAnotherMessage = true - - while (shouldSendAnotherMessage) { + const getLatestMessages = async () => { // recompute files in last message const selectionsStr = await chat_selectionsString(prevSelns, currSelns, this._voidFileService) // all the file CONTENTS or "selections" de-duped const userMessageFullContent = chat_lastUserMessageWithFilesAdded(userMessageContent, selectionsStr) // full last message: user message + CONTENTS of all files - nMessagesSent += 1 - shouldSendAnotherMessage = false // false by default - - let resMessageIsDonePromise: () => void // resolves when user approves this tool use (or if tool doesn't require approval) - const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) - // replace last userMessage with userMessageFullContent (which contains all the files too) const messages_ = toLLMChatMessages(this.getCurrentThread().messages) const lastUserMsgIdx = findLastIndex(messages_, m => m.role === 'user') - if (lastUserMsgIdx === -1) throw new Error(`Void: No user message found.`) // should never be -1 // system message @@ -387,8 +410,132 @@ class ChatThreadService extends Disposable implements IChatThreadService { { role: 'user', content: userMessageFullContent }, ...messages_.slice(lastUserMsgIdx + 1, Infinity), ] + return messages + } + + + + const handleToolCall = async (tool: ToolCallType) => { + shouldSendAnotherMessage = true + + const toolName: ToolName = tool.name + const toolParamsStr = tool.paramsStr + const toolId = tool.id + + // compute these below + let toolParams: ToolCallParams[ToolName] + let toolResult: ToolResultType[typeof toolName] + let toolResultStr: string + + // 1. validate tool params + try { + const params = await this._toolsService.validateParams[toolName](toolParamsStr) + toolParams = params + } catch (error) { + const errorMessage = getErrorMessage(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: undefined, value: errorMessage }, }) + return false + } + + // 2. if tool requires approval, await the approval + if (toolNamesThatRequireApproval.has(toolName)) { + const voidToolId = generateUuid() + const toolApprovalPromise = new Promise((res, rej) => { this.resRejOfToolAwaitingApproval[voidToolId] = { res, rej } }) + this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, paramsStr: toolParamsStr, params: toolParams, voidToolId: voidToolId }) + try { + await toolApprovalPromise + // accepted tool + } + catch (e) { + shouldSendAnotherMessage = false // interrupt flow by rejecting + const errorMessage = this.errMsgs.rejected + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'rejected', params: toolParams }, }) + return false + } + } + + // 3. call the tool + try { + toolResult = await this._toolsService.callTool[toolName](toolParams as any) // ts is bad... + } catch (error) { + const errorMessage = getErrorMessage(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) + return false + } + + // 4. stringify the result to give to the LLM + try { + toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) + } catch (error) { + const errorMessage = this.errMsgs.errWhenStringifying(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) + return false + } + + // 5. add to history and keep going + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, }) + return true + }; + + + + // CALL GIVEN TOOL before entering agent loop + const handleFirstToolCall = async (callThisTool: ToolRequestApproval) => { + const toolName: ToolName = callThisTool.name + const toolParamsStr = callThisTool.paramsStr + const toolId = callThisTool.voidToolId + + const toolParams = callThisTool.params + let toolResult: ToolResultType[typeof toolName] + let toolResultStr: string + + // 3. call the tool + try { + toolResult = await this._toolsService.callTool[toolName](toolParams as any) // ts is bad... + } catch (error) { + const errorMessage = getErrorMessage(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) + return false + } + + // 4. stringify the result to give to the LLM + try { + toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) + } catch (error) { + const errorMessage = this.errMsgs.errWhenStringifying(error) + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) + return false + } + + // 5. add to history and keep going + this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: toolParamsStr, id: toolId, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, }) + return true + } + + + this._setStreamState(threadId, { error: undefined }) // clear any previous error + + let nMessagesSent = 0 + let shouldSendAnotherMessage = true + + if (callThisTool) { + const keepGoing = await handleFirstToolCall(callThisTool) + if (!keepGoing) { + this._setStreamState(threadId, { streamingToken: undefined }) + return + } + } + + while (shouldSendAnotherMessage) { + + shouldSendAnotherMessage = false // false by default + nMessagesSent += 1 + + let resMessageIsDonePromise: () => void // resolves when user approves this tool use (or if tool doesn't require approval) + const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) // send llm message + const messages = await getLatestMessages() const llmCancelToken = this._llmMessageService.sendLLMMessage({ messagesType: 'chatMessages', messages, @@ -409,78 +556,12 @@ class ChatThreadService extends Disposable implements IChatThreadService { resMessageIsDonePromise() return } - - // if tool - // clear messageSoFar since we added it to the chat history (but don't clear streamingToken, we're still streaming) - this._setStreamState(threadId, { messageSoFar: undefined, reasoningSoFar: undefined }) - - // deal with the tool - const toolName: ToolName = tool.name - shouldSendAnotherMessage = true - - // 1. validate tool params - let toolParams: ToolCallParams[ToolName] - try { - const params = await this._toolsService.validateParams[toolName](tool.paramsStr) - toolParams = params - } catch (error) { - const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: undefined, value: errorMessage }, }) - this._setStreamState(threadId, { streamingToken: undefined }) + else { + const keepGoing = await handleToolCall(tool) + if (!keepGoing) { this._setStreamState(threadId, { streamingToken: undefined }) } resMessageIsDonePromise() return } - - // 2. if tool requires approval, await the approval - if (toolNamesThatRequireApproval.has(toolName)) { - const voidToolId = generateUuid() - const toolApprovalPromise = new Promise((res, rej) => { this.resRejOfToolAwaitingApproval[voidToolId] = { res, rej } }) - this._addMessageToThread(threadId, { role: 'tool_request', name: toolName, params: toolParams, voidToolId: voidToolId }) - try { - await toolApprovalPromise - // accepted tool - } - catch (e) { - // TODO!!! test rejection - // if (Math.random() > 0) throw new Error('TESTING') - shouldSendAnotherMessage = false // interrupt flow by rejecting - - const errorMessage = 'Tool call was rejected by the user.' - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'rejected', params: toolParams, value: errorMessage }, }) - this._setStreamState(threadId, { streamingToken: undefined }) - resMessageIsDonePromise() - return - } - } - - // 3. call the tool - let toolResult: ToolResultType[typeof toolName] - try { - toolResult = await this._toolsService.callTool[toolName](toolParams as any) // ts is bad... - } catch (error) { - const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) - this._setStreamState(threadId, { streamingToken: undefined }) - resMessageIsDonePromise() - return - } - - // 4. stringify the result to give to the LLM - let toolResultStr: string - try { - toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) - } catch (error) { - const errorMessage = `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: errorMessage, result: { type: 'error', params: toolParams, value: errorMessage }, }) - this._setStreamState(threadId, { streamingToken: undefined }) - resMessageIsDonePromise() - return - } - - // 5. add to history and keep going - this._addMessageToThread(threadId, { role: 'tool', name: toolName, paramsStr: tool.paramsStr, id: tool.id, content: toolResultStr, result: { type: 'success', params: toolParams, value: toolResult }, }) - resMessageIsDonePromise() - }, onError: (error) => { const messageSoFar = this.streamState[threadId]?.messageSoFar ?? '' @@ -506,12 +587,46 @@ class ChatThreadService extends Disposable implements IChatThreadService { await messageIsDonePromise } // end while + + // TODO!!! metrics on nMessagesSent and all the file extensions sent here + } - // TODO!!!! + private async _rejectTool_NotStreamingNow({ }) { + const thread = this.getCurrentThread() + const threadId = thread.id + + const lastMessage = thread.messages[thread.messages.length - 1] + if (lastMessage.role !== 'tool_request') return // should never happen + const { name, params, paramsStr, voidToolId, } = lastMessage + + const errorMessage = this.errMsgs.rejected + this._addMessageToThread(threadId, { role: 'tool', name: name, paramsStr: paramsStr, id: voidToolId, content: errorMessage, result: { type: 'rejected', params: params }, }) + + } + // called if we stopped streaming but want to accept the tool afterwards, lets us jump back into the loop as if no interruption happened - async approveToolAndStreamResponse({ chatMode, _chatSelections }: { userMessage: string, chatMode: ChatMode, _chatSelections?: { prevSelns?: StagingSelectionItem[], currSelns?: StagingSelectionItem[] } }) { + private async _approveToolAndStreamResponse_NotStreamingNow({ chatMode }: { chatMode: ChatMode }) { + const thread = this.getCurrentThread() + const threadId = thread.id + + const lastMessage = thread.messages[thread.messages.length - 1] + if (lastMessage.role !== 'tool_request') return // should never happen + + const lastUserMsgIdx = findLastIndex(thread.messages, m => m.role === 'user') + const lastUserMessage = thread.messages[lastUserMsgIdx] as ChatMessage & { role: 'user' } + if (lastUserMsgIdx === -1 || !lastUserMessage) return // should never happen + + const instructions = lastUserMessage.displayContent || '' + const prevSelns: StagingSelectionItem[] = this._getAllSelections() + const currSelns: StagingSelectionItem[] = [] + + const tools = this._tools(chatMode) + + + const callThisTool: ToolRequestApproval = lastMessage + this._agentLoop({ callThisTool, tools, prevSelns, currSelns, threadId, chatMode, userMessageContent: instructions, ...this._currentModelSelectionProps() }) } @@ -532,17 +647,9 @@ class ChatThreadService extends Disposable implements IChatThreadService { const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState } this._addMessageToThread(threadId, userHistoryElt) - const toolNames: ToolName[] | undefined = chatMode === 'chat' ? undefined - : chatMode === 'gather' ? (Object.keys(voidTools) as ToolName[]).filter(toolName => !toolNamesThatRequireApproval.has(toolName)) - : chatMode === 'agent' ? Object.keys(voidTools) as ToolName[] - : undefined - - const tools: InternalToolInfo[] | undefined = toolNames?.map(toolName => voidTools[toolName]) - - const ps = this._staticAgentLoopsProps() - - this._agentLoop({ tools, prevSelns, currSelns, threadId, chatMode, userMessageContent, ...ps, }) + const tools = this._tools(chatMode) + this._agentLoop({ tools, prevSelns, currSelns, threadId, chatMode, userMessageContent, ...this._currentModelSelectionProps(), }) } cancelStreaming(threadId: string) { diff --git a/src/vs/workbench/contrib/void/browser/editCodeService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts index 9fd24052..9bd03b56 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -50,10 +50,10 @@ const configOfBG = (color: Color) => { return { dark: color, light: color, hcDark: color, hcLight: color, } } // gets converted to --vscode-void-greenBG, see void.css, asCssVariable -const greenBG = new Color(new RGBA(155, 185, 85, .1)); // default is RGBA(155, 185, 85, .2) +const greenBG = new Color(new RGBA(155, 185, 85, .2)); // default is RGBA(155, 185, 85, .2) registerColor('void.greenBG', configOfBG(greenBG), '', true); -const redBG = new Color(new RGBA(255, 0, 0, .05)); // default is RGBA(255, 0, 0, .2) +const redBG = new Color(new RGBA(255, 0, 0, .2)); // default is RGBA(255, 0, 0, .2) registerColor('void.redBG', configOfBG(redBG), '', true); const sweepBG = new Color(new RGBA(100, 100, 100, .2)); diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index a54f5cb9..d6394b6d 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -828,7 +828,11 @@ const UserMessageComponent = ({ chatMessage, messageIdx, isLoading }: ChatBubble // stream the edit const userMessage = textAreaRefState.value; - await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx, }) + try { + await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, chatMode: 'agent', messageIdx, }) + } catch (e) { + console.error('Error while editing message:', e) + } } const onAbort = () => { @@ -1058,12 +1062,16 @@ const ToolRequestAcceptRejectButtons = ({ voidToolId }: { voidToolId: string }) const metricsService = accessor.get('IMetricsService') const onAccept = useCallback(() => { - chatThreadsService.approveTool(voidToolId) - metricsService.capture('Tool Request Accepted', {}) + try { + chatThreadsService.approveTool(voidToolId) + metricsService.capture('Tool Request Accepted', {}) + } catch (e) { console.error('Error while approving message in chat:', e) } }, [chatThreadsService, voidToolId, metricsService]) const onReject = useCallback(() => { - chatThreadsService.rejectTool(voidToolId) + try { + chatThreadsService.rejectTool(voidToolId) + } catch (e) { console.error('Error while approving message in chat:', e) } metricsService.capture('Tool Request Rejected', {}) }, [chatThreadsService, voidToolId, metricsService]) @@ -1583,7 +1591,11 @@ export const SidebarChat = () => { // getModelCapabilities() // TODO!!! check if can go into agent mode - await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, chatMode: 'agent' }) + try { + await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, chatMode: 'agent' }) + } catch (e) { + console.error('Error while sending message in chat:', e) + } setSelections([]) // clear staging textAreaFnsRef.current?.setValue('') diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts index c66b7400..62051a00 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts @@ -14,12 +14,13 @@ export type ToolMessage = { result: | { type: 'success'; params: ToolCallParams[T]; value: ToolResultType[T], } | { type: 'error'; params: ToolCallParams[T] | undefined; value: string } - | { type: 'rejected'; params: ToolCallParams[T]; value: string } + | { type: 'rejected'; params: ToolCallParams[T] } } export type ToolRequestApproval = { role: 'tool_request'; name: T; // internal use params: ToolCallParams[T]; // internal use + paramsStr: string; // internal use - this is what the LLM outputted, not necessarily JSON.stringify(params) voidToolId: string; // internal id Void uses } @@ -28,7 +29,7 @@ export type ChatMessage = | { role: 'user'; content: string; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty) - displayContent: string | null; // content displayed to user - allowed to be '', will be ignored + displayContent: string; // content displayed to user - allowed to be '', will be ignored selections: StagingSelectionItem[] | null; // the user's selection state: { stagingSelections: StagingSelectionItem[];