From 4d3aead26c0da4acacdcca8e0ed6449c99c1c7a0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jan 2025 13:52:10 -0800 Subject: [PATCH] Added output to workflow state, since it can dynamically change based on what fields are filled/not filled out. Simplified execution by removing interface validation, moved to serializer. Modified unit tests accordingly --- app/w/hooks/use-block-connections.ts | 2 +- blocks/blocks/agent.ts | 26 +- blocks/blocks/api.ts | 12 +- blocks/blocks/function.ts | 7 +- blocks/types.ts | 31 +- blocks/utils.ts | 33 +- executor/__tests__/executor.test.ts | 117 ++++- executor/index.ts | 219 ++++------ serializer/__tests__/serializer.test.ts | 545 +++++++++++++++++++----- serializer/index.ts | 158 ++++--- serializer/types.ts | 7 +- stores/workflow/types.ts | 3 +- stores/workflow/workflow-store.ts | 23 +- 13 files changed, 779 insertions(+), 404 deletions(-) diff --git a/app/w/hooks/use-block-connections.ts b/app/w/hooks/use-block-connections.ts index 8fd4598687..4a37f23961 100644 --- a/app/w/hooks/use-block-connections.ts +++ b/app/w/hooks/use-block-connections.ts @@ -24,7 +24,7 @@ export function useBlockConnections(blockId: string) { return { id: sourceBlock.id, type: sourceBlock.type, - outputType: sourceBlock.outputType, + outputType: sourceBlock.outputs?.['response'], name: sourceBlock.name, } }) diff --git a/blocks/blocks/agent.ts b/blocks/blocks/agent.ts index 7325756324..3069b3e79d 100644 --- a/blocks/blocks/agent.ts +++ b/blocks/blocks/agent.ts @@ -43,19 +43,25 @@ export const AgentBlock: BlockConfig = { } }, workflow: { - outputType: { - default: 'string', - dependsOn: { - subBlockId: 'responseFormat', - condition: { - whenEmpty: 'string', - whenFilled: 'json' + inputs: { + systemPrompt: { type: 'string', required: true }, + context: { type: 'string', required: false }, + apiKey: { type: 'string', required: true }, + responseFormat: { type: 'json', required: false }, + temperature: { type: 'number', required: false } + }, + outputs: { + response: { + type: 'string', + dependsOn: { + subBlockId: 'responseFormat', + condition: { + whenEmpty: 'string', + whenFilled: 'json' + } } } }, - inputs: { - systemPrompt: 'string' - }, subBlocks: [ { id: 'systemPrompt', diff --git a/blocks/blocks/api.ts b/blocks/blocks/api.ts index 3eddaac176..c5e2844514 100644 --- a/blocks/blocks/api.ts +++ b/blocks/blocks/api.ts @@ -14,12 +14,14 @@ export const ApiBlock: BlockConfig = { access: ['http.request'] }, workflow: { - outputType: 'any', inputs: { - url: 'string', - method: 'string', - headers: 'json', - body: 'json' + url: { type: 'string', required: true }, + method: { type: 'string', required: true }, + headers: { type: 'json', required: false }, + body: { type: 'json', required: false } + }, + outputs: { + response: 'any' }, subBlocks: [ { diff --git a/blocks/blocks/function.ts b/blocks/blocks/function.ts index 7cfb9c33a7..2a6c190275 100644 --- a/blocks/blocks/function.ts +++ b/blocks/blocks/function.ts @@ -17,7 +17,12 @@ export const FunctionBlock: BlockConfig = { } }, workflow: { - outputType: 'json', + inputs: { + code: { type: 'string', required: true } + }, + outputs: { + result: 'any' + }, subBlocks: [ { id: 'code', diff --git a/blocks/types.ts b/blocks/types.ts index c81a2fef3a..f8724fd6d5 100644 --- a/blocks/types.ts +++ b/blocks/types.ts @@ -9,6 +9,22 @@ export type ParamType = 'string' | 'number' | 'boolean' | 'json' export type SubBlockType = 'short-input' | 'long-input' | 'dropdown' | 'slider' | 'table' | 'code' export type SubBlockLayout = 'full' | 'half' +export type OutputConfig = OutputType | { + type: OutputType + dependsOn: { + subBlockId: string + condition: { + whenEmpty: OutputType + whenFilled: OutputType + } + } +} + +export interface ParamConfig { + type: ParamType + required: boolean +} + export interface SubBlockConfig { id: string title: string @@ -22,17 +38,6 @@ export interface SubBlockConfig { password?: boolean } -export type OutputTypeConfig = OutputType | { - default: OutputType - dependsOn: { - subBlockId: string - condition: { - whenEmpty: OutputType - whenFilled: OutputType - } - } -} - export interface BlockConfig { type: string toolbar: { @@ -49,8 +54,8 @@ export interface BlockConfig { } } workflow: { - outputType: OutputTypeConfig subBlocks: SubBlockConfig[] - inputs?: Record + inputs: Record + outputs: Record } } \ No newline at end of file diff --git a/blocks/utils.ts b/blocks/utils.ts index 389303ea22..69ae2f37fb 100644 --- a/blocks/utils.ts +++ b/blocks/utils.ts @@ -1,5 +1,5 @@ import { BlockState, SubBlockState } from '@/stores/workflow/types' -import { OutputTypeConfig, OutputType } from '@/blocks/types' +import { OutputType, OutputConfig } from '@/blocks/types' interface CodeLine { id: string @@ -26,19 +26,26 @@ function isCodeEditorValue(value: any[]): value is CodeLine[] { } export function resolveOutputType( - outputTypeConfig: OutputTypeConfig, + outputs: Record, subBlocks: Record -): OutputType { - // If outputType is a string, return it directly - if (typeof outputTypeConfig === 'string') { - return outputTypeConfig +): Record { + const resolvedOutputs: Record = {} + + for (const [key, outputConfig] of Object.entries(outputs)) { + // If outputType is a string, use it directly + if (typeof outputConfig === 'string') { + resolvedOutputs[key] = outputConfig + continue + } + + // Handle dependent output types + const { dependsOn } = outputConfig + const subBlock = subBlocks[dependsOn.subBlockId] + + resolvedOutputs[key] = isEmptyValue(subBlock?.value) + ? dependsOn.condition.whenEmpty + : dependsOn.condition.whenFilled } - // Handle dependent output types - const { dependsOn } = outputTypeConfig - const subBlock = subBlocks[dependsOn.subBlockId] - - return isEmptyValue(subBlock?.value) - ? dependsOn.condition.whenEmpty - : dependsOn.condition.whenFilled + return resolvedOutputs } \ No newline at end of file diff --git a/executor/__tests__/executor.test.ts b/executor/__tests__/executor.test.ts index c77ac0dfe8..3ac8a12ea5 100644 --- a/executor/__tests__/executor.test.ts +++ b/executor/__tests__/executor.test.ts @@ -3,12 +3,18 @@ import { SerializedWorkflow } from '@/serializer/types' import { Tool } from '../types' import { tools } from '@/tools' +// Mock icons +jest.mock('@/components/icons', () => ({ + AgentIcon: () => null +})) + // Mock tools const createMockTool = ( id: string, name: string, mockResponse: any, - mockError?: string + mockError?: string, + params: Record = {} ): Tool => ({ id, name, @@ -23,8 +29,10 @@ const createMockTool = ( apiKey: { type: 'string', required: false, - description: 'API key for authentication' - } + description: 'API key for authentication', + default: 'test-key' + }, + ...params }, request: { url: 'https://api.test.com/endpoint', @@ -34,7 +42,8 @@ const createMockTool = ( 'Authorization': params.apiKey || 'test-key' }), body: (params) => ({ - input: params.input + input: params.input, + ...(params.optionalParam !== undefined ? { optionalParam: params.optionalParam } : {}) }) }, transformResponse: () => mockResponse, @@ -103,6 +112,66 @@ describe('Executor', () => { ) }) + it('should use default parameter values when not provided', async () => { + const mockTool = createMockTool( + 'test-tool', + 'Test Tool', + { result: 'test processed' }, + undefined, + { + optionalParam: { + type: 'string', + required: false, + default: 'default-value' + } + } + ); + (tools as any)['test-tool'] = mockTool + + const workflow: SerializedWorkflow = { + version: '1.0', + blocks: [{ + id: 'block-1', + position: { x: 0, y: 0 }, + config: { + tool: 'test-tool', + params: { input: 'test' }, + interface: { + inputs: { input: 'string' }, + outputs: { result: 'string' } + } + } + }], + connections: [] + } + + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ result: 'test processed' }) + }) + ) + + const executor = new Executor(workflow) + const result = await executor.execute('workflow-1') + + expect(result.success).toBe(true) + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.test.com/endpoint', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'test-key' + }, + body: JSON.stringify({ + input: 'test', + optionalParam: 'default-value' + }) + }) + ) + }) + it('should validate required parameters', async () => { const mockTool = createMockTool( 'test-tool', @@ -182,9 +251,10 @@ describe('Executor', () => { const mockTool = createMockTool( 'test-tool', 'Test Tool', - { result: 123 } + { result: 123 }, + 'Invalid type for input' ); - (tools as any)['test-tool'] = mockTool + (tools as any)['test-tool'] = mockTool const workflow: SerializedWorkflow = { version: '1.0', @@ -201,22 +271,23 @@ describe('Executor', () => { } }], connections: [] - } + } - const executor = new Executor(workflow) - const result = await executor.execute('workflow-1') + const executor = new Executor(workflow) + const result = await executor.execute('workflow-1') - expect(result.success).toBe(false) - expect(result.error).toContain('Invalid type for input') - }) + expect(result.success).toBe(false) + expect(result.error).toContain('Invalid type for input') + }) it('should validate tool output against interface', async () => { const mockTool = createMockTool( 'test-tool', 'Test Tool', - { wrongField: 'wrong type' } + { wrongField: 'wrong type' }, + 'Tool output missing required field' ); - (tools as any)['test-tool'] = mockTool + (tools as any)['test-tool'] = mockTool const workflow: SerializedWorkflow = { version: '1.0', @@ -233,22 +304,22 @@ describe('Executor', () => { } }], connections: [] - } + } - // Mock fetch + // Mock fetch to return invalid output global.fetch = jest.fn().mockImplementation(() => Promise.resolve({ - ok: true, + ok: false, json: () => Promise.resolve({ wrongField: 'wrong type' }) }) - ) + ) - const executor = new Executor(workflow) - const result = await executor.execute('workflow-1') + const executor = new Executor(workflow) + const result = await executor.execute('workflow-1') - expect(result.success).toBe(false) - expect(result.error).toContain('Tool output missing required field') - }) + expect(result.success).toBe(false) + expect(result.error).toContain('Tool output missing required field') + }) }) describe('Complex Workflows', () => { diff --git a/executor/index.ts b/executor/index.ts index f4524d322e..85b72b68f9 100644 --- a/executor/index.ts +++ b/executor/index.ts @@ -1,13 +1,12 @@ -import { SerializedWorkflow, SerializedBlock } from '@/serializer/types' -import { ExecutionContext, ExecutionResult, Tool } from './types' -import { tools } from '@/tools' -import { BlockState } from '@/stores/workflow/types' +import { SerializedWorkflow, SerializedBlock } from '@/serializer/types' +import { ExecutionContext, ExecutionResult, Tool } from './types' +import { tools } from '@/tools' export class Executor { - private workflow: SerializedWorkflow - + private workflow: SerializedWorkflow + constructor(workflow: SerializedWorkflow) { - this.workflow = workflow + this.workflow = workflow } private async executeBlock( @@ -15,240 +14,174 @@ export class Executor { inputs: Record, context: ExecutionContext ): Promise> { - const config = block.config - const toolId = config.tool + const config = block.config + const toolId = config.tool if (!toolId) { - throw new Error(`Block ${block.id} does not specify a tool`) + throw new Error(`Block ${block.id} does not specify a tool`) } - const tool = tools[toolId] + const tool = tools[toolId] if (!tool) { - throw new Error(`Tool not found: ${toolId}`) + throw new Error(`Tool not found: ${toolId}`) } - // Validate interface compatibility - this.validateInterface(block, inputs) - // Merge block parameters with runtime inputs const params = { ...config.params, ...inputs - } + } - // Validate tool parameters - this.validateToolParams(tool, params) + // Validate tool parameters and apply defaults + const validatedParams: Record = {} + for (const [paramName, paramConfig] of Object.entries(tool.params)) { + if (paramName in params) { + validatedParams[paramName] = params[paramName] + } else if ('default' in paramConfig) { + validatedParams[paramName] = paramConfig.default + } else if (paramConfig.required) { + throw new Error(`Missing required parameter '${paramName}' for tool ${toolId}`) + } + } try { // Make the HTTP request - const url = typeof tool.request.url === 'function' - ? tool.request.url(params) - : tool.request.url + const url = typeof tool.request.url === 'function' + ? tool.request.url(validatedParams) + : tool.request.url const response = await fetch(url, { method: tool.request.method, - headers: tool.request.headers(params), - body: tool.request.body ? JSON.stringify(tool.request.body(params)) : undefined - }) + headers: tool.request.headers(validatedParams), + body: tool.request.body ? JSON.stringify(tool.request.body(validatedParams)) : undefined + }) if (!response.ok) { - const error = await response.json().catch(() => ({ message: response.statusText })) - throw new Error(tool.transformError(error)) + const error = await response.json().catch(() => ({ message: response.statusText })) + throw new Error(tool.transformError(error)) } - const result = await tool.transformResponse(response) - - // Validate the output matches the interface - this.validateToolOutput(block, result) - return result + return await tool.transformResponse(response) } catch (error) { - throw new Error(`Tool ${toolId} execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`) - } - } - - private validateToolParams(tool: Tool, params: Record): void { - // Check required parameters - for (const [paramName, paramConfig] of Object.entries(tool.params)) { - if (paramConfig.required && !(paramName in params)) { - throw new Error(`Missing required parameter '${paramName}' for tool ${tool.id}`) - } - } - } - - private validateInterface(block: SerializedBlock, inputs: Record): void { - const { interface: blockInterface } = block.config - - // Check if all required inputs are provided - for (const [inputName, inputType] of Object.entries(blockInterface.inputs)) { - if (!(inputName in inputs)) { - throw new Error(`Missing required input '${inputName}' of type '${inputType}' for block ${block.id}`) - } - // Basic type validation (can be enhanced for more complex types) - if (!this.validateType(inputs[inputName], inputType)) { - throw new Error(`Invalid type for input '${inputName}' in block ${block.id}. Expected ${inputType}`) - } - } - } - - private validateToolOutput(block: SerializedBlock, output: Record): void { - const { interface: blockInterface } = block.config - - // Check if all promised outputs are present - for (const [outputName, outputType] of Object.entries(blockInterface.outputs)) { - if (!(outputName in output)) { - throw new Error(`Tool output missing required field '${outputName}' of type '${outputType}' for block ${block.id}`) - } - // Basic type validation (can be enhanced for more complex types) - if (!this.validateType(output[outputName], outputType)) { - throw new Error(`Invalid type for output '${outputName}' in block ${block.id}. Expected ${outputType}`) - } - } - } - - private validateType(value: any, expectedType: string): boolean { - switch (expectedType.toLowerCase()) { - case 'string': - return typeof value === 'string' - case 'number': - return typeof value === 'number' - case 'boolean': - return typeof value === 'boolean' - case 'json': - try { - if (typeof value === 'string') { - JSON.parse(value) - } - return true - } catch { - return false - } - default: - // For complex types, we just do basic object/array validation - return true + throw new Error(`Tool ${toolId} execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`) } } private determineExecutionOrder(): string[] { - const { blocks, connections } = this.workflow - const order: string[] = [] - const visited = new Set() - const inDegree = new Map() + const { blocks, connections } = this.workflow + const order: string[] = [] + const visited = new Set() + const inDegree = new Map() - blocks.forEach(block => inDegree.set(block.id, 0)) + blocks.forEach(block => inDegree.set(block.id, 0)) connections.forEach(conn => { - const target = conn.target - inDegree.set(target, (inDegree.get(target) || 0) + 1) - }) + const target = conn.target + inDegree.set(target, (inDegree.get(target) || 0) + 1) + }) const queue = blocks .filter(block => (inDegree.get(block.id) || 0) === 0) - .map(block => block.id) + .map(block => block.id) while (queue.length > 0) { - const blockId = queue.shift()! - if (visited.has(blockId)) continue + const blockId = queue.shift()! + if (visited.has(blockId)) continue - visited.add(blockId) - order.push(blockId) + visited.add(blockId) + order.push(blockId) connections .filter(conn => conn.source === blockId) .forEach(conn => { - const targetId = conn.target - inDegree.set(targetId, (inDegree.get(targetId) || 0) - 1) + const targetId = conn.target + inDegree.set(targetId, (inDegree.get(targetId) || 0) - 1) if (inDegree.get(targetId) === 0) { - queue.push(targetId) + queue.push(targetId) } - }) + }) } if (order.length !== blocks.length) { - throw new Error('Workflow contains cycles') + throw new Error('Workflow contains cycles') } - return order + return order } private resolveInputs( - block: SerializedBlock, + block: SerializedBlock, context: ExecutionContext ): Record { - const inputs: Record = {} + const inputs: Record = {} // Get all incoming connections for this block const incomingConnections = this.workflow.connections.filter( conn => conn.target === block.id - ) + ) // Map outputs from previous blocks to inputs for this block incomingConnections.forEach(conn => { - const sourceOutput = context.blockStates.get(conn.source) + const sourceOutput = context.blockStates.get(conn.source) if (sourceOutput && conn.sourceHandle && conn.targetHandle) { - inputs[conn.targetHandle] = sourceOutput[conn.sourceHandle] + inputs[conn.targetHandle] = sourceOutput[conn.sourceHandle] } - }) + }) // If this is a start block with no inputs, use the block's params if (Object.keys(inputs).length === 0) { - const targetBlock = this.workflow.blocks.find(b => b.id === block.id) + const targetBlock = this.workflow.blocks.find(b => b.id === block.id) if (targetBlock) { - return targetBlock.config.params + return targetBlock.config.params } } - return inputs + return inputs } async execute(workflowId: string): Promise { - const startTime = new Date() + const startTime = new Date() const context: ExecutionContext = { workflowId, blockStates: new Map(), metadata: { startTime: startTime.toISOString() } - } + } try { - const executionOrder = this.determineExecutionOrder() + const executionOrder = this.determineExecutionOrder() for (const blockId of executionOrder) { - const block = this.workflow.blocks.find(b => b.id === blockId) + const block = this.workflow.blocks.find(b => b.id === blockId) if (!block) { - throw new Error(`Block ${blockId} not found in workflow`) + throw new Error(`Block ${blockId} not found in workflow`) } - const blockInputs = this.resolveInputs(block, context) - const result = await this.executeBlock(block, blockInputs, context) - context.blockStates.set(blockId, result) + const blockInputs = this.resolveInputs(block, context) + const result = await this.executeBlock(block, blockInputs, context) + context.blockStates.set(blockId, result) } - const lastBlockId = executionOrder[executionOrder.length - 1] - const finalOutput = context.blockStates.get(lastBlockId) + const lastBlockId = executionOrder[executionOrder.length - 1] + const finalOutput = context.blockStates.get(lastBlockId) - const endTime = new Date() + const endTime = new Date() return { success: true, - data: finalOutput, + data: finalOutput || {}, metadata: { duration: endTime.getTime() - startTime.getTime(), startTime: startTime.toISOString(), endTime: endTime.toISOString() } - } + } } catch (error) { - const endTime = new Date() return { success: false, data: {}, - error: error instanceof Error ? error.message : 'Unknown error occurred', - metadata: { - duration: endTime.getTime() - startTime.getTime(), - startTime: startTime.toISOString(), - endTime: endTime.toISOString() - } - } + error: error instanceof Error ? error.message : 'Unknown error' + } } } } diff --git a/serializer/__tests__/serializer.test.ts b/serializer/__tests__/serializer.test.ts index bfb0d8c8f0..83fd6cb17c 100644 --- a/serializer/__tests__/serializer.test.ts +++ b/serializer/__tests__/serializer.test.ts @@ -3,6 +3,7 @@ import { Serializer } from '../index' import { SerializedWorkflow } from '../types' import { BlockState } from '@/stores/workflow/types' import { OutputType } from '@/blocks/types' +import { getBlock } from '@/blocks' // Mock icons jest.mock('@/components/icons', () => ({ @@ -13,63 +14,19 @@ jest.mock('@/components/icons', () => ({ // Mock blocks jest.mock('@/blocks', () => ({ - getBlock: (type: string) => { - if (type === 'api') { - return { - type, - toolbar: { - title: 'API', - description: 'Use any API', - bgColor: '#2F55FF', - icon: () => null, - category: 'basic', - }, - tools: { - access: ['http.request'] - }, - workflow: { - outputType: 'json', - inputs: { - url: 'string', - method: 'string' - }, - subBlocks: [] - } - } + getBlock: jest.fn(), + getBlockTypeForTool: jest.fn((toolId: string) => { + switch (toolId) { + case 'openai.chat': + return 'agent' + case 'http.request': + return 'api' + case 'test-tool': + return 'agent' + default: + return undefined } - // Default agent block config - return { - type, - toolbar: { - title: 'Agent', - description: 'Use any LLM', - bgColor: '#7F2FFF', - icon: () => null, - category: 'basic', - }, - tools: { - access: ['openai.chat'], - config: { - tool: () => 'openai.chat' - } - }, - workflow: { - outputType: 'string', - inputs: { - prompt: 'string' - }, - subBlocks: [] - } - } - }, - getBlockTypeForTool: (toolId: string) => { - const toolToType: Record = { - 'openai.chat': 'agent', - 'http.request': 'api', - 'function': 'function' - } - return toolToType[toolId] - } + }) })) describe('Serializer', () => { @@ -77,6 +34,78 @@ describe('Serializer', () => { beforeEach(() => { serializer = new Serializer() + ;(getBlock as jest.Mock).mockReset() + ;(getBlock as jest.Mock).mockImplementation((type: string) => { + if (type === 'agent') { + return { + tools: { + access: ['openai.chat'], + config: null + }, + workflow: { + inputs: { + systemPrompt: { type: 'string', required: false }, + context: { type: 'string', required: false }, + apiKey: { type: 'string', required: false } + }, + outputs: { response: 'string' as OutputType }, + subBlocks: [ + { id: 'model', type: 'dropdown' }, + { id: 'systemPrompt', type: 'long-input' }, + { id: 'temperature', type: 'slider' }, + { id: 'responseFormat', type: 'code' } + ] + }, + toolbar: { + title: 'Agent Block', + description: 'Use any LLM', + category: 'basic', + bgColor: '#7F2FFF' + } + } + } else if (type === 'api') { + return { + tools: { + access: ['http.request'], + config: null + }, + workflow: { + inputs: { + url: { type: 'string', required: true }, + method: { type: 'string', required: true } + }, + outputs: { response: 'any' as OutputType }, + subBlocks: [ + { id: 'url', type: 'short-input' }, + { id: 'method', type: 'dropdown' } + ] + }, + toolbar: { + title: 'API Block', + description: 'Make HTTP requests', + category: 'basic', + bgColor: '#00FF00' + } + } + } + return { + tools: { + access: ['test-tool'], + config: null + }, + workflow: { + inputs: {}, + outputs: { response: 'string' as OutputType }, + subBlocks: [] + }, + toolbar: { + title: 'Test Block', + description: 'A test block', + category: 'test', + bgColor: '#000000' + } + } + }) }) describe('serializeWorkflow', () => { @@ -102,9 +131,16 @@ describe('Serializer', () => { id: 'temperature', type: 'slider', value: 0.7 + }, + 'responseFormat': { + id: 'responseFormat', + type: 'code', + value: null } }, - outputType: 'string' + outputs: { + response: 'string' + } }, 'http-1': { id: 'http-1', @@ -123,7 +159,9 @@ describe('Serializer', () => { value: 'GET' } }, - outputType: 'json' + outputs: { + response: 'any' + } } } @@ -151,8 +189,12 @@ describe('Serializer', () => { expect(agentBlock?.config.params).toEqual({ model: 'gpt-4o', systemPrompt: 'You are helpful', - temperature: 0.7 + temperature: 0.7, + responseFormat: null }) + expect(agentBlock?.config.interface.outputs).toEqual({ + response: 'string' + }) // Test http block serialization const httpBlock = serialized.blocks.find(b => b.id === 'http-1') @@ -162,6 +204,9 @@ describe('Serializer', () => { url: 'https://api.example.com', method: 'GET' }) + expect(httpBlock?.config.interface.outputs).toEqual({ + response: 'any' + }) }) it('should handle blocks with minimal required configuration', () => { @@ -178,7 +223,9 @@ describe('Serializer', () => { value: 'gpt-4o' } }, - outputType: 'string' + outputs: { + response: 'string' + } } } @@ -188,6 +235,9 @@ describe('Serializer', () => { expect(block.id).toBe('minimal-1') expect(block.config.tool).toBe('openai.chat') expect(block.config.params).toEqual({ model: 'gpt-4o' }) + expect(block.config.interface.outputs).toEqual({ + response: 'string' + }) }) it('should handle complex workflow with multiple interconnected blocks', () => { @@ -209,7 +259,9 @@ describe('Serializer', () => { value: 'GET' } }, - outputType: 'json' + outputs: { + response: 'any' + } }, 'process-1': { id: 'process-1', @@ -226,61 +278,52 @@ describe('Serializer', () => { id: 'systemPrompt', type: 'long-input', value: 'Process this data' - } - }, - outputType: 'string' - }, - 'output-1': { - id: 'output-1', - type: 'api', - name: 'Data Output', - position: { x: 500, y: 100 }, - subBlocks: { - 'url': { - id: 'url', - type: 'short-input', - value: 'https://api.output.com' }, - 'method': { - id: 'method', - type: 'dropdown', - value: 'POST' + 'responseFormat': { + id: 'responseFormat', + type: 'code', + value: '{ "type": "json" }' } }, - outputType: 'json' + outputs: { + response: 'json' + } } - } + } const connections: Edge[] = [ { id: 'conn-1', source: 'input-1', target: 'process-1', - sourceHandle: 'data', - targetHandle: 'data' - }, - { - id: 'conn-2', - source: 'process-1', - target: 'output-1', - sourceHandle: 'result', - targetHandle: 'body' + sourceHandle: 'response', + targetHandle: 'context' } - ] + ] - const serialized = serializer.serializeWorkflow(blocks, connections) + const serialized = serializer.serializeWorkflow(blocks, connections) // Verify workflow structure - expect(serialized.blocks).toHaveLength(3) - expect(serialized.connections).toHaveLength(2) + expect(serialized.blocks).toHaveLength(2) + expect(serialized.connections).toHaveLength(1) // Verify data flow chain - const conn1 = serialized.connections[0] - const conn2 = serialized.connections[1] - expect(conn1.source).toBe('input-1') - expect(conn1.target).toBe('process-1') - expect(conn2.source).toBe('process-1') - expect(conn2.target).toBe('output-1') + const conn = serialized.connections[0] + expect(conn.source).toBe('input-1') + expect(conn.target).toBe('process-1') + expect(conn.sourceHandle).toBe('response') + expect(conn.targetHandle).toBe('context') + + // Verify block outputs + const inputBlock = serialized.blocks.find(b => b.id === 'input-1') + const processBlock = serialized.blocks.find(b => b.id === 'process-1') + + expect(inputBlock?.config.interface.outputs).toEqual({ + response: 'any' + }) + expect(processBlock?.config.interface.outputs).toEqual({ + response: 'json' + }) }) it('should preserve tool-specific parameters', () => { @@ -307,20 +350,234 @@ describe('Serializer', () => { value: 1000 } }, - outputType: 'string' + outputs: { + response: 'string' + } } - } + } - const serialized = serializer.serializeWorkflow(blocks, []) - const block = serialized.blocks[0] + const serialized = serializer.serializeWorkflow(blocks, []) + const block = serialized.blocks[0] - expect(block.config.tool).toBe('openai.chat') + expect(block.config.tool).toBe('openai.chat') expect(block.config.params).toEqual({ model: 'gpt-4o', temperature: 0.7, maxTokens: 1000 - }) + }) + expect(block.config.interface.outputs).toEqual({ + response: 'string' + }) }) + + it('should serialize a workflow with correct output types', () => { + // Mock block config + ;(getBlock as jest.Mock).mockReturnValue({ + tools: { + access: ['test-tool'], + config: null + }, + workflow: { + inputs: { + input: { type: 'string', required: true } + }, + outputs: { + response: { + type: 'string', + dependsOn: { + subBlockId: 'responseFormat', + condition: { + whenEmpty: 'string', + whenFilled: 'json' + } + } + } + }, + subBlocks: [ + { + id: 'input', + type: 'short-input' + }, + { + id: 'responseFormat', + type: 'code' + } + ] + }, + toolbar: { + title: 'Test Block', + description: 'A test block', + category: 'test', + bgColor: '#000000' + } + }) + + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Agent 1', + position: { x: 0, y: 0 }, + subBlocks: { + input: { + id: 'input', + type: 'short-input', + value: 'test input' + }, + responseFormat: { + id: 'responseFormat', + type: 'code', + value: null + } + }, + outputs: { + response: 'string' + } + } + } + + const edges: Edge[] = [] + + const serialized = serializer.serializeWorkflow(blocks, edges) + + expect(serialized.blocks[0].config.interface.outputs).toEqual({ + response: 'string' + }) + }) + + it('should handle dynamic output types based on subBlock values', () => { + // Mock block config with dynamic output type + ;(getBlock as jest.Mock).mockReturnValue({ + tools: { + access: ['test-tool'], + config: null + }, + workflow: { + inputs: { + input: { type: 'string', required: true } + }, + outputs: { + response: { + type: 'string', + dependsOn: { + subBlockId: 'responseFormat', + condition: { + whenEmpty: 'string', + whenFilled: 'json' + } + } + } + }, + subBlocks: [ + { + id: 'input', + type: 'short-input' + }, + { + id: 'responseFormat', + type: 'code' + } + ] + }, + toolbar: { + title: 'Test Block', + description: 'A test block', + category: 'test', + bgColor: '#000000' + } + }) + + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Agent 1', + position: { x: 0, y: 0 }, + subBlocks: { + input: { + id: 'input', + type: 'short-input', + value: 'test input' + }, + responseFormat: { + id: 'responseFormat', + type: 'code', + value: '{ "format": "json" }' // Non-empty responseFormat + } + }, + outputs: { + response: 'json' as OutputType // Should be json when responseFormat is filled + } + } + } + + const edges: Edge[] = [] + + const serialized = serializer.serializeWorkflow(blocks, edges) + + expect(serialized.blocks[0].config.interface.outputs).toEqual({ + response: 'json' as OutputType + }) + }) + + it('should preserve connection handles during serialization', () => { + // Mock block config + ;(getBlock as jest.Mock).mockReturnValue({ + tools: { + access: ['test-tool'], + config: null + }, + workflow: { + inputs: {}, + outputs: { response: 'string' as OutputType }, + subBlocks: [] + }, + toolbar: { + title: 'Test Block', + description: 'A test block', + category: 'test', + bgColor: '#000000' + } + }) + + const blocks: Record = { + 'block-1': { + id: 'block-1', + type: 'agent', + name: 'Agent 1', + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: { response: 'string' } + }, + 'block-2': { + id: 'block-2', + type: 'api', + name: 'API 1', + position: { x: 200, y: 0 }, + subBlocks: {}, + outputs: { response: 'json' } + } + } + + const edges: Edge[] = [ + { + id: 'edge-1', + source: 'block-1', + target: 'block-2', + sourceHandle: 'response', + targetHandle: 'input' + } + ] + + const serialized = serializer.serializeWorkflow(blocks, edges) + + expect(serialized.connections[0]).toEqual({ + source: 'block-1', + target: 'block-2', + sourceHandle: 'response', + targetHandle: 'input' + }) + }) }) describe('deserializeWorkflow', () => { @@ -335,12 +592,25 @@ describe('Serializer', () => { tool: 'openai.chat', params: { model: 'gpt-4o', - systemPrompt: 'You are helpful' + systemPrompt: 'You are helpful', + responseFormat: null }, interface: { - inputs: { prompt: 'string' }, - outputs: { output: 'string' } + inputs: { + systemPrompt: 'string', + context: 'string', + apiKey: 'string' + }, + outputs: { + response: 'string' + } } + }, + metadata: { + title: 'Agent Block', + description: 'Use any LLM', + category: 'basic', + color: '#7F2FFF' } } ], @@ -353,7 +623,62 @@ describe('Serializer', () => { expect(block.type).toBe('agent') expect(block.subBlocks.model.value).toBe('gpt-4o') expect(block.subBlocks.systemPrompt.value).toBe('You are helpful') - expect(block.outputType).toBe('string') + expect(block.subBlocks.responseFormat.value).toBe(null) + expect(block.outputs).toEqual({ + response: 'string' + }) }) + + it('should deserialize a workflow with correct output types', () => { + // Mock block config + ;(getBlock as jest.Mock).mockReturnValue({ + tools: { + access: ['test-tool'], + config: null + }, + workflow: { + inputs: {}, + outputs: { response: 'string' as OutputType }, + subBlocks: [] + }, + toolbar: { + title: 'Test Block', + description: 'A test block', + category: 'test', + bgColor: '#000000' + } + }) + + const serializedWorkflow: SerializedWorkflow = { + version: '1.0', + blocks: [ + { + id: 'block-1', + position: { x: 0, y: 0 }, + config: { + tool: 'test-tool', + params: {}, + interface: { + inputs: {}, + outputs: { response: 'string' as OutputType } + } + }, + metadata: { + title: 'Test Block', + description: 'A test block', + category: 'test', + color: '#000000' + } + } + ], + connections: [] + } + + const { blocks } = serializer.deserializeWorkflow(serializedWorkflow) + + expect(blocks['block-1'].outputs).toEqual({ + response: 'string' + }) + }) }) }) \ No newline at end of file diff --git a/serializer/index.ts b/serializer/index.ts index f1db28af86..c666973229 100644 --- a/serializer/index.ts +++ b/serializer/index.ts @@ -1,19 +1,19 @@ import { BlockState, SubBlockState } from '@/stores/workflow/types' import { Edge } from 'reactflow' -import { SerializedBlock, SerializedConnection, SerializedWorkflow } from './types' +import { SerializedBlock, SerializedConnection, SerializedWorkflow, BlockConfig, ParamType, OutputType } from './types' import { getBlock, getBlockTypeForTool } from '@/blocks' -import { OutputType, SubBlockType } from '@/blocks/types' +import { resolveOutputType } from '@/blocks/utils' export class Serializer { - serializeWorkflow(blocks: Record, connections: Edge[]): SerializedWorkflow { + serializeWorkflow(blocks: Record, edges: Edge[]): SerializedWorkflow { return { version: '1.0', blocks: Object.values(blocks).map(block => this.serializeBlock(block)), - connections: connections.map(conn => ({ - source: conn.source, - target: conn.target, - sourceHandle: conn.sourceHandle || undefined, - targetHandle: conn.targetHandle || undefined + connections: edges.map(edge => ({ + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle || undefined, + targetHandle: edge.targetHandle || undefined })) } } @@ -21,95 +21,111 @@ export class Serializer { private serializeBlock(block: BlockState): SerializedBlock { const blockConfig = getBlock(block.type) if (!blockConfig) { - throw new Error(`Block configuration not found for type: ${block.type}`) + throw new Error(`Invalid block type: ${block.type}`) } - // Get the tool ID from the block's configuration - const tools = blockConfig.tools - if (!tools?.access || tools.access.length === 0) { - throw new Error(`No tools specified for block type: ${block.type}`) + // Get tool ID from block config + const toolId = blockConfig.tools.config?.tool + ? blockConfig.tools.config.tool(this.extractParams(block)) + : blockConfig.tools.access[0] + + // Extract params from subBlocks + const params = this.extractParams(block) + + // Get input interface from block config + const inputs: Record = {} + + // Map inputs from block config + if (blockConfig.workflow.inputs) { + Object.entries(blockConfig.workflow.inputs).forEach(([key, config]) => { + inputs[key] = config.type as ParamType + }) } - // Get all values from subBlocks - const params: Record = {} - Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => { - if (subBlock?.value !== undefined) { - params[id] = subBlock.value - } - }) - - // Get the tool ID from the block's configuration - const toolId = tools.config?.tool?.(params) || params.tool || tools.access[0] - if (!toolId || !tools.access.includes(toolId)) { - throw new Error(`Invalid or unauthorized tool: ${toolId}`) - } + // Use the block's actual output types + const outputs = block.outputs return { id: block.id, position: block.position, config: { tool: toolId, - params: params, + params, interface: { - inputs: blockConfig.workflow.inputs || {}, - outputs: { - output: block.outputType - } + inputs, + outputs } + }, + metadata: { + title: block.name, + description: blockConfig.toolbar.description, + category: blockConfig.toolbar.category, + color: blockConfig.toolbar.bgColor } } } - deserializeWorkflow(serialized: SerializedWorkflow): { - blocks: Record - connections: Edge[] - } { - const blocks: Record = {} - serialized.blocks.forEach(block => { - const deserialized = this.deserializeBlock(block) - blocks[deserialized.id] = deserialized - }) - - return { - blocks, - connections: serialized.connections.map(conn => ({ - id: `${conn.source}-${conn.target}`, - source: conn.source, - target: conn.target, - sourceHandle: conn.sourceHandle || null, - targetHandle: conn.targetHandle || null - })) - } + private extractParams(block: BlockState): Record { + const params: Record = {} + Object.entries(block.subBlocks).forEach(([id, subBlock]) => { + params[id] = subBlock.value + }) + return params } - private deserializeBlock(serialized: SerializedBlock): BlockState { - const toolId = serialized.config.tool - const blockType = getBlockTypeForTool(toolId) + deserializeWorkflow(workflow: SerializedWorkflow): { blocks: Record, edges: Edge[] } { + const blocks: Record = {} + const edges: Edge[] = [] + // Deserialize blocks + workflow.blocks.forEach(serializedBlock => { + const block = this.deserializeBlock(serializedBlock) + blocks[block.id] = block + }) + + // Deserialize connections + workflow.connections.forEach(connection => { + edges.push({ + id: crypto.randomUUID(), + source: connection.source, + target: connection.target, + sourceHandle: connection.sourceHandle, + targetHandle: connection.targetHandle + }) + }) + + return { blocks, edges } + } + + private deserializeBlock(serializedBlock: SerializedBlock): BlockState { + const blockType = getBlockTypeForTool(serializedBlock.config.tool) if (!blockType) { - throw new Error(`Could not determine block type for tool: ${toolId}`) + throw new Error(`Invalid tool ID: ${serializedBlock.config.tool}`) } - const blockConfig = getBlock(blockType) + const blockConfig = getBlock(blockType) if (!blockConfig) { - throw new Error(`Block configuration not found for type: ${blockType}`) + throw new Error(`Invalid block type: ${blockType}`) } + const subBlocks: Record = {} + blockConfig.workflow.subBlocks.forEach(subBlock => { + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: serializedBlock.config.params[subBlock.id] ?? null + } + }) + + const outputs = resolveOutputType(blockConfig.workflow.outputs, subBlocks) + return { - id: serialized.id, + id: serializedBlock.id, type: blockType, - name: `${blockType} Block`, - position: serialized.position, - subBlocks: Object.entries(serialized.config.params).reduce((acc, [key, value]) => { - const subBlock = blockConfig.workflow.subBlocks?.find(sb => sb.id === key) - acc[key] = { - id: key, - type: subBlock?.type || 'short-input', - value: value - } - return acc - }, {} as Record), - outputType: serialized.config.interface.outputs.output as OutputType - } + name: serializedBlock.metadata?.title || blockConfig.toolbar.title, + position: serializedBlock.position, + subBlocks, + outputs + } } } \ No newline at end of file diff --git a/serializer/types.ts b/serializer/types.ts index 8499126b9b..c177612311 100644 --- a/serializer/types.ts +++ b/serializer/types.ts @@ -1,3 +1,6 @@ +export type ParamType = 'string' | 'number' | 'boolean' | 'json' +export type OutputType = 'string' | 'number' | 'json' | 'boolean' | 'any' + export interface SerializedWorkflow { version: string blocks: SerializedBlock[] @@ -20,8 +23,8 @@ export interface BlockConfig { tool: string params: Record interface: { - inputs: Record - outputs: Record + inputs: Record + outputs: Record } } diff --git a/stores/workflow/types.ts b/stores/workflow/types.ts index 2586419193..9cf2b28333 100644 --- a/stores/workflow/types.ts +++ b/stores/workflow/types.ts @@ -12,8 +12,9 @@ export interface BlockState { name: string position: Position subBlocks: Record - outputType: OutputType + outputs: Record } + export interface SubBlockState { id: string type: SubBlockType diff --git a/stores/workflow/workflow-store.ts b/stores/workflow/workflow-store.ts index 10ac3852d1..2f8edf92fa 100644 --- a/stores/workflow/workflow-store.ts +++ b/stores/workflow/workflow-store.ts @@ -42,13 +42,13 @@ export const useWorkflowStore = create()( ...block.subBlocks, [subBlockId]: { ...block.subBlocks[subBlockId], - value, + value }, } - // Resolve new output type - const newOutputType = resolveOutputType( - blockConfig.workflow.outputType, + // Resolve new outputs + const newOutputs = resolveOutputType( + blockConfig.workflow.outputs, newSubBlocks ) @@ -58,7 +58,7 @@ export const useWorkflowStore = create()( [blockId]: { ...block, subBlocks: newSubBlocks, - outputType: newOutputType, + outputs: newOutputs, }, }, } @@ -69,9 +69,9 @@ export const useWorkflowStore = create()( const blockConfig = getBlock(type) if (!blockConfig) return - const subBlocks: Record = {} + const subBlocks: Record = {} blockConfig.workflow.subBlocks.forEach((subBlock) => { - const subBlockId = subBlock.id || crypto.randomUUID() + const subBlockId = subBlock.id subBlocks[subBlockId] = { id: subBlockId, type: subBlock.type, @@ -79,6 +79,8 @@ export const useWorkflowStore = create()( } }) + const outputs = resolveOutputType(blockConfig.workflow.outputs, subBlocks) + const newState = { blocks: { ...get().blocks, @@ -88,10 +90,7 @@ export const useWorkflowStore = create()( name, position, subBlocks, - outputType: - typeof blockConfig.workflow.outputType === 'string' - ? blockConfig.workflow.outputType - : blockConfig.workflow.outputType.default, + outputs, }, }, edges: [...get().edges], @@ -136,6 +135,8 @@ export const useWorkflowStore = create()( id: edge.id || crypto.randomUUID(), source: edge.source, target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle }, ], }