This commit is contained in:
Andrew Pareles 2025-05-22 16:42:02 -07:00
parent f99589aaa6
commit 0a02342128
4 changed files with 94 additions and 64 deletions

View file

@ -187,6 +187,7 @@ export type ThreadStreamState = {
id: string;
content: string;
rawParams: RawToolParamsObj;
mcpServerName: string | undefined;
};
interrupt: Promise<() => void>;
} | {
@ -448,7 +449,8 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// if running now but stream state doesn't indicate it (happens if restart Void), cancel that last tool
if (lastMessage && lastMessage.role === 'tool' && lastMessage.type === 'running_now') {
this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', content: lastMessage.content, id: lastMessage.id, rawParams: lastMessage.rawParams, result: null, name: lastMessage.name, params: lastMessage.params })
this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', content: lastMessage.content, id: lastMessage.id, rawParams: lastMessage.rawParams, result: null, name: lastMessage.name, params: lastMessage.params, mcpServerName: lastMessage.mcpServerName })
}
}
@ -541,13 +543,17 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
else return
const { name, id, rawParams } = lastMsg
const { name, id, rawParams, mcpServerName } = lastMsg
const errorMessage = this.toolErrMsgs.rejected
this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', params: params, name: name, content: errorMessage, result: null, id, rawParams })
this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', params: params, name: name, content: errorMessage, result: null, id, rawParams, mcpServerName })
this._setStreamState(threadId, undefined)
}
private _computeMCPServerOfToolName = (toolName: string) => {
return this._mcpService.getMCPTools()?.find(t => t.name === toolName)?.mcpServerName
}
async abortRunning(threadId: string) {
const thread = this.state.allThreads[threadId]
if (!thread) return // should never happen
@ -556,13 +562,13 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (this.streamState[threadId]?.isRunning === 'LLM') {
const { displayContentSoFar, reasoningSoFar, toolCallSoFar } = this.streamState[threadId].llmInfo
this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null })
if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name })
if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name, mcpServerName: this._computeMCPServerOfToolName(toolCallSoFar.name) })
}
// add tool that's running
else if (this.streamState[threadId]?.isRunning === 'tool') {
const { toolName, toolParams, id, content: content_, rawParams } = this.streamState[threadId].toolInfo
const { toolName, toolParams, id, content: content_, rawParams, mcpServerName } = this.streamState[threadId].toolInfo
const content = content_ || this.toolErrMsgs.interrupted
this._updateLatestTool(threadId, { role: 'tool', name: toolName, params: toolParams, id, content, rawParams, type: 'rejected', result: null })
this._updateLatestTool(threadId, { role: 'tool', name: toolName, params: toolParams, id, content, rawParams, type: 'rejected', result: null, mcpServerName })
}
// reject the tool for the user if relevant
else if (this.streamState[threadId]?.isRunning === 'awaiting_user') {
@ -600,6 +606,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
threadId: string,
toolName: ToolName,
toolId: string,
mcpServerName: string | undefined,
opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: ToolCallParams<ToolName> } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj },
): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => {
@ -625,7 +632,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
catch (error) {
const errorMessage = getErrorMessage(error)
this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', rawParams: opts.unvalidatedToolParams, result: null, name: toolName, content: errorMessage, id: toolId, })
this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', rawParams: opts.unvalidatedToolParams, result: null, name: toolName, content: errorMessage, id: toolId, mcpServerName })
return {}
}
// once validated, add checkpoint for edit
@ -638,7 +645,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (approvalType) {
const autoApprove = this._settingsService.state.globalSettings.autoApprove[approvalType]
// add a tool_request because we use it for UI if a tool is loading (this should be improved in the future)
this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams })
this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName })
if (!autoApprove) {
return { awaitingUserApproval: true }
}
@ -655,7 +662,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// 3. call the tool
// this._setStreamState(threadId, { isRunning: 'tool' }, 'merge')
const runningTool = { role: 'tool', type: 'running_now', name: toolName, params: toolParams, content: '(value not received yet...)', result: null, id: toolId, rawParams: opts.unvalidatedToolParams } as const
const runningTool = { role: 'tool', type: 'running_now', name: toolName, params: toolParams, content: '(value not received yet...)', result: null, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName } as const
this._updateLatestTool(threadId, runningTool)
@ -665,7 +672,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
try {
// set stream state
this._setStreamState(threadId, { isRunning: 'tool', interrupt: interruptorPromise, toolInfo: { toolName, toolParams, id: toolId, content: 'interrupted...', rawParams: opts.unvalidatedToolParams } })
this._setStreamState(threadId, { isRunning: 'tool', interrupt: interruptorPromise, toolInfo: { toolName, toolParams, id: toolId, content: 'interrupted...', rawParams: opts.unvalidatedToolParams, mcpServerName } })
if (isBuiltInTool) {
const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any)
@ -695,7 +702,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here
const errorMessage = getErrorMessage(error)
this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams })
this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName })
return {}
}
@ -723,12 +730,12 @@ class ChatThreadService extends Disposable implements IChatThreadService {
}
} catch (error) {
const errorMessage = this.toolErrMsgs.errWhenStringifying(error)
this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams })
this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName })
return {}
}
// 5. add to history and keep going
this._updateLatestTool(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, id: toolId, rawParams: opts.unvalidatedToolParams })
this._updateLatestTool(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName })
return {}
};
@ -763,7 +770,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// before enter loop, call tool
if (callThisToolFirst) {
const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, callThisToolFirst.id, { preapproved: true, unvalidatedToolParams: callThisToolFirst.rawParams, validatedParams: callThisToolFirst.params })
const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, callThisToolFirst.id, callThisToolFirst.mcpServerName, { preapproved: true, unvalidatedToolParams: callThisToolFirst.rawParams, validatedParams: callThisToolFirst.params })
if (interrupted) {
this._setStreamState(threadId, undefined)
this._addUserCheckpoint({ threadId })
@ -872,7 +879,7 @@ class ChatThreadService extends Disposable implements IChatThreadService {
const { error } = llmRes
const { displayContentSoFar, reasoningSoFar, toolCallSoFar } = this.streamState[threadId].llmInfo
this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null })
if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name })
if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name, mcpServerName: this._computeMCPServerOfToolName(toolCallSoFar.name) })
this._setStreamState(threadId, { isRunning: undefined, error })
this._addUserCheckpoint({ threadId })
@ -889,7 +896,10 @@ class ChatThreadService extends Disposable implements IChatThreadService {
// call tool if there is one
if (toolCall) {
const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, toolCall.name, toolCall.id, { preapproved: false, unvalidatedToolParams: toolCall.rawParams })
const mcpTools = this._mcpService.getMCPTools()
const mcpTool = mcpTools?.find(t => t.name === toolCall.name)
const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, toolCall.name, toolCall.id, mcpTool?.mcpServerName, { preapproved: false, unvalidatedToolParams: toolCall.rawParams })
if (interrupted) {
this._setStreamState(threadId, undefined)
return

View file

@ -1414,14 +1414,36 @@ const titleOfBuiltinToolName = {
} as const satisfies Record<BuiltinToolName, { done: any, proposed: any, running: any }>
const getTitle = (toolMessage: Pick<ChatMessage & { role: 'tool' }, 'name' | 'type'>): React.ReactNode => {
const getTitle = (toolMessage: Pick<ChatMessage & { role: 'tool' }, 'name' | 'type' | 'mcpServerName'>): React.ReactNode => {
const t = toolMessage
if (!builtinToolNames.includes(t.name as BuiltinToolName)) return t.name // good measure
const toolName = t.name as BuiltinToolName
if (t.type === 'success') return titleOfBuiltinToolName[toolName].done
if (t.type === 'running_now') return titleOfBuiltinToolName[toolName].running
return titleOfBuiltinToolName[toolName].proposed
// non-built-in title
if (!builtinToolNames.includes(t.name as BuiltinToolName)) {
// descriptor of Running or Ran etc
const descriptor =
t.type === 'success' ? 'Ran'
: t.type === 'running_now' ? 'Running'
: t.type === 'tool_request' ? 'Requested'
: t.type === 'rejected' ? 'Canceled'
: t.type === 'invalid_params' ? 'Canceled'
: t.type === 'tool_error' ? 'Canceled'
: 'Ran'
const title = `${descriptor} ${t.name}`
if (t.type === 'running_now' || t.type === 'tool_request')
return loadingTitleWrapper(title)
return title
}
// built-in title
else {
const toolName = t.name as BuiltinToolName
if (t.type === 'success') return titleOfBuiltinToolName[toolName].done
if (t.type === 'running_now') return titleOfBuiltinToolName[toolName].running
return titleOfBuiltinToolName[toolName].proposed
}
}
@ -1700,9 +1722,9 @@ const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr, toolName, threadId }:
const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: string }) => {
const InvalidTool = ({ toolName, message, mcpServerName }: { toolName: ToolName, message: string, mcpServerName: string | undefined }) => {
const accessor = useAccessor()
const title = getTitle({ name: toolName, type: 'invalid_params' })
const title = getTitle({ name: toolName, type: 'invalid_params', mcpServerName })
const desc1 = 'Invalid parameters'
const icon = null
const isError = true
@ -1716,9 +1738,9 @@ const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: strin
return <ToolHeaderWrapper {...componentParams} />
}
const CanceledTool = ({ toolName }: { toolName: ToolName }) => {
const CanceledTool = ({ toolName, mcpServerName }: { toolName: ToolName, mcpServerName: string | undefined }) => {
const accessor = useAccessor()
const title = getTitle({ name: toolName, type: 'rejected' })
const title = getTitle({ name: toolName, type: 'rejected', mcpServerName })
const desc1 = ''
const icon = null
const isRejected = true
@ -1839,7 +1861,7 @@ const MCPToolWrapper = ({ toolMessage }: WrapperProps<string>) => {
const desc1 = toolMessage.name
const icon = null
if (toolMessage.type === 'tool_request') return null // do not show past requests
if (toolMessage.type === 'running_now') return null // do not show running
const isError = false
@ -1847,16 +1869,17 @@ const MCPToolWrapper = ({ toolMessage }: WrapperProps<string>) => {
const { rawParams, params } = toolMessage
const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected, }
if (toolMessage.type === 'success') {
componentParams.info = `${toolMessage.mcpServerName} MCP server`
if (toolMessage.type === 'success' || toolMessage.type === 'tool_request') {
const { result } = toolMessage
componentParams.children = <ToolChildrenWrapper>
<SmallProseWrapper>
<ChatMarkdownRender
string={`
## Parameters
\`\`\`\n${JSON.stringify(params, null, 2)}\n\`\`\`
## Result
\`\`\`\n${JSON.stringify(result, null, 2)}\n\`\`\`
## (Parameters:)
\`\`\`\n${JSON.stringify(params, null, 2)}\n\`\`\`
`}
chatMessageLocation={undefined}
isApplyEnabled={false}
@ -2501,7 +2524,7 @@ const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, me
if (chatMessage.type === 'invalid_params') {
return <div className={`${isCheckpointGhost ? 'opacity-50' : ''}`}>
<InvalidTool toolName={chatMessage.name} message={chatMessage.content} />
<InvalidTool toolName={chatMessage.name} message={chatMessage.content} mcpServerName={chatMessage.mcpServerName} />
</div>
}
@ -2529,7 +2552,7 @@ const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, me
else if (role === 'interrupted_streaming_tool') {
return <div className={`${isCheckpointGhost ? 'opacity-50' : ''}`}>
<CanceledTool toolName={chatMessage.name} />
<CanceledTool toolName={chatMessage.name} mcpServerName={chatMessage.mcpServerName} />
</div>
}
@ -2809,7 +2832,7 @@ const CommandBarInChat = () => {
const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) => {
if (!isABuiltinToolName( toolCallSoFar.name)) return null
if (!isABuiltinToolName(toolCallSoFar.name)) return null
const accessor = useAccessor()

View file

@ -13,6 +13,7 @@ export type ToolMessage<T extends ToolName> = {
content: string; // give this result to LLM (string of value)
id: string;
rawParams: RawToolParamsObj;
mcpServerName: string | undefined; // the server name at the time of the call
} & (
// in order of events:
| { type: 'invalid_params', result: null, name: T, }
@ -29,6 +30,7 @@ export type ToolMessage<T extends ToolName> = {
export type DecorativeCanceledTool = {
role: 'interrupted_streaming_tool';
name: ToolName;
mcpServerName: string | undefined; // the server name at the time of the call
}

View file

@ -18,7 +18,7 @@ import { AnthropicLLMChatMessage, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMe
import { ChatMode, displayInfoOfProviderName, ModelSelectionOptions, OverridesOfModel, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js';
import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities, defaultProviderSettings, getReservedOutputTokenSpace } from '../../common/modelCapabilities.js';
import { extractReasoningWrapper, extractXMLToolsWrapper } from './extractGrammar.js';
import { availableTools, InternalToolInfo, builtinTools, isABuiltinToolName } from '../../common/prompt/prompts.js';
import { availableTools, InternalToolInfo } from '../../common/prompt/prompts.js';
import { generateUuid } from '../../../../../base/common/uuid.js';
import { BuiltinToolParamName } from '../../common/toolsServiceTypes.js';
@ -221,21 +221,26 @@ const openAITools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[] | u
// convert LLM tool call to our tool format
const rawToolCallObjOf = (name: string, toolParamsStr: string, id: string): RawToolCallObj | null => {
if (!isABuiltinToolName(name)) return null
const rawParams: RawToolParamsObj = {}
const rawToolCallObjOfParamsStr = (name: string, toolParamsStr: string, id: string): RawToolCallObj | null => {
let input: unknown
try {
input = JSON.parse(toolParamsStr)
}
catch (e) {
return null
}
try { input = JSON.parse(toolParamsStr) }
catch (e) { return null }
if (input === null) return null
if (typeof input !== 'object') return null
for (const paramName in builtinTools[name].params) {
rawParams[paramName as BuiltinToolParamName] = (input as any)[paramName]
}
const rawParams: RawToolParamsObj = input
return { id, name, rawParams, doneParams: Object.keys(rawParams) as BuiltinToolParamName[], isDone: true }
}
const rawToolCallObjOfAnthropicParams = (toolBlock: Anthropic.Messages.ToolUseBlock): RawToolCallObj | null => {
const { id, name, input } = toolBlock
if (input === null) return null
if (typeof input !== 'object') return null
const rawParams: RawToolParamsObj = input
return { id, name, rawParams, doneParams: Object.keys(rawParams) as BuiltinToolParamName[], isDone: true }
}
@ -339,7 +344,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE
onText({
fullText: fullTextSoFar,
fullReasoning: fullReasoningSoFar,
toolCall: isABuiltinToolName(toolName) ? { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId } : undefined,
toolCall: { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId },
})
}
@ -348,7 +353,7 @@ const _sendOpenAICompatibleChat = async ({ messages, onText, onFinalMessage, onE
onError({ message: 'Void: Response from model was empty.', fullError: null })
}
else {
const toolCall = rawToolCallObjOf(toolName, toolParamsStr, toolId)
const toolCall = rawToolCallObjOfParamsStr(toolName, toolParamsStr, toolId)
const toolCallObj = toolCall ? { toolCall } : {}
onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null, ...toolCallObj });
}
@ -425,17 +430,7 @@ const anthropicTools = (chatMode: ChatMode | null, mcpTools: InternalToolInfo[]
return anthropicTools
}
const anthropicToolToRawToolCallObj = (toolBlock: Anthropic.Messages.ToolUseBlock): RawToolCallObj | null => {
const { id, name, input } = toolBlock
if (!isABuiltinToolName(name)) return null
const rawParams: RawToolParamsObj = {}
if (input === null) return null
if (typeof input !== 'object') return null
for (const paramName in builtinTools[name].params) {
rawParams[paramName as BuiltinToolParamName] = (input as any)[paramName]
}
return { id, name, rawParams, doneParams: Object.keys(rawParams) as BuiltinToolParamName[], isDone: true }
}
// ------------ ANTHROPIC ------------
const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessage, onError, settingsOfProvider, modelSelectionOptions, overridesOfModel, modelName: modelName_, _setAborter, separateSystemMessage, chatMode, mcpTools }: SendChatParams_Internal) => {
@ -496,7 +491,7 @@ const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessag
onText({
fullText,
fullReasoning,
toolCall: isABuiltinToolName(fullToolName) ? { name: fullToolName, rawParams: {}, isDone: false, doneParams: [], id: 'dummy' } : undefined,
toolCall: { name: fullToolName, rawParams: {}, isDone: false, doneParams: [], id: 'dummy' },
})
}
// there are no events for tool_use, it comes in at the end
@ -546,7 +541,7 @@ const sendAnthropicChat = async ({ messages, providerName, onText, onFinalMessag
stream.on('finalMessage', (response) => {
const anthropicReasoning = response.content.filter(c => c.type === 'thinking' || c.type === 'redacted_thinking')
const tools = response.content.filter(c => c.type === 'tool_use')
const toolCall = tools[0] && anthropicToolToRawToolCallObj(tools[0])
const toolCall = tools[0] && rawToolCallObjOfAnthropicParams(tools[0])
const toolCallObj = toolCall ? { toolCall } : {}
onFinalMessage({ fullText, fullReasoning, anthropicReasoning, ...toolCallObj })
})
@ -791,7 +786,7 @@ const sendGeminiChat = async ({
onText({
fullText: fullTextSoFar,
fullReasoning: fullReasoningSoFar,
toolCall: isABuiltinToolName(toolName) ? { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId } : undefined,
toolCall: { name: toolName, rawParams: {}, isDone: false, doneParams: [], id: toolId },
})
}
@ -800,7 +795,7 @@ const sendGeminiChat = async ({
onError({ message: 'Void: Response from model was empty.', fullError: null })
} else {
if (!toolId) toolId = generateUuid() // ids are empty, but other providers might expect an id
const toolCall = rawToolCallObjOf(toolName, toolParamsStr, toolId)
const toolCall = rawToolCallObjOfParamsStr(toolName, toolParamsStr, toolId)
const toolCallObj = toolCall ? { toolCall } : {}
onFinalMessage({ fullText: fullTextSoFar, fullReasoning: fullReasoningSoFar, anthropicReasoning: null, ...toolCallObj });
}