import { createLogger } from '@sim/logger' import { extractFieldsFromSchema, parseResponseFormatSafely, } from '@/lib/core/utils/response-format' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { classifyStartBlockType, StartBlockPath, TRIGGER_TYPES, } from '@/lib/workflows/triggers/triggers' import { type InputFormatField, START_BLOCK_RESERVED_FIELDS, USER_FILE_ACCESSIBLE_PROPERTIES, USER_FILE_PROPERTY_TYPES, } from '@/lib/workflows/types' import { getBlock } from '@/blocks' import { type BlockConfig, isHiddenFromDisplay, type OutputCondition, type OutputFieldDefinition, } from '@/blocks/types' import { getTool } from '@/tools/utils' import { getTrigger, isTriggerValid } from '@/triggers' const logger = createLogger('BlockOutputs') type OutputDefinition = Record interface SubBlockWithValue { value?: unknown } interface EffectiveOutputOptions { triggerMode?: boolean preferToolOutputs?: boolean includeHidden?: boolean } type ConditionValue = string | number | boolean /** * Checks if a value is a valid primitive for condition comparison. */ function isConditionPrimitive(value: unknown): value is ConditionValue { return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' } /** * Evaluates an output condition against subBlock values. * Returns true if the condition is met and the output should be shown. */ function evaluateOutputCondition( condition: OutputCondition, subBlocks: Record | undefined ): boolean { if (!subBlocks) return false const fieldValue = subBlocks[condition.field]?.value let matches: boolean if (Array.isArray(condition.value)) { // For array conditions, check if fieldValue is a valid primitive and included matches = isConditionPrimitive(fieldValue) && condition.value.includes(fieldValue) } else { matches = fieldValue === condition.value } if (condition.not) { matches = !matches } if (condition.and) { const andFieldValue = subBlocks[condition.and.field]?.value let andMatches: boolean if (Array.isArray(condition.and.value)) { const primitiveMatch = isConditionPrimitive(andFieldValue) && condition.and.value.includes(andFieldValue) const undefinedMatch = andFieldValue === undefined && condition.and.value.includes(undefined) const nullMatch = andFieldValue === null && condition.and.value.includes(null) andMatches = primitiveMatch || undefinedMatch || nullMatch } else { andMatches = andFieldValue === condition.and.value } if (condition.and.not) { andMatches = !andMatches } matches = matches && andMatches } return matches } /** * Filters outputs based on their conditions and hiddenFromDisplay flag. * Returns a new OutputDefinition with only the outputs that should be shown. */ function filterOutputsByCondition( outputs: OutputDefinition, subBlocks: Record | undefined, includeHidden = false ): OutputDefinition { const filtered: OutputDefinition = {} for (const [key, value] of Object.entries(outputs)) { if (!includeHidden && isHiddenFromDisplay(value)) continue if (!value || typeof value !== 'object' || !('condition' in value)) { filtered[key] = value continue } const condition = value.condition as OutputCondition | undefined const passes = !condition || evaluateOutputCondition(condition, subBlocks) if (passes) { if (includeHidden) { const { condition: _, ...rest } = value filtered[key] = rest } else { const { condition: _, hiddenFromDisplay: __, ...rest } = value filtered[key] = rest } } } return filtered } const CHAT_OUTPUTS: OutputDefinition = { input: { type: 'string', description: 'User message' }, conversationId: { type: 'string', description: 'Conversation ID' }, files: { type: 'file[]', description: 'Uploaded files' }, } const UNIFIED_START_OUTPUTS: OutputDefinition = { input: { type: 'string', description: 'Primary user input or message' }, conversationId: { type: 'string', description: 'Conversation thread identifier' }, files: { type: 'file[]', description: 'User uploaded files' }, } function applyInputFormatFields( inputFormat: InputFormatField[], outputs: OutputDefinition ): OutputDefinition { for (const field of inputFormat) { const fieldName = field?.name?.trim() if (!fieldName) continue outputs[fieldName] = { type: (field?.type || 'any') as any, description: `Field from input format`, } } return outputs } function hasInputFormat(blockConfig: BlockConfig): boolean { return blockConfig.subBlocks?.some((sb) => sb.type === 'input-format') || false } function getTriggerId( subBlocks: Record | undefined, blockConfig: BlockConfig ): string | undefined { const selectedTriggerIdValue = subBlocks?.selectedTriggerId?.value const triggerIdValue = subBlocks?.triggerId?.value return ( (typeof selectedTriggerIdValue === 'string' && isTriggerValid(selectedTriggerIdValue) ? selectedTriggerIdValue : undefined) || (typeof triggerIdValue === 'string' && isTriggerValid(triggerIdValue) ? triggerIdValue : undefined) || blockConfig.triggers?.available?.[0] ) } function getUnifiedStartOutputs( subBlocks: Record | undefined ): OutputDefinition { const outputs = { ...UNIFIED_START_OUTPUTS } const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) return applyInputFormatFields(normalizedInputFormat, outputs) } function getLegacyStarterOutputs( subBlocks: Record | undefined ): OutputDefinition { const startWorkflowValue = subBlocks?.startWorkflow?.value if (startWorkflowValue === 'chat') { return { ...CHAT_OUTPUTS } } if ( startWorkflowValue === 'api' || startWorkflowValue === 'run' || startWorkflowValue === 'manual' ) { const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) return applyInputFormatFields(normalizedInputFormat, {}) } return {} } function shouldClearBaseOutputs( blockType: string, normalizedInputFormat: InputFormatField[] ): boolean { if (blockType === TRIGGER_TYPES.API || blockType === TRIGGER_TYPES.INPUT) { return true } if (blockType === TRIGGER_TYPES.GENERIC_WEBHOOK && normalizedInputFormat.length > 0) { return true } return false } function applyInputFormatToOutputs( blockType: string, blockConfig: BlockConfig, subBlocks: Record | undefined, baseOutputs: OutputDefinition ): OutputDefinition { if (!hasInputFormat(blockConfig) || !subBlocks?.inputFormat?.value) { return baseOutputs } const normalizedInputFormat = normalizeInputFormatValue(subBlocks.inputFormat.value) if (!Array.isArray(subBlocks.inputFormat.value)) { if (blockType === TRIGGER_TYPES.API || blockType === TRIGGER_TYPES.INPUT) { return {} } return baseOutputs } const shouldClear = shouldClearBaseOutputs(blockType, normalizedInputFormat) const outputs = shouldClear ? {} : { ...baseOutputs } return applyInputFormatFields(normalizedInputFormat, outputs) } export function getBlockOutputs( blockType: string, subBlocks?: Record, triggerMode?: boolean, options?: { includeHidden?: boolean } ): OutputDefinition { const includeHidden = options?.includeHidden ?? false const blockConfig = getBlock(blockType) if (!blockConfig) return {} if (triggerMode && blockConfig.triggers?.enabled) { const triggerId = getTriggerId(subBlocks, blockConfig) if (triggerId && isTriggerValid(triggerId)) { const trigger = getTrigger(triggerId) if (trigger.outputs) { // TriggerOutput is compatible with OutputFieldDefinition at runtime. // Conditions narrow outputs to the selected trigger configuration // (e.g. the Sim trigger only surfaces execution fields for // execution-backed event types). return filterOutputsByCondition( trigger.outputs as OutputDefinition, subBlocks, includeHidden ) } } } const startPath = classifyStartBlockType(blockType) if (startPath === StartBlockPath.UNIFIED) { return getUnifiedStartOutputs(subBlocks) } if (blockType === 'human_in_the_loop') { // Start with block config outputs (respects hiddenFromDisplay via filterOutputsByCondition) const baseOutputs = filterOutputsByCondition( { ...(blockConfig.outputs || {}) } as OutputDefinition, subBlocks, includeHidden ) // Add inputFormat fields (resume form fields) const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) for (const field of normalizedInputFormat) { const fieldName = field?.name?.trim() if (!fieldName) continue baseOutputs[fieldName] = { type: (field?.type || 'any') as any, description: field?.description || `Field from resume form`, } } return baseOutputs } if (startPath === StartBlockPath.LEGACY_STARTER) { return getLegacyStarterOutputs(subBlocks) } const baseOutputs = { ...(blockConfig.outputs || {}) } const filteredOutputs = filterOutputsByCondition(baseOutputs, subBlocks, includeHidden) return applyInputFormatToOutputs(blockType, blockConfig, subBlocks, filteredOutputs) } export function getResponseFormatOutputs( subBlocks?: Record, blockId = 'block' ): OutputDefinition | undefined { const responseFormatValue = subBlocks?.responseFormat?.value if (!responseFormatValue) return undefined const parsed = parseResponseFormatSafely(responseFormatValue, blockId) if (!parsed) return undefined const fields = extractFieldsFromSchema(parsed) if (fields.length === 0) return undefined const outputs: OutputDefinition = {} for (const field of fields) { outputs[field.name] = { type: (field.type || 'any') as any, description: field.description || `Field from Agent: ${field.name}`, } } return outputs } export function getEvaluatorMetricOutputs( subBlocks?: Record ): OutputDefinition | undefined { const metricsValue = subBlocks?.metrics?.value if (!metricsValue || !Array.isArray(metricsValue) || metricsValue.length === 0) return undefined const validMetrics = metricsValue.filter((metric: { name?: string }) => metric?.name) if (validMetrics.length === 0) return undefined const outputs: OutputDefinition = {} for (const metric of validMetrics as Array<{ name: string }>) { outputs[metric.name.toLowerCase()] = { type: 'number', description: `Metric score: ${metric.name}`, } } return outputs } export function getEffectiveBlockOutputs( blockType: string, subBlocks?: Record, options?: EffectiveOutputOptions ): OutputDefinition { const triggerMode = options?.triggerMode ?? false const preferToolOutputs = options?.preferToolOutputs ?? !triggerMode const includeHidden = options?.includeHidden ?? false if (blockType === 'agent') { const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent') if (responseFormatOutputs) return responseFormatOutputs } let baseOutputs: OutputDefinition if (triggerMode) { baseOutputs = getBlockOutputs(blockType, subBlocks, true, { includeHidden }) } else if (preferToolOutputs) { const blockConfig = getBlock(blockType) const toolOutputs = blockConfig ? (getToolOutputs(blockConfig, subBlocks, { includeHidden }) as OutputDefinition) : {} baseOutputs = toolOutputs && Object.keys(toolOutputs).length > 0 ? toolOutputs : getBlockOutputs(blockType, subBlocks, false, { includeHidden }) } else { baseOutputs = getBlockOutputs(blockType, subBlocks, false, { includeHidden }) } if (blockType === 'evaluator') { const metricOutputs = getEvaluatorMetricOutputs(subBlocks) if (metricOutputs) { return { ...baseOutputs, ...metricOutputs } } } return baseOutputs } export function getEffectiveBlockOutputPaths( blockType: string, subBlocks?: Record, options?: EffectiveOutputOptions ): string[] { const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options) const paths = generateOutputPaths(outputs) if (blockType === TRIGGER_TYPES.START) { return paths.filter((path) => { const key = path.split('.')[0] return !shouldFilterReservedField(blockType, key, '', subBlocks) }) } return paths } function shouldFilterReservedField( blockType: string, key: string, prefix: string, subBlocks: Record | undefined ): boolean { if (blockType !== TRIGGER_TYPES.START || prefix) { return false } if (!START_BLOCK_RESERVED_FIELDS.includes(key as any)) { return false } const normalizedInputFormat = normalizeInputFormatValue(subBlocks?.inputFormat?.value) const isExplicitlyDefined = normalizedInputFormat.some((field) => field?.name?.trim() === key) return !isExplicitlyDefined } function expandFileTypeProperties(path: string): string[] { return USER_FILE_ACCESSIBLE_PROPERTIES.map((prop) => `${path}.${prop}`) } type FileOutputType = 'file' | 'file[]' function isFileOutputDefinition(value: unknown): value is { type: FileOutputType } { if (!value || typeof value !== 'object' || !('type' in value)) { return false } const { type } = value as { type?: unknown } return type === 'file' || type === 'file[]' } function getFilePropertyType(outputs: OutputDefinition, pathParts: string[]): string | null { const lastPart = pathParts[pathParts.length - 1] if (!lastPart || !USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES]) { return null } let current: unknown = outputs for (const part of pathParts.slice(0, -1)) { if (!current || typeof current !== 'object') { return null } current = (current as Record)[part] } if (isFileOutputDefinition(current)) { return USER_FILE_PROPERTY_TYPES[lastPart as keyof typeof USER_FILE_PROPERTY_TYPES] } return null } function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unknown { let current: unknown = outputs for (const part of pathParts) { if (!current || typeof current !== 'object') { return null } const currentObj = current as Record if (part in currentObj) { current = currentObj[part] } else if ( 'type' in currentObj && currentObj.type === 'object' && 'properties' in currentObj && currentObj.properties && typeof currentObj.properties === 'object' ) { const props = currentObj.properties as Record if (part in props) { current = props[part] } else { return null } } else if ( 'type' in currentObj && currentObj.type === 'array' && 'items' in currentObj && currentObj.items && typeof currentObj.items === 'object' ) { const items = currentObj.items as Record if ('properties' in items && items.properties && typeof items.properties === 'object') { const itemProps = items.properties as Record if (part in itemProps) { current = itemProps[part] } else { return null } } else { return null } } else { return null } } return current } function extractType(value: unknown): string { if (!value) return 'any' if (typeof value === 'object' && 'type' in value) { const typeValue = (value as { type: unknown }).type return typeof typeValue === 'string' ? typeValue : 'any' } return typeof value === 'string' ? value : 'any' } export function getEffectiveBlockOutputType( blockType: string, outputPath: string, subBlocks?: Record, options?: EffectiveOutputOptions ): string { const outputs = getEffectiveBlockOutputs(blockType, subBlocks, options) const cleanPath = outputPath.replace(/\[(\d+)\]/g, '') const pathParts = cleanPath.split('.').filter(Boolean) const filePropertyType = getFilePropertyType(outputs, pathParts) if (filePropertyType) { return filePropertyType } const value = traverseOutputPath(outputs, pathParts) return extractType(value) } /** * Recursively generates all output paths from an outputs schema. * * @param outputs - The outputs schema object * @param prefix - Current path prefix for recursion * @returns Array of dot-separated paths to all output fields */ function generateOutputPaths(outputs: Record, prefix = ''): string[] { const paths: string[] = [] for (const [key, value] of Object.entries(outputs)) { const currentPath = prefix ? `${prefix}.${key}` : key if (typeof value === 'string') { paths.push(currentPath) } else if (typeof value === 'object' && value !== null) { if ('type' in value && typeof value.type === 'string') { if (isFileOutputDefinition(value)) { paths.push(...expandFileTypeProperties(currentPath)) continue } const hasNestedProperties = ((value.type === 'object' || value.type === 'json') && value.properties) || (value.type === 'array' && value.items?.properties) || (value.type === 'array' && value.items && typeof value.items === 'object' && !('type' in value.items)) if (!hasNestedProperties) { paths.push(currentPath) } if ((value.type === 'object' || value.type === 'json') && value.properties) { paths.push(...generateOutputPaths(value.properties, currentPath)) } else if (value.type === 'array' && value.items?.properties) { paths.push(...generateOutputPaths(value.items.properties, currentPath)) } else if ( value.type === 'array' && value.items && typeof value.items === 'object' && !('type' in value.items) ) { paths.push(...generateOutputPaths(value.items, currentPath)) } } else { const subPaths = generateOutputPaths(value, currentPath) paths.push(...subPaths) } } else { paths.push(currentPath) } } return paths } /** * Gets the tool outputs for a block operation. * * @param blockConfig - The block configuration containing tools config * @param subBlocks - SubBlock values to pass to the tool selector * @returns Outputs schema for the tool, or empty object on error */ export function getToolOutputs( blockConfig: BlockConfig, subBlocks?: Record, options?: { includeHidden?: boolean } ): Record { const includeHidden = options?.includeHidden ?? false if (!blockConfig?.tools?.config?.tool) return {} try { // Build params object from subBlock values for tool selector // This allows tool selectors to use any field (operation, provider, etc.) const params: Record = {} if (subBlocks) { for (const [key, subBlock] of Object.entries(subBlocks)) { params[key] = subBlock.value } } const toolId = blockConfig.tools.config.tool(params) if (!toolId) return {} const toolConfig = getTool(toolId) if (!toolConfig?.outputs) return {} if (includeHidden) { return toolConfig.outputs } return Object.fromEntries( Object.entries(toolConfig.outputs).filter(([_, def]) => !isHiddenFromDisplay(def)) ) } catch (error) { logger.warn('Failed to get tool outputs', { error }) return {} } } /** * Generates output paths from a schema definition. * * @param outputs - The outputs schema object * @returns Array of dot-separated paths to all output fields */ export function getOutputPathsFromSchema(outputs: Record): string[] { return generateOutputPaths(outputs) }