chore: Refactor chat flow – remove loop, centralize tool handling, add step limit

* Move the assistant‑loop logic out of `useChat` and into `postMessageProcessing`.
* Eliminate the while‑loop that drove repeated completions; now a single completion is sent and subsequent tool calls are processed recursively.
* Introduce early‑abort checks and guard against missing provider before proceeding.
* Add `ReasoningProcessor` import and use it consistently for streaming reasoning chunks.
* Add `ToolCallEntry` type and a global `toolStepCounter` to track and cap total tool steps (default 20) to prevent infinite loops.
* Extend `postMessageProcessing` signature to accept thread, provider, tools, UI update callback, and max tool steps.
* Update UI‑update logic to use a single `updateStreamingUI` callback and ensure RAF scheduling is cleaned up reliably.
* Refactor token‑speed / progress handling, improve error handling for out‑of‑context situations, and tidy up code formatting.
* Minor clean‑ups: const‑ify `availableTools`, remove unused variables, improve readability.
This commit is contained in:
Akarshan 2025-10-20 21:06:23 +05:30
parent 2f00ae0d33
commit c129757097
No known key found for this signature in database
GPG Key ID: D75C9634A870665F
2 changed files with 450 additions and 352 deletions

View File

@ -425,10 +425,8 @@ export const useChat = () => {
// Using addUserMessage to respect legacy code. Should be using the userContent above. // Using addUserMessage to respect legacy code. Should be using the userContent above.
if (troubleshooting) builder.addUserMessage(userContent) if (troubleshooting) builder.addUserMessage(userContent)
let isCompleted = false
// Filter tools based on model capabilities and available tools for this thread // Filter tools based on model capabilities and available tools for this thread
let availableTools = selectedModel?.capabilities?.includes('tools') const availableTools = selectedModel?.capabilities?.includes('tools')
? useAppState.getState().tools.filter((tool) => { ? useAppState.getState().tools.filter((tool) => {
const disabledTools = getDisabledToolsForThread(activeThread.id) const disabledTools = getDisabledToolsForThread(activeThread.id)
return !disabledTools.includes(tool.name) return !disabledTools.includes(tool.name)
@ -436,13 +434,21 @@ export const useChat = () => {
: [] : []
// Check if proactive mode is enabled // Check if proactive mode is enabled
const isProactiveMode = selectedModel?.capabilities?.includes('proactive') ?? false const isProactiveMode =
selectedModel?.capabilities?.includes('proactive') ?? false
// Proactive mode: Capture initial screenshot/snapshot before first LLM call // Proactive mode: Capture initial screenshot/snapshot before first LLM call
if (isProactiveMode && availableTools.length > 0 && !abortController.signal.aborted) { if (
console.log('Proactive mode: Capturing initial screenshots before LLM call') isProactiveMode &&
availableTools.length > 0 &&
!abortController.signal.aborted
) {
console.log(
'Proactive mode: Capturing initial screenshots before LLM call'
)
try { try {
const initialScreenshots = await captureProactiveScreenshots(abortController) const initialScreenshots =
await captureProactiveScreenshots(abortController)
// Add initial screenshots to builder // Add initial screenshots to builder
for (const screenshot of initialScreenshots) { for (const screenshot of initialScreenshots) {
@ -456,131 +462,91 @@ export const useChat = () => {
} }
} }
let assistantLoopSteps = 0 // The agent logic is now self-contained within postMessageProcessing.
// We no longer need a `while` loop here.
while ( if (abortController.signal.aborted || !activeProvider) return
!isCompleted &&
!abortController.signal.aborted &&
activeProvider
) {
const modelConfig = activeProvider.models.find(
(m) => m.id === selectedModel?.id
)
assistantLoopSteps += 1
const modelSettings = modelConfig?.settings const modelConfig = activeProvider.models.find(
? Object.fromEntries( (m) => m.id === selectedModel?.id
Object.entries(modelConfig.settings) )
.filter(
([key, value]) =>
key !== 'ctx_len' &&
key !== 'ngl' &&
value.controller_props?.value !== undefined &&
value.controller_props?.value !== null &&
value.controller_props?.value !== ''
)
.map(([key, value]) => [key, value.controller_props?.value])
)
: undefined
const completion = await sendCompletion( const modelSettings = modelConfig?.settings
activeThread, ? Object.fromEntries(
activeProvider, Object.entries(modelConfig.settings)
builder.getMessages(), .filter(
abortController, ([key, value]) =>
availableTools, key !== 'ctx_len' &&
currentAssistant?.parameters?.stream === false ? false : true, key !== 'ngl' &&
{ value.controller_props?.value !== undefined &&
...modelSettings, value.controller_props?.value !== null &&
...(currentAssistant?.parameters || {}), value.controller_props?.value !== ''
} as unknown as Record<string, object> )
) .map(([key, value]) => [key, value.controller_props?.value])
)
: undefined
if (!completion) throw new Error('No completion received') const completion = await sendCompletion(
let accumulatedText = '' activeThread,
const currentCall: ChatCompletionMessageToolCall | null = null activeProvider,
const toolCalls: ChatCompletionMessageToolCall[] = [] builder.getMessages(),
const timeToFirstToken = Date.now() abortController,
let tokenUsage: CompletionUsage | undefined = undefined availableTools,
try { currentAssistant?.parameters?.stream === false ? false : true,
if (isCompletionResponse(completion)) { {
const message = completion.choices[0]?.message ...modelSettings,
accumulatedText = (message?.content as string) || '' ...(currentAssistant?.parameters || {}),
} as unknown as Record<string, object>
)
// Handle reasoning field if there is one if (!completion) throw new Error('No completion received')
const reasoning = extractReasoningFromMessage(message) let accumulatedText = ''
if (reasoning) { const currentCall: ChatCompletionMessageToolCall | null = null
accumulatedText = const toolCalls: ChatCompletionMessageToolCall[] = []
`<think>${reasoning}</think>` + accumulatedText const timeToFirstToken = Date.now()
} let tokenUsage: CompletionUsage | undefined = undefined
try {
if (isCompletionResponse(completion)) {
const message = completion.choices[0]?.message
accumulatedText = (message?.content as string) || ''
if (message?.tool_calls) { // Handle reasoning field if there is one
toolCalls.push(...message.tool_calls) const reasoning = extractReasoningFromMessage(message)
} if (reasoning) {
if ('usage' in completion) { accumulatedText = `<think>${reasoning}</think>` + accumulatedText
tokenUsage = completion.usage }
}
} else { if (message?.tool_calls) {
// High-throughput scheduler: batch UI updates on rAF (requestAnimationFrame) toolCalls.push(...message.tool_calls)
let rafScheduled = false }
let rafHandle: number | undefined if ('usage' in completion) {
let pendingDeltaCount = 0 tokenUsage = completion.usage
const reasoningProcessor = new ReasoningProcessor() }
const scheduleFlush = () => { } else {
if (rafScheduled || abortController.signal.aborted) return // High-throughput scheduler: batch UI updates on rAF (requestAnimationFrame)
rafScheduled = true let rafScheduled = false
const doSchedule = (cb: () => void) => { let rafHandle: number | undefined
if (typeof requestAnimationFrame !== 'undefined') { let pendingDeltaCount = 0
rafHandle = requestAnimationFrame(() => cb()) const reasoningProcessor = new ReasoningProcessor()
} else { const scheduleFlush = () => {
// Fallback for non-browser test environments if (rafScheduled || abortController.signal.aborted) return
const t = setTimeout(() => cb(), 0) as unknown as number rafScheduled = true
rafHandle = t const doSchedule = (cb: () => void) => {
} if (typeof requestAnimationFrame !== 'undefined') {
rafHandle = requestAnimationFrame(() => cb())
} else {
// Fallback for non-browser test environments
const t = setTimeout(() => cb(), 0) as unknown as number
rafHandle = t
} }
doSchedule(() => { }
// Check abort status before executing the scheduled callback doSchedule(() => {
if (abortController.signal.aborted) { // Check abort status before executing the scheduled callback
rafScheduled = false if (abortController.signal.aborted) {
return
}
const currentContent = newAssistantThreadContent(
activeThread.id,
accumulatedText,
{
tool_calls: toolCalls.map((e) => ({
...e,
state: 'pending',
})),
}
)
updateStreamingContent(currentContent)
if (tokenUsage) {
setTokenSpeed(
currentContent,
tokenUsage.completion_tokens /
Math.max((Date.now() - timeToFirstToken) / 1000, 1),
tokenUsage.completion_tokens
)
} else if (pendingDeltaCount > 0) {
updateTokenSpeed(currentContent, pendingDeltaCount)
}
pendingDeltaCount = 0
rafScheduled = false rafScheduled = false
}) return
}
const flushIfPending = () => {
if (!rafScheduled) return
if (
typeof cancelAnimationFrame !== 'undefined' &&
rafHandle !== undefined
) {
cancelAnimationFrame(rafHandle)
} else if (rafHandle !== undefined) {
clearTimeout(rafHandle)
} }
// Do an immediate flush
const currentContent = newAssistantThreadContent( const currentContent = newAssistantThreadContent(
activeThread.id, activeThread.id,
accumulatedText, accumulatedText,
@ -604,176 +570,207 @@ export const useChat = () => {
} }
pendingDeltaCount = 0 pendingDeltaCount = 0
rafScheduled = false rafScheduled = false
} })
try {
for await (const part of completion) {
// Check if aborted before processing each part
if (abortController.signal.aborted) {
break
}
// Handle prompt progress if available
if ('prompt_progress' in part && part.prompt_progress) {
// Force immediate state update to ensure we see intermediate values
flushSync(() => {
updatePromptProgress(part.prompt_progress)
})
// Add a small delay to make progress visible
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Error message
if (!part.choices) {
throw new Error(
'message' in part
? (part.message as string)
: (JSON.stringify(part) ?? '')
)
}
if ('usage' in part && part.usage) {
tokenUsage = part.usage
}
if (part.choices[0]?.delta?.tool_calls) {
extractToolCall(part, currentCall, toolCalls)
// Schedule a flush to reflect tool update
scheduleFlush()
}
const deltaReasoning =
reasoningProcessor.processReasoningChunk(part)
if (deltaReasoning) {
accumulatedText += deltaReasoning
pendingDeltaCount += 1
// Schedule flush for reasoning updates
scheduleFlush()
}
const deltaContent = part.choices[0]?.delta?.content || ''
if (deltaContent) {
accumulatedText += deltaContent
pendingDeltaCount += 1
// Batch UI update on next animation frame
scheduleFlush()
}
}
} finally {
// Always clean up scheduled RAF when stream ends (either normally or via abort)
if (rafHandle !== undefined) {
if (typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(rafHandle)
} else {
clearTimeout(rafHandle)
}
rafHandle = undefined
rafScheduled = false
}
// Only finalize and flush if not aborted
if (!abortController.signal.aborted) {
// Finalize reasoning (close any open think tags)
accumulatedText += reasoningProcessor.finalize()
// Ensure any pending buffered content is rendered at the end
flushIfPending()
}
}
} }
} catch (error) { const flushIfPending = () => {
const errorMessage = if (!rafScheduled) return
error && typeof error === 'object' && 'message' in error if (
? error.message typeof cancelAnimationFrame !== 'undefined' &&
: error rafHandle !== undefined
if ( ) {
typeof errorMessage === 'string' && cancelAnimationFrame(rafHandle)
errorMessage.includes(OUT_OF_CONTEXT_SIZE) && } else if (rafHandle !== undefined) {
selectedModel clearTimeout(rafHandle)
) { }
const method = await showIncreaseContextSizeModal() // Do an immediate flush
if (method === 'ctx_len') { const currentContent = newAssistantThreadContent(
/// Increase context size activeThread.id,
activeProvider = await increaseModelContextSize( accumulatedText,
selectedModel.id, {
activeProvider tool_calls: toolCalls.map((e) => ({
...e,
state: 'pending',
})),
}
)
updateStreamingContent(currentContent)
if (tokenUsage) {
setTokenSpeed(
currentContent,
tokenUsage.completion_tokens /
Math.max((Date.now() - timeToFirstToken) / 1000, 1),
tokenUsage.completion_tokens
) )
continue } else if (pendingDeltaCount > 0) {
} else if (method === 'context_shift' && selectedModel?.id) { updateTokenSpeed(currentContent, pendingDeltaCount)
/// Enable context_shift }
activeProvider = await toggleOnContextShifting( pendingDeltaCount = 0
selectedModel?.id, rafScheduled = false
activeProvider }
) try {
continue for await (const part of completion) {
} else throw error // Check if aborted before processing each part
} else { if (abortController.signal.aborted) {
throw error break
}
// Handle prompt progress if available
if ('prompt_progress' in part && part.prompt_progress) {
// Force immediate state update to ensure we see intermediate values
flushSync(() => {
updatePromptProgress(part.prompt_progress)
})
// Add a small delay to make progress visible
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Error message
if (!part.choices) {
throw new Error(
'message' in part
? (part.message as string)
: (JSON.stringify(part) ?? '')
)
}
if ('usage' in part && part.usage) {
tokenUsage = part.usage
}
if (part.choices[0]?.delta?.tool_calls) {
extractToolCall(part, currentCall, toolCalls)
// Schedule a flush to reflect tool update
scheduleFlush()
}
const deltaReasoning =
reasoningProcessor.processReasoningChunk(part)
if (deltaReasoning) {
accumulatedText += deltaReasoning
pendingDeltaCount += 1
// Schedule flush for reasoning updates
scheduleFlush()
}
const deltaContent = part.choices[0]?.delta?.content || ''
if (deltaContent) {
accumulatedText += deltaContent
pendingDeltaCount += 1
// Batch UI update on next animation frame
scheduleFlush()
}
}
} finally {
// Always clean up scheduled RAF when stream ends (either normally or via abort)
if (rafHandle !== undefined) {
if (typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(rafHandle)
} else {
clearTimeout(rafHandle)
}
rafHandle = undefined
rafScheduled = false
}
// Only finalize and flush if not aborted
if (!abortController.signal.aborted) {
// Finalize reasoning (close any open think tags)
accumulatedText += reasoningProcessor.finalize()
// Ensure any pending buffered content is rendered at the end
flushIfPending()
}
} }
} }
// TODO: Remove this check when integrating new llama.cpp extension } catch (error) {
const errorMessage =
error && typeof error === 'object' && 'message' in error
? error.message
: error
if ( if (
accumulatedText.length === 0 && typeof errorMessage === 'string' &&
toolCalls.length === 0 && errorMessage.includes(OUT_OF_CONTEXT_SIZE) &&
activeThread.model?.id && selectedModel
activeProvider?.provider === 'llamacpp'
) { ) {
await serviceHub const method = await showIncreaseContextSizeModal()
.models() if (method === 'ctx_len') {
.stopModel(activeThread.model.id, 'llamacpp') /// Increase context size
throw new Error('No response received from the model') activeProvider = await increaseModelContextSize(
} selectedModel.id,
activeProvider
const totalThinkingTime = Date.now() - startTime // Calculate total elapsed time )
// NOTE: This will exit and not retry. A more robust solution might re-call sendMessage.
// Create a final content object for adding to the thread // For this change, we keep the existing behavior.
const messageMetadata: Record<string, any> = { return
tokenSpeed: useAppState.getState().tokenSpeed, } else if (method === 'context_shift' && selectedModel?.id) {
assistant: currentAssistant, /// Enable context_shift
} activeProvider = await toggleOnContextShifting(
selectedModel?.id,
if (accumulatedText.includes('<think>') || toolCalls.length > 0) { activeProvider
messageMetadata.totalThinkingTime = totalThinkingTime )
} // NOTE: See above comment about retry.
return
const finalContent = newAssistantThreadContent( } else throw error
activeThread.id, } else {
accumulatedText, throw error
messageMetadata
)
builder.addAssistantMessage(accumulatedText, undefined, toolCalls)
// Check if proactive mode is enabled for this model
const isProactiveMode = selectedModel?.capabilities?.includes('proactive') ?? false
const updatedMessage = await postMessageProcessing(
toolCalls,
builder,
finalContent,
abortController,
useToolApproval.getState().approvedTools,
allowAllMCPPermissions ? undefined : showApprovalModal,
allowAllMCPPermissions,
isProactiveMode
)
if (updatedMessage && updatedMessage.metadata) {
if (finalContent.metadata?.totalThinkingTime !== undefined) {
updatedMessage.metadata.totalThinkingTime =
finalContent.metadata.totalThinkingTime
}
}
addMessage(updatedMessage ?? finalContent)
updateStreamingContent(emptyThreadContent)
updatePromptProgress(undefined)
updateThreadTimestamp(activeThread.id)
isCompleted = !toolCalls.length
// Do not create agent loop if there is no need for it
// Check if assistant loop steps are within limits
if (assistantLoopSteps >= (currentAssistant?.tool_steps ?? 20)) {
// Stop the assistant tool call if it exceeds the maximum steps
availableTools = []
} }
} }
// TODO: Remove this check when integrating new llama.cpp extension
if (
accumulatedText.length === 0 &&
toolCalls.length === 0 &&
activeThread.model?.id &&
activeProvider?.provider === 'llamacpp'
) {
await serviceHub.models().stopModel(activeThread.model.id, 'llamacpp')
throw new Error('No response received from the model')
}
const totalThinkingTime = Date.now() - startTime // Calculate total elapsed time
const messageMetadata: Record<string, any> = {
tokenSpeed: useAppState.getState().tokenSpeed,
assistant: currentAssistant,
}
if (accumulatedText.includes('<think>') || toolCalls.length > 0) {
messageMetadata.totalThinkingTime = totalThinkingTime
}
// This is the message object that will be built upon by postMessageProcessing
const finalContent = newAssistantThreadContent(
activeThread.id,
accumulatedText,
messageMetadata
)
builder.addAssistantMessage(accumulatedText, undefined, toolCalls)
// All subsequent tool calls and follow-up completions will modify `finalContent`.
const updatedMessage = await postMessageProcessing(
toolCalls,
builder,
finalContent,
abortController,
useToolApproval.getState().approvedTools,
allowAllMCPPermissions ? undefined : showApprovalModal,
allowAllMCPPermissions,
activeThread,
activeProvider,
availableTools,
updateStreamingContent, // Pass the callback to update UI
currentAssistant?.tool_steps,
isProactiveMode
)
if (updatedMessage && updatedMessage.metadata) {
if (finalContent.metadata?.totalThinkingTime !== undefined) {
updatedMessage.metadata.totalThinkingTime =
finalContent.metadata.totalThinkingTime
}
}
// Add the single, final, composite message to the store.
addMessage(updatedMessage ?? finalContent)
updateStreamingContent(emptyThreadContent)
updatePromptProgress(undefined)
updateThreadTimestamp(activeThread.id)
} catch (error) { } catch (error) {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
if (error && typeof error === 'object' && 'message' in error) { if (error && typeof error === 'object' && 'message' in error) {

View File

@ -41,6 +41,7 @@ import { useAppState } from '@/hooks/useAppState'
import { injectFilesIntoPrompt } from './fileMetadata' import { injectFilesIntoPrompt } from './fileMetadata'
import { Attachment } from '@/types/attachment' import { Attachment } from '@/types/attachment'
import { ModelCapabilities } from '@/types/models' import { ModelCapabilities } from '@/types/models'
import { ReasoningProcessor } from '@/utils/reasoning'
export type ChatCompletionResponse = export type ChatCompletionResponse =
| chatCompletion | chatCompletion
@ -48,6 +49,12 @@ export type ChatCompletionResponse =
| StreamCompletionResponse | StreamCompletionResponse
| CompletionResponse | CompletionResponse
type ToolCallEntry = {
tool: object
response: any
state: 'pending' | 'ready'
}
/** /**
* @fileoverview Helper functions for creating thread content. * @fileoverview Helper functions for creating thread content.
* These functions are used to create thread content objects * These functions are used to create thread content objects
@ -73,11 +80,14 @@ export const newUserThreadContent = (
name: doc.name, name: doc.name,
type: doc.fileType, type: doc.fileType,
size: typeof doc.size === 'number' ? doc.size : undefined, size: typeof doc.size === 'number' ? doc.size : undefined,
chunkCount: typeof doc.chunkCount === 'number' ? doc.chunkCount : undefined, chunkCount:
typeof doc.chunkCount === 'number' ? doc.chunkCount : undefined,
})) }))
const textWithFiles = const textWithFiles =
docMetadata.length > 0 ? injectFilesIntoPrompt(content, docMetadata) : content docMetadata.length > 0
? injectFilesIntoPrompt(content, docMetadata)
: content
const contentParts = [ const contentParts = [
{ {
@ -238,10 +248,8 @@ export const sendCompletion = async (
const providerModelConfig = provider.models?.find( const providerModelConfig = provider.models?.find(
(model) => model.id === thread.model?.id || model.model === thread.model?.id (model) => model.id === thread.model?.id || model.model === thread.model?.id
) )
const effectiveCapabilities = Array.isArray( const effectiveCapabilities = Array.isArray(providerModelConfig?.capabilities)
providerModelConfig?.capabilities ? (providerModelConfig?.capabilities ?? [])
)
? providerModelConfig?.capabilities ?? []
: getModelCapabilities(provider.provider, thread.model.id) : getModelCapabilities(provider.provider, thread.model.id)
const modelSupportsTools = effectiveCapabilities.includes( const modelSupportsTools = effectiveCapabilities.includes(
ModelCapabilities.TOOLS ModelCapabilities.TOOLS
@ -254,7 +262,10 @@ export const sendCompletion = async (
PlatformFeatures[PlatformFeature.ATTACHMENTS] && PlatformFeatures[PlatformFeature.ATTACHMENTS] &&
modelSupportsTools modelSupportsTools
) { ) {
const ragTools = await getServiceHub().rag().getTools().catch(() => []) const ragTools = await getServiceHub()
.rag()
.getTools()
.catch(() => [])
if (Array.isArray(ragTools) && ragTools.length) { if (Array.isArray(ragTools) && ragTools.length) {
usableTools = [...tools, ...ragTools] usableTools = [...tools, ...ragTools]
} }
@ -396,6 +407,9 @@ export const extractToolCall = (
return calls return calls
} }
// Keep track of total tool steps to prevent infinite loops
let toolStepCounter = 0
/** /**
* Helper function to check if a tool call is a browser MCP tool * Helper function to check if a tool call is a browser MCP tool
* @param toolName - The name of the tool * @param toolName - The name of the tool
@ -533,10 +547,22 @@ export const postMessageProcessing = async (
toolParameters?: object toolParameters?: object
) => Promise<boolean>, ) => Promise<boolean>,
allowAllMCPPermissions: boolean = false, allowAllMCPPermissions: boolean = false,
thread?: Thread,
provider?: ModelProvider,
tools: MCPTool[] = [],
updateStreamingUI?: (content: ThreadMessage) => void,
maxToolSteps: number = 20,
isProactiveMode: boolean = false isProactiveMode: boolean = false
) => { ): Promise<ThreadMessage> => {
// Reset counter at the start of a new message processing chain
if (toolStepCounter === 0) {
toolStepCounter = 0
}
// Handle completed tool calls // Handle completed tool calls
if (calls.length) { if (calls.length > 0) {
toolStepCounter++
// Fetch RAG tool names from RAG service // Fetch RAG tool names from RAG service
let ragToolNames = new Set<string>() let ragToolNames = new Set<string>()
try { try {
@ -546,43 +572,41 @@ export const postMessageProcessing = async (
console.error('Failed to load RAG tool names:', e) console.error('Failed to load RAG tool names:', e)
} }
const ragFeatureAvailable = const ragFeatureAvailable =
useAttachments.getState().enabled && PlatformFeatures[PlatformFeature.ATTACHMENTS] useAttachments.getState().enabled &&
PlatformFeatures[PlatformFeature.ATTACHMENTS]
const currentToolCalls =
message.metadata?.tool_calls && Array.isArray(message.metadata.tool_calls)
? [...message.metadata.tool_calls]
: []
for (const toolCall of calls) { for (const toolCall of calls) {
if (abortController.signal.aborted) break if (abortController.signal.aborted) break
const toolId = ulid() const toolId = ulid()
const toolCallsMetadata =
message.metadata?.tool_calls && const toolCallEntry: ToolCallEntry = {
Array.isArray(message.metadata?.tool_calls) tool: {
? message.metadata?.tool_calls ...(toolCall as object),
: [] id: toolId,
},
response: undefined,
state: 'pending' as 'pending' | 'ready',
}
currentToolCalls.push(toolCallEntry)
message.metadata = { message.metadata = {
...(message.metadata ?? {}), ...(message.metadata ?? {}),
tool_calls: [ tool_calls: currentToolCalls,
...toolCallsMetadata,
{
tool: {
...(toolCall as object),
id: toolId,
},
response: undefined,
state: 'pending',
},
],
} }
if (updateStreamingUI) updateStreamingUI({ ...message }) // Show pending call
// Check if tool is approved or show modal for approval // Check if tool is approved or show modal for approval
let toolParameters = {} let toolParameters = {}
if (toolCall.function.arguments.length) { if (toolCall.function.arguments.length) {
try { try {
console.log('Raw tool arguments:', toolCall.function.arguments)
toolParameters = JSON.parse(toolCall.function.arguments) toolParameters = JSON.parse(toolCall.function.arguments)
console.log('Parsed tool parameters:', toolParameters)
} catch (error) { } catch (error) {
console.error('Failed to parse tool arguments:', error) console.error('Failed to parse tool arguments:', error)
console.error(
'Raw arguments that failed:',
toolCall.function.arguments
)
} }
} }
@ -591,7 +615,6 @@ export const postMessageProcessing = async (
const isRagTool = ragToolNames.has(toolName) const isRagTool = ragToolNames.has(toolName)
const isBrowserTool = isBrowserMCPTool(toolName) const isBrowserTool = isBrowserMCPTool(toolName)
// Auto-approve RAG tools (local/safe operations), require permission for MCP tools
const approved = isRagTool const approved = isRagTool
? true ? true
: allowAllMCPPermissions || : allowAllMCPPermissions ||
@ -607,7 +630,11 @@ export const postMessageProcessing = async (
const { promise, cancel } = isRagTool const { promise, cancel } = isRagTool
? ragFeatureAvailable ? ragFeatureAvailable
? { ? {
promise: getServiceHub().rag().callTool({ toolName, arguments: toolArgs, threadId: message.thread_id }), promise: getServiceHub().rag().callTool({
toolName,
arguments: toolArgs,
threadId: message.thread_id,
}),
cancel: async () => {}, cancel: async () => {},
} }
: { : {
@ -630,18 +657,15 @@ export const postMessageProcessing = async (
useAppState.getState().setCancelToolCall(cancel) useAppState.getState().setCancelToolCall(cancel)
let result = approved let result = approved
? await promise.catch((e) => { ? await promise.catch((e) => ({
console.error('Tool call failed:', e) content: [
return { {
content: [ type: 'text',
{ text: `Error calling tool ${toolCall.function.name}: ${e.message ?? e}`,
type: 'text', },
text: `Error calling tool ${toolCall.function.name}: ${e.message ?? e}`, ],
}, error: String(e?.message ?? e ?? 'Tool call failed'),
], }))
error: String(e?.message ?? e ?? 'Tool call failed'),
}
})
: { : {
content: [ content: [
{ {
@ -654,30 +678,15 @@ export const postMessageProcessing = async (
if (typeof result === 'string') { if (typeof result === 'string') {
result = { result = {
content: [ content: [{ type: 'text', text: result }],
{
type: 'text',
text: result,
},
],
error: '', error: '',
} }
} }
message.metadata = { // Update the entry in the metadata array
...(message.metadata ?? {}), toolCallEntry.response = result
tool_calls: [ toolCallEntry.state = 'ready'
...toolCallsMetadata, if (updateStreamingUI) updateStreamingUI({ ...message }) // Show result
{
tool: {
...toolCall,
id: toolId,
},
response: result,
state: 'ready',
},
],
}
builder.addToolMessage(result as ToolResult, toolCall.id) builder.addToolMessage(result as ToolResult, toolCall.id)
// Proactive mode: Capture screenshot/snapshot after browser tool execution // Proactive mode: Capture screenshot/snapshot after browser tool execution
@ -702,6 +711,98 @@ export const postMessageProcessing = async (
// update message metadata // update message metadata
} }
return message
if (
thread &&
provider &&
!abortController.signal.aborted &&
toolStepCounter < maxToolSteps
) {
try {
const messagesWithToolResults = builder.getMessages()
const followUpCompletion = await sendCompletion(
thread,
provider,
messagesWithToolResults,
abortController,
tools,
true,
{}
)
if (followUpCompletion) {
let followUpText = ''
const newToolCalls: ChatCompletionMessageToolCall[] = []
const textContent = message.content.find(
(c) => c.type === ContentType.Text
)
if (isCompletionResponse(followUpCompletion)) {
const choice = followUpCompletion.choices[0]
const content = choice?.message?.content
if (content) followUpText = content as string
if (choice?.message?.tool_calls) {
newToolCalls.push(...choice.message.tool_calls)
}
if (textContent?.text) textContent.text.value += followUpText
if (updateStreamingUI) updateStreamingUI({ ...message })
} else {
const reasoningProcessor = new ReasoningProcessor()
for await (const chunk of followUpCompletion) {
if (abortController.signal.aborted) break
const deltaReasoning =
reasoningProcessor.processReasoningChunk(chunk)
const deltaContent = chunk.choices[0]?.delta?.content || ''
if (textContent?.text) {
if (deltaReasoning) textContent.text.value += deltaReasoning
if (deltaContent) textContent.text.value += deltaContent
}
if (deltaContent) followUpText += deltaContent
if (chunk.choices[0]?.delta?.tool_calls) {
extractToolCall(chunk, null, newToolCalls)
}
if (updateStreamingUI) updateStreamingUI({ ...message })
}
if (textContent?.text) {
textContent.text.value += reasoningProcessor.finalize()
if (updateStreamingUI) updateStreamingUI({ ...message })
}
}
if (newToolCalls.length > 0) {
builder.addAssistantMessage(followUpText, undefined, newToolCalls)
await postMessageProcessing(
newToolCalls,
builder,
message,
abortController,
approvedTools,
showModal,
allowAllMCPPermissions,
thread,
provider,
tools,
updateStreamingUI,
maxToolSteps,
isProactiveMode
)
}
}
} catch (error) {
console.error(
'Failed to get follow-up completion after tool execution:',
String(error)
)
}
}
} }
// Reset counter when the chain is fully resolved
toolStepCounter = 0
return message
} }