From bb6d674fc89253a9c82c561187100a4b2b0e32a1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Dec 2025 22:09:27 +0000 Subject: [PATCH] feat: Implement router block with conditional routing Co-authored-by: emir --- .../components/sub-block/components/index.ts | 1 + .../components/router-input/router-input.tsx | 978 ++++++++++++++++++ .../sub-block/hooks/use-sub-block-value.ts | 3 +- .../editor/components/sub-block/sub-block.tsx | 12 + .../workflow-block/workflow-block.tsx | 134 ++- apps/sim/blocks/blocks/router.ts | 255 +---- apps/sim/blocks/types.ts | 1 + .../executor/dag/construction/edges.test.ts | 15 +- apps/sim/executor/dag/construction/edges.ts | 55 +- .../handlers/router/router-handler.test.ts | 343 +++--- .../handlers/router/router-handler.ts | 230 ++-- apps/sim/executor/variables/resolver.ts | 28 + .../tools/server/workflow/edit-workflow.ts | 96 +- apps/sim/lib/tokenization/streaming.ts | 2 +- apps/sim/lib/workflows/autolayout/core.ts | 18 + .../workflows/sanitization/json-sanitizer.ts | 7 + 16 files changed, 1639 insertions(+), 539 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/router-input/router-input.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index dc5ba115e0..a162f6ea92 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -21,6 +21,7 @@ export { McpToolSelector } from './mcp-server-modal/mcp-tool-selector' export { MessagesInput } from './messages-input/messages-input' export { ProjectSelectorInput } from './project-selector/project-selector-input' export { ResponseFormat } from './response/response-format' +export { RouterInput } from './router-input/router-input' export { ScheduleSave } from './schedule-save/schedule-save' export { ShortInput } from './short-input/short-input' export { SlackSelectorInput } from './slack-selector/slack-selector-input' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/router-input/router-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/router-input/router-input.tsx new file mode 100644 index 0000000000..1f161aa250 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/router-input/router-input.tsx @@ -0,0 +1,978 @@ +import type { ReactElement } from 'react' +import { useEffect, useRef, useState } from 'react' +import { ChevronDown, ChevronUp, Plus } from 'lucide-react' +import { useParams } from 'next/navigation' +import Editor from 'react-simple-code-editor' +import { useUpdateNodeInternals } from 'reactflow' +import { + Button, + Code, + calculateGutterWidth, + getCodeEditorProps, + highlight, + languages, + Tooltip, +} from '@/components/emcn' +import { Trash } from '@/components/emcn/icons/trash' +import { cn } from '@/lib/core/utils/cn' +import { createLogger } from '@/lib/logs/console/logger' +import { + isLikelyReferenceSegment, + SYSTEM_REFERENCE_PREFIXES, + splitReferenceSegment, +} from '@/lib/workflows/sanitization/references' +import { + checkEnvVarTrigger, + EnvVarDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/env-var-dropdown' +import { + checkTagTrigger, + TagDropdown, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes' +import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' +import { useTagSelection } from '@/hooks/use-tag-selection' +import { normalizeBlockName } from '@/stores/workflows/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' + +const logger = createLogger('RouterInput') + +/** + * Represents a single route block (if/else if/else). + */ +interface RouteBlock { + /** Unique identifier for the block */ + id: string + /** Block title (if/else if/else) */ + title: string + /** Code content of the condition */ + value: string + /** Whether tag dropdown is visible */ + showTags: boolean + /** Whether environment variable dropdown is visible */ + showEnvVars: boolean + /** Current search term for env var dropdown */ + searchTerm: string + /** Current cursor position in the editor */ + cursorPosition: number + /** ID of the active source block for connections */ + activeSourceBlockId: string | null +} + +/** + * Props for the RouterInput component. + */ +interface RouterInputProps { + /** ID of the parent workflow block */ + blockId: string + /** ID of this subblock */ + subBlockId: string + /** Whether component is in preview mode */ + isPreview?: boolean + /** Preview value to display instead of store value */ + previewValue?: string | null + /** Whether the component is disabled */ + disabled?: boolean +} + +/** + * Generates a stable ID for route blocks. + * + * @param blockId - The parent block ID + * @param suffix - Suffix to append (e.g., 'if', 'else', 'else-if-timestamp') + * @returns A stable composite ID + */ +const generateStableId = (blockId: string, suffix: string): string => { + return `${blockId}-${suffix}` +} + +/** + * Router input component for creating if/else if/else routing logic blocks. + * Similar to condition-input but used specifically for router blocks. + * + * @param props - Component props + * @returns Rendered router input component + */ +export function RouterInput({ + blockId, + subBlockId, + isPreview = false, + previewValue, + disabled = false, +}: RouterInputProps) { + const params = useParams() + const workspaceId = params.workspaceId as string + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId) + + const emitTagSelection = useTagSelection(blockId, subBlockId) + const accessiblePrefixes = useAccessibleReferencePrefixes(blockId) + + const containerRef = useRef(null) + + /** + * Determines if a reference string should be highlighted in the editor. + * + * @param part - String segment to check (e.g., '') + * @returns True if the reference should be highlighted + */ + const shouldHighlightReference = (part: string): boolean => { + if (!part.startsWith('<') || !part.endsWith('>')) { + return false + } + + if (!isLikelyReferenceSegment(part)) { + return false + } + + const split = splitReferenceSegment(part) + if (!split) { + return false + } + + const reference = split.reference + + if (!accessiblePrefixes) { + return true + } + + const inner = reference.slice(1, -1) + const [prefix] = inner.split('.') + const normalizedPrefix = normalizeBlockName(prefix) + + if (SYSTEM_REFERENCE_PREFIXES.has(normalizedPrefix)) { + return true + } + + return accessiblePrefixes.has(normalizedPrefix) + } + const [visualLineHeights, setVisualLineHeights] = useState<{ + [key: string]: number[] + }>({}) + const updateNodeInternals = useUpdateNodeInternals() + const removeEdge = useWorkflowStore((state) => state.removeEdge) + const edges = useWorkflowStore((state) => state.edges) + + // Use a ref to track the previous store value for comparison + const prevStoreValueRef = useRef(null) + // Use a ref to track if we're currently syncing from store to prevent loops + const isSyncingFromStoreRef = useRef(false) + // Use a ref to track if we've already initialized from store + const hasInitializedRef = useRef(false) + // Track previous blockId to detect workflow changes + const previousBlockIdRef = useRef(blockId) + const shouldPersistRef = useRef(false) + + /** + * Creates default if/else route blocks with stable IDs. + * + * @returns Array of two default blocks (if and else) + */ + const createDefaultBlocks = (): RouteBlock[] => [ + { + id: generateStableId(blockId, 'if'), + title: 'if', + value: '', + showTags: false, + showEnvVars: false, + searchTerm: '', + cursorPosition: 0, + activeSourceBlockId: null, + }, + { + id: generateStableId(blockId, 'else'), + title: 'else', + value: '', + showTags: false, + showEnvVars: false, + searchTerm: '', + cursorPosition: 0, + activeSourceBlockId: null, + }, + ] + + // Initialize with a loading state instead of default blocks + const [routeBlocks, setRouteBlocks] = useState([]) + const [isReady, setIsReady] = useState(false) + + // Reset initialization state when blockId changes (workflow navigation) + useEffect(() => { + if (blockId !== previousBlockIdRef.current) { + // Reset refs and state for new workflow/block + hasInitializedRef.current = false + isSyncingFromStoreRef.current = false + prevStoreValueRef.current = null + previousBlockIdRef.current = blockId + setIsReady(false) + setRouteBlocks([]) + } + }, [blockId]) + + /** + * Safely parses JSON string into route blocks array. + * + * @param jsonString - JSON string to parse + * @returns Parsed blocks array or null if invalid + */ + const safeParseJSON = (jsonString: string | null): RouteBlock[] | null => { + if (!jsonString) return null + try { + const parsed = JSON.parse(jsonString) + if (!Array.isArray(parsed)) return null + + // Validate that the parsed data has the expected structure + if (parsed.length === 0 || !('id' in parsed[0]) || !('title' in parsed[0])) { + return null + } + + return parsed + } catch (error) { + logger.error('Failed to parse JSON:', { error, jsonString }) + return null + } + } + + // Sync store value with route blocks when storeValue changes + useEffect(() => { + // Skip if syncing is already in progress + if (isSyncingFromStoreRef.current) return + + // Use preview value when in preview mode, otherwise use store value + const effectiveValue = isPreview ? previewValue : storeValue + // Convert effectiveValue to string if it's not null + const effectiveValueStr = effectiveValue !== null ? effectiveValue?.toString() : null + + // Set that we're syncing from store to prevent loops + isSyncingFromStoreRef.current = true + + try { + // If effective value is null, and we've already initialized, keep current state + if (effectiveValueStr === null) { + if (hasInitializedRef.current) { + if (!isReady) setIsReady(true) + isSyncingFromStoreRef.current = false + return + } + + setRouteBlocks(createDefaultBlocks()) + hasInitializedRef.current = true + setIsReady(true) + shouldPersistRef.current = false + isSyncingFromStoreRef.current = false + return + } + + if (effectiveValueStr === prevStoreValueRef.current && hasInitializedRef.current) { + if (!isReady) setIsReady(true) + isSyncingFromStoreRef.current = false + return + } + + prevStoreValueRef.current = effectiveValueStr + + const parsedBlocks = safeParseJSON(effectiveValueStr) + + if (parsedBlocks) { + const blocksWithCorrectTitles = parsedBlocks.map((block, index) => ({ + ...block, + title: index === 0 ? 'if' : index === parsedBlocks.length - 1 ? 'else' : 'else if', + })) + + setRouteBlocks(blocksWithCorrectTitles) + hasInitializedRef.current = true + if (!isReady) setIsReady(true) + shouldPersistRef.current = false + } else if (!hasInitializedRef.current) { + setRouteBlocks(createDefaultBlocks()) + hasInitializedRef.current = true + setIsReady(true) + shouldPersistRef.current = false + } + } finally { + setTimeout(() => { + isSyncingFromStoreRef.current = false + }, 0) + } + }, [storeValue, previewValue, isPreview, blockId, isReady]) + + // Update store whenever route blocks change + useEffect(() => { + if ( + isSyncingFromStoreRef.current || + !isReady || + routeBlocks.length === 0 || + isPreview || + !shouldPersistRef.current + ) + return + + const newValue = JSON.stringify(routeBlocks) + + if (newValue !== prevStoreValueRef.current) { + prevStoreValueRef.current = newValue + setStoreValue(newValue) + updateNodeInternals(blockId) + } + }, [routeBlocks, blockId, subBlockId, setStoreValue, updateNodeInternals, isReady, isPreview]) + + // Cleanup when component unmounts + useEffect(() => { + return () => { + hasInitializedRef.current = false + prevStoreValueRef.current = null + isSyncingFromStoreRef.current = false + } + }, []) + + // Update the line counting logic to be block-specific + useEffect(() => { + if (!containerRef.current || routeBlocks.length === 0) return + + const calculateVisualLines = () => { + const preElement = containerRef.current?.querySelector('pre') + if (!preElement) return + + const newVisualLineHeights: { [key: string]: number[] } = {} + + routeBlocks.forEach((block) => { + const lines = block.value.split('\n') + const blockVisualHeights: number[] = [] + + // Create a hidden container with the same width as the editor + const container = document.createElement('div') + container.style.cssText = ` + position: absolute; + visibility: hidden; + width: ${preElement.clientWidth}px; + font-family: ${window.getComputedStyle(preElement).fontFamily}; + font-size: ${window.getComputedStyle(preElement).fontSize}; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; + ` + document.body.appendChild(container) + + // Process each line + lines.forEach((line) => { + const lineDiv = document.createElement('div') + + if (line.includes('<') && line.includes('>')) { + const parts = line.split(/(<[^>]+>)/g) + parts.forEach((part) => { + const span = document.createElement('span') + span.textContent = part + if (part.startsWith('<') && part.endsWith('>')) { + span.style.color = 'rgb(153, 0, 85)' + } + lineDiv.appendChild(span) + }) + } else { + lineDiv.textContent = line || ' ' + } + + container.appendChild(lineDiv) + + const actualHeight = lineDiv.getBoundingClientRect().height + const lineUnits = Math.ceil(actualHeight / 21) + blockVisualHeights.push(lineUnits) + + container.removeChild(lineDiv) + }) + + document.body.removeChild(container) + newVisualLineHeights[block.id] = blockVisualHeights + }) + + setVisualLineHeights(newVisualLineHeights) + } + + calculateVisualLines() + + const resizeObserver = new ResizeObserver(calculateVisualLines) + resizeObserver.observe(containerRef.current) + + return () => resizeObserver.disconnect() + }, [routeBlocks]) + + /** + * Renders line numbers for a specific route block. + * + * @param routeBlockId - ID of the block to render line numbers for + * @returns Array of line number elements + */ + const renderLineNumbers = (routeBlockId: string) => { + const numbers: ReactElement[] = [] + let lineNumber = 1 + const blockHeights = visualLineHeights[routeBlockId] || [] + + blockHeights.forEach((height) => { + for (let i = 0; i < height; i++) { + numbers.push( +
0 && 'invisible')} + > + {lineNumber} +
+ ) + } + lineNumber++ + }) + + return numbers + } + + /** + * Handles dropping a connection block onto a route editor. + * + * @param routeBlockId - ID of the route block receiving the drop + * @param e - Drag event + */ + const handleDrop = (routeBlockId: string, e: React.DragEvent) => { + if (isPreview || disabled) return + e.preventDefault() + try { + const data = JSON.parse(e.dataTransfer.getData('application/json')) + if (data.type !== 'connectionBlock') return + + const textarea: any = containerRef.current?.querySelector( + `[data-block-id="${routeBlockId}"] textarea` + ) + const dropPosition = textarea?.selectionStart ?? 0 + + shouldPersistRef.current = true + setRouteBlocks((blocks) => + blocks.map((block) => { + if (block.id === routeBlockId) { + const newValue = `${block.value.slice(0, dropPosition)}<${block.value.slice(dropPosition)}` + return { + ...block, + value: newValue, + showTags: true, + cursorPosition: dropPosition + 1, + activeSourceBlockId: data.connectionData?.sourceBlockId || null, + } + } + return block + }) + ) + + // Set cursor position after state updates + setTimeout(() => { + if (textarea) { + textarea.selectionStart = dropPosition + 1 + textarea.selectionEnd = dropPosition + 1 + textarea.focus() + } + }, 0) + } catch (error) { + logger.error('Failed to parse drop data:', { error }) + } + } + + // Handle tag selection - updated for individual blocks + const handleTagSelect = (routeBlockId: string, newValue: string) => { + if (isPreview || disabled) return + shouldPersistRef.current = true + setRouteBlocks((blocks) => + blocks.map((block) => + block.id === routeBlockId + ? { + ...block, + value: newValue, + showTags: false, + activeSourceBlockId: null, + } + : block + ) + ) + } + + // Handle environment variable selection - updated for individual blocks + const handleEnvVarSelect = (routeBlockId: string, newValue: string) => { + if (isPreview || disabled) return + shouldPersistRef.current = true + setRouteBlocks((blocks) => + blocks.map((block) => + block.id === routeBlockId + ? { + ...block, + value: newValue, + showEnvVars: false, + searchTerm: '', + } + : block + ) + ) + } + + const handleTagSelectImmediate = (routeBlockId: string, newValue: string) => { + if (isPreview || disabled) return + + shouldPersistRef.current = true + setRouteBlocks((blocks) => + blocks.map((block) => + block.id === routeBlockId + ? { + ...block, + value: newValue, + showTags: false, + activeSourceBlockId: null, + } + : block + ) + ) + + const updatedBlocks = routeBlocks.map((block) => + block.id === routeBlockId + ? { + ...block, + value: newValue, + showTags: false, + activeSourceBlockId: null, + } + : block + ) + emitTagSelection(JSON.stringify(updatedBlocks)) + } + + const handleEnvVarSelectImmediate = (routeBlockId: string, newValue: string) => { + if (isPreview || disabled) return + + shouldPersistRef.current = true + setRouteBlocks((blocks) => + blocks.map((block) => + block.id === routeBlockId + ? { + ...block, + value: newValue, + showEnvVars: false, + searchTerm: '', + } + : block + ) + ) + + const updatedBlocks = routeBlocks.map((block) => + block.id === routeBlockId + ? { + ...block, + value: newValue, + showEnvVars: false, + searchTerm: '', + } + : block + ) + emitTagSelection(JSON.stringify(updatedBlocks)) + } + + /** + * Updates block titles based on their position in the array. + * First block is always 'if', last is 'else', middle ones are 'else if'. + * + * @param blocks - Array of route blocks + * @returns Updated blocks with correct titles + */ + const updateBlockTitles = (blocks: RouteBlock[]): RouteBlock[] => { + return blocks.map((block, index) => ({ + ...block, + title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if', + })) + } + + // Update these functions to use updateBlockTitles and stable IDs + const addBlock = (afterId: string) => { + if (isPreview || disabled) return + + const blockIndex = routeBlocks.findIndex((block) => block.id === afterId) + if (routeBlocks[blockIndex]?.title === 'else') return + + const newBlockId = generateStableId(blockId, `else-if-${Date.now()}`) + + const newBlock: RouteBlock = { + id: newBlockId, + title: '', + value: '', + showTags: false, + showEnvVars: false, + searchTerm: '', + cursorPosition: 0, + activeSourceBlockId: null, + } + + const newBlocks = [...routeBlocks] + newBlocks.splice(blockIndex + 1, 0, newBlock) + shouldPersistRef.current = true + setRouteBlocks(updateBlockTitles(newBlocks)) + + setTimeout(() => { + const textarea: any = containerRef.current?.querySelector( + `[data-block-id="${newBlock.id}"] textarea` + ) + if (textarea) { + textarea.focus() + } + }, 0) + } + + const removeBlock = (id: string) => { + if (isPreview || disabled || routeBlocks.length <= 2) return + + // Remove any associated edges before removing the block + edges.forEach((edge) => { + if (edge.sourceHandle?.startsWith(`router-${id}`)) { + removeEdge(edge.id) + } + }) + + if (routeBlocks.length === 1) return + shouldPersistRef.current = true + setRouteBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id))) + + setTimeout(() => updateNodeInternals(blockId), 0) + } + + const moveBlock = (id: string, direction: 'up' | 'down') => { + if (isPreview || disabled) return + + const blockIndex = routeBlocks.findIndex((block) => block.id === id) + if (blockIndex === -1) return + + if (routeBlocks[blockIndex]?.title === 'else') return + + if ( + (direction === 'up' && blockIndex === 0) || + (direction === 'down' && blockIndex === routeBlocks.length - 1) + ) + return + + const newBlocks = [...routeBlocks] + const targetIndex = direction === 'up' ? blockIndex - 1 : blockIndex + 1 + + if (direction === 'down' && newBlocks[targetIndex]?.title === 'else') return + + ;[newBlocks[blockIndex], newBlocks[targetIndex]] = [ + newBlocks[targetIndex], + newBlocks[blockIndex], + ] + shouldPersistRef.current = true + setRouteBlocks(updateBlockTitles(newBlocks)) + + setTimeout(() => updateNodeInternals(blockId), 0) + } + + // Add useEffect to handle keyboard events for both dropdowns + useEffect(() => { + routeBlocks.forEach((block) => { + const textarea = containerRef.current?.querySelector(`[data-block-id="${block.id}"] textarea`) + if (textarea) { + textarea.addEventListener('keydown', (e: Event) => { + if ((e as KeyboardEvent).key === 'Escape') { + setRouteBlocks((blocks) => + blocks.map((b) => + b.id === block.id + ? { + ...b, + showTags: false, + showEnvVars: false, + searchTerm: '', + } + : b + ) + ) + } + }) + } + }) + }, [routeBlocks.length]) + + // Show loading or empty state if not ready or no blocks + if (!isReady || routeBlocks.length === 0) { + return ( +
+ Loading routes... +
+ ) + } + + return ( +
+ {routeBlocks.map((block, index) => ( +
+
+ + {block.title} + +
+ + + + + Add Block + + + + + + + Move Up + + + + + + + Move Down + + + + + + + Delete Route + +
+
+ {block.title !== 'else' && + (() => { + const blockLineCount = block.value.split('\n').length + const blockGutterWidth = calculateGutterWidth(blockLineCount) + + return ( + e.preventDefault()} + onDrop={(e) => handleDrop(block.id, e)} + className='rounded-t-none border-0' + > + {renderLineNumbers(block.id)} + + +
+ + {' === true'} + + + { + if (!isPreview && !disabled) { + const textarea = containerRef.current?.querySelector( + `[data-block-id="${block.id}"] textarea` + ) as HTMLTextAreaElement | null + if (textarea) { + const pos = textarea.selectionStart ?? 0 + + const tagTrigger = checkTagTrigger(newCode, pos) + const envVarTrigger = checkEnvVarTrigger(newCode, pos) + + shouldPersistRef.current = true + setRouteBlocks((blocks) => + blocks.map((b) => { + if (b.id === block.id) { + return { + ...b, + value: newCode, + showTags: tagTrigger.show, + showEnvVars: envVarTrigger.show, + searchTerm: envVarTrigger.show + ? envVarTrigger.searchTerm + : '', + cursorPosition: pos, + activeSourceBlockId: tagTrigger.show + ? b.activeSourceBlockId + : null, + } + } + return b + }) + ) + } + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setRouteBlocks((blocks) => + blocks.map((b) => + b.id === block.id + ? { ...b, showTags: false, showEnvVars: false } + : b + ) + ) + } + }} + highlight={(codeToHighlight) => { + const placeholders: { + placeholder: string + original: string + type: 'var' | 'env' + shouldHighlight: boolean + }[] = [] + let processedCode = codeToHighlight + + // Replace environment variables with placeholders + processedCode = processedCode.replace(createEnvVarPattern(), (match) => { + const placeholder = `__ENV_VAR_${placeholders.length}__` + placeholders.push({ + placeholder, + original: match, + type: 'env', + shouldHighlight: true, + }) + return placeholder + }) + + // Replace variable references with placeholders + // Use [^<>]+ to prevent matching across nested brackets (e.g., "<3 " should match separately) + processedCode = processedCode.replace( + createReferencePattern(), + (match) => { + const shouldHighlight = shouldHighlightReference(match) + if (shouldHighlight) { + const placeholder = `__VAR_REF_${placeholders.length}__` + placeholders.push({ + placeholder, + original: match, + type: 'var', + shouldHighlight: true, + }) + return placeholder + } + return match + } + ) + + // Apply Prism syntax highlighting + let highlightedCode = highlight( + processedCode, + languages.javascript, + 'javascript' + ) + + // Restore and highlight the placeholders + placeholders.forEach( + ({ placeholder, original, type, shouldHighlight }) => { + if (!shouldHighlight) return + + if (type === 'env') { + highlightedCode = highlightedCode.replace( + placeholder, + `${original}` + ) + } else if (type === 'var') { + // Escape the < and > for display + const escaped = original.replace(//g, '>') + highlightedCode = highlightedCode.replace( + placeholder, + `${escaped}` + ) + } + } + ) + + return highlightedCode + }} + {...getCodeEditorProps({ isPreview, disabled })} + /> + + {block.showEnvVars && ( + handleEnvVarSelectImmediate(block.id, newValue)} + searchTerm={block.searchTerm} + inputValue={block.value} + cursorPosition={block.cursorPosition} + workspaceId={workspaceId} + onClose={() => { + setRouteBlocks((blocks) => + blocks.map((b) => + b.id === block.id ? { ...b, showEnvVars: false, searchTerm: '' } : b + ) + ) + }} + /> + )} + + {block.showTags && ( + handleTagSelectImmediate(block.id, newValue)} + blockId={blockId} + activeSourceBlockId={block.activeSourceBlockId} + inputValue={block.value} + cursorPosition={block.cursorPosition} + onClose={() => { + setRouteBlocks((blocks) => + blocks.map((b) => + b.id === block.id + ? { + ...b, + showTags: false, + activeSourceBlockId: null, + } + : b + ) + ) + }} + /> + )} +
+
+
+ ) + })()} +
+ ))} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value.ts index 9c4ae35e40..66d388e290 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value.ts @@ -95,8 +95,7 @@ export function useSubBlockValue( ) // Determine if this is a provider-based block type - const isProviderBasedBlock = - blockType === 'agent' || blockType === 'router' || blockType === 'evaluator' + const isProviderBasedBlock = blockType === 'agent' || blockType === 'evaluator' // Compute the modelValue based on block type const modelValue = isProviderBasedBlock ? (modelSubBlockValue as string) : null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 0fb5dc6eda..01e4b38b36 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -29,6 +29,7 @@ import { MessagesInput, ProjectSelectorInput, ResponseFormat, + RouterInput, ScheduleSave, ShortInput, SlackSelectorInput, @@ -555,6 +556,17 @@ function SubBlockComponent({ /> ) + case 'router-input': + return ( + + ) + case 'eval-input': return ( { + if (type !== 'router') return [] as { id: string; title: string; value: string }[] + + const routesValue = subBlockState.routes?.value + const raw = typeof routesValue === 'string' ? routesValue : undefined + + try { + if (raw) { + const parsed = JSON.parse(raw) as unknown + if (Array.isArray(parsed)) { + return parsed.map((item: unknown, index: number) => { + const routeItem = item as { id?: string; value?: unknown } + const title = index === 0 ? 'if' : index === parsed.length - 1 ? 'else' : 'else if' + return { + id: routeItem?.id ?? `${id}-route-${index}`, + title, + value: typeof routeItem?.value === 'string' ? routeItem.value : '', + } + }) + } + } + } catch (error) { + logger.warn('Failed to parse router subblock value', { error, blockId: id }) + } + + return [ + { id: `${id}-if`, title: 'if', value: '' }, + { id: `${id}-else`, title: 'else', value: '' }, + ] + }, [type, subBlockState, id]) + /** * Compute and publish deterministic layout metrics for workflow blocks. * This avoids ResizeObserver/animation-frame jitter and prevents initial "jump". @@ -859,6 +894,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({ let rowsCount = 0 if (type === 'condition') { rowsCount = conditionRows.length + defaultHandlesRow + } else if (type === 'router') { + rowsCount = routerRows.length + defaultHandlesRow } else { const subblockRowCount = subBlockRows.reduce((acc, row) => acc + row.length, 0) rowsCount = subblockRowCount + defaultHandlesRow @@ -881,6 +918,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ displayTriggerMode, subBlockRows.length, conditionRows.length, + routerRows.length, horizontalHandles, ], }) @@ -1096,24 +1134,32 @@ export const WorkflowBlock = memo(function WorkflowBlock({ value={getDisplayValue(cond.value)} /> )) - : subBlockRows.map((row, rowIndex) => - row.map((subBlock) => { - const rawValue = subBlockState[subBlock.id]?.value - return ( - - ) - }) - )} + : type === 'router' + ? routerRows.map((route) => ( + + )) + : subBlockRows.map((row, rowIndex) => + row.map((subBlock) => { + const rawValue = subBlockState[subBlock.id]?.value + return ( + + ) + }) + )} {shouldShowDefaultHandles && } )} @@ -1168,7 +1214,57 @@ export const WorkflowBlock = memo(function WorkflowBlock({ )} - {type !== 'condition' && type !== 'response' && ( + {type === 'router' && ( + <> + {routerRows.map((route, routeIndex) => { + const topOffset = + HANDLE_POSITIONS.CONDITION_START_Y + + routeIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT + return ( + { + if (connection.target === id) return false + const edges = useWorkflowStore.getState().edges + return !wouldCreateCycle(edges, connection.source!, connection.target!) + }} + /> + ) + })} + { + if (connection.target === id) return false + const edges = useWorkflowStore.getState().edges + return !wouldCreateCycle(edges, connection.source!, connection.target!) + }} + /> + + )} + + {type !== 'condition' && type !== 'router' && type !== 'response' && ( <> { - return useProvidersStore.getState().providers.ollama.models -} - interface RouterResponse extends ToolResponse { output: { - prompt: string - model: string - tokens?: { - prompt?: number - completion?: number - total?: number - } - cost?: { - input: number - output: number - total: number - } + conditionResult: boolean selectedPath: { blockId: string blockType: string blockTitle: string } + selectedOption: string } } -interface TargetBlock { - id: string - type?: string - title?: string - description?: string - category?: string - subBlocks?: Record - currentState?: any -} - -export const generateRouterPrompt = (prompt: string, targetBlocks?: TargetBlock[]): string => { - const basePrompt = `You are an intelligent routing agent responsible for directing workflow requests to the most appropriate block. Your task is to analyze the input and determine the single most suitable destination based on the request. - -Key Instructions: -1. You MUST choose exactly ONE destination from the IDs of the blocks in the workflow. The destination must be a valid block id. - -2. Analysis Framework: - - Carefully evaluate the intent and requirements of the request - - Consider the primary action needed - - Match the core functionality with the most appropriate destination` - - // If we have target blocks, add their information to the prompt - const targetBlocksInfo = targetBlocks - ? ` - -Available Target Blocks: -${targetBlocks - .map( - (block) => ` -ID: ${block.id} -Type: ${block.type} -Title: ${block.title} -Description: ${block.description} -System Prompt: ${JSON.stringify(block.subBlocks?.systemPrompt || '')} -Configuration: ${JSON.stringify(block.subBlocks, null, 2)} -${block.currentState ? `Current State: ${JSON.stringify(block.currentState, null, 2)}` : ''} ----` - ) - .join('\n')} - -Routing Instructions: -1. Analyze the input request carefully against each block's: - - Primary purpose (from title, description, and system prompt) - - Look for keywords in the system prompt that match the user's request - - Configuration settings - - Current state (if available) - - Processing capabilities - -2. Selection Criteria: - - Choose the block that best matches the input's requirements - - Consider the block's specific functionality and constraints - - Factor in any relevant current state or configuration - - Prioritize blocks that can handle the input most effectively` - : '' - - return `${basePrompt}${targetBlocksInfo} - -Routing Request: ${prompt} - -Response Format: -Return ONLY the destination id as a single word, lowercase, no punctuation or explanation. -Example: "2acd9007-27e8-4510-a487-73d3b825e7c1" - -Remember: Your response must be ONLY the block ID - no additional text, formatting, or explanation.` -} - export const RouterBlock: BlockConfig = { type: 'router', name: 'Router', description: 'Route workflow', - authMode: AuthMode.ApiKey, longDescription: - 'This is a core workflow block. Intelligently direct workflow execution to different paths based on input analysis. Use natural language to instruct the router to route to certain blocks based on the input.', + 'This is a core workflow block. Intelligently direct workflow execution to different paths based on conditional logic. Define conditions to evaluate and route to different blocks.', bestPractices: ` - - For the prompt, make it almost programmatic. Use the system prompt to define the routing criteria. Should be very specific with no ambiguity. - - Use the target block *names* to define the routing criteria. + - Write the conditions using standard javascript syntax, referencing outputs of previous blocks using <> syntax. + - The first matching condition will be selected, otherwise the else route is taken. + - Can reference workflow variables using syntax within conditions. `, + docsLink: 'https://docs.sim.ai/blocks/router', category: 'blocks', bgColor: '#28C43F', icon: ConnectIcon, subBlocks: [ { - id: 'prompt', - title: 'Prompt', - type: 'long-input', - placeholder: 'Route to the correct block based on the input...', - required: true, - }, - { - id: 'model', - title: 'Model', - type: 'combobox', - placeholder: 'Type or select a model...', - required: true, - options: () => { - const providersState = useProvidersStore.getState() - const baseModels = providersState.providers.base.models - const ollamaModels = providersState.providers.ollama.models - const openrouterModels = providersState.providers.openrouter.models - const allModels = Array.from(new Set([...baseModels, ...ollamaModels, ...openrouterModels])) - - return allModels.map((model) => { - const icon = getProviderIcon(model) - return { label: model, id: model, ...(icon && { icon }) } - }) - }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - placeholder: 'Enter your API key', - password: true, - connectionDroppable: false, - required: true, - // Hide API key for hosted models and Ollama models - condition: isHosted - ? { - field: 'model', - value: getHostedModels(), - not: true, // Show for all models EXCEPT those listed - } - : () => ({ - field: 'model', - value: getCurrentOllamaModels(), - not: true, // Show for all models EXCEPT Ollama models - }), - }, - { - id: 'azureEndpoint', - title: 'Azure OpenAI Endpoint', - type: 'short-input', - password: true, - placeholder: 'https://your-resource.openai.azure.com', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'azureApiVersion', - title: 'Azure API Version', - type: 'short-input', - placeholder: '2024-07-01-preview', - connectionDroppable: false, - condition: { - field: 'model', - value: providers['azure-openai'].models, - }, - }, - { - id: 'vertexProject', - title: 'Vertex AI Project', - type: 'short-input', - placeholder: 'your-gcp-project-id', - connectionDroppable: false, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'vertexLocation', - title: 'Vertex AI Location', - type: 'short-input', - placeholder: 'us-central1', - connectionDroppable: false, - condition: { - field: 'model', - value: providers.vertex.models, - }, - }, - { - id: 'temperature', - title: 'Temperature', - type: 'slider', - hidden: true, - min: 0, - max: 2, - }, - { - id: 'systemPrompt', - title: 'System Prompt', - type: 'code', - hidden: true, - value: (params: Record) => { - return generateRouterPrompt(params.prompt || '') - }, + id: 'routes', + type: 'router-input', }, ], tools: { - access: [ - 'openai_chat', - 'anthropic_chat', - 'google_chat', - 'xai_chat', - 'deepseek_chat', - 'deepseek_reasoner', - ], - config: { - tool: (params: Record) => { - const model = params.model || 'gpt-4o' - if (!model) { - throw new Error('No model selected') - } - const tool = getAllModelProviders()[model as ProviderId] - if (!tool) { - throw new Error(`Invalid model selected: ${model}`) - } - return tool - }, - }, - }, - inputs: { - prompt: { type: 'string', description: 'Routing prompt content' }, - model: { type: 'string', description: 'AI model to use' }, - apiKey: { type: 'string', description: 'Provider API key' }, - azureEndpoint: { type: 'string', description: 'Azure OpenAI endpoint URL' }, - azureApiVersion: { type: 'string', description: 'Azure API version' }, - vertexProject: { type: 'string', description: 'Google Cloud project ID for Vertex AI' }, - vertexLocation: { type: 'string', description: 'Google Cloud location for Vertex AI' }, - temperature: { - type: 'number', - description: 'Response randomness level (low for consistent routing)', - }, + access: [], }, + inputs: {}, outputs: { - prompt: { type: 'string', description: 'Routing prompt used' }, - model: { type: 'string', description: 'Model used' }, - tokens: { type: 'json', description: 'Token usage' }, - cost: { type: 'json', description: 'Cost information' }, + conditionResult: { type: 'boolean', description: 'Whether a condition matched' }, selectedPath: { type: 'json', description: 'Selected routing path' }, + selectedOption: { type: 'string', description: 'Selected route option ID' }, }, } diff --git a/apps/sim/blocks/types.ts b/apps/sim/blocks/types.ts index 64c1a89a24..b49f5255bc 100644 --- a/apps/sim/blocks/types.ts +++ b/apps/sim/blocks/types.ts @@ -51,6 +51,7 @@ export type SubBlockType = | 'checkbox-list' // Multiple selection | 'grouped-checkbox-list' // Grouped, scrollable checkbox list with select all | 'condition-input' // Conditional logic + | 'router-input' // Router conditional logic | 'eval-input' // Evaluation input | 'time-input' // Time input | 'oauth-input' // OAuth credential selector diff --git a/apps/sim/executor/dag/construction/edges.test.ts b/apps/sim/executor/dag/construction/edges.test.ts index 3859ca086e..04d02cc133 100644 --- a/apps/sim/executor/dag/construction/edges.test.ts +++ b/apps/sim/executor/dag/construction/edges.test.ts @@ -241,7 +241,14 @@ describe('EdgeConstructor', () => { const target1Id = 'target-1' const target2Id = 'target-2' - const routerBlock = createMockBlock(routerId, 'router') + const routes = [ + { id: 'route-if', title: 'if', value: 'value > 10' }, + { id: 'route-else', title: 'else', value: '' }, + ] + + const routerBlock = createMockBlock(routerId, 'router', { + routes: JSON.stringify(routes), + }) const workflow = createMockWorkflow( [routerBlock, createMockBlock(target1Id), createMockBlock(target2Id)], @@ -265,9 +272,9 @@ describe('EdgeConstructor', () => { const routerNode = dag.nodes.get(routerId)! const edges = Array.from(routerNode.outgoingEdges.values()) - // Router edges should have router- prefix with target ID - expect(edges[0].sourceHandle).toBe(`router-${target1Id}`) - expect(edges[1].sourceHandle).toBe(`router-${target2Id}`) + // Router edges should have router- prefix with route ID + expect(edges[0].sourceHandle).toBe('router-route-if') + expect(edges[1].sourceHandle).toBe('router-route-else') }) }) diff --git a/apps/sim/executor/dag/construction/edges.ts b/apps/sim/executor/dag/construction/edges.ts index 2b652a5dba..14f90ebcdc 100644 --- a/apps/sim/executor/dag/construction/edges.ts +++ b/apps/sim/executor/dag/construction/edges.ts @@ -19,10 +19,16 @@ interface ConditionConfig { condition: string } +interface RouterRouteConfig { + id: string + title: string + value: string +} + interface EdgeMetadata { blockTypeMap: Map conditionConfigMap: Map - routerBlockIds: Set + routerRouteConfigMap: Map } export class EdgeConstructor { @@ -57,7 +63,7 @@ export class EdgeConstructor { private buildMetadataMaps(workflow: SerializedWorkflow): EdgeMetadata { const blockTypeMap = new Map() const conditionConfigMap = new Map() - const routerBlockIds = new Set() + const routerRouteConfigMap = new Map() for (const block of workflow.blocks) { const blockType = block.metadata?.id ?? '' @@ -70,11 +76,15 @@ export class EdgeConstructor { conditionConfigMap.set(block.id, conditions) } } else if (isRouterBlockType(blockType)) { - routerBlockIds.add(block.id) + const routes = this.parseRouterRouteConfig(block) + + if (routes) { + routerRouteConfigMap.set(block.id, routes) + } } } - return { blockTypeMap, conditionConfigMap, routerBlockIds } + return { blockTypeMap, conditionConfigMap, routerRouteConfigMap } } private parseConditionConfig(block: any): ConditionConfig[] | null { @@ -100,6 +110,29 @@ export class EdgeConstructor { } } + private parseRouterRouteConfig(block: any): RouterRouteConfig[] | null { + try { + const routesJson = block.config.params?.routes + + if (typeof routesJson === 'string') { + return JSON.parse(routesJson) + } + + if (Array.isArray(routesJson)) { + return routesJson + } + + return null + } catch (error) { + logger.warn('Failed to parse router route config', { + blockId: block.id, + error: error instanceof Error ? error.message : String(error), + }) + + return null + } + } + private generateSourceHandle( source: string, target: string, @@ -123,8 +156,18 @@ export class EdgeConstructor { } } - if (metadata.routerBlockIds.has(source) && handle !== EDGE.ERROR) { - handle = `${EDGE.ROUTER_PREFIX}${target}` + if (!handle && isRouterBlockType(metadata.blockTypeMap.get(source) ?? '')) { + const routes = metadata.routerRouteConfigMap.get(source) + + if (routes && routes.length > 0) { + const edgesFromRouter = workflow.connections.filter((c) => c.source === source) + const edgeIndex = edgesFromRouter.findIndex((e) => e.target === target) + + if (edgeIndex >= 0 && edgeIndex < routes.length) { + const correspondingRoute = routes[edgeIndex] + handle = `${EDGE.ROUTER_PREFIX}${correspondingRoute.id}` + } + } } return handle diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index eb3cc73337..29ab4934ea 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -1,16 +1,27 @@ import '@/executor/__test-utils__/mock-dependencies' -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' -import { generateRouterPrompt } from '@/blocks/blocks/router' -import { BlockType } from '@/executor/constants' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { BlockType, EDGE } from '@/executor/constants' +import { evaluateConditionExpression } from '@/executor/handlers/condition/condition-handler' import { RouterBlockHandler } from '@/executor/handlers/router/router-handler' -import type { ExecutionContext } from '@/executor/types' -import { getProviderFromModel } from '@/providers/utils' +import type { BlockState, ExecutionContext } from '@/executor/types' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' -const mockGenerateRouterPrompt = generateRouterPrompt as Mock -const mockGetProviderFromModel = getProviderFromModel as Mock -const mockFetch = global.fetch as unknown as Mock +interface RouterResult { + conditionResult: boolean + selectedPath: { + blockId: string + blockType: string + blockTitle: string + } | null + selectedOption: string | null +} + +vi.mock('@/executor/handlers/condition/condition-handler', () => ({ + evaluateConditionExpression: vi.fn(), +})) + +const mockEvaluateConditionExpression = evaluateConditionExpression as ReturnType describe('RouterBlockHandler', () => { let handler: RouterBlockHandler @@ -19,11 +30,12 @@ describe('RouterBlockHandler', () => { let mockWorkflow: Partial let mockTargetBlock1: SerializedBlock let mockTargetBlock2: SerializedBlock + let mockTargetBlock3: SerializedBlock beforeEach(() => { mockTargetBlock1 = { id: 'target-block-1', - metadata: { id: 'target', name: 'Option A', description: 'Choose A' }, + metadata: { id: 'agent', name: 'Option A', description: 'Choose A' }, position: { x: 100, y: 100 }, config: { tool: 'tool_a', params: { p: 'a' } }, inputs: {}, @@ -32,31 +44,60 @@ describe('RouterBlockHandler', () => { } mockTargetBlock2 = { id: 'target-block-2', - metadata: { id: 'target', name: 'Option B', description: 'Choose B' }, + metadata: { id: 'agent', name: 'Option B', description: 'Choose B' }, position: { x: 100, y: 150 }, config: { tool: 'tool_b', params: { p: 'b' } }, inputs: {}, outputs: {}, enabled: true, } + mockTargetBlock3 = { + id: 'target-block-3', + metadata: { id: 'agent', name: 'Option C', description: 'Choose C' }, + position: { x: 100, y: 200 }, + config: { tool: 'tool_c', params: { p: 'c' } }, + inputs: {}, + outputs: {}, + enabled: true, + } + + const routes = [ + { id: 'route-if', title: 'if', value: 'value > 10' }, + { id: 'route-else-if', title: 'else if', value: 'value > 5' }, + { id: 'route-else', title: 'else', value: '' }, + ] + mockBlock = { id: 'router-block-1', metadata: { id: BlockType.ROUTER, name: 'Test Router' }, position: { x: 50, y: 50 }, - config: { tool: BlockType.ROUTER, params: {} }, - inputs: { prompt: 'string', model: 'string' }, // Using ParamType strings + config: { tool: BlockType.ROUTER, params: { routes: JSON.stringify(routes) } }, + inputs: {}, outputs: {}, enabled: true, } mockWorkflow = { - blocks: [mockBlock, mockTargetBlock1, mockTargetBlock2], + blocks: [mockBlock, mockTargetBlock1, mockTargetBlock2, mockTargetBlock3], connections: [ - { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-then1' }, - { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' }, + { + source: mockBlock.id, + target: mockTargetBlock1.id, + sourceHandle: `${EDGE.ROUTER_PREFIX}route-if`, + }, + { + source: mockBlock.id, + target: mockTargetBlock2.id, + sourceHandle: `${EDGE.ROUTER_PREFIX}route-else-if`, + }, + { + source: mockBlock.id, + target: mockTargetBlock3.id, + sourceHandle: `${EDGE.ROUTER_PREFIX}route-else`, + }, ], } - handler = new RouterBlockHandler({}) + handler = new RouterBlockHandler() mockContext = { workflowId: 'test-workflow-id', @@ -72,27 +113,7 @@ describe('RouterBlockHandler', () => { workflow: mockWorkflow as SerializedWorkflow, } - // Reset mocks using vi vi.clearAllMocks() - - // Default mock implementations - mockGetProviderFromModel.mockReturnValue('openai') - mockGenerateRouterPrompt.mockReturnValue('Generated System Prompt') - - // Set up fetch mock to return a successful response - mockFetch.mockImplementation(() => { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - content: 'target-block-1', - model: 'mock-model', - tokens: { prompt: 100, completion: 5, total: 105 }, - cost: 0.003, - timing: { total: 300 }, - }), - }) - }) }) it('should handle router blocks', () => { @@ -101,141 +122,173 @@ describe('RouterBlockHandler', () => { expect(handler.canHandle(nonRouterBlock)).toBe(false) }) - it('should execute router block correctly and select a path', async () => { + it('should select first route when its condition is true', async () => { + mockEvaluateConditionExpression.mockResolvedValueOnce(true) + const inputs = { - prompt: 'Choose the best option.', - model: 'gpt-4o', - temperature: 0.1, + routes: JSON.stringify([ + { id: 'route-if', title: 'if', value: 'value > 10' }, + { id: 'route-else-if', title: 'else if', value: 'value > 5' }, + { id: 'route-else', title: 'else', value: '' }, + ]), } - const expectedTargetBlocks = [ - { - id: 'target-block-1', - type: 'target', - title: 'Option A', - description: 'Choose A', - subBlocks: { - p: 'a', - systemPrompt: '', - }, - currentState: undefined, - }, + const result = (await handler.execute( + mockContext, + mockBlock, + inputs + )) as unknown as RouterResult + + expect(mockEvaluateConditionExpression).toHaveBeenCalledWith(mockContext, 'value > 10', {}) + expect(result.conditionResult).toBe(true) + expect(result.selectedPath).toEqual({ + blockId: 'target-block-1', + blockType: 'agent', + blockTitle: 'Option A', + }) + expect(result.selectedOption).toBe('route-if') + expect(mockContext.decisions.router.get(mockBlock.id)).toBe('route-if') + }) + + it('should select second route when first condition is false', async () => { + mockEvaluateConditionExpression.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + const inputs = { + routes: JSON.stringify([ + { id: 'route-if', title: 'if', value: 'value > 10' }, + { id: 'route-else-if', title: 'else if', value: 'value > 5' }, + { id: 'route-else', title: 'else', value: '' }, + ]), + } + + const result = (await handler.execute( + mockContext, + mockBlock, + inputs + )) as unknown as RouterResult + + expect(mockEvaluateConditionExpression).toHaveBeenCalledTimes(2) + expect(result.conditionResult).toBe(true) + expect(result.selectedPath).toEqual({ + blockId: 'target-block-2', + blockType: 'agent', + blockTitle: 'Option B', + }) + expect(result.selectedOption).toBe('route-else-if') + expect(mockContext.decisions.router.get(mockBlock.id)).toBe('route-else-if') + }) + + it('should select else route when all conditions are false', async () => { + mockEvaluateConditionExpression.mockResolvedValueOnce(false).mockResolvedValueOnce(false) + + const inputs = { + routes: JSON.stringify([ + { id: 'route-if', title: 'if', value: 'value > 10' }, + { id: 'route-else-if', title: 'else if', value: 'value > 5' }, + { id: 'route-else', title: 'else', value: '' }, + ]), + } + + const result = (await handler.execute( + mockContext, + mockBlock, + inputs + )) as unknown as RouterResult + + expect(mockEvaluateConditionExpression).toHaveBeenCalledTimes(2) + expect(result.conditionResult).toBe(true) + expect(result.selectedPath).toEqual({ + blockId: 'target-block-3', + blockType: 'agent', + blockTitle: 'Option C', + }) + expect(result.selectedOption).toBe('route-else') + expect(mockContext.decisions.router.get(mockBlock.id)).toBe('route-else') + }) + + it('should return no selection when no routes match and no else', async () => { + mockEvaluateConditionExpression.mockResolvedValue(false) + + const routes = [{ id: 'route-if', title: 'if', value: 'value > 10' }] + + mockWorkflow.connections = [ { - id: 'target-block-2', - type: 'target', - title: 'Option B', - description: 'Choose B', - subBlocks: { - p: 'b', - systemPrompt: '', - }, - currentState: undefined, + source: mockBlock.id, + target: mockTargetBlock1.id, + sourceHandle: `${EDGE.ROUTER_PREFIX}route-if`, }, ] - const result = await handler.execute(mockContext, mockBlock, inputs) - - expect(mockGenerateRouterPrompt).toHaveBeenCalledWith(inputs.prompt, expectedTargetBlocks) - expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o') - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: 'POST', - headers: expect.any(Object), - body: expect.any(String), - }) - ) + const inputs = { + routes: JSON.stringify(routes), + } - // Verify the request body contains the expected data - const fetchCallArgs = mockFetch.mock.calls[0] - const requestBody = JSON.parse(fetchCallArgs[1].body) - expect(requestBody).toMatchObject({ - provider: 'openai', - model: 'gpt-4o', - systemPrompt: 'Generated System Prompt', - context: JSON.stringify([{ role: 'user', content: 'Choose the best option.' }]), - temperature: 0.1, - }) + const result = (await handler.execute( + mockContext, + mockBlock, + inputs + )) as unknown as RouterResult - expect(result).toEqual({ - prompt: 'Choose the best option.', - model: 'mock-model', - tokens: { prompt: 100, completion: 5, total: 105 }, - cost: { - input: 0, - output: 0, - total: 0, - }, - selectedPath: { - blockId: 'target-block-1', - blockType: 'target', - blockTitle: 'Option A', - }, - selectedRoute: 'target-block-1', - }) + expect(result.conditionResult).toBe(false) + expect(result.selectedPath).toBe(null) + expect(result.selectedOption).toBe(null) }) - it('should throw error if target block is missing', async () => { - const inputs = { prompt: 'Test' } - mockContext.workflow!.blocks = [mockBlock, mockTargetBlock2] + it('should throw error on invalid routes format', async () => { + const inputs = { + routes: 'invalid-json', + } - // Expect execute to throw because getTargetBlocks (called internally) will throw await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - 'Target block target-block-1 not found' + 'Invalid routes format' ) - expect(mockFetch).not.toHaveBeenCalled() }) - it('should throw error if LLM response is not a valid target block ID', async () => { - const inputs = { prompt: 'Test' } - - // Override fetch mock to return an invalid block ID - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - json: () => - Promise.resolve({ - content: 'invalid-block-id', - model: 'mock-model', - tokens: {}, - cost: 0, - timing: {}, - }), - }) - }) + it('should throw error when condition evaluation fails', async () => { + mockEvaluateConditionExpression.mockRejectedValue(new Error('Evaluation failed')) + + const inputs = { + routes: JSON.stringify([{ id: 'route-if', title: 'if', value: 'invalid.expression' }]), + } await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( - 'Invalid routing decision: invalid-block-id' + 'Evaluation error in route "if": Evaluation failed' ) }) - it('should use default model and temperature if not provided', async () => { - const inputs = { prompt: 'Choose.' } + it('should use source block output for evaluation context', async () => { + const sourceBlockId = 'source-block' + const sourceOutput = { value: 15, message: 'test' } - await handler.execute(mockContext, mockBlock, inputs) + const blockStates = new Map() + blockStates.set(sourceBlockId, { + output: sourceOutput, + executed: true, + executionTime: 100, + }) + mockContext.blockStates = blockStates - expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o') + mockWorkflow.connections = [ + { source: sourceBlockId, target: mockBlock.id }, + { + source: mockBlock.id, + target: mockTargetBlock1.id, + sourceHandle: `${EDGE.ROUTER_PREFIX}route-if`, + }, + ] - const fetchCallArgs = mockFetch.mock.calls[0] - const requestBody = JSON.parse(fetchCallArgs[1].body) - expect(requestBody).toMatchObject({ - model: 'gpt-4o', - temperature: 0.1, - }) - }) + mockEvaluateConditionExpression.mockResolvedValueOnce(true) - it('should handle server error responses', async () => { - const inputs = { prompt: 'Test error handling.' } + const inputs = { + routes: JSON.stringify([{ id: 'route-if', title: 'if', value: 'value > 10' }]), + } - // Override fetch mock to return an error - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: false, - status: 500, - json: () => Promise.resolve({ error: 'Server error' }), - }) - }) + await handler.execute(mockContext, mockBlock, inputs) - await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow('Server error') + expect(mockEvaluateConditionExpression).toHaveBeenCalledWith( + mockContext, + 'value > 10', + sourceOutput + ) }) }) diff --git a/apps/sim/executor/handlers/router/router-handler.ts b/apps/sim/executor/handlers/router/router-handler.ts index 327d490f35..1fb0beabb8 100644 --- a/apps/sim/executor/handlers/router/router-handler.ts +++ b/apps/sim/executor/handlers/router/router-handler.ts @@ -1,20 +1,21 @@ -import { getBaseUrl } from '@/lib/core/utils/urls' import { createLogger } from '@/lib/logs/console/logger' -import { generateRouterPrompt } from '@/blocks/blocks/router' import type { BlockOutput } from '@/blocks/types' -import { BlockType, DEFAULTS, HTTP, isAgentBlockType, ROUTER } from '@/executor/constants' +import { BlockType, DEFAULTS, EDGE } from '@/executor/constants' +import { evaluateConditionExpression } from '@/executor/handlers/condition/condition-handler' import type { BlockHandler, ExecutionContext } from '@/executor/types' -import { calculateCost, getProviderFromModel } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' const logger = createLogger('RouterBlockHandler') +const ROUTER = { + ELSE_TITLE: 'else', +} as const + /** - * Handler for Router blocks that dynamically select execution paths. + * Handler for Router blocks that evaluate conditions to determine execution paths. + * Works similarly to the ConditionBlockHandler but with router-specific logic. */ export class RouterBlockHandler implements BlockHandler { - constructor(private pathTracker?: any) {} - canHandle(block: SerializedBlock): boolean { return block.metadata?.id === BlockType.ROUTER } @@ -24,132 +25,131 @@ export class RouterBlockHandler implements BlockHandler { block: SerializedBlock, inputs: Record ): Promise { - const targetBlocks = this.getTargetBlocks(ctx, block) + const routes = this.parseRoutes(inputs.routes) - const routerConfig = { - prompt: inputs.prompt, - model: inputs.model || ROUTER.DEFAULT_MODEL, - apiKey: inputs.apiKey, - } + const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source + const evalContext = this.buildEvaluationContext(ctx, sourceBlockId) + const sourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null - const providerId = getProviderFromModel(routerConfig.model) + const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id) - try { - const url = new URL('/api/providers', getBaseUrl()) - - const messages = [{ role: 'user', content: routerConfig.prompt }] - const systemPrompt = generateRouterPrompt(routerConfig.prompt, targetBlocks) - const providerRequest = { - provider: providerId, - model: routerConfig.model, - systemPrompt: systemPrompt, - context: JSON.stringify(messages), - temperature: ROUTER.INFERENCE_TEMPERATURE, - apiKey: routerConfig.apiKey, - workflowId: ctx.workflowId, - } + const { selectedConnection, selectedRoute } = await this.evaluateRoutes( + routes, + outgoingConnections || [], + evalContext, + ctx + ) - const response = await fetch(url.toString(), { - method: 'POST', - headers: { - 'Content-Type': HTTP.CONTENT_TYPE.JSON, - }, - body: JSON.stringify(providerRequest), - }) - - if (!response.ok) { - let errorMessage = `Provider API request failed with status ${response.status}` - try { - const errorData = await response.json() - if (errorData.error) { - errorMessage = errorData.error - } - } catch (_e) {} - throw new Error(errorMessage) + if (!selectedConnection || !selectedRoute) { + return { + ...((sourceOutput as any) || {}), + conditionResult: false, + selectedPath: null, + selectedOption: null, } + } - const result = await response.json() + const targetBlock = ctx.workflow?.blocks.find((b) => b.id === selectedConnection?.target) + if (!targetBlock) { + throw new Error(`Target block ${selectedConnection?.target} not found`) + } - const chosenBlockId = result.content.trim().toLowerCase() - const chosenBlock = targetBlocks?.find((b) => b.id === chosenBlockId) + const decisionKey = ctx.currentVirtualBlockId || block.id + ctx.decisions.router.set(decisionKey, selectedRoute.id) + + return { + ...((sourceOutput as any) || {}), + conditionResult: true, + selectedPath: { + blockId: targetBlock.id, + blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE, + blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE, + }, + selectedOption: selectedRoute.id, + } + } - if (!chosenBlock) { - logger.error( - `Invalid routing decision. Response content: "${result.content}", available blocks:`, - targetBlocks?.map((b) => ({ id: b.id, title: b.title })) || [] - ) - throw new Error(`Invalid routing decision: ${chosenBlockId}`) - } + private parseRoutes(input: any): Array<{ id: string; title: string; value: string }> { + try { + const routes = Array.isArray(input) ? input : JSON.parse(input || '[]') + return routes + } catch (error: any) { + logger.error('Failed to parse routes:', { input, error }) + throw new Error(`Invalid routes format: ${error.message}`) + } + } - const tokens = result.tokens || { - prompt: DEFAULTS.TOKENS.PROMPT, - completion: DEFAULTS.TOKENS.COMPLETION, - total: DEFAULTS.TOKENS.TOTAL, + private buildEvaluationContext( + ctx: ExecutionContext, + sourceBlockId?: string + ): Record { + let evalContext: Record = {} + + if (sourceBlockId) { + const sourceOutput = ctx.blockStates.get(sourceBlockId)?.output + if (sourceOutput && typeof sourceOutput === 'object' && sourceOutput !== null) { + evalContext = { + ...evalContext, + ...sourceOutput, + } } - - const cost = calculateCost( - result.model, - tokens.prompt || DEFAULTS.TOKENS.PROMPT, - tokens.completion || DEFAULTS.TOKENS.COMPLETION, - false - ) - - return { - prompt: inputs.prompt, - model: result.model, - tokens: { - prompt: tokens.prompt || DEFAULTS.TOKENS.PROMPT, - completion: tokens.completion || DEFAULTS.TOKENS.COMPLETION, - total: tokens.total || DEFAULTS.TOKENS.TOTAL, - }, - cost: { - input: cost.input, - output: cost.output, - total: cost.total, - }, - selectedPath: { - blockId: chosenBlock.id, - blockType: chosenBlock.type || DEFAULTS.BLOCK_TYPE, - blockTitle: chosenBlock.title || DEFAULTS.BLOCK_TITLE, - }, - selectedRoute: String(chosenBlock.id), - } as BlockOutput - } catch (error) { - logger.error('Router execution failed:', error) - throw error } + + return evalContext } - private getTargetBlocks(ctx: ExecutionContext, block: SerializedBlock) { - return ctx.workflow?.connections - .filter((conn) => conn.source === block.id) - .map((conn) => { - const targetBlock = ctx.workflow?.blocks.find((b) => b.id === conn.target) - if (!targetBlock) { - throw new Error(`Target block ${conn.target} not found`) + private async evaluateRoutes( + routes: Array<{ id: string; title: string; value: string }>, + outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>, + evalContext: Record, + ctx: ExecutionContext + ): Promise<{ + selectedConnection: { target: string; sourceHandle?: string } | null + selectedRoute: { id: string; title: string; value: string } | null + }> { + for (const route of routes) { + if (route.title === ROUTER.ELSE_TITLE) { + const connection = this.findConnectionForRoute(outgoingConnections, route.id) + if (connection) { + return { selectedConnection: connection, selectedRoute: route } } + continue + } - let systemPrompt = '' - if (isAgentBlockType(targetBlock.metadata?.id)) { - systemPrompt = - targetBlock.config?.params?.systemPrompt || targetBlock.inputs?.systemPrompt || '' + const routeValueString = String(route.value || '') + try { + const conditionMet = await evaluateConditionExpression(ctx, routeValueString, evalContext) - if (!systemPrompt && targetBlock.inputs) { - systemPrompt = targetBlock.inputs.systemPrompt || '' + if (conditionMet) { + const connection = this.findConnectionForRoute(outgoingConnections, route.id) + if (connection) { + return { selectedConnection: connection, selectedRoute: route } } + // Condition is true but has no outgoing edge - branch ends gracefully + return { selectedConnection: null, selectedRoute: null } } + } catch (error: any) { + logger.error(`Failed to evaluate route "${route.title}": ${error.message}`) + throw new Error(`Evaluation error in route "${route.title}": ${error.message}`) + } + } - return { - id: targetBlock.id, - type: targetBlock.metadata?.id, - title: targetBlock.metadata?.name, - description: targetBlock.metadata?.description, - subBlocks: { - ...targetBlock.config.params, - systemPrompt: systemPrompt, - }, - currentState: ctx.blockStates.get(targetBlock.id)?.output, - } - }) + const elseRoute = routes.find((r) => r.title === ROUTER.ELSE_TITLE) + if (elseRoute) { + const elseConnection = this.findConnectionForRoute(outgoingConnections, elseRoute.id) + if (elseConnection) { + return { selectedConnection: elseConnection, selectedRoute: elseRoute } + } + return { selectedConnection: null, selectedRoute: null } + } + + return { selectedConnection: null, selectedRoute: null } + } + + private findConnectionForRoute( + connections: Array<{ source: string; target: string; sourceHandle?: string }>, + routeId: string + ): { target: string; sourceHandle?: string } | undefined { + return connections.find((conn) => conn.sourceHandle === `${EDGE.ROUTER_PREFIX}${routeId}`) } } diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index bcf34d82e4..d16989e127 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -44,6 +44,8 @@ export class VariableResolver { const resolved: Record = {} const isConditionBlock = block?.metadata?.id === BlockType.CONDITION + const isRouterBlock = block?.metadata?.id === BlockType.ROUTER + if (isConditionBlock && typeof params.conditions === 'string') { try { const parsed = JSON.parse(params.conditions) @@ -79,10 +81,36 @@ export class VariableResolver { } } + if (isRouterBlock && typeof params.routes === 'string') { + try { + const parsed = JSON.parse(params.routes) + if (Array.isArray(parsed)) { + resolved.routes = parsed.map((route: any) => ({ + ...route, + value: + typeof route.value === 'string' + ? this.resolveTemplateWithoutConditionFormatting(ctx, currentNodeId, route.value) + : route.value, + })) + } else { + resolved.routes = this.resolveValue(ctx, currentNodeId, params.routes, undefined, block) + } + } catch (parseError) { + logger.warn('Failed to parse routes JSON, falling back to normal resolution', { + error: parseError, + routes: params.routes, + }) + resolved.routes = this.resolveValue(ctx, currentNodeId, params.routes, undefined, block) + } + } + for (const [key, value] of Object.entries(params)) { if (isConditionBlock && key === 'conditions') { continue } + if (isRouterBlock && key === 'routes') { + continue + } resolved[key] = this.resolveValue(ctx, currentNodeId, value, undefined, block) } return resolved 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 909f3ee74f..4cb9084de2 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow.ts @@ -791,15 +791,25 @@ function validateSourceHandleForBlock( return validateConditionHandle(sourceHandle, sourceBlock.id, conditionsValue) } - case 'router': - if (sourceHandle === 'source' || sourceHandle.startsWith('router-')) { - return { valid: true } + case 'router': { + if (!sourceHandle.startsWith('router-')) { + return { + valid: false, + error: `Invalid source handle "${sourceHandle}" for router block. Must start with "router-"`, + } } - return { - valid: false, - error: `Invalid source handle "${sourceHandle}" for router block. Valid handles: source, router-{targetId}, error`, + + const routesValue = sourceBlock?.subBlocks?.routes?.value + if (!routesValue) { + return { + valid: false, + error: `Invalid router handle "${sourceHandle}" - no routes defined`, + } } + return validateRouterHandle(sourceHandle, sourceBlock.id, routesValue) + } + default: if (sourceHandle === 'source') { return { valid: true } @@ -885,6 +895,80 @@ function validateConditionHandle( } } +/** + * Validates router handle references a valid route in the block. + * Accepts both internal IDs (router-blockId-if) and semantic keys (router-blockId-else-if) + */ +function validateRouterHandle( + sourceHandle: string, + blockId: string, + routesValue: string | any[] +): EdgeHandleValidationResult { + let routes: any[] + if (typeof routesValue === 'string') { + try { + routes = JSON.parse(routesValue) + } catch { + return { + valid: false, + error: `Cannot validate router handle "${sourceHandle}" - routes is not valid JSON`, + } + } + } else if (Array.isArray(routesValue)) { + routes = routesValue + } else { + return { + valid: false, + error: `Cannot validate router handle "${sourceHandle}" - routes is not an array`, + } + } + + if (!Array.isArray(routes) || routes.length === 0) { + return { + valid: false, + error: `Invalid router handle "${sourceHandle}" - no routes defined`, + } + } + + const validHandles = new Set() + const semanticPrefix = `router-${blockId}-` + let elseIfCount = 0 + + for (const route of routes) { + if (route.id) { + validHandles.add(`router-${route.id}`) + } + + const title = route.title?.toLowerCase() + if (title === 'if') { + validHandles.add(`${semanticPrefix}if`) + } else if (title === 'else if') { + elseIfCount++ + validHandles.add( + elseIfCount === 1 ? `${semanticPrefix}else-if` : `${semanticPrefix}else-if-${elseIfCount}` + ) + } else if (title === 'else') { + validHandles.add(`${semanticPrefix}else`) + } + } + + if (validHandles.has(sourceHandle)) { + return { valid: true } + } + + const validOptions = Array.from(validHandles).slice(0, 5) + const moreCount = validHandles.size - validOptions.length + let validOptionsStr = validOptions.join(', ') + if (moreCount > 0) { + validOptionsStr += `, ... and ${moreCount} more` + } + + return { + valid: false, + error: `Invalid router handle "${sourceHandle}". Valid handles: ${validOptionsStr}`, + } +} + /** * Validates target handle is valid (must be 'target') */ diff --git a/apps/sim/lib/tokenization/streaming.ts b/apps/sim/lib/tokenization/streaming.ts index 49dfcf9cb0..a30958524c 100644 --- a/apps/sim/lib/tokenization/streaming.ts +++ b/apps/sim/lib/tokenization/streaming.ts @@ -102,7 +102,7 @@ function getModelForBlock(log: BlockLog): string { // Use block type specific defaults const blockType = log.blockType - if (blockType === 'agent' || blockType === 'router' || blockType === 'evaluator') { + if (blockType === 'agent' || blockType === 'evaluator') { return TOKENIZATION_CONFIG.defaults.model } diff --git a/apps/sim/lib/workflows/autolayout/core.ts b/apps/sim/lib/workflows/autolayout/core.ts index 745b4865ef..675a92d498 100644 --- a/apps/sim/lib/workflows/autolayout/core.ts +++ b/apps/sim/lib/workflows/autolayout/core.ts @@ -50,6 +50,24 @@ function getSourceHandleYOffset(block: BlockState, sourceHandle?: string | null) } } + if (block.type === 'router' && sourceHandle?.startsWith('router-')) { + const routeId = sourceHandle.replace('router-', '') + try { + const routesValue = block.subBlocks?.routes?.value + if (typeof routesValue === 'string' && routesValue) { + const routes = JSON.parse(routesValue) as Array<{ id?: string }> + const routeIndex = routes.findIndex((r) => r.id === routeId) + if (routeIndex >= 0) { + return ( + HANDLE_POSITIONS.CONDITION_START_Y + routeIndex * HANDLE_POSITIONS.CONDITION_ROW_HEIGHT + ) + } + } + } catch { + // Fall back to default offset + } + } + return HANDLE_POSITIONS.DEFAULT_Y_OFFSET } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index eb062599f0..8a844407a2 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -200,6 +200,13 @@ function sanitizeSubBlocks( return } + // Special handling for router-input type - clean UI metadata (same format as conditions) + if (subBlock.type === 'router-input' && typeof subBlock.value === 'string') { + const cleanedRoutes: string = sanitizeConditions(subBlock.value) + sanitized[key] = cleanedRoutes + return + } + if (key === 'tools' && Array.isArray(subBlock.value)) { sanitized[key] = sanitizeTools(subBlock.value) return