This commit is contained in:
Siddharth Ganesan
2026-01-29 10:18:07 -08:00
parent a6e2e4bdb9
commit 95283127e3
7 changed files with 362 additions and 63 deletions

View File

@@ -158,10 +158,12 @@ export async function POST(req: NextRequest) {
}
// Build execution context that will be passed to Go and used for tool execution
// Source is 'ui' since this route is called from the browser UI
const executionContext = {
userId: authenticatedUserId,
workflowId: resolvedWorkflowId,
workspaceId: resolvedWorkspaceId,
source: 'ui' as const,
}
logger.debug(`[${tracker.requestId}] Resolved execution context`, executionContext)
@@ -686,6 +688,7 @@ export async function POST(req: NextRequest) {
workspaceId:
event.data.executionContext?.workspaceId || resolvedWorkspaceId,
chatId: actualChatId,
source: 'ui' as const,
}
handleToolCallEvent(
{

View File

@@ -44,10 +44,12 @@ export async function POST(req: NextRequest) {
logger.info('Test copilot request', { query, userId, workflowId, workspaceId, stream })
// Build execution context
// Source is 'headless' since this is a test/API endpoint without UI
const executionContext = {
userId,
workflowId,
workspaceId,
source: 'headless' as const,
}
// Build request payload for Go copilot
@@ -119,6 +121,7 @@ export async function POST(req: NextRequest) {
workflowId: event.data.executionContext?.workflowId || workflowId,
workspaceId: event.data.executionContext?.workspaceId || workspaceId,
chatId: undefined,
source: 'headless' as const,
}
handleToolCallEvent(

View File

@@ -363,17 +363,38 @@ const TOOL_REGISTRY: Record<string, ToolRegistration> = {
},
}
/**
* Tools that should only be auto-intercepted in headless mode.
* In UI mode, the client tool handles these (e.g., to show diff review).
*/
const HEADLESS_ONLY_TOOLS = new Set(['edit_workflow'])
/**
* List of all server-executed tool names.
* Export this so clients know which tools NOT to execute locally.
* Note: edit_workflow is excluded because it needs client-side diff review in UI mode.
*/
export const SERVER_EXECUTED_TOOLS = Object.keys(TOOL_REGISTRY)
export const SERVER_EXECUTED_TOOLS = Object.keys(TOOL_REGISTRY).filter(
(name) => !HEADLESS_ONLY_TOOLS.has(name)
)
/**
* Check if a tool is registered for server execution.
* @param toolName - The tool name to check
* @param source - Optional execution source. If 'ui', headless-only tools return false.
*/
export function isServerExecutedTool(toolName: string): boolean {
return toolName in TOOL_REGISTRY
export function isServerExecutedTool(
toolName: string,
source?: 'ui' | 'headless'
): boolean {
if (!(toolName in TOOL_REGISTRY)) {
return false
}
// In UI mode, headless-only tools are NOT server-executed (client handles them)
if (source === 'ui' && HEADLESS_ONLY_TOOLS.has(toolName)) {
return false
}
return true
}
/**

View File

@@ -182,10 +182,12 @@ export async function handleToolCallEvent(
}
// Check if this tool should be executed server-side
if (!isServerExecutedTool(event.name)) {
// Pass source to handle headless-only tools (e.g., edit_workflow in UI mode is client-handled)
if (!isServerExecutedTool(event.name, context.source)) {
logger.debug('Tool not server-executed, client will handle', {
toolCallId: event.id,
toolName: event.name,
source: context.source,
})
return false
}

View File

@@ -21,6 +21,11 @@ export interface ToolResult<T = unknown> {
}
}
/**
* Execution source - whether the request came from UI or headless API.
*/
export type ExecutionSource = 'ui' | 'headless'
/**
* Context passed to tool executors.
*
@@ -33,6 +38,8 @@ export interface ExecutionContext {
workflowId?: string
workspaceId?: string
chatId?: string
/** Whether request came from UI (with diff review) or headless API (direct save) */
source?: ExecutionSource
}
/**

View File

@@ -1,3 +1,4 @@
import { createLogger } from '@sim/logger'
import { Grid2x2, Grid2x2Check, Grid2x2X, Loader2, MinusCircle, XCircle } from 'lucide-react'
import {
BaseClientTool,
@@ -5,15 +6,108 @@ import {
ClientToolCallState,
} from '@/lib/copilot/tools/client/base-tool'
import { registerToolUIConfig } from '@/lib/copilot/tools/client/ui-config'
import { ExecuteResponseSuccessSchema } from '@/lib/copilot/tools/shared/schemas'
import { stripWorkflowDiffMarkers } from '@/lib/workflows/diff'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
interface EditWorkflowOperation {
operation_type: 'add' | 'edit' | 'delete'
block_id: string
params?: Record<string, any>
}
interface EditWorkflowArgs {
operations: EditWorkflowOperation[]
workflowId: string
currentUserWorkflow?: string
}
export class EditWorkflowClientTool extends BaseClientTool {
static readonly id = 'edit_workflow'
private lastResult: any | undefined
private hasExecuted = false
private hasAppliedDiff = false
private workflowId: string | undefined
constructor(toolCallId: string) {
super(toolCallId, EditWorkflowClientTool.id, EditWorkflowClientTool.metadata)
}
async markToolComplete(status: number, message?: any, data?: any): Promise<boolean> {
const logger = createLogger('EditWorkflowClientTool')
logger.info('markToolComplete payload', {
toolCallId: this.toolCallId,
toolName: this.name,
status,
message,
data,
})
return super.markToolComplete(status, message, data)
}
/**
* Get sanitized workflow JSON from a workflow state, merge subblocks, and sanitize for copilot
*/
private getSanitizedWorkflowJson(workflowState: any): string | undefined {
const logger = createLogger('EditWorkflowClientTool')
if (!this.workflowId) {
logger.warn('No workflowId available for getting sanitized workflow JSON')
return undefined
}
if (!workflowState) {
logger.warn('No workflow state provided')
return undefined
}
try {
// Normalize required properties
if (!workflowState.loops) workflowState.loops = {}
if (!workflowState.parallels) workflowState.parallels = {}
if (!workflowState.edges) workflowState.edges = []
if (!workflowState.blocks) workflowState.blocks = {}
// Merge latest subblock values so edits are reflected
let mergedState = workflowState
if (workflowState.blocks) {
mergedState = {
...workflowState,
blocks: mergeSubblockState(workflowState.blocks, this.workflowId as any),
}
}
// Sanitize workflow state for copilot (remove UI-specific data)
const sanitizedState = sanitizeForCopilot(mergedState)
// Convert to JSON string for transport
const workflowJson = JSON.stringify(sanitizedState, null, 2)
return workflowJson
} catch (error) {
logger.warn('Failed to get sanitized workflow JSON', {
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}
private getCurrentWorkflowJsonSafe(logger: ReturnType<typeof createLogger>): string | undefined {
try {
const workflowStore = useWorkflowStore.getState()
const currentState = workflowStore.getWorkflowState()
return this.getSanitizedWorkflowJson(currentState)
} catch {
logger.warn('Failed to get current workflow JSON for error response')
return undefined
}
}
static readonly metadata: BaseClientToolMetadata = {
displayNames: {
[ClientToolCallState.generating]: { text: 'Editing your workflow', icon: Loader2 },
@@ -56,9 +150,163 @@ export class EditWorkflowClientTool extends BaseClientTool {
},
}
// Executed server-side via handleToolCallEvent in stream-handler.ts
// Client tool provides UI metadata only for rendering tool call cards
// The server applies workflow changes directly in headless mode
handleAccept(): void {
const logger = createLogger('EditWorkflowClientTool')
logger.info('handleAccept called', { toolCallId: this.toolCallId, state: this.getState() })
// The actual accept is handled by useWorkflowDiffStore.acceptChanges()
// This just updates the tool state
this.setState(ClientToolCallState.success)
}
handleReject(): void {
const logger = createLogger('EditWorkflowClientTool')
logger.info('handleReject called', { toolCallId: this.toolCallId, state: this.getState() })
// Tool was already marked complete in execute() - this is just for UI state
this.setState(ClientToolCallState.rejected)
}
async execute(args?: EditWorkflowArgs): Promise<void> {
const logger = createLogger('EditWorkflowClientTool')
if (this.hasExecuted) {
logger.info('execute skipped (already executed)', { toolCallId: this.toolCallId })
return
}
this.hasExecuted = true
this.setState(ClientToolCallState.executing)
// Get workflow ID from args or active workflow
const workflowId = args?.workflowId || useWorkflowRegistry.getState().activeWorkflowId
if (!workflowId) {
this.setState(ClientToolCallState.error)
await this.markToolComplete(400, 'No workflow ID provided or active')
return
}
this.workflowId = workflowId
logger.info('execute starting', {
toolCallId: this.toolCallId,
workflowId,
operationCount: args?.operations?.length,
})
try {
// Get current workflow state to send to server
const workflowStore = useWorkflowStore.getState()
const fullState = workflowStore.getWorkflowState()
const mergedBlocks = mergeSubblockState(fullState.blocks, workflowId as any)
const payloadState = stripWorkflowDiffMarkers({
...fullState,
blocks: mergedBlocks,
edges: fullState.edges || [],
loops: fullState.loops || {},
parallels: fullState.parallels || {},
})
// Call server to execute the tool (without saving to DB in UI mode)
const response = await fetch('/api/copilot/execute-copilot-server-tool', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
toolName: 'edit_workflow',
toolCallId: this.toolCallId,
args: {
...args,
workflowId,
currentUserWorkflow: JSON.stringify(payloadState),
},
}),
})
if (!response.ok) {
const errorText = await response.text()
logger.error('Server execution failed', { status: response.status, error: errorText })
this.setState(ClientToolCallState.error)
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
response.status,
errorText || 'Server execution failed',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
return
}
const result = await response.json()
logger.info('Server execution result', {
success: result.success,
hasWorkflowState: !!result.data?.workflowState,
})
// Validate result
const parseResult = ExecuteResponseSuccessSchema.safeParse(result)
if (!parseResult.success || !result.data?.workflowState) {
logger.error('Invalid response from server', { errors: parseResult.error?.errors })
this.setState(ClientToolCallState.error)
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
500,
'Invalid response from server',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
return
}
this.lastResult = result.data
// Apply the proposed state to the diff store for review
if (!this.hasAppliedDiff) {
const diffStore = useWorkflowDiffStore.getState()
// setProposedChanges applies the state optimistically to the workflow store
// and sets up diff markers for visual feedback
await diffStore.setProposedChanges(result.data.workflowState)
logger.info('Diff proposed changes set for edit_workflow with direct workflow state')
this.hasAppliedDiff = true
}
// Read back the applied state from the workflow store
const actualDiffWorkflow = workflowStore.getWorkflowState()
if (!actualDiffWorkflow) {
this.setState(ClientToolCallState.error)
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
500,
'Failed to retrieve workflow state after applying changes',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
return
}
// Set state to review so user can accept/reject
this.setState(ClientToolCallState.review)
// Mark tool complete with success - the workflow state is ready for review
const sanitizedJson = this.getSanitizedWorkflowJson(actualDiffWorkflow)
await this.markToolComplete(
200,
result.data.inputValidationMessage || result.data.skippedItemsMessage || undefined,
sanitizedJson ? { userWorkflow: sanitizedJson } : undefined
)
logger.info('execute completed successfully - awaiting user review', {
toolCallId: this.toolCallId,
state: this.getState(),
})
} catch (error) {
logger.error('execute failed with exception', {
toolCallId: this.toolCallId,
error: error instanceof Error ? error.message : String(error),
})
this.setState(ClientToolCallState.error)
const currentWorkflowJson = this.getCurrentWorkflowJsonSafe(logger)
await this.markToolComplete(
500,
error instanceof Error ? error.message : 'Unknown error',
currentWorkflowJson ? { userWorkflow: currentWorkflowJson } : undefined
)
}
}
}
// Register UI config at module load

View File

@@ -2631,7 +2631,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
name: 'edit_workflow',
async execute(
params: EditWorkflowParams,
context?: { userId: string; workflowId?: string }
context?: { userId: string; workflowId?: string; workspaceId?: string; source?: 'ui' | 'headless' }
): Promise<any> {
const logger = createLogger('EditWorkflowServerTool')
const { operations, currentUserWorkflow } = params
@@ -2753,68 +2753,83 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, any> = {
const finalWorkflowState = validation.sanitizedState || modifiedWorkflowState
// ─────────────────────────────────────────────────────────────────────────
// PERSIST THE CHANGES TO THE DATABASE
// This is critical for headless mode and ensures changes are saved
// PERSIST THE CHANGES TO THE DATABASE (headless mode only)
// In UI mode, we return the proposed state for client-side diff review.
// The client will persist after user accepts the changes.
// In headless mode, we save directly since there's no UI to review.
// ─────────────────────────────────────────────────────────────────────────
const workflowStateForPersistence = {
blocks: finalWorkflowState.blocks,
edges: finalWorkflowState.edges,
loops: finalWorkflowState.loops || {},
parallels: finalWorkflowState.parallels || {},
lastSaved: Date.now(),
}
const isHeadlessMode = context?.source !== 'ui'
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForPersistence)
if (isHeadlessMode) {
const workflowStateForPersistence = {
blocks: finalWorkflowState.blocks,
edges: finalWorkflowState.edges,
loops: finalWorkflowState.loops || {},
parallels: finalWorkflowState.parallels || {},
lastSaved: Date.now(),
}
if (!saveResult.success) {
logger.error('Failed to persist workflow changes to database', {
workflowId,
error: saveResult.error,
const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowStateForPersistence)
if (!saveResult.success) {
logger.error('Failed to persist workflow changes 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))
// Notify socket server so connected clients can refresh
// This uses the copilot-specific endpoint to trigger UI refresh
try {
const socketUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002'
const operationsSummary = operations
.map((op: any) => `${op.operation_type} ${op.block_id || 'block'}`)
.slice(0, 3)
.join(', ')
const description = `Applied ${operations.length} operation(s): ${operationsSummary}${operations.length > 3 ? '...' : ''}`
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflowId, description }),
}).catch((err) => {
logger.warn('Failed to notify socket server about copilot edit', { error: err.message })
})
} catch (notifyError) {
// Non-fatal - log and continue
logger.warn('Error notifying socket server', { error: notifyError })
}
logger.info('edit_workflow successfully applied and persisted operations (headless mode)', {
operationCount: operations.length,
blocksCount: Object.keys(finalWorkflowState.blocks).length,
edgesCount: finalWorkflowState.edges.length,
inputValidationErrors: validationErrors.length,
skippedItemsCount: skippedItems.length,
schemaValidationErrors: validation.errors.length,
validationWarnings: validation.warnings.length,
})
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(),
} else {
// UI mode - don't persist, let client handle after user accepts
logger.info('edit_workflow returning proposed state for UI review (not persisted)', {
operationCount: operations.length,
blocksCount: Object.keys(finalWorkflowState.blocks).length,
edgesCount: finalWorkflowState.edges.length,
inputValidationErrors: validationErrors.length,
skippedItemsCount: skippedItems.length,
})
.where(eq(workflowTable.id, workflowId))
// Notify socket server so connected clients can refresh
// This uses the copilot-specific endpoint to trigger UI refresh
try {
const socketUrl = process.env.SOCKET_SERVER_URL || 'http://localhost:3002'
const operationsSummary = operations
.map((op: any) => `${op.operation_type} ${op.block_id || 'block'}`)
.slice(0, 3)
.join(', ')
const description = `Applied ${operations.length} operation(s): ${operationsSummary}${operations.length > 3 ? '...' : ''}`
await fetch(`${socketUrl}/api/copilot-workflow-edit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflowId, description }),
}).catch((err) => {
logger.warn('Failed to notify socket server about copilot edit', { error: err.message })
})
} catch (notifyError) {
// Non-fatal - log and continue
logger.warn('Error notifying socket server', { error: notifyError })
}
logger.info('edit_workflow successfully applied and persisted operations', {
operationCount: operations.length,
blocksCount: Object.keys(finalWorkflowState.blocks).length,
edgesCount: finalWorkflowState.edges.length,
inputValidationErrors: validationErrors.length,
skippedItemsCount: skippedItems.length,
schemaValidationErrors: validation.errors.length,
validationWarnings: validation.warnings.length,
})
// Format validation errors for LLM feedback
const inputErrors =
validationErrors.length > 0