approve tool works even if closed void and reopened

This commit is contained in:
Andrew Pareles 2025-03-12 05:16:40 -07:00
parent bb370b390c
commit 1b6d81f4e0
4 changed files with 242 additions and 122 deletions

View file

@ -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<void>;
// call to add a message
addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise<void>;
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<void>;
// call to add a message - CAN THROW
addUserMessageAndStreamResponse({ userMessage, chatMode }: { userMessage: string, chatMode: ChatMode }): Promise<void>;
// 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<ToolName>
}) {
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<void>((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<void>((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<ToolName>) => {
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<void>((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<void>((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<ToolName> = 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) {

View file

@ -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));

View file

@ -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('')

View file

@ -14,12 +14,13 @@ export type ToolMessage<T extends ToolName> = {
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<T extends ToolName> = {
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[];