diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts index 24746c24d..e3a0b35fc 100644 --- a/apps/sim/lib/copilot/orchestrator/index.ts +++ b/apps/sim/lib/copilot/orchestrator/index.ts @@ -3,9 +3,14 @@ import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants' import { env } from '@/lib/core/config/env' import { parseSSEStream } from '@/lib/copilot/orchestrator/sse-parser' import { + getToolCallIdFromEvent, handleSubagentRouting, + markToolCallSeen, + markToolResultSeen, sseHandlers, subAgentHandlers, + wasToolCallSeen, + wasToolResultSeen, } from '@/lib/copilot/orchestrator/sse-handlers' import { prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor' import type { @@ -90,7 +95,45 @@ export async function orchestrateCopilotStream( break } - await forwardEvent(event, options) + // Skip tool_result events for tools the sim-side already executed. + // The sim-side emits its own tool_result with complete data. + // For server-side tools (not executed by sim), we still forward the Go backend's tool_result. + const toolCallId = getToolCallIdFromEvent(event) + const eventData = + typeof event.data === 'string' + ? (() => { + try { + return JSON.parse(event.data) + } catch { + return undefined + } + })() + : event.data + + const isPartialToolCall = event.type === 'tool_call' && eventData?.partial === true + + const shouldSkipToolCall = + event.type === 'tool_call' && + !!toolCallId && + !isPartialToolCall && + (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) + + if (event.type === 'tool_call' && toolCallId && !isPartialToolCall && !shouldSkipToolCall) { + markToolCallSeen(toolCallId) + } + + const shouldSkipToolResult = + event.type === 'tool_result' && + (() => { + if (!toolCallId) return false + if (wasToolResultSeen(toolCallId)) return true + markToolResultSeen(toolCallId) + return false + })() + + if (!shouldSkipToolCall && !shouldSkipToolResult) { + await forwardEvent(event, options) + } if (event.type === 'subagent_start') { const toolCallId = event.data?.tool_call_id diff --git a/apps/sim/lib/copilot/orchestrator/sse-handlers.ts b/apps/sim/lib/copilot/orchestrator/sse-handlers.ts index c738674be..0198599c9 100644 --- a/apps/sim/lib/copilot/orchestrator/sse-handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse-handlers.ts @@ -13,6 +13,64 @@ import { INTERRUPT_TOOL_SET, SUBAGENT_TOOL_SET } from '@/lib/copilot/orchestrato const logger = createLogger('CopilotSseHandlers') +/** + * Tracks tool call IDs for which a tool_call has already been forwarded/emitted (non-partial). + */ +const seenToolCalls = new Set() + +/** + * Tracks tool call IDs for which a tool_result has already been emitted or forwarded. + */ +const seenToolResults = new Set() + +export function markToolCallSeen(toolCallId: string): void { + seenToolCalls.add(toolCallId) + setTimeout(() => { + seenToolCalls.delete(toolCallId) + }, 5 * 60 * 1000) +} + +export function wasToolCallSeen(toolCallId: string): boolean { + return seenToolCalls.has(toolCallId) +} + +type EventDataObject = Record | undefined + +const parseEventData = (data: unknown): EventDataObject => { + if (!data) return undefined + if (typeof data !== 'string') return data as EventDataObject + try { + return JSON.parse(data) as EventDataObject + } catch { + return undefined + } +} + +const getEventData = (event: SSEEvent): EventDataObject => parseEventData(event.data) + +export function getToolCallIdFromEvent(event: SSEEvent): string | undefined { + const data = getEventData(event) + return event.toolCallId || data?.id || data?.toolCallId +} + +/** + * Mark a tool call as executed by the sim-side. + * This prevents the Go backend's duplicate tool_result from being forwarded. + */ +export function markToolResultSeen(toolCallId: string): void { + seenToolResults.add(toolCallId) + setTimeout(() => { + seenToolResults.delete(toolCallId) + }, 5 * 60 * 1000) +} + +/** + * Check if a tool call was executed by the sim-side. + */ +export function wasToolResultSeen(toolCallId: string): boolean { + return seenToolResults.has(toolCallId) +} + /** * Respond tools are internal to the copilot's subagent system. * They're used by subagents to signal completion and should NOT be executed by the sim side. @@ -56,6 +114,7 @@ async function executeToolAndReport( if (!toolCall) return if (toolCall.status === 'executing') return + if (wasToolResultSeen(toolCall.id)) return toolCall.status = 'executing' try { @@ -79,6 +138,8 @@ async function executeToolAndReport( } } + markToolResultSeen(toolCall.id) + await markToolComplete( toolCall.id, toolCall.name, @@ -90,8 +151,11 @@ async function executeToolAndReport( await options?.onEvent?.({ type: 'tool_result', toolCallId: toolCall.id, + toolName: toolCall.name, + success: result.success, + result: result.output, data: { - id: toolCall.id, + id: toolCall.id, name: toolCall.name, success: result.success, result: result.output, @@ -102,6 +166,8 @@ async function executeToolAndReport( toolCall.error = error instanceof Error ? error.message : String(error) toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await markToolComplete(toolCall.id, toolCall.name, 500, toolCall.error) await options?.onEvent?.({ @@ -137,16 +203,17 @@ export const sseHandlers: Record = { }, title_updated: () => {}, tool_result: (event, context) => { - const toolCallId = event.toolCallId || event.data?.id + const data = getEventData(event) + const toolCallId = event.toolCallId || data?.id if (!toolCallId) return const current = context.toolCalls.get(toolCallId) if (!current) return // Determine success: explicit success field, or if there's result data without explicit failure - const hasExplicitSuccess = event.data?.success !== undefined || event.data?.result?.success !== undefined - const explicitSuccess = event.data?.success ?? event.data?.result?.success - const hasResultData = event.data?.result !== undefined || event.data?.data !== undefined - const hasError = !!event.data?.error || !!event.data?.result?.error + const hasExplicitSuccess = data?.success !== undefined || data?.result?.success !== undefined + const explicitSuccess = data?.success ?? data?.result?.success + const hasResultData = data?.result !== undefined || data?.data !== undefined + const hasError = !!data?.error || !!data?.result?.error // If explicitly set, use that; otherwise infer from data presence const success = hasExplicitSuccess ? !!explicitSuccess : (hasResultData && !hasError) @@ -156,25 +223,27 @@ export const sseHandlers: Record = { if (hasResultData) { current.result = { success, - output: event.data?.result || event.data?.data, + output: data?.result || data?.data, } } if (hasError) { - current.error = event.data?.error || event.data?.result?.error + current.error = data?.error || data?.result?.error } }, tool_error: (event, context) => { - const toolCallId = event.toolCallId || event.data?.id + const data = getEventData(event) + const toolCallId = event.toolCallId || data?.id if (!toolCallId) return const current = context.toolCalls.get(toolCallId) if (!current) return current.status = 'error' - current.error = event.data?.error || 'Tool execution failed' + current.error = data?.error || 'Tool execution failed' current.endTime = Date.now() }, tool_generating: (event, context) => { - const toolCallId = event.toolCallId || event.data?.toolCallId || event.data?.id - const toolName = event.toolName || event.data?.toolName || event.data?.name + const data = getEventData(event) + const toolCallId = event.toolCallId || data?.toolCallId || data?.id + const toolName = event.toolName || data?.toolName || data?.name if (!toolCallId || !toolName) return if (!context.toolCalls.has(toolCallId)) { context.toolCalls.set(toolCallId, { @@ -186,7 +255,7 @@ export const sseHandlers: Record = { } }, tool_call: async (event, context, execContext, options) => { - const toolData = event.data || {} + const toolData = getEventData(event) || {} const toolCallId = toolData.id || event.toolCallId const toolName = toolData.name || event.toolName if (!toolCallId || !toolName) return @@ -194,20 +263,35 @@ export const sseHandlers: Record = { const args = toolData.arguments || toolData.input || event.data?.input const isPartial = toolData.partial === true const existing = context.toolCalls.get(toolCallId) - const toolCall: ToolCallState = existing - ? { ...existing, status: 'pending', params: args || existing.params } - : { - id: toolCallId, - name: toolName, - status: 'pending', - params: args, - startTime: Date.now(), - } - context.toolCalls.set(toolCallId, toolCall) - addContentBlock(context, { type: 'tool_call', toolCall }) + // If we've already completed this tool call, ignore late/duplicate tool_call events + // to avoid resetting UI/state back to pending and re-executing. + if (existing?.endTime || (existing && existing.status !== 'pending' && existing.status !== 'executing')) { + if (!existing.params && args) { + existing.params = args + } + return + } + + if (existing) { + if (args && !existing.params) existing.params = args + } else { + context.toolCalls.set(toolCallId, { + id: toolCallId, + name: toolName, + status: 'pending', + params: args, + startTime: Date.now(), + }) + const created = context.toolCalls.get(toolCallId)! + addContentBlock(context, { type: 'tool_call', toolCall: created }) + } if (isPartial) return + if (wasToolResultSeen(toolCallId)) return + + const toolCall = context.toolCalls.get(toolCallId) + if (!toolCall) return // Subagent tools are executed by the copilot backend, not sim side if (SUBAGENT_TOOL_SET.has(toolName)) { @@ -243,6 +327,7 @@ export const sseHandlers: Record = { decision.message || 'Tool execution rejected', { skipped: true, reason: 'user_rejected' } ) + markToolResultSeen(toolCall.id) await options.onEvent?.({ type: 'tool_result', toolCallId: toolCall.id, @@ -266,6 +351,7 @@ export const sseHandlers: Record = { decision.message || 'Tool execution moved to background', { background: true } ) + markToolResultSeen(toolCall.id) await options.onEvent?.({ type: 'tool_result', toolCallId: toolCall.id, @@ -346,13 +432,19 @@ export const subAgentHandlers: Record = { tool_call: async (event, context, execContext, options) => { const parentToolCallId = context.subAgentParentToolCallId if (!parentToolCallId) return - const toolData = event.data || {} + const toolData = getEventData(event) || {} const toolCallId = toolData.id || event.toolCallId const toolName = toolData.name || event.toolName if (!toolCallId || !toolName) return const isPartial = toolData.partial === true const args = toolData.arguments || toolData.input || event.data?.input + const existing = context.toolCalls.get(toolCallId) + // Ignore late/duplicate tool_call events once we already have a result + if (wasToolResultSeen(toolCallId) || existing?.endTime) { + return + } + const toolCall: ToolCallState = { id: toolCallId, name: toolName, @@ -361,12 +453,16 @@ export const subAgentHandlers: Record = { startTime: Date.now(), } - // Store in both places - subAgentToolCalls for tracking and toolCalls for executeToolAndReport + // Store in both places - but do NOT overwrite existing tool call state for the same id if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] } - context.subAgentToolCalls[parentToolCallId].push(toolCall) - context.toolCalls.set(toolCallId, toolCall) + if (!context.subAgentToolCalls[parentToolCallId].some((tc) => tc.id === toolCallId)) { + context.subAgentToolCalls[parentToolCallId].push(toolCall) + } + if (!context.toolCalls.has(toolCallId)) { + context.toolCalls.set(toolCallId, toolCall) + } if (isPartial) return @@ -385,7 +481,8 @@ export const subAgentHandlers: Record = { tool_result: (event, context) => { const parentToolCallId = context.subAgentParentToolCallId if (!parentToolCallId) return - const toolCallId = event.toolCallId || event.data?.id + const data = getEventData(event) + const toolCallId = event.toolCallId || data?.id if (!toolCallId) return // Update in subAgentToolCalls @@ -396,31 +493,30 @@ export const subAgentHandlers: Record = { const mainToolCall = context.toolCalls.get(toolCallId) // Use same success inference logic as main handler - const hasExplicitSuccess = - event.data?.success !== undefined || event.data?.result?.success !== undefined - const explicitSuccess = event.data?.success ?? event.data?.result?.success - const hasResultData = event.data?.result !== undefined || event.data?.data !== undefined - const hasError = !!event.data?.error || !!event.data?.result?.error + const hasExplicitSuccess = data?.success !== undefined || data?.result?.success !== undefined + const explicitSuccess = data?.success ?? data?.result?.success + const hasResultData = data?.result !== undefined || data?.data !== undefined + const hasError = !!data?.error || !!data?.result?.error const success = hasExplicitSuccess ? !!explicitSuccess : hasResultData && !hasError const status = success ? 'success' : 'error' const endTime = Date.now() const result = hasResultData - ? { success, output: event.data?.result || event.data?.data } + ? { success, output: data?.result || data?.data } : undefined if (subAgentToolCall) { subAgentToolCall.status = status subAgentToolCall.endTime = endTime if (result) subAgentToolCall.result = result - if (hasError) subAgentToolCall.error = event.data?.error || event.data?.result?.error + if (hasError) subAgentToolCall.error = data?.error || data?.result?.error } if (mainToolCall) { mainToolCall.status = status mainToolCall.endTime = endTime if (result) mainToolCall.result = result - if (hasError) mainToolCall.error = event.data?.error || event.data?.result?.error + if (hasError) mainToolCall.error = data?.error || data?.result?.error } }, } diff --git a/apps/sim/lib/copilot/orchestrator/subagent.ts b/apps/sim/lib/copilot/orchestrator/subagent.ts index bdc69fd68..3d3795f7b 100644 --- a/apps/sim/lib/copilot/orchestrator/subagent.ts +++ b/apps/sim/lib/copilot/orchestrator/subagent.ts @@ -2,9 +2,14 @@ import { createLogger } from '@sim/logger' import { SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants' import { parseSSEStream } from '@/lib/copilot/orchestrator/sse-parser' import { + getToolCallIdFromEvent, + handleSubagentRouting, + markToolCallSeen, + markToolResultSeen, sseHandlers, subAgentHandlers, - handleSubagentRouting, + wasToolCallSeen, + wasToolResultSeen, } from '@/lib/copilot/orchestrator/sse-handlers' import { prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor' import type { @@ -20,10 +25,11 @@ import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' const logger = createLogger('CopilotSubagentOrchestrator') const SIM_AGENT_API_URL = env.SIM_AGENT_API_URL || SIM_AGENT_API_URL_DEFAULT -export interface SubagentOrchestratorOptions extends OrchestratorOptions { +export interface SubagentOrchestratorOptions extends Omit { userId: string workflowId?: string workspaceId?: string + onComplete?: (result: SubagentOrchestratorResult) => void | Promise } export interface SubagentOrchestratorResult { @@ -106,7 +112,45 @@ export async function orchestrateSubagentStream( break } - await forwardEvent(event, options) + // Skip tool_result events for tools the sim-side already executed. + // The sim-side emits its own tool_result with complete data. + // For server-side tools (not executed by sim), we still forward the Go backend's tool_result. + const toolCallId = getToolCallIdFromEvent(event) + const eventData = + typeof event.data === 'string' + ? (() => { + try { + return JSON.parse(event.data) + } catch { + return undefined + } + })() + : event.data + + const isPartialToolCall = event.type === 'tool_call' && eventData?.partial === true + + const shouldSkipToolCall = + event.type === 'tool_call' && + !!toolCallId && + !isPartialToolCall && + (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) + + if (event.type === 'tool_call' && toolCallId && !isPartialToolCall && !shouldSkipToolCall) { + markToolCallSeen(toolCallId) + } + + const shouldSkipToolResult = + event.type === 'tool_result' && + (() => { + if (!toolCallId) return false + if (wasToolResultSeen(toolCallId)) return true + markToolResultSeen(toolCallId) + return false + })() + + if (!shouldSkipToolCall && !shouldSkipToolResult) { + await forwardEvent(event, options) + } if (event.type === 'structured_result' || event.type === 'subagent_result') { structuredResult = normalizeStructuredResult(event.data) diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/orchestrator/types.ts index 12cdee9da..52966d0b3 100644 --- a/apps/sim/lib/copilot/orchestrator/types.ts +++ b/apps/sim/lib/copilot/orchestrator/types.ts @@ -23,6 +23,8 @@ export interface SSEEvent { subagent?: string toolCallId?: string toolName?: string + success?: boolean + result?: any } export type ToolCallStatus = 'pending' | 'executing' | 'success' | 'error' | 'skipped' | 'rejected'