diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 412ed8052..cab197ad5 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { authenticateV1Request } from '@/app/api/v1/auth' import { getCopilotModel } from '@/lib/copilot/config' import { SIM_AGENT_VERSION } from '@/lib/copilot/constants' +import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' const logger = createLogger('CopilotHeadlessAPI') @@ -16,7 +17,7 @@ const RequestSchema = z.object({ workflowId: z.string().optional(), workflowName: z.string().optional(), chatId: z.string().optional(), - mode: z.enum(['agent', 'ask', 'plan', 'fast']).optional().default('fast'), + mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), model: z.string().optional(), autoExecuteTools: z.boolean().optional().default(true), timeout: z.number().optional().default(300000), @@ -100,6 +101,14 @@ export async function POST(req: NextRequest) { ) } + // Transform mode to transport mode (same as client API) + // build and agent both map to 'agent' on the backend + const effectiveMode = parsed.mode === 'agent' ? 'build' : parsed.mode + const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode + + // Always generate a chatId - required for artifacts system to work with subagents + const chatId = parsed.chatId || crypto.randomUUID() + const requestPayload = { message: parsed.message, workflowId: resolved.workflowId, @@ -107,17 +116,17 @@ export async function POST(req: NextRequest) { stream: true, streamToolCalls: true, model: selectedModel, - mode: parsed.mode, + mode: transportMode, messageId: crypto.randomUUID(), version: SIM_AGENT_VERSION, headless: true, // Enable cross-workflow operations via workflowId params - ...(parsed.chatId ? { chatId: parsed.chatId } : {}), + chatId, } const result = await orchestrateCopilotStream(requestPayload, { userId: auth.userId, workflowId: resolved.workflowId, - chatId: parsed.chatId, + chatId, autoExecuteTools: parsed.autoExecuteTools, timeout: parsed.timeout, interactive: false, @@ -127,7 +136,7 @@ export async function POST(req: NextRequest) { success: result.success, content: result.content, toolCalls: result.toolCalls, - chatId: result.chatId, + chatId: result.chatId || chatId, // Return the chatId for conversation continuity conversationId: result.conversationId, error: result.error, }) diff --git a/apps/sim/lib/copilot/orchestrator/sse-handlers.ts b/apps/sim/lib/copilot/orchestrator/sse-handlers.ts index 269f2e43b..e3b26df53 100644 --- a/apps/sim/lib/copilot/orchestrator/sse-handlers.ts +++ b/apps/sim/lib/copilot/orchestrator/sse-handlers.ts @@ -13,6 +13,21 @@ import { INTERRUPT_TOOL_SET, SUBAGENT_TOOL_SET } from '@/lib/copilot/orchestrato const logger = createLogger('CopilotSseHandlers') +/** + * 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. + * The copilot backend handles these internally. + */ +const RESPOND_TOOL_SET = new Set([ + 'plan_respond', + 'edit_respond', + 'debug_respond', + 'info_respond', + 'research_respond', + 'deploy_respond', + 'superagent_respond', +]) + export type SSEHandler = ( event: SSEEvent, context: StreamingContext, @@ -112,15 +127,26 @@ export const sseHandlers: Record = { const current = context.toolCalls.get(toolCallId) if (!current) return - const success = event.data?.success ?? event.data?.result?.success + // 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 + + // If explicitly set, use that; otherwise infer from data presence + const success = hasExplicitSuccess ? !!explicitSuccess : (hasResultData && !hasError) + current.status = success ? 'success' : 'error' current.endTime = Date.now() - if (event.data?.result || event.data?.data) { + if (hasResultData) { current.result = { - success: !!success, + success, output: event.data?.result || event.data?.data, } } + if (hasError) { + current.error = event.data?.error || event.data?.result?.error + } }, tool_error: (event, context) => { const toolCallId = event.toolCallId || event.data?.id @@ -168,10 +194,17 @@ export const sseHandlers: Record = { if (isPartial) return + // Subagent tools are executed by the copilot backend, not sim side if (SUBAGENT_TOOL_SET.has(toolName)) { return } + // Respond tools are internal to copilot's subagent system - skip execution + // The copilot backend handles these internally to signal subagent completion + if (RESPOND_TOOL_SET.has(toolName)) { + return + } + const isInterruptTool = INTERRUPT_TOOL_SET.has(toolName) const isInteractive = options.interactive === true @@ -309,12 +342,21 @@ export const subAgentHandlers: Record = { params: args, startTime: Date.now(), } + + // Store in both places - subAgentToolCalls for tracking and toolCalls for executeToolAndReport if (!context.subAgentToolCalls[parentToolCallId]) { context.subAgentToolCalls[parentToolCallId] = [] } context.subAgentToolCalls[parentToolCallId].push(toolCall) + context.toolCalls.set(toolCallId, toolCall) if (isPartial) return + + // Respond tools are internal to copilot's subagent system - skip execution + if (RESPOND_TOOL_SET.has(toolName)) { + return + } + if (options.autoExecuteTools !== false) { await executeToolAndReport(toolCallId, context, execContext, options) } @@ -324,11 +366,41 @@ export const subAgentHandlers: Record = { if (!parentToolCallId) return const toolCallId = event.toolCallId || event.data?.id if (!toolCallId) return + + // Update in subAgentToolCalls const toolCalls = context.subAgentToolCalls[parentToolCallId] || [] - const toolCall = toolCalls.find((tc) => tc.id === toolCallId) - if (!toolCall) return - toolCall.status = event.data?.success ? 'success' : 'error' - toolCall.endTime = Date.now() + const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId) + + // Also update in main toolCalls (where we added it for execution) + 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 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 } + : 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 (mainToolCall) { + mainToolCall.status = status + mainToolCall.endTime = endTime + if (result) mainToolCall.result = result + if (hasError) mainToolCall.error = event.data?.error || event.data?.result?.error + } }, } diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts index 61866dbd9..711be838f 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -8,7 +8,10 @@ import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator import type { PermissionGroupConfig } from '@/lib/permission-groups/types' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { extractAndPersistCustomTools } from '@/lib/workflows/persistence/custom-tools-persistence' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' +import { + loadWorkflowFromNormalizedTables, + saveWorkflowToNormalizedTables, +} from '@/lib/workflows/persistence/utils' import { isValidKey } from '@/lib/workflows/sanitization/key-validation' import { validateWorkflowState } from '@/lib/workflows/sanitization/validation' import { buildCanonicalIndex, isCanonicalPair } from '@/lib/workflows/subblocks/visibility' @@ -3067,6 +3070,37 @@ export const editWorkflowServerTool: BaseServerTool = { const skippedMessages = skippedItems.length > 0 ? skippedItems.map((item) => item.reason) : undefined + // Persist the workflow state to the database + const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState + const workflowStateForDb = { + blocks: finalWorkflowState.blocks, + edges: finalWorkflowState.edges, + loops: generateLoopBlocks(finalWorkflowState.blocks as any), + parallels: generateParallelBlocks(finalWorkflowState.blocks as any), + lastSaved: Date.now(), + isDeployed: false, + } + + const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForDb as any) + if (!saveResult.success) { + logger.error('Failed to persist workflow state to database', { + workflowId, + error: saveResult.error, + }) + throw new Error(`Failed to save workflow: ${saveResult.error}`) + } + + // Update workflow's lastSynced timestamp + await db + .update(workflowTable) + .set({ + lastSynced: new Date(), + updatedAt: new Date(), + }) + .where(eq(workflowTable.id, workflowId)) + + logger.info('Workflow state persisted to database', { workflowId }) + // Return the modified workflow state for the client to convert to YAML if needed return { success: true,