Standardized output format for blocks/tools. Updated executor so we can now resolve sub-json values for tagged inputs. Updated serializer to match new block output format.

This commit is contained in:
Waleed Latif
2025-01-30 13:50:38 -08:00
parent 15b42c7d19
commit 3850c112ca
31 changed files with 798 additions and 775 deletions
+270 -122
View File
@@ -1,7 +1,8 @@
import { Executor } from '../index'
import { SerializedWorkflow } from '@/serializer/types'
import { Tool } from '../types'
import { tools } from '@/tools'
import { tools } from '@/tools'
import { BlockOutput, ValueType } from '@/blocks/types'
// Mock tools
const createMockTool = (
@@ -41,7 +42,13 @@ const createMockTool = (
...(params.optionalParam !== undefined ? { optionalParam: params.optionalParam } : {})
})
},
transformResponse: () => mockResponse,
transformResponse: async () => ({
success: true,
output: {
text: mockResponse.result,
...mockResponse.data
}
}),
transformError: () => mockError || 'Mock error'
})
@@ -60,7 +67,7 @@ describe('Executor', () => {
const mockTool = createMockTool(
'test-tool',
'Test Tool',
{ result: 'test processed' }
{ result: 'test processed', data: { status: 200 } }
);
(tools as any)['test-tool'] = mockTool
@@ -71,21 +78,32 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: { input: 'test' },
interface: {
inputs: { input: 'string' },
outputs: { result: 'string' }
}
params: { input: 'test' }
},
inputs: { input: 'string' },
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}],
connections: []
}
}
// Mock fetch
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ result: 'test processed' })
json: () => Promise.resolve({
success: true,
output: {
text: 'test processed',
status: 200
}
})
})
)
@@ -93,7 +111,12 @@ describe('Executor', () => {
const result = await executor.execute('workflow-1')
expect(result.success).toBe(true)
expect(result.data).toEqual({ result: 'test processed' })
expect(result.output).toEqual({
response: {
text: 'test processed',
status: 200
}
})
expect(global.fetch).toHaveBeenCalledWith(
'https://api.test.com/endpoint',
expect.objectContaining({
@@ -111,7 +134,7 @@ describe('Executor', () => {
const mockTool = createMockTool(
'test-tool',
'Test Tool',
{ result: 'test processed' },
{ result: 'test processed', data: { status: 200 } },
undefined,
{
optionalParam: {
@@ -130,11 +153,16 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: { input: 'test' },
interface: {
inputs: { input: 'string' },
outputs: { result: 'string' }
}
params: { input: 'test' }
},
inputs: { input: 'string' },
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}],
connections: []
@@ -143,7 +171,13 @@ describe('Executor', () => {
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ result: 'test processed' })
json: () => Promise.resolve({
success: true,
output: {
text: 'test processed',
status: 200
}
})
})
)
@@ -171,7 +205,7 @@ describe('Executor', () => {
const mockTool = createMockTool(
'test-tool',
'Test Tool',
{ result: 'test processed' }
{ result: 'test processed', data: { status: 200 } }
);
(tools as any)['test-tool'] = mockTool
@@ -182,15 +216,20 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: {}, // Missing required 'input' parameter
interface: {
inputs: {},
outputs: { result: 'string' }
}
params: {} // Missing required 'input' parameter
},
inputs: {},
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}],
connections: []
}
}
const executor = new Executor(workflow)
const result = await executor.execute('workflow-1')
@@ -206,7 +245,7 @@ describe('Executor', () => {
{},
'API Error'
);
(tools as any)['test-tool'] = mockTool
(tools as any)['test-tool'] = mockTool
const workflow: SerializedWorkflow = {
version: '1.0',
@@ -215,15 +254,20 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: { input: 'test' },
interface: {
inputs: { input: 'string' },
outputs: { result: 'string' }
}
params: { input: 'test' }
},
inputs: { input: 'string' },
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}],
connections: []
}
}
// Mock fetch to fail
global.fetch = jest.fn().mockImplementation(() =>
@@ -231,22 +275,22 @@ describe('Executor', () => {
ok: false,
json: () => Promise.resolve({ error: 'API Error' })
})
)
)
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.success).toBe(false)
expect(result.error).toContain('API Error')
})
})
})
})
describe('Interface Validation', () => {
it('should validate input types', async () => {
const mockTool = createMockTool(
'test-tool',
'Test Tool',
{ result: 123 },
{ result: 123, data: { status: 200 } },
'Invalid type for input'
);
(tools as any)['test-tool'] = mockTool
@@ -258,11 +302,16 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: { input: 42 }, // Wrong type for input
interface: {
inputs: { input: 'string' },
outputs: { result: 'number' }
}
params: { input: 42 } // Wrong type for input
},
inputs: { input: 'string' },
outputs: {
output: {
response: {
text: 'number',
status: 'number'
} as ValueType
} as BlockOutput
}
}],
connections: []
@@ -291,11 +340,16 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: { input: 'test' },
interface: {
inputs: { input: 'string' },
outputs: { result: 'string' }
}
params: { input: 'test' }
},
inputs: { input: 'string' },
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}],
connections: []
@@ -322,12 +376,12 @@ describe('Executor', () => {
const mockTool1 = createMockTool(
'tool-1',
'Tool 1',
{ response: 'test data' }
{ result: 'test data', data: { status: 200 } }
);
const mockTool2 = createMockTool(
'tool-2',
'Tool 2',
{ response: 'processed data' }
{ result: 'processed data', data: { status: 201 } }
);
(tools as any)['tool-1'] = mockTool1;
(tools as any)['tool-2'] = mockTool2;
@@ -340,11 +394,16 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'tool-1',
params: { input: 'initial' },
interface: {
inputs: {},
outputs: { response: 'string' }
}
params: { input: 'initial' }
},
inputs: {},
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
},
{
@@ -353,16 +412,28 @@ describe('Executor', () => {
config: {
tool: 'tool-2',
params: {
input: '<block1.string>'
},
interface: {
inputs: { input: 'string' },
outputs: { response: 'string' }
input: '<block1.output.response.text>'
}
},
inputs: { input: 'string' },
outputs: {
output: {
response: {
text: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}
],
connections: []
connections: [
{
source: 'block1',
target: 'block2',
sourceHandle: 'output.response.text',
targetHandle: 'input'
}
]
};
// Mock fetch for both tools
@@ -370,13 +441,25 @@ describe('Executor', () => {
.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ response: 'test data' })
json: () => Promise.resolve({
success: true,
output: {
text: 'test data',
status: 200
}
})
})
)
.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ response: 'processed data' })
json: () => Promise.resolve({
success: true,
output: {
text: 'processed data',
status: 201
}
})
})
);
@@ -384,7 +467,12 @@ describe('Executor', () => {
const result = await executor.execute('workflow-1');
expect(result.success).toBe(true);
expect(result.data).toEqual({ response: 'processed data' });
expect(result.output).toEqual({
response: {
text: 'processed data',
status: 201
}
});
expect(global.fetch).toHaveBeenCalledTimes(2);
});
@@ -397,11 +485,15 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: {},
interface: {
inputs: {},
outputs: {}
}
params: {}
},
inputs: {},
outputs: {
output: {
response: {
text: 'string'
} as ValueType
} as BlockOutput
}
},
{
@@ -409,11 +501,15 @@ describe('Executor', () => {
position: { x: 200, y: 0 },
config: {
tool: 'test-tool',
params: {},
interface: {
inputs: {},
outputs: {}
}
params: {}
},
inputs: {},
outputs: {
output: {
response: {
text: 'string'
} as ValueType
} as BlockOutput
}
}
],
@@ -421,13 +517,13 @@ describe('Executor', () => {
{
source: 'block-1',
target: 'block-2',
sourceHandle: 'output',
sourceHandle: 'output.response.text',
targetHandle: 'input'
},
{
source: 'block-2',
target: 'block-1',
sourceHandle: 'output',
sourceHandle: 'output.response.text',
targetHandle: 'input'
}
]
@@ -469,14 +565,18 @@ describe('Executor', () => {
'Authorization': `Bearer ${params.apiKey}`
}),
body: (params) => ({
model: 'gpt-4',
model: 'gpt-4o',
messages: [
{ role: 'system', content: params.systemPrompt }
]
})
},
transformResponse: async () => ({
response: 'https://api.example.com/data'
success: true,
output: {
text: 'https://api.example.com/data',
model: 'gpt-4o'
}
}),
transformError: () => 'OpenAI error'
};
@@ -506,7 +606,11 @@ describe('Executor', () => {
body: (params) => ({ code: params.code, url: params.url })
},
transformResponse: async () => ({
response: { method: 'GET', headers: { 'Accept': 'application/json' } }
success: true,
output: {
method: 'GET',
headers: { 'Accept': 'application/json' }
}
}),
transformError: () => 'Function execution error'
};
@@ -536,7 +640,11 @@ describe('Executor', () => {
body: undefined
},
transformResponse: async () => ({
response: { status: 200, data: { message: 'Success!' } }
success: true,
output: {
message: 'Success!',
status: 200
}
}),
transformError: () => 'HTTP request error'
};
@@ -556,16 +664,19 @@ describe('Executor', () => {
params: {
systemPrompt: 'Generate an API endpoint',
apiKey: 'test-key'
},
interface: {
inputs: {
systemPrompt: 'string',
apiKey: 'string'
},
outputs: {
response: 'string'
}
}
},
inputs: {
systemPrompt: 'string',
apiKey: 'string'
},
outputs: {
output: {
response: {
text: 'string',
model: 'string'
} as ValueType
} as BlockOutput
}
},
{
@@ -575,17 +686,20 @@ describe('Executor', () => {
tool: 'function.execute',
params: {
code: 'return { method: "GET", headers: { "Accept": "application/json" } }',
url: '<agent1.string>'
},
interface: {
inputs: {
code: 'string',
url: 'string'
},
outputs: {
response: 'any'
}
url: '<agent1.output.response.text>'
}
},
inputs: {
code: 'string',
url: 'string'
},
outputs: {
output: {
response: {
method: 'string',
headers: 'json'
} as ValueType
} as BlockOutput
}
},
{
@@ -594,31 +708,54 @@ describe('Executor', () => {
config: {
tool: 'http.request',
params: {
url: '<agent1.string>',
method: '<function1.res>'
},
interface: {
inputs: {
url: 'string',
method: 'string'
},
outputs: {
response: 'any'
}
url: '<agent1.output.response.text>',
method: '<function1.output.response.method>'
}
},
inputs: {
url: 'string',
method: 'string'
},
outputs: {
output: {
response: {
message: 'string',
status: 'number'
} as ValueType
} as BlockOutput
}
}
],
connections: []
connections: [
{
source: 'agent1',
target: 'function1',
sourceHandle: 'output.response.text',
targetHandle: 'url'
},
{
source: 'function1',
target: 'api1',
sourceHandle: 'output.response.method',
targetHandle: 'method'
}
]
};
// Mock fetch responses
// Mock fetch responses with sequential data flow
const apiEndpoint = 'https://api.example.com/data';
const requestMethod = 'GET';
global.fetch = jest.fn()
.mockImplementationOnce(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
response: 'https://api.example.com/data'
success: true,
output: {
text: apiEndpoint,
model: 'gpt-4o'
}
})
})
)
@@ -626,7 +763,11 @@ describe('Executor', () => {
Promise.resolve({
ok: true,
json: () => Promise.resolve({
response: { method: 'GET', headers: { 'Accept': 'application/json' } }
success: true,
output: {
method: requestMethod,
headers: { 'Accept': 'application/json' }
}
})
})
)
@@ -634,7 +775,11 @@ describe('Executor', () => {
Promise.resolve({
ok: true,
json: () => Promise.resolve({
response: { status: 200, data: { message: 'Success!' } }
success: true,
output: {
message: 'Success!',
status: 200
}
})
})
);
@@ -643,8 +788,11 @@ describe('Executor', () => {
const result = await executor.execute('test-workflow');
expect(result.success).toBe(true);
expect(result.data).toEqual({
response: { status: 200, data: { message: 'Success!' } }
expect(result.output).toEqual({
response: {
message: 'Success!',
status: 200
}
});
// Verify the execution order and data flow
@@ -653,7 +801,7 @@ describe('Executor', () => {
// First call - Agent generates API endpoint
expect(JSON.parse(fetchCalls[0][1].body)).toEqual({
model: 'gpt-4',
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'Generate an API endpoint' }
]
@@ -662,12 +810,12 @@ describe('Executor', () => {
// Second call - Function processes the URL
expect(JSON.parse(fetchCalls[1][1].body)).toEqual({
code: 'return { method: "GET", headers: { "Accept": "application/json" } }',
url: 'https://api.example.com/data'
url: "<agent1.output.response.text>" // Should be resolved value from first call
});
// Third call - API makes the request
expect(fetchCalls[2][0]).toBe('https://api.example.com/data');
expect(fetchCalls[2][1].method).toBe('GET');
expect(fetchCalls[2][0]).toBe("<agent1.output.response.text>"); // Should be resolved value from first call
expect(fetchCalls[2][1].method).toBe("<function1.output.response.method>"); // Should be resolved value from second call
});
});
})
+98 -42
View File
@@ -1,18 +1,19 @@
import { SerializedWorkflow, SerializedBlock } from '@/serializer/types'
import { ExecutionContext, ExecutionResult, Tool } from './types'
import { tools } from '@/tools'
import { BlockOutput } from '@/blocks/types'
export class Executor {
constructor(
private workflow: SerializedWorkflow,
private initialBlockStates: Record<string, any> = {}
private initialBlockStates: Record<string, BlockOutput> = {}
) {}
private async executeBlock(
block: SerializedBlock,
inputs: Record<string, any>,
context: ExecutionContext
): Promise<Record<string, any>> {
): Promise<BlockOutput> {
const toolId = block.config.tool
if (!toolId) throw new Error(`Block ${block.id} does not specify a tool`)
@@ -38,7 +39,16 @@ export class Executor {
const error = await response.json().catch(() => ({ message: response.statusText }))
throw new Error(tool.transformError(error))
}
return await tool.transformResponse(response)
const result = await tool.transformResponse(response)
if (!result.success) {
throw new Error(tool.transformError(result))
}
return {
response: result.output
}
} catch (error) {
throw new Error(`Tool ${toolId} execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
@@ -89,57 +99,97 @@ export class Executor {
private resolveInputs(block: SerializedBlock, context: ExecutionContext): Record<string, any> {
const inputs = { ...block.config.params }
const blockNameMap = new Map(
this.workflow.blocks.map(b => {
const title = b.metadata?.title || '';
const normalizedName = title.toLowerCase().replace(/\s+/g, '');
return [normalizedName, b.id];
})
);
const blockStateMap = new Map(
Object.entries(this.initialBlockStates)
.filter(([_, state]) => state !== undefined)
// Create maps for both ID and name lookups
const blockById = new Map(
this.workflow.blocks.map(b => [b.id, b])
)
const blockByName = new Map(
this.workflow.blocks.map(b => [
b.metadata?.title?.toLowerCase().replace(/\s+/g, '') || '',
b
])
)
const connectionPattern = /<([^>]+)\.(string|number|boolean|res|any)>/g
return Object.entries(block.config.params || {}).reduce((acc, [key, value]) => {
const resolvedInputs = Object.entries(inputs).reduce((acc, [key, value]) => {
if (typeof value === 'string') {
let resolvedValue = value
Array.from(value.matchAll(connectionPattern)).forEach(match => {
const [fullMatch, blockName, type] = match
const matches = value.match(/<([^>]+)>/g)
if (matches) {
let resolvedValue = value
// Try both the original format and normalized format
const normalizedBlockName = blockName.toLowerCase().replace(/\s+/g, '');
const blockId = blockNameMap.get(normalizedBlockName);
if (!blockId) {
return;
}
const sourceOutput = context.blockStates.get(blockId) || blockStateMap.get(blockId)
if (sourceOutput) {
const replacementValue = type === 'res'
? sourceOutput.response
: (sourceOutput.output || sourceOutput.response)
matches.forEach(match => {
const path = match.slice(1, -1) // Remove < and >
const [blockRef, ...pathParts] = path.split('.')
if (replacementValue !== undefined) {
resolvedValue = resolvedValue.replace(fullMatch, replacementValue.toString())
// Try to find block by ID first, then by normalized name
let sourceBlock = blockById.get(blockRef)
if (!sourceBlock) {
const normalizedName = blockRef.toLowerCase().replace(/\s+/g, '')
sourceBlock = blockByName.get(normalizedName)
}
if (!sourceBlock) {
console.warn(`Block ${blockRef} not found by ID or name`)
return
}
const sourceState = context.blockStates.get(sourceBlock.id)
if (!sourceState) {
console.warn(`No state found for block ${sourceBlock.id}`)
return
}
// Start with the block's state
let replacementValue: any = sourceState
// Traverse the path parts to get the final value
for (const part of pathParts) {
if (!replacementValue || typeof replacementValue !== 'object') {
console.warn(`Invalid path part ${part} in ${path}`)
return
}
replacementValue = replacementValue[part]
}
if (replacementValue !== undefined) {
// Replace the entire template expression with the resolved value
resolvedValue = resolvedValue.replace(match,
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: String(replacementValue)
)
} else {
console.warn(`No value found at path ${path}`)
}
})
// Try to parse the value if it looks like JSON
try {
if (resolvedValue.startsWith('{') || resolvedValue.startsWith('[')) {
acc[key] = JSON.parse(resolvedValue)
} else {
acc[key] = resolvedValue
}
} catch {
acc[key] = resolvedValue
}
})
acc[key] = resolvedValue
} else {
acc[key] = value
}
} else {
acc[key] = value
}
return acc
}, inputs)
}, {} as Record<string, any>)
return resolvedInputs
}
async execute(workflowId: string): Promise<ExecutionResult> {
const startTime = new Date()
const context: ExecutionContext = {
workflowId,
blockStates: new Map(),
@@ -153,14 +203,20 @@ export class Executor {
const block = this.workflow.blocks.find(b => b.id === blockId)
if (!block) throw new Error(`Block ${blockId} not found in workflow`)
const result = await this.executeBlock(block, this.resolveInputs(block, context), context)
context.blockStates.set(blockId, result)
const output = await this.executeBlock(block, this.resolveInputs(block, context), context)
context.blockStates.set(blockId, output)
}
const endTime = new Date()
const lastOutput = context.blockStates.get(executionOrder[executionOrder.length - 1])
if (!lastOutput) {
throw new Error('No output from workflow execution')
}
return {
success: true,
data: context.blockStates.get(executionOrder[executionOrder.length - 1]) || {},
output: lastOutput,
metadata: {
duration: endTime.getTime() - startTime.getTime(),
startTime: startTime.toISOString(),
@@ -170,7 +226,7 @@ export class Executor {
} catch (error) {
return {
success: false,
data: {},
output: { response: {} },
error: error instanceof Error ? error.message : 'Unknown error'
}
}
+10 -5
View File
@@ -1,4 +1,6 @@
export interface Tool<P = any, R = any> {
import { BlockOutput } from '@/blocks/types'
export interface Tool<P = any, O = Record<string, any>> {
id: string
name: string
description: string
@@ -17,7 +19,11 @@ export interface Tool<P = any, R = any> {
headers: (params: P) => Record<string, string>
body?: (params: P) => Record<string, any>
}
transformResponse: (response: any) => R
transformResponse: (response: any) => Promise<{
success: boolean
output: O
error?: string
}>
transformError: (error: any) => string
}
@@ -27,14 +33,13 @@ export interface ToolRegistry {
export interface ExecutionContext {
workflowId: string
blockStates: Map<string, any>
input?: Record<string, any>
blockStates: Map<string, BlockOutput>
metadata?: Record<string, any>
}
export interface ExecutionResult {
success: boolean
data: Record<string, any>
output: BlockOutput
error?: string
metadata?: {
duration: number