Modified tools directory to be more generic, updated serializer and executor accordingly

This commit is contained in:
Waleed Latif
2025-01-17 19:08:40 -08:00
parent 85a2528e4d
commit 07cb295200
30 changed files with 1234 additions and 1194 deletions

View File

@@ -271,23 +271,13 @@ function WorkflowCanvas() {
* @returns {Object} The initial input parameters for the block
*/
const getInitialInput = (block: BlockState, blockConfig: BlockConfig) => {
if (block.type === 'agent') {
return {
model: block.subBlocks?.['model']?.value || 'gpt-4o',
systemPrompt: block.subBlocks?.['systemPrompt']?.value,
temperature: block.subBlocks?.['temperature']?.value,
apiKey: block.subBlocks?.['apiKey']?.value,
prompt: block.subBlocks?.['systemPrompt']?.value,
const params: Record<string, any> = {};
Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => {
if (subBlock?.value !== undefined) {
params[id] = subBlock.value;
}
} else if (block.type === 'api') {
return {
url: block.subBlocks?.['url']?.value,
method: block.subBlocks?.['method']?.value,
headers: block.subBlocks?.['headers']?.value,
body: block.subBlocks?.['body']?.value,
}
}
return {}
});
return params;
}
/**
@@ -302,35 +292,40 @@ function WorkflowCanvas() {
throw new Error(`Block configuration not found for type: ${block.type}`)
}
const tools = blockConfig.workflow.tools
if (!tools || !tools.access || tools.access.length === 0) {
throw new Error(`No tools specified for block type: ${block.type}`)
const tools = blockConfig.workflow.tools;
if (!tools?.access || tools.access.length === 0) {
throw new Error(`No tools specified for block type: ${block.type}`);
}
// Get the values from subBlocks
const params: Record<string, any> = {}
// Get all values from subBlocks
const params: Record<string, any> = {};
Object.entries(block.subBlocks || {}).forEach(([id, subBlock]) => {
if (subBlock) {
params[id] = subBlock.value
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;
if (!toolId || !tools.access.includes(toolId)) {
throw new Error(`Invalid or unauthorized tool: ${toolId}`);
}
return {
id: block.id,
type: 'custom',
position: block.position,
data: {
tool: tools.access[0],
params,
tool: toolId,
params: params,
interface: {
inputs: block.type === 'agent' ? { prompt: 'string' } : {},
inputs: blockConfig.workflow.inputs || {},
outputs: {
[block.type === 'agent' ? 'response' : 'output']:
typeof blockConfig.workflow.outputType === 'string'
? blockConfig.workflow.outputType
: blockConfig.workflow.outputType.default,
},
},
output: typeof blockConfig.workflow.outputType === 'string'
? blockConfig.workflow.outputType
: blockConfig.workflow.outputType.default
}
}
},
}
}

View File

@@ -1,6 +1,15 @@
import { AgentIcon } from '@/components/icons'
import { BlockConfig } from '../types'
// Map of models to their tools
const MODEL_TOOLS = {
'gpt-4o': 'openai.chat',
'o1-mini': 'openai.chat',
'claude-3-5-sonnet-20241022': 'anthropic.chat',
'gemini-pro': 'google.chat',
'grok-2-latest': 'xai.chat'
} as const;
export const AgentBlock: BlockConfig = {
type: 'agent',
toolbar: {
@@ -22,7 +31,23 @@ export const AgentBlock: BlockConfig = {
}
},
tools: {
access: ['model']
access: ['openai.chat', 'anthropic.chat', 'google.chat', 'xai.chat'],
config: {
tool: (params: Record<string, any>) => {
const model = params.model || 'gpt-4o';
if (!model) {
throw new Error('No model selected');
}
const tool = MODEL_TOOLS[model as keyof typeof MODEL_TOOLS];
if (!tool) {
throw new Error(`Invalid model selected: ${model}`);
}
return tool;
}
}
},
inputs: {
systemPrompt: 'string'
},
subBlocks: [
{
@@ -37,14 +62,14 @@ export const AgentBlock: BlockConfig = {
title: 'Context',
type: 'short-input',
layout: 'full',
placeholder: 'Enter text',
placeholder: 'Enter text'
},
{
id: 'model',
title: 'Model',
type: 'dropdown',
layout: 'half',
options: ['gpt-4o', 'gemini-pro', 'claude-3-5-sonnet-20241022', 'grok-2-latest', 'deepseek-v3'],
options: Object.keys(MODEL_TOOLS)
},
{
id: 'temperature',

View File

@@ -13,7 +13,13 @@ export const ApiBlock: BlockConfig = {
workflow: {
outputType: 'json',
tools: {
access: ['http']
access: ['http.request']
},
inputs: {
url: 'string',
method: 'string',
headers: 'json',
body: 'json'
},
subBlocks: [
{

View File

@@ -15,6 +15,9 @@ export const FunctionBlock: BlockConfig = {
tools: {
access: ['function']
},
inputs: {
code: 'string'
},
subBlocks: [
{
id: 'code',

View File

@@ -4,6 +4,7 @@ import type { JSX } from 'react'
export type BlockIcon = (props: SVGProps<SVGSVGElement>) => JSX.Element
export type BlockCategory = 'basic' | 'advanced'
export type OutputType = 'string' | 'number' | 'json' | 'boolean'
export type ParamType = 'string' | 'number' | 'boolean' | 'json'
export type SubBlockType = 'short-input' | 'long-input' | 'dropdown' | 'slider' | 'table' | 'code'
export type SubBlockLayout = 'full' | 'half'
@@ -44,9 +45,12 @@ export interface BlockConfig {
workflow: {
outputType: OutputTypeConfig
subBlocks: SubBlockConfig[]
tools?: {
tools: {
access: string[]
config?: Record<string, any>
config?: {
tool: (params: Record<string, any>) => string
}
}
inputs?: Record<string, ParamType>
}
}

View File

@@ -1,38 +1,60 @@
import { Executor } from '../index';
import { SerializedWorkflow } from '@/serializer/types';
import { Tool } from '../types';
import { toolRegistry } from '@/tools/registry';
import { tools } from '@/tools/registry';
// Mock tools
class MockTool implements Tool {
constructor(
public name: string,
private mockExecute: (params: Record<string, any>) => Promise<Record<string, any>>,
private mockValidate: (params: Record<string, any>) => boolean | string = () => true
) {}
async execute(params: Record<string, any>): Promise<Record<string, any>> {
return this.mockExecute(params);
}
validateParams(params: Record<string, any>): boolean | string {
return this.mockValidate(params);
}
}
const createMockTool = (
id: string,
name: string,
mockResponse: any,
mockError?: string
): Tool => ({
id,
name,
description: 'Mock tool for testing',
version: '1.0.0',
params: {
input: {
type: 'string',
required: true,
description: 'Input to process'
},
apiKey: {
type: 'string',
required: false,
description: 'API key for authentication'
}
},
request: {
url: 'https://api.test.com/endpoint',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'Authorization': params.apiKey || 'test-key'
}),
body: (params) => ({
input: params.input
})
},
transformResponse: () => mockResponse,
transformError: () => mockError || 'Mock error'
});
describe('Executor', () => {
beforeEach(() => {
// Reset toolRegistry mock
(toolRegistry as any) = {};
// Reset tools mock
(tools as any) = {};
});
describe('Tool Execution', () => {
it('should execute a simple workflow with one tool', async () => {
const mockTool = new MockTool(
const mockTool = createMockTool(
'test-tool',
async (params) => ({ result: params.input + ' processed' })
'Test Tool',
{ result: 'test processed' }
);
(toolRegistry as any)['test-tool'] = mockTool;
(tools as any)['test-tool'] = mockTool;
const workflow: SerializedWorkflow = {
version: '1.0',
@@ -41,7 +63,7 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: {},
params: { input: 'test' },
interface: {
inputs: { input: 'string' },
outputs: { result: 'string' }
@@ -51,20 +73,39 @@ describe('Executor', () => {
connections: []
};
// Mock fetch
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', { input: 'test' });
expect(result.success).toBe(true);
expect(result.data).toEqual({ result: 'test processed' });
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' })
})
);
});
it('should validate tool parameters', async () => {
const mockTool = new MockTool(
it('should validate required parameters', async () => {
const mockTool = createMockTool(
'test-tool',
async () => ({}),
(params) => params.required ? true : 'Missing required parameter'
'Test Tool',
{ result: 'test processed' }
);
(toolRegistry as any)['test-tool'] = mockTool;
(tools as any)['test-tool'] = mockTool;
const workflow: SerializedWorkflow = {
version: '1.0',
@@ -73,10 +114,10 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: {},
params: {}, // Missing required 'input' parameter
interface: {
inputs: {},
outputs: {}
outputs: { result: 'string' }
}
}
}],
@@ -89,15 +130,15 @@ describe('Executor', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('Missing required parameter');
});
});
describe('Interface Validation', () => {
it('should validate input types', async () => {
const mockTool = new MockTool(
it('should handle tool execution errors', async () => {
const mockTool = createMockTool(
'test-tool',
async (params) => ({ result: params.input })
'Test Tool',
{},
'API Error'
);
(toolRegistry as any)['test-tool'] = mockTool;
(tools as any)['test-tool'] = mockTool;
const workflow: SerializedWorkflow = {
version: '1.0',
@@ -106,9 +147,51 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: {},
params: { input: 'test' },
interface: {
inputs: { input: 'number' },
inputs: { input: 'string' },
outputs: { result: 'string' }
}
}
}],
connections: []
};
// Mock fetch to fail
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: false,
json: () => Promise.resolve({ error: 'API Error' })
})
);
const executor = new Executor(workflow);
const result = await executor.execute('workflow-1', { input: 'test' });
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 }
);
(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: 42 }, // Wrong type for input
interface: {
inputs: { input: 'string' },
outputs: { result: 'number' }
}
}
@@ -117,18 +200,19 @@ describe('Executor', () => {
};
const executor = new Executor(workflow);
const result = await executor.execute('workflow-1', { input: 'not a number' });
const result = await executor.execute('workflow-1', { input: 42 });
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid type for input');
});
it('should validate tool output against interface', async () => {
const mockTool = new MockTool(
const mockTool = createMockTool(
'test-tool',
async () => ({ wrongField: 'wrong type' })
'Test Tool',
{ wrongField: 'wrong type' }
);
(toolRegistry as any)['test-tool'] = mockTool;
(tools as any)['test-tool'] = mockTool;
const workflow: SerializedWorkflow = {
version: '1.0',
@@ -137,9 +221,9 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'test-tool',
params: {},
params: { input: 'test' },
interface: {
inputs: {},
inputs: { input: 'string' },
outputs: { result: 'string' }
}
}
@@ -147,8 +231,16 @@ describe('Executor', () => {
connections: []
};
// Mock fetch
global.fetch = jest.fn().mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ wrongField: 'wrong type' })
})
);
const executor = new Executor(workflow);
const result = await executor.execute('workflow-1', {});
const result = await executor.execute('workflow-1', { input: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Tool output missing required field');
@@ -157,16 +249,18 @@ describe('Executor', () => {
describe('Complex Workflows', () => {
it('should execute a workflow with multiple connected blocks', async () => {
const processorTool = new MockTool(
const processorTool = createMockTool(
'processor',
async (params) => ({ processed: params.input.toUpperCase() })
'Processor Tool',
{ processed: 'TEST' }
);
const formatterTool = new MockTool(
const formatterTool = createMockTool(
'formatter',
async (params) => ({ result: `<${params.processed}>` })
'Formatter Tool',
{ result: '<TEST>' }
);
(toolRegistry as any)['processor'] = processorTool;
(toolRegistry as any)['formatter'] = formatterTool;
(tools as any)['processor'] = processorTool;
(tools as any)['formatter'] = formatterTool;
const workflow: SerializedWorkflow = {
version: '1.0',
@@ -176,7 +270,7 @@ describe('Executor', () => {
position: { x: 0, y: 0 },
config: {
tool: 'processor',
params: {},
params: { input: 'test' },
interface: {
inputs: { input: 'string' },
outputs: { processed: 'string' }
@@ -190,7 +284,7 @@ describe('Executor', () => {
tool: 'formatter',
params: {},
interface: {
inputs: { processed: 'string' },
inputs: { input: 'string' },
outputs: { result: 'string' }
}
}
@@ -200,73 +294,27 @@ describe('Executor', () => {
source: 'process',
target: 'format',
sourceHandle: 'processed',
targetHandle: 'processed'
targetHandle: 'input'
}]
};
// Mock fetch for both tools
global.fetch = jest.fn()
.mockImplementationOnce(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ processed: 'TEST' })
}))
.mockImplementationOnce(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ result: '<TEST>' })
}));
const executor = new Executor(workflow);
const result = await executor.execute('workflow-1', { input: 'test' });
expect(result.success).toBe(true);
expect(result.data).toEqual({ result: '<TEST>' });
});
it('should handle circular dependencies', async () => {
const mockTool = new MockTool(
'test-tool',
async () => ({ output: 'test' })
);
(toolRegistry 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: {},
interface: {
inputs: { input: 'string' },
outputs: { output: 'string' }
}
}
},
{
id: 'block-2',
position: { x: 100, y: 0 },
config: {
tool: 'test-tool',
params: {},
interface: {
inputs: { input: 'string' },
outputs: { output: 'string' }
}
}
}
],
connections: [
{
source: 'block-1',
target: 'block-2',
sourceHandle: 'output',
targetHandle: 'input'
},
{
source: 'block-2',
target: 'block-1',
sourceHandle: 'output',
targetHandle: 'input'
}
]
};
const executor = new Executor(workflow);
const result = await executor.execute('workflow-1', {});
expect(result.success).toBe(false);
expect(result.error).toContain('Workflow contains cycles');
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,6 +1,6 @@
import { SerializedWorkflow, SerializedBlock } from '@/serializer/types';
import { SerializedWorkflow, SerializedBlock, BlockConfig } from '@/serializer/types';
import { ExecutionContext, ExecutionResult, Tool } from './types';
import { toolRegistry } from '@/tools/registry';
import { tools } from '@/tools/registry';
export class Executor {
private workflow: SerializedWorkflow;
@@ -14,39 +14,64 @@ export class Executor {
inputs: Record<string, any>,
context: ExecutionContext
): Promise<Record<string, any>> {
// Get the tool specified by the block's tool property
const toolName = block.config.tool;
if (!toolName) {
const config = block.config as BlockConfig;
const toolId = config.tool;
if (!toolId) {
throw new Error(`Block ${block.id} does not specify a tool`);
}
const tool = toolRegistry[toolName];
const tool = tools[toolId];
if (!tool) {
throw new Error(`Tool not found: ${toolName}`);
throw new Error(`Tool not found: ${toolId}`);
}
// Validate interface compatibility
this.validateInterface(block, inputs);
// Merge tool parameters with runtime inputs
// Merge block parameters with runtime inputs
const params = {
...block.config.params,
...config.params,
...inputs
};
// Validate the parameters against tool requirements
const validationResult = tool.validateParams(params);
if (typeof validationResult === 'string') {
throw new Error(`Invalid parameters for tool ${toolName}: ${validationResult}`);
}
// Validate tool parameters
this.validateToolParams(tool, params);
try {
// Execute the tool and validate its output matches the interface
const result = await tool.execute(params);
// Make the HTTP request
const url = typeof tool.request.url === 'function'
? tool.request.url(params)
: 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
});
if (!response.ok) {
const error = await response.json();
throw new Error(tool.transformError(error));
}
const data = await response.json();
const result = tool.transformResponse(data);
// Validate the output matches the interface
this.validateToolOutput(block, result);
return result;
} catch (error) {
throw new Error(`Tool ${toolName} execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw new Error(`Tool ${toolId} execution failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
private validateToolParams(tool: Tool, params: Record<string, any>): 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}`);
}
}
}

View File

@@ -1,7 +1,24 @@
export interface Tool {
export interface Tool<P = any, R = any> {
id: string;
name: string;
execute(params: Record<string, any>): Promise<Record<string, any>>;
validateParams(params: Record<string, any>): boolean | string;
description: string;
version: string;
params: {
[key: string]: {
type: string;
required?: boolean;
description?: string;
default?: any;
};
};
request: {
url: string | ((params: P) => string);
method: string;
headers: (params: P) => Record<string, string>;
body?: (params: P) => Record<string, any>;
};
transformResponse: (response: any) => R;
transformError: (error: any) => string;
}
export interface ToolRegistry {

View File

@@ -10,14 +10,14 @@ describe('Serializer', () => {
});
describe('serializeWorkflow', () => {
it('should serialize a workflow with model and http blocks', () => {
it('should serialize a workflow with agent and http blocks', () => {
const blocks: Node[] = [
{
id: 'model-1',
id: 'agent-1',
type: 'custom',
position: { x: 100, y: 100 },
data: {
tool: 'model',
tool: 'openai.chat',
params: {
model: 'gpt-4o',
systemPrompt: 'You are helpful',
@@ -44,7 +44,7 @@ describe('Serializer', () => {
type: 'custom',
position: { x: 400, y: 100 },
data: {
tool: 'http',
tool: 'http.request',
params: {
url: 'https://api.example.com',
method: 'GET'
@@ -70,7 +70,7 @@ describe('Serializer', () => {
const connections: Edge[] = [
{
id: 'conn-1',
source: 'model-1',
source: 'agent-1',
target: 'http-1',
sourceHandle: 'response',
targetHandle: 'body'
@@ -84,16 +84,16 @@ describe('Serializer', () => {
expect(serialized.blocks).toHaveLength(2);
expect(serialized.connections).toHaveLength(1);
// Test model block serialization
const modelBlock = serialized.blocks.find(b => b.id === 'model-1');
expect(modelBlock).toBeDefined();
expect(modelBlock?.config.tool).toBe('model');
expect(modelBlock?.config.params).toEqual({
// Test agent block serialization
const agentBlock = serialized.blocks.find(b => b.id === 'agent-1');
expect(agentBlock).toBeDefined();
expect(agentBlock?.config.tool).toBe('openai.chat');
expect(agentBlock?.config.params).toEqual({
model: 'gpt-4o',
systemPrompt: 'You are helpful',
temperature: 0.7
});
expect(modelBlock?.metadata).toEqual({
expect(agentBlock?.metadata).toEqual({
title: 'GPT-4o Agent',
description: 'Language model block',
category: 'AI',
@@ -104,20 +104,11 @@ describe('Serializer', () => {
// Test http block serialization
const httpBlock = serialized.blocks.find(b => b.id === 'http-1');
expect(httpBlock).toBeDefined();
expect(httpBlock?.config.tool).toBe('http');
expect(httpBlock?.config.tool).toBe('http.request');
expect(httpBlock?.config.params).toEqual({
url: 'https://api.example.com',
method: 'GET'
});
// Test connection serialization
const connection = serialized.connections[0];
expect(connection).toEqual({
source: 'model-1',
target: 'http-1',
sourceHandle: 'response',
targetHandle: 'body'
});
});
it('should handle blocks with minimal required configuration', () => {
@@ -126,7 +117,7 @@ describe('Serializer', () => {
type: 'custom',
position: { x: 0, y: 0 },
data: {
tool: 'model',
tool: 'openai.chat',
params: {
model: 'gpt-4o'
},
@@ -141,7 +132,7 @@ describe('Serializer', () => {
const block = serialized.blocks[0];
expect(block.id).toBe('minimal-1');
expect(block.config.tool).toBe('model');
expect(block.config.tool).toBe('openai.chat');
expect(block.config.params).toEqual({ model: 'gpt-4o' });
expect(block.metadata).toBeUndefined();
});
@@ -153,7 +144,7 @@ describe('Serializer', () => {
type: 'custom',
position: { x: 100, y: 100 },
data: {
tool: 'http',
tool: 'http.request',
params: {
url: 'https://api.data.com',
method: 'GET'
@@ -171,7 +162,7 @@ describe('Serializer', () => {
type: 'custom',
position: { x: 300, y: 100 },
data: {
tool: 'model',
tool: 'openai.chat',
params: {
model: 'gpt-4o',
systemPrompt: 'Process this data'
@@ -192,7 +183,7 @@ describe('Serializer', () => {
type: 'custom',
position: { x: 500, y: 100 },
data: {
tool: 'http',
tool: 'http.request',
params: {
url: 'https://api.output.com',
method: 'POST'
@@ -248,19 +239,18 @@ describe('Serializer', () => {
it('should preserve tool-specific parameters', () => {
const blocks: Node[] = [{
id: 'model-1',
id: 'agent-1',
type: 'custom',
position: { x: 0, y: 0 },
data: {
tool: 'model',
tool: 'openai.chat',
params: {
model: 'gpt-4o',
temperature: 0.7,
maxTokens: 1000,
topP: 0.9,
frequencyPenalty: 0.5,
presencePenalty: 0.5,
stop: ['###']
frequencyPenalty: 0.1,
presencePenalty: 0.1
},
interface: {
inputs: { prompt: 'string' },
@@ -271,29 +261,28 @@ describe('Serializer', () => {
const serialized = serializer.serializeWorkflow(blocks, []);
const block = serialized.blocks[0];
expect(block.config.params).toEqual({
model: 'gpt-4o',
temperature: 0.7,
maxTokens: 1000,
topP: 0.9,
frequencyPenalty: 0.5,
presencePenalty: 0.5,
stop: ['###']
frequencyPenalty: 0.1,
presencePenalty: 0.1
});
});
});
describe('deserializeWorkflow', () => {
it('should deserialize a workflow back to ReactFlow format', () => {
const serialized: SerializedWorkflow = {
const workflow: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'model-1',
id: 'agent-1',
position: { x: 100, y: 100 },
config: {
tool: 'model',
tool: 'openai.chat',
params: {
model: 'gpt-4o',
systemPrompt: 'You are helpful'
@@ -309,159 +298,18 @@ describe('Serializer', () => {
}
}
],
connections: [
{
source: 'model-1',
target: 'http-1',
sourceHandle: 'response',
targetHandle: 'body'
}
]
connections: []
};
const deserialized = serializer.deserializeWorkflow(serialized);
const { blocks, connections } = serializer.deserializeWorkflow(workflow);
// Test blocks deserialization
expect(deserialized.blocks).toHaveLength(1);
const block = deserialized.blocks[0];
expect(block.id).toBe('model-1');
expect(blocks).toHaveLength(1);
const block = blocks[0];
expect(block.id).toBe('agent-1');
expect(block.type).toBe('custom');
expect(block.position).toEqual({ x: 100, y: 100 });
expect(block.data).toEqual({
tool: 'model',
params: {
model: 'gpt-4o',
systemPrompt: 'You are helpful'
},
interface: {
inputs: { prompt: 'string' },
outputs: { response: 'string' }
},
title: 'GPT-4o Agent',
category: 'AI'
});
// Test connections deserialization
expect(deserialized.connections).toHaveLength(1);
const connection = deserialized.connections[0];
expect(connection).toEqual({
id: 'model-1-http-1',
source: 'model-1',
target: 'http-1',
sourceHandle: 'response',
targetHandle: 'body'
});
});
it('should handle empty workflow', () => {
const serialized: SerializedWorkflow = {
version: '1.0',
blocks: [],
connections: []
};
const deserialized = serializer.deserializeWorkflow(serialized);
expect(deserialized.blocks).toHaveLength(0);
expect(deserialized.connections).toHaveLength(0);
});
it('should handle blocks with complex interface types', () => {
const serialized: SerializedWorkflow = {
version: '1.0',
blocks: [{
id: 'complex-1',
position: { x: 0, y: 0 },
config: {
tool: 'model',
params: {
model: 'gpt-4o'
},
interface: {
inputs: {
context: 'object',
options: 'array',
metadata: 'Record<string, any>',
callback: 'function'
},
outputs: {
result: 'object',
errors: 'array',
logs: 'string[]'
}
}
}
}],
connections: []
};
const deserialized = serializer.deserializeWorkflow(serialized);
const block = deserialized.blocks[0];
expect(block.data.interface.inputs).toEqual({
context: 'object',
options: 'array',
metadata: 'Record<string, any>',
callback: 'function'
});
expect(block.data.interface.outputs).toEqual({
result: 'object',
errors: 'array',
logs: 'string[]'
});
});
it('should handle circular connections', () => {
const serialized: SerializedWorkflow = {
version: '1.0',
blocks: [
{
id: 'loop-1',
position: { x: 0, y: 0 },
config: {
tool: 'model',
params: {},
interface: {
inputs: { input: 'string' },
outputs: { output: 'string' }
}
}
},
{
id: 'loop-2',
position: { x: 200, y: 0 },
config: {
tool: 'model',
params: {},
interface: {
inputs: { input: 'string' },
outputs: { output: 'string' }
}
}
}
],
connections: [
{
source: 'loop-1',
target: 'loop-2',
sourceHandle: 'output',
targetHandle: 'input'
},
{
source: 'loop-2',
target: 'loop-1',
sourceHandle: 'output',
targetHandle: 'input'
}
]
};
const deserialized = serializer.deserializeWorkflow(serialized);
expect(deserialized.connections).toHaveLength(2);
expect(deserialized.connections[0].source).toBe('loop-1');
expect(deserialized.connections[0].target).toBe('loop-2');
expect(deserialized.connections[1].source).toBe('loop-2');
expect(deserialized.connections[1].target).toBe('loop-1');
expect(block.data.tool).toBe('openai.chat');
expect(block.data.params.model).toBe('gpt-4o');
expect(block.data.title).toBe('GPT-4o Agent');
});
});
});

View File

@@ -16,21 +16,19 @@ export interface Position {
y: number;
}
export interface BlockConfig {
tool: string;
params: Record<string, any>;
interface: {
inputs: Record<string, string>;
outputs: Record<string, string>;
};
}
export interface SerializedBlock {
id: string;
position: Position;
config: {
// The tool this block uses
tool: string;
// Tool-specific parameters
params: Record<string, any>;
// Block's input/output interface
interface: {
inputs: Record<string, string>;
outputs: Record<string, string>;
};
};
// UI-specific metadata (optional)
config: BlockConfig;
metadata?: {
title?: string;
description?: string;
@@ -39,13 +37,3 @@ export interface SerializedBlock {
color?: string;
};
}
export interface SubBlockValue {
title: string;
type: 'long-input' | 'short-input' | 'dropdown' | 'slider' | 'code';
value: string | number | boolean;
}
export interface BlockValues {
[key: string]: string | number | boolean;
}

84
tools/anthropic/chat.ts Normal file
View File

@@ -0,0 +1,84 @@
import { ToolConfig } from '../types';
interface ChatParams {
apiKey: string;
systemPrompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
topP?: number;
stream?: boolean;
}
interface ChatResponse {
output: string;
tokens?: number;
model: string;
}
export const chatTool: ToolConfig<ChatParams, ChatResponse> = {
id: 'anthropic.chat',
name: 'Anthropic Chat',
description: 'Chat with Anthropic Claude models',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
description: 'Anthropic API key'
},
systemPrompt: {
type: 'string',
required: true,
description: 'System prompt to send to the model'
},
model: {
type: 'string',
default: 'claude-3-5-sonnet-20241022',
description: 'Model to use'
},
temperature: {
type: 'number',
default: 0.7,
description: 'Controls randomness in the response'
}
},
request: {
url: 'https://api.anthropic.com/v1/messages',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'x-api-key': params.apiKey,
'anthropic-version': '2023-06-01'
}),
body: (params) => {
const body = {
model: params.model || 'claude-3-5-sonnet-20241022',
messages: [
{ role: 'user', content: params.systemPrompt }
],
temperature: params.temperature,
max_tokens: params.maxTokens,
top_p: params.topP,
stream: params.stream
};
return body;
}
},
transformResponse: (data) => {
return {
output: data.content[0].text,
tokens: data.usage?.input_tokens + data.usage?.output_tokens,
model: data.model
};
},
transformError: (error) => {
const message = error.error?.message || error.message;
const code = error.error?.type || error.code;
return `${message} (${code})`;
}
};

86
tools/google/chat.ts Normal file
View File

@@ -0,0 +1,86 @@
import { ToolConfig } from '../types';
interface ChatParams {
apiKey: string;
systemPrompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
topP?: number;
topK?: number;
}
interface ChatResponse {
output: string;
tokens?: number;
model: string;
}
export const chatTool: ToolConfig<ChatParams, ChatResponse> = {
id: 'google.chat',
name: 'Google Chat',
description: 'Chat with Google Gemini models',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
description: 'Google API key'
},
systemPrompt: {
type: 'string',
required: true,
description: 'System prompt to send to the model'
},
model: {
type: 'string',
default: 'gemini-pro',
description: 'Model to use'
},
temperature: {
type: 'number',
default: 0.7,
description: 'Controls randomness in the response'
}
},
request: {
url: 'https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'x-goog-api-key': params.apiKey
}),
body: (params) => {
const body = {
contents: [
{
role: 'user',
parts: [{ text: params.systemPrompt }]
}
],
generationConfig: {
temperature: params.temperature,
maxOutputTokens: params.maxTokens,
topP: params.topP,
topK: params.topK
}
};
return body;
}
},
transformResponse: (data) => {
return {
output: data.candidates[0].content.parts[0].text,
model: 'gemini-pro'
};
},
transformError: (error) => {
const message = error.error?.message || error.message;
const code = error.error?.status || error.code;
return `${message} (${code})`;
}
};

View File

@@ -1,120 +0,0 @@
import { describe, expect, test, jest, beforeEach } from '@jest/globals';
import { HttpService } from '../index';
import { HttpRequestConfig } from '../types/http';
// Setup fetch mock
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = mockFetch;
describe('HttpService', () => {
let service: HttpService;
beforeEach(() => {
jest.clearAllMocks();
service = HttpService.getInstance();
});
test('should make successful GET request', async () => {
const mockResponse = { message: 'Success' };
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(mockResponse)
} as Response);
const response = await service.get('https://api.example.com/data');
expect(response.data).toEqual(mockResponse);
expect(response.status).toBe(200);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'GET'
})
);
});
test('should make successful POST request with JSON body', async () => {
const requestBody = { key: 'value' };
const mockResponse = { id: 1 };
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
statusText: 'Created',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(mockResponse)
} as Response);
const response = await service.post('https://api.example.com/data', requestBody);
expect(response.data).toEqual(mockResponse);
expect(response.status).toBe(201);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/data',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(requestBody)
})
);
});
test('should handle request with authentication', async () => {
const mockResponse = { message: 'Authenticated' };
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
statusText: 'OK',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(mockResponse)
} as Response);
const config: Omit<HttpRequestConfig, 'url' | 'method'> = {
auth: {
type: 'bearer',
token: 'test-token'
}
};
const response = await service.get('https://api.example.com/protected', config);
expect(response.data).toEqual(mockResponse);
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/protected',
expect.objectContaining({
headers: expect.any(Headers)
})
);
const headers = mockFetch.mock.calls[0][1]?.headers as Headers;
expect(headers.get('Authorization')).toBe('Bearer test-token');
});
test('should handle request timeout', async () => {
mockFetch.mockImplementationOnce(() =>
new Promise((_, reject) => {
setTimeout(() => reject(new Error('The operation was aborted')), 50);
})
);
await expect(
service.get('https://api.example.com/data', { timeout: 10 })
).rejects.toThrow('The operation was aborted');
});
test('should handle API errors', async () => {
const errorResponse = { error: 'Not Found' };
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
statusText: 'Not Found',
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(errorResponse)
} as Response);
const promise = service.get('https://api.example.com/nonexistent');
await expect(promise).rejects.toThrow('Not Found');
await expect(promise).rejects.toMatchObject({
status: 404,
data: errorResponse
});
});
});

View File

@@ -1,108 +0,0 @@
import { HttpRequestConfig, HttpResponse, HttpError } from './types/http';
export class HttpService {
private static instance: HttpService;
constructor() {}
public static getInstance(): HttpService {
if (!HttpService.instance) {
HttpService.instance = new HttpService();
}
return HttpService.instance;
}
private getHeaders(config: HttpRequestConfig): Headers {
const headers = new Headers(config.headers);
if (!headers.has('Content-Type') && config.body) {
headers.set('Content-Type', 'application/json');
}
if (config.auth) {
switch (config.auth.type) {
case 'bearer':
headers.set('Authorization', `Bearer ${config.auth.token}`);
break;
case 'basic':
const credentials = btoa(`${config.auth.username}:${config.auth.password}`);
headers.set('Authorization', `Basic ${credentials}`);
break;
}
}
return headers;
}
private async handleResponse<T>(response: Response): Promise<HttpResponse<T>> {
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
if (!response.ok) {
const error = new Error(response.statusText) as HttpError;
error.status = response.status;
error.statusText = response.statusText;
try {
error.data = await response.json();
} catch {
error.data = await response.text();
}
throw error;
}
let data: T;
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
data = await response.json();
} else {
data = await response.text() as T;
}
return {
data,
status: response.status,
statusText: response.statusText,
headers
};
}
public async request<T = any>(config: HttpRequestConfig): Promise<HttpResponse<T>> {
const controller = new AbortController();
const timeoutId = config.timeout ? setTimeout(() => controller.abort(), config.timeout) : null;
try {
const response = await fetch(config.url, {
method: config.method,
headers: this.getHeaders(config),
body: config.body ? JSON.stringify(config.body) : undefined,
signal: controller.signal
});
return await this.handleResponse<T>(response);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
public async get<T = any>(url: string, config: Omit<HttpRequestConfig, 'url' | 'method'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'GET' });
}
public async post<T = any>(url: string, data?: any, config: Omit<HttpRequestConfig, 'url' | 'method' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'POST', body: data });
}
public async put<T = any>(url: string, data?: any, config: Omit<HttpRequestConfig, 'url' | 'method' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'PUT', body: data });
}
public async delete<T = any>(url: string, config: Omit<HttpRequestConfig, 'url' | 'method'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'DELETE' });
}
public async patch<T = any>(url: string, data?: any, config: Omit<HttpRequestConfig, 'url' | 'method' | 'body'> = {}): Promise<HttpResponse<T>> {
return this.request<T>({ ...config, url, method: 'PATCH', body: data });
}
}

View File

@@ -1,26 +0,0 @@
export interface HttpRequestConfig {
url: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: any;
timeout?: number;
auth?: {
type: 'basic' | 'bearer';
token?: string;
username?: string;
password?: string;
};
}
export interface HttpResponse<T = any> {
data: T;
status: number;
headers: Record<string, string>;
statusText: string;
}
export interface HttpError extends Error {
status?: number;
statusText?: string;
data?: any;
}

151
tools/http/request.ts Normal file
View File

@@ -0,0 +1,151 @@
import { ToolConfig, HttpMethod } from '../types';
interface RequestParams {
url: string;
method?: HttpMethod;
headers?: Record<string, string>;
body?: any;
queryParams?: Record<string, string>;
pathParams?: Record<string, string>;
formData?: Record<string, string | Blob>;
timeout?: number;
validateStatus?: (status: number) => boolean;
}
interface RequestResponse {
data: any;
status: number;
headers: Record<string, string>;
}
export const requestTool: ToolConfig<RequestParams, RequestResponse> = {
id: 'http.request',
name: 'HTTP Request',
description: 'Make HTTP requests to any endpoint with support for CRUD operations',
version: '1.0.0',
params: {
url: {
type: 'string',
required: true,
description: 'The URL to send the request to'
},
method: {
type: 'string',
default: 'GET',
description: 'HTTP method (GET, POST, PUT, PATCH, DELETE)'
},
headers: {
type: 'object',
description: 'HTTP headers to include'
},
body: {
type: 'object',
description: 'Request body (for POST, PUT, PATCH)'
},
queryParams: {
type: 'object',
description: 'URL query parameters to append'
},
pathParams: {
type: 'object',
description: 'URL path parameters to replace (e.g., :id in /users/:id)'
},
formData: {
type: 'object',
description: 'Form data to send (will set appropriate Content-Type)'
},
timeout: {
type: 'number',
default: 10000,
description: 'Request timeout in milliseconds'
},
validateStatus: {
type: 'object',
description: 'Custom status validation function'
}
},
request: {
url: (params: RequestParams) => {
let url = params.url;
// Replace path parameters
if (params.pathParams) {
Object.entries(params.pathParams).forEach(([key, value]) => {
url = url.replace(`:${key}`, encodeURIComponent(value));
});
}
// Append query parameters
if (params.queryParams) {
const queryString = Object.entries(params.queryParams)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += (url.includes('?') ? '&' : '?') + queryString;
}
return url;
},
method: 'POST' as HttpMethod,
headers: (params: RequestParams) => {
const headers: Record<string, string> = {
...params.headers
};
// Set appropriate Content-Type
if (params.formData) {
// Don't set Content-Type for FormData, browser will set it with boundary
return headers;
} else if (params.body) {
headers['Content-Type'] = 'application/json';
}
return headers;
},
body: (params: RequestParams) => {
if (params.formData) {
const formData = new FormData();
Object.entries(params.formData).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
}
if (params.body) {
return params.body;
}
return undefined;
}
},
transformResponse: (response) => {
// Try to parse response based on content-type
const contentType = response.headers?.['content-type'] || '';
let data = response.data;
if (contentType.includes('application/json')) {
try {
data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
} catch (e) {
// Keep original data if parsing fails
}
}
return {
data,
status: response.status,
headers: response.headers
};
},
transformError: (error) => {
const message = error.message || error.error?.message;
const code = error.status || error.error?.status;
const details = error.response?.data
? `\nDetails: ${JSON.stringify(error.response.data)}`
: '';
return `${message} (${code})${details}`;
}
};

143
tools/hubspot/contacts.ts Normal file
View File

@@ -0,0 +1,143 @@
import { ToolConfig } from '../types';
interface ContactParams {
apiKey: string;
email: string;
firstName?: string;
lastName?: string;
phone?: string;
company?: string;
id?: string;
properties?: Record<string, string>;
limit?: number;
after?: string;
}
interface ContactResponse {
id: string;
properties: {
email: string;
firstname?: string;
lastname?: string;
phone?: string;
company?: string;
[key: string]: any;
};
createdAt: string;
updatedAt: string;
}
export const contactsTool: ToolConfig<ContactParams, ContactResponse | ContactResponse[]> = {
id: 'hubspot.contacts',
name: 'HubSpot Contacts',
description: 'Manage HubSpot contacts - create, search, and update contact records',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
description: 'HubSpot API key'
},
email: {
type: 'string',
required: true,
description: 'Contact email address'
},
firstName: {
type: 'string',
description: 'Contact first name'
},
lastName: {
type: 'string',
description: 'Contact last name'
},
phone: {
type: 'string',
description: 'Contact phone number'
},
company: {
type: 'string',
description: 'Contact company name'
},
id: {
type: 'string',
description: 'Contact ID (required for updates)'
},
properties: {
type: 'object',
description: 'Additional contact properties'
},
limit: {
type: 'number',
default: 100,
description: 'Number of records to return'
},
after: {
type: 'string',
description: 'Pagination cursor'
}
},
request: {
url: (params) => {
const baseUrl = 'https://api.hubapi.com/crm/v3/objects/contacts';
if (params.id) {
return `${baseUrl}/${params.id}`;
}
return baseUrl;
},
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${params.apiKey}`
}),
body: (params) => {
const properties = {
email: params.email,
...(params.firstName && { firstname: params.firstName }),
...(params.lastName && { lastname: params.lastName }),
...(params.phone && { phone: params.phone }),
...(params.company && { company: params.company }),
...params.properties
};
if (params.id) {
// Update existing contact
return { properties };
}
// Create new contact or search
return {
properties,
...(params.limit && { limit: params.limit }),
...(params.after && { after: params.after })
};
}
},
transformResponse: (data) => {
if (Array.isArray(data.results)) {
// Search response
return data.results.map((contact: { id: string; properties: Record<string, any>; createdAt: string; updatedAt: string }) => ({
id: contact.id,
properties: contact.properties,
createdAt: contact.createdAt,
updatedAt: contact.updatedAt
}));
}
// Single contact response
return {
id: data.id,
properties: data.properties,
createdAt: data.createdAt,
updatedAt: data.updatedAt
};
},
transformError: (error) => {
const message = error.message || error.error?.message;
const code = error.status || error.error?.status;
return `${message} (${code})`;
}
};

View File

@@ -1,201 +0,0 @@
import { describe, expect, test, jest, beforeEach } from '@jest/globals';
import { OpenAIProvider } from '../providers/openai';
import { AnthropicProvider } from '../providers/anthropic';
import { GoogleProvider } from '../providers/google';
import { XAIProvider } from '../providers/xai';
import { AgentConfig } from '../types/agent';
import { ModelRequestOptions } from '../types/model';
// Setup fetch mock
const mockFetch = jest.fn() as jest.MockedFunction<typeof fetch>;
global.fetch = mockFetch;
describe('Model Providers', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('OpenAI Provider', () => {
const provider = new OpenAIProvider();
test('should call OpenAI API successfully', async () => {
const mockResponse = {
choices: [{ message: { content: 'Test response' } }],
usage: { total_tokens: 10 }
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const config: AgentConfig = {
model: 'gpt-4o',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'test-key'
};
const result = await provider.callModel(config, { maxTokens: 100 });
expect(result.response).toBe('Test response');
expect(result.tokens).toBe(10);
expect(result.model).toBe('gpt-4o');
});
test('should handle API errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: { message: 'API Error' } })
} as Response);
const config: AgentConfig = {
model: 'gpt-4o',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'invalid-key'
};
await expect(provider.callModel(config, {})).rejects.toThrow('API Error');
});
});
describe('Anthropic Provider', () => {
const provider = new AnthropicProvider();
test('should call Anthropic API successfully', async () => {
const mockResponse = {
content: [{ text: 'Test response' }],
usage: { input_tokens: 5, output_tokens: 5 }
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const config: AgentConfig = {
model: 'claude-3-5-sonnet-20241022',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'test-key'
};
const result = await provider.callModel(config, { maxTokens: 100 });
expect(result.response).toBe('Test response');
expect(result.tokens).toBe(10);
expect(result.model).toBe('claude-3-5-sonnet-20241022');
});
test('should handle API errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: { message: 'API Error' } })
} as Response);
const config: AgentConfig = {
model: 'claude-3-5-sonnet-20241022',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'invalid-key'
};
await expect(provider.callModel(config, {})).rejects.toThrow('API Error');
});
});
describe('Google Provider', () => {
const provider = new GoogleProvider();
test('should call Google API successfully', async () => {
const mockResponse = {
candidates: [{
content: {
parts: [{ text: 'Test response' }]
}
}],
usage: { totalTokens: 10 }
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const config: AgentConfig = {
model: 'gemini-pro',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'test-key'
};
const result = await provider.callModel(config, { maxTokens: 100 });
expect(result.response).toBe('Test response');
expect(result.tokens).toBe(10);
expect(result.model).toBe('gemini-pro');
});
test('should handle API errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: { message: 'API Error' } })
} as Response);
const config: AgentConfig = {
model: 'gemini-pro',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'invalid-key'
};
await expect(provider.callModel(config, {})).rejects.toThrow('API Error');
});
});
describe('XAI Provider', () => {
const provider = new XAIProvider();
test('should call XAI API successfully', async () => {
const mockResponse = {
choices: [{
message: {
content: 'Test response'
}
}],
usage: { total_tokens: 10 }
};
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockResponse)
} as Response);
const config: AgentConfig = {
model: 'grok-2-latest',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'test-key'
};
const result = await provider.callModel(config, { maxTokens: 100 });
expect(result.response).toBe('Test response');
expect(result.tokens).toBe(10);
expect(result.model).toBe('grok-2-latest');
});
test('should handle API errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve({ error: { message: 'API Error' } })
} as Response);
const config: AgentConfig = {
model: 'grok-2-latest',
systemPrompt: 'Test prompt',
temperature: 0.7,
apiKey: 'invalid-key'
};
await expect(provider.callModel(config, {})).rejects.toThrow('API Error');
});
});
});

View File

@@ -1,62 +0,0 @@
import { ModelProvider, ModelRequestOptions, ModelResponse } from './types/model';
import { OpenAIProvider } from './providers/openai';
import { AnthropicProvider } from './providers/anthropic';
import { GoogleProvider } from './providers/google';
import { XAIProvider } from './providers/xai';
import { AgentConfig } from './types/agent';
export class ModelService {
private static instance: ModelService;
private providers: Map<string, ModelProvider>;
constructor() {
this.providers = new Map();
this.initializeProviders();
}
public static getInstance(): ModelService {
if (!ModelService.instance) {
ModelService.instance = new ModelService();
}
return ModelService.instance;
}
private initializeProviders() {
const openai = new OpenAIProvider();
const anthropic = new AnthropicProvider();
const google = new GoogleProvider();
const xai = new XAIProvider();
// OpenAI models
this.providers.set('gpt-4o', openai);
// Anthropic models
this.providers.set('claude-3-5-sonnet-20241022', anthropic);
// Google models
this.providers.set('gemini-pro', google);
// XAI models
this.providers.set('grok-2-latest', xai);
}
public async callModel(config: AgentConfig, options: ModelRequestOptions = {}): Promise<ModelResponse> {
const provider = this.providers.get(config.model);
if (!provider) {
throw new Error(`No provider found for model: ${config.model}`);
}
await provider.validateConfig(config);
return provider.callModel(config, options);
}
public setApiKey(provider: string, apiKey: string): void {
// Store API keys securely (in memory for now)
// TODO: Implement secure storage
}
public getApiKey(provider: string): string | null {
// Retrieve API key
// TODO: Implement secure retrieval
return null;
}
}

View File

@@ -1,47 +0,0 @@
import { AgentConfig } from '../types/agent';
import { ModelProvider, ModelRequestOptions, ModelResponse } from '../types/model';
export class AnthropicProvider implements ModelProvider {
private readonly SUPPORTED_MODELS = ['claude-3-5-sonnet-20241022'];
private readonly API_URL = 'https://api.anthropic.com/v1/messages';
async callModel(config: AgentConfig, options: ModelRequestOptions): Promise<ModelResponse> {
const response = await fetch(this.API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': config.apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'user', content: config.systemPrompt + '\n' + (config.prompt || '') }
],
temperature: config.temperature,
max_tokens: options.maxTokens
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'Anthropic API error');
}
const data = await response.json();
return {
response: data.content[0].text,
tokens: data.usage.input_tokens + data.usage.output_tokens,
model: config.model
};
}
async validateConfig(config: AgentConfig): Promise<void> {
if (!config.apiKey) {
throw new Error('Anthropic API key is required');
}
if (!this.SUPPORTED_MODELS.includes(config.model)) {
throw new Error(`Model ${config.model} is not supported. Use one of: ${this.SUPPORTED_MODELS.join(', ')}`);
}
}
}

View File

@@ -1,48 +0,0 @@
import { AgentConfig } from '../types/agent';
import { ModelProvider, ModelRequestOptions, ModelResponse } from '../types/model';
export class GoogleProvider implements ModelProvider {
private readonly SUPPORTED_MODELS = ['gemini-pro'];
private readonly API_URL = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent';
async callModel(config: AgentConfig, options: ModelRequestOptions): Promise<ModelResponse> {
const response = await fetch(`${this.API_URL}?key=${config.apiKey}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{
parts: [{
text: config.systemPrompt + '\n' + (config.prompt || '')
}]
}],
generationConfig: {
temperature: config.temperature,
maxOutputTokens: options.maxTokens
}
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'Google API error');
}
const data = await response.json();
return {
response: data.candidates[0].content.parts[0].text,
tokens: data.usage?.totalTokens || 0,
model: config.model
};
}
async validateConfig(config: AgentConfig): Promise<void> {
if (!config.apiKey) {
throw new Error('Google API key is required');
}
if (!this.SUPPORTED_MODELS.includes(config.model)) {
throw new Error(`Model ${config.model} is not supported. Use one of: ${this.SUPPORTED_MODELS.join(', ')}`);
}
}
}

View File

@@ -1,47 +0,0 @@
import { AgentConfig } from '../types/agent';
import { ModelProvider, ModelRequestOptions, ModelResponse } from '../types/model';
export class OpenAIProvider implements ModelProvider {
private readonly SUPPORTED_MODELS = ['gpt-4o'];
private readonly API_URL = 'https://api.openai.com/v1/chat/completions';
async callModel(config: AgentConfig, options: ModelRequestOptions): Promise<ModelResponse> {
const response = await fetch(this.API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
},
body: JSON.stringify({
model: config.model,
messages: [
{ role: 'system', content: config.systemPrompt },
{ role: 'user', content: config.prompt || '' }
],
temperature: config.temperature,
max_tokens: options.maxTokens,
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'OpenAI API error');
}
const data = await response.json();
return {
response: data.choices[0].message.content,
tokens: data.usage.total_tokens,
model: config.model
};
}
async validateConfig(config: AgentConfig): Promise<void> {
if (!config.apiKey) {
throw new Error('OpenAI API key is required');
}
if (!this.SUPPORTED_MODELS.includes(config.model)) {
throw new Error(`Model ${config.model} is not supported. Use one of: ${this.SUPPORTED_MODELS.join(', ')}`);
}
}
}

View File

@@ -1,50 +0,0 @@
import { AgentConfig } from '../types/agent';
import { ModelProvider, ModelRequestOptions, ModelResponse } from '../types/model';
export class XAIProvider implements ModelProvider {
async callModel(
config: AgentConfig,
options: ModelRequestOptions
): Promise<ModelResponse> {
const response = await fetch('https://api.x.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`
},
body: JSON.stringify({
model: config.model,
messages: [
{
role: 'system',
content: config.systemPrompt
}
],
temperature: config.temperature,
max_tokens: options.maxTokens
}),
signal: AbortSignal.timeout(options.timeout || 10000)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'xAI API call failed');
}
const result = await response.json();
return {
response: result.choices[0].message.content,
tokens: result.usage?.total_tokens || 0,
model: config.model
};
}
async validateConfig(config: AgentConfig): Promise<void> {
if (!config.apiKey) {
throw new Error('xAI API key is required');
}
if (!config.model.startsWith('grok')) {
throw new Error('Invalid xAI model specified');
}
}
}

View File

@@ -1,17 +0,0 @@
export interface AgentConfig {
model: string;
systemPrompt: string;
prompt?: string;
temperature: number;
apiKey: string;
}
export interface AgentResult {
success: boolean;
data?: {
response: string;
tokens: number;
model: string;
};
error?: string;
}

View File

@@ -1,25 +0,0 @@
import { AgentConfig } from "./agent";
export interface ModelResponse {
response: string;
tokens: number;
model: string;
}
export interface ModelRequestOptions {
maxTokens?: number;
timeout?: number;
}
export interface ModelProvider {
callModel(config: AgentConfig, options: ModelRequestOptions): Promise<ModelResponse>;
validateConfig(config: AgentConfig): Promise<void>;
}
export const DEFAULT_MODEL_CONFIGS = {
'gpt-4o': { provider: 'openai' },
'claude': { provider: 'anthropic' },
'gemini': { provider: 'google' },
'grok': { provider: 'xai' },
'deepseek': { provider: 'deepseek' }
} as const;

95
tools/openai/chat.ts Normal file
View File

@@ -0,0 +1,95 @@
import { ToolConfig } from '../types';
interface ChatParams {
apiKey: string;
systemPrompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
topP?: number;
frequencyPenalty?: number;
presencePenalty?: number;
stream?: boolean;
}
interface ChatResponse {
output: string;
tokens?: number;
model: string;
}
export const chatTool: ToolConfig<ChatParams, ChatResponse> = {
id: 'openai.chat',
name: 'OpenAI Chat',
description: 'Chat with OpenAI models',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
description: 'OpenAI API key'
},
systemPrompt: {
type: 'string',
required: true,
description: 'System prompt to send to the model'
},
model: {
type: 'string',
default: 'gpt-4o',
description: 'Model to use (gpt-4o, o1-mini)'
},
temperature: {
type: 'number',
default: 0.7,
description: 'Controls randomness in the response'
}
},
request: {
url: 'https://api.openai.com/v1/chat/completions',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${params.apiKey}`
}),
body: (params) => {
console.log('OpenAI Chat Tool - Request Params:', JSON.stringify(params, null, 2));
const body = {
model: params.model || 'gpt-4o',
messages: [
{ role: 'system', content: params.systemPrompt }
],
temperature: params.temperature,
max_tokens: params.maxTokens,
top_p: params.topP,
frequency_penalty: params.frequencyPenalty,
presence_penalty: params.presencePenalty,
stream: params.stream
};
console.log('OpenAI Chat Tool - Request Body:', JSON.stringify(body, null, 2));
return body;
}
},
transformResponse: (data) => {
if (data.choices?.[0]?.delta?.content) {
return {
output: data.choices[0].delta.content,
model: data.model
};
}
return {
output: data.choices[0].message.content,
tokens: data.usage?.total_tokens,
model: data.model
};
},
transformError: (error) => {
const message = error.error?.message || error.message;
const code = error.error?.type || error.code;
return `${message} (${code})`;
}
};

View File

@@ -1,76 +1,63 @@
import { Tool, ToolRegistry } from '@/executor/types';
import { ModelService } from './model-service';
import { HttpService } from './http-service';
import { AgentConfig } from './model-service/types/agent';
import { ToolConfig } from './types';
import { chatTool as openaiChat } from './openai/chat';
import { chatTool as anthropicChat } from './anthropic/chat';
import { chatTool as googleChat } from './google/chat';
import { chatTool as xaiChat } from './xai/chat';
import { requestTool as httpRequest } from './http/request';
import { contactsTool as hubspotContacts } from './hubspot/contacts';
import { opportunitiesTool as salesforceOpportunities } from './salesforce/opportunities';
class ModelTool implements Tool {
name = 'model';
private service: ModelService;
// Registry of all available tools
export const tools: Record<string, ToolConfig> = {
// AI Models
'openai.chat': openaiChat,
'anthropic.chat': anthropicChat,
'google.chat': googleChat,
'xai.chat': xaiChat,
// HTTP
'http.request': httpRequest,
// CRM Tools
'hubspot.contacts': hubspotContacts,
'salesforce.opportunities': salesforceOpportunities
};
constructor() {
this.service = ModelService.getInstance();
}
validateParams(params: Record<string, any>): boolean | string {
const required = ['model', 'prompt'];
const missing = required.filter(param => !params[param]);
if (missing.length > 0) {
return `Missing required parameters: ${missing.join(', ')}`;
}
return true;
}
async execute(params: Record<string, any>): Promise<Record<string, any>> {
const config: AgentConfig = {
model: params.model,
systemPrompt: params.systemPrompt || 'You are a helpful assistant.',
prompt: params.prompt,
temperature: params.temperature || 0.7,
apiKey: params.apiKey || process.env.OPENAI_API_KEY || ''
};
const response = await this.service.callModel(config);
return {
response: response.response,
tokens: response.tokens,
model: response.model
};
}
// Get a tool by its ID
export function getTool(toolId: string): ToolConfig | undefined {
return tools[toolId];
}
class HttpTool implements Tool {
name = 'http';
private service: HttpService;
// Execute a tool with parameters
export async function executeTool(
toolId: string,
params: Record<string, any>
): Promise<any> {
const tool = getTool(toolId);
constructor() {
this.service = HttpService.getInstance();
if (!tool) {
throw new Error(`Tool not found: ${toolId}`);
}
validateParams(params: Record<string, any>): boolean | string {
if (!params.url) {
return 'Missing required parameter: url';
}
return true;
}
try {
// Get the URL (which might be a function or string)
const url = typeof tool.request.url === 'function'
? tool.request.url(params)
: tool.request.url;
async execute(params: Record<string, any>): Promise<Record<string, any>> {
const response = await this.service.request({
url: params.url,
method: params.method || 'GET',
headers: params.headers || {},
body: params.body,
timeout: params.timeout
// Make the HTTP request
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
});
return {
data: response.data,
status: response.status,
headers: response.headers
};
}
}
if (!response.ok) {
const error = await response.json();
throw new Error(tool.transformError(error));
}
export const toolRegistry: ToolRegistry = {
model: new ModelTool(),
http: new HttpTool()
};
const data = await response.json();
return tool.transformResponse(data);
} catch (error) {
throw new Error(tool.transformError(error));
}
}

View File

@@ -0,0 +1,172 @@
import { ToolConfig } from '../types';
interface OpportunityParams {
instanceUrl: string;
accessToken: string;
name: string;
accountId?: string;
stage?: string;
amount?: number;
closeDate?: string;
probability?: number;
description?: string;
id?: string;
properties?: Record<string, any>;
query?: string;
limit?: number;
}
interface OpportunityResponse {
id: string;
name: string;
accountId?: string;
stage?: string;
amount?: number;
closeDate?: string;
probability?: number;
description?: string;
createdDate: string;
lastModifiedDate: string;
[key: string]: any;
}
export const opportunitiesTool: ToolConfig<OpportunityParams, OpportunityResponse | OpportunityResponse[]> = {
id: 'salesforce.opportunities',
name: 'Salesforce Opportunities',
description: 'Manage Salesforce opportunities - create, query, and update opportunity records',
version: '1.0.0',
params: {
instanceUrl: {
type: 'string',
required: true,
description: 'Salesforce instance URL'
},
accessToken: {
type: 'string',
required: true,
description: 'Salesforce access token'
},
name: {
type: 'string',
required: true,
description: 'Opportunity name'
},
accountId: {
type: 'string',
description: 'Associated account ID'
},
stage: {
type: 'string',
description: 'Opportunity stage'
},
amount: {
type: 'number',
description: 'Opportunity amount'
},
closeDate: {
type: 'string',
description: 'Expected close date (YYYY-MM-DD)'
},
probability: {
type: 'number',
description: 'Probability of closing (%)'
},
description: {
type: 'string',
description: 'Opportunity description'
},
id: {
type: 'string',
description: 'Opportunity ID (required for updates)'
},
properties: {
type: 'object',
description: 'Additional opportunity fields'
},
query: {
type: 'string',
description: 'SOQL query for searching opportunities'
},
limit: {
type: 'number',
default: 100,
description: 'Maximum number of records to return'
}
},
request: {
url: (params) => {
const baseUrl = `${params.instanceUrl}/services/data/v58.0/sobjects/Opportunity`;
if (params.query) {
return `${params.instanceUrl}/services/data/v58.0/query?q=${encodeURIComponent(params.query)}`;
}
if (params.id) {
return `${baseUrl}/${params.id}`;
}
return baseUrl;
},
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${params.accessToken}`
}),
body: (params) => {
if (params.query) {
return {}; // Empty body for queries
}
const fields = {
Name: params.name,
...(params.accountId && { AccountId: params.accountId }),
...(params.stage && { StageName: params.stage }),
...(params.amount && { Amount: params.amount }),
...(params.closeDate && { CloseDate: params.closeDate }),
...(params.probability && { Probability: params.probability }),
...(params.description && { Description: params.description }),
...params.properties
};
return fields;
}
},
transformResponse: (data) => {
if (data.records) {
// Query response
return data.records.map((record: any) => ({
id: record.Id,
name: record.Name,
accountId: record.AccountId,
stage: record.StageName,
amount: record.Amount,
closeDate: record.CloseDate,
probability: record.Probability,
description: record.Description,
createdDate: record.CreatedDate,
lastModifiedDate: record.LastModifiedDate,
...record
}));
}
// Single record response
return {
id: data.id || data.Id,
name: data.name || data.Name,
accountId: data.accountId || data.AccountId,
stage: data.stage || data.StageName,
amount: data.amount || data.Amount,
closeDate: data.closeDate || data.CloseDate,
probability: data.probability || data.Probability,
description: data.description || data.Description,
createdDate: data.CreatedDate,
lastModifiedDate: data.LastModifiedDate,
...data
};
},
transformError: (error) => {
const message = error.message || error.error?.message;
const code = error.errorCode || error.error?.errorCode;
return `${message} (${code})`;
}
};

29
tools/types.ts Normal file
View File

@@ -0,0 +1,29 @@
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export interface ToolConfig<P = any, R = any> {
// Basic tool identification
id: string;
name: string;
description: string;
version: string;
// Parameter schema - what this tool accepts
params: Record<string, {
type: string;
required?: boolean;
default?: any;
description?: string;
}>;
// Request configuration
request: {
url: string | ((params: P) => string);
method: string;
headers: (params: P) => Record<string, string>;
body?: (params: P) => Record<string, any>;
};
// Response handling
transformResponse: (data: any) => R;
transformError: (error: any) => string;
}

85
tools/xai/chat.ts Normal file
View File

@@ -0,0 +1,85 @@
import { ToolConfig } from '../types';
interface ChatParams {
apiKey: string;
systemPrompt: string;
model?: string;
temperature?: number;
maxTokens?: number;
topP?: number;
frequencyPenalty?: number;
presencePenalty?: number;
}
interface ChatResponse {
output: string;
tokens?: number;
model: string;
}
export const chatTool: ToolConfig<ChatParams, ChatResponse> = {
id: 'xai.chat',
name: 'xAI Chat',
description: 'Chat with xAI models',
version: '1.0.0',
params: {
apiKey: {
type: 'string',
required: true,
description: 'xAI API key'
},
systemPrompt: {
type: 'string',
required: true,
description: 'System prompt to send to the model'
},
model: {
type: 'string',
default: 'grok-2-latest',
description: 'Model to use'
},
temperature: {
type: 'number',
default: 0.7,
description: 'Controls randomness in the response'
}
},
request: {
url: 'https://api.x.ai/v1/chat/completions',
method: 'POST',
headers: (params) => ({
'Content-Type': 'application/json',
'Authorization': `Bearer ${params.apiKey}`
}),
body: (params) => {
const body = {
model: params.model || 'grok-2-latest',
messages: [
{ role: 'system', content: params.systemPrompt }
],
temperature: params.temperature,
max_tokens: params.maxTokens,
top_p: params.topP,
frequency_penalty: params.frequencyPenalty,
presence_penalty: params.presencePenalty
};
return body;
}
},
transformResponse: (data) => {
return {
output: data.choices[0].message.content,
tokens: data.usage?.total_tokens,
model: data.model
};
},
transformError: (error) => {
const message = error.error?.message || error.message;
const code = error.error?.type || error.code;
return `${message} (${code})`;
}
};