This commit is contained in:
Siddharth Ganesan
2026-01-30 16:52:23 -08:00
parent e37c33eb9f
commit f00c710d58
3 changed files with 128 additions and 13 deletions

View File

@@ -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,
})

View File

@@ -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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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<string, SSEHandler> = {
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
}
},
}

View File

@@ -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<EditWorkflowParams, any> = {
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,