Fix edge issue

This commit is contained in:
Siddharth Ganesan
2026-02-04 11:16:19 -08:00
parent 0ba8c0ad29
commit 2198a6caae
4 changed files with 226 additions and 41 deletions

View File

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

View File

@@ -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<string>()
/**
* Tracks tool call IDs for which a tool_result has already been emitted or forwarded.
*/
const seenToolResults = new Set<string>()
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<string, any> | 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<string, SSEHandler> = {
},
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<string, SSEHandler> = {
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<string, SSEHandler> = {
}
},
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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
}
},
}

View File

@@ -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<OrchestratorOptions, 'onComplete'> {
userId: string
workflowId?: string
workspaceId?: string
onComplete?: (result: SubagentOrchestratorResult) => void | Promise<void>
}
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)

View File

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