mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
Co-authored-by: Charlie Kolb <charlie@n8n.io> Co-authored-by: Milorad FIlipović <milorad@n8n.io> Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
@@ -1,567 +1,42 @@
|
||||
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import get from 'lodash/get';
|
||||
import isObject from 'lodash/isObject';
|
||||
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
|
||||
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
|
||||
import type {
|
||||
IExecuteWorkflowInfo,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IWorkflowBase,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
ExecutionError,
|
||||
ExecuteWorkflowData,
|
||||
IDataObject,
|
||||
INodeParameterResourceLocator,
|
||||
ITaskMetadata,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { jsonSchemaExampleField, schemaTypeField, inputSchemaField } from '@utils/descriptions';
|
||||
import { convertJsonSchemaToZod, generateSchema } from '@utils/schemaParsing';
|
||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||
import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node';
|
||||
import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node';
|
||||
|
||||
import type { DynamicZodObject } from '../../../types/zod.types';
|
||||
|
||||
export class ToolWorkflow implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Call n8n Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
icon: 'fa:network-wired',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
||||
defaults: {
|
||||
name: 'Call n8n Workflow Tool',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Recommended Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
||||
inputs: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
|
||||
outputs: [NodeConnectionType.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'See an example of a workflow to suggest meeting slots using AI <a href="/templates/1953" target="_blank">here</a>.',
|
||||
name: 'noticeTemplateExample',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'My_Color_Tool',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
export class ToolWorkflow extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Call n8n Sub-Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
icon: 'fa:network-wired',
|
||||
group: ['transform'],
|
||||
description:
|
||||
'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Recommended Tools'],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. My_Color_Tool',
|
||||
validateType: 'string-alphanumeric',
|
||||
description:
|
||||
'The name of the function to be called, could contain letters, numbers, and underscores only',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder:
|
||||
'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.',
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
displayName:
|
||||
'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger',
|
||||
name: 'executeNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Source',
|
||||
name: 'source',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Database',
|
||||
value: 'database',
|
||||
description: 'Load the workflow from the database by ID',
|
||||
},
|
||||
{
|
||||
name: 'Define Below',
|
||||
value: 'parameter',
|
||||
description: 'Pass the JSON code of a workflow',
|
||||
},
|
||||
],
|
||||
default: 'database',
|
||||
description: 'Where to get the workflow to execute from',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:database
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Workflow ID',
|
||||
name: 'workflowId',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { lte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'The workflow to execute',
|
||||
hint: 'Can be found in the URL of the workflow',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Workflow',
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:parameter
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Workflow JSON',
|
||||
name: 'workflowJson',
|
||||
type: 'json',
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['parameter'],
|
||||
},
|
||||
},
|
||||
default: '\n\n\n\n\n\n\n\n\n',
|
||||
required: true,
|
||||
description: 'The workflow JSON code to execute',
|
||||
},
|
||||
// ----------------------------------
|
||||
// For all
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Field to Return',
|
||||
name: 'responsePropertyName',
|
||||
type: 'string',
|
||||
default: 'response',
|
||||
required: true,
|
||||
hint: 'The field in the last-executed node of the workflow that contains the response',
|
||||
description:
|
||||
'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Extra Workflow Inputs',
|
||||
name: 'fields',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
description:
|
||||
"These will be output by the 'execute workflow' trigger of the workflow being called",
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
sortable: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldName',
|
||||
description:
|
||||
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
description: 'The field value type',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'String',
|
||||
value: 'stringValue',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'numberValue',
|
||||
},
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'booleanValue',
|
||||
},
|
||||
{
|
||||
name: 'Array',
|
||||
value: 'arrayValue',
|
||||
},
|
||||
{
|
||||
name: 'Object',
|
||||
value: 'objectValue',
|
||||
},
|
||||
],
|
||||
default: 'stringValue',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'stringValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['stringValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'string',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'numberValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['numberValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'number',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'booleanValue',
|
||||
type: 'options',
|
||||
default: 'true',
|
||||
options: [
|
||||
{
|
||||
name: 'True',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
name: 'False',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['booleanValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'boolean',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'arrayValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['arrayValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'array',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'objectValue',
|
||||
type: 'json',
|
||||
default: '={}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['objectValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'object',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// ----------------------------------
|
||||
// Output Parsing
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Specify Input Schema',
|
||||
name: 'specifyInputSchema',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.',
|
||||
noDataExpression: true,
|
||||
default: false,
|
||||
},
|
||||
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
|
||||
jsonSchemaExampleField,
|
||||
inputSchemaField,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const workflowProxy = this.getWorkflowDataProxy(0);
|
||||
|
||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||
const description = this.getNodeParameter('description', itemIndex) as string;
|
||||
|
||||
let subExecutionId: string | undefined;
|
||||
let subWorkflowId: string | undefined;
|
||||
|
||||
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
|
||||
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
|
||||
|
||||
const runFunction = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<string> => {
|
||||
const source = this.getNodeParameter('source', itemIndex) as string;
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
if (source === 'database') {
|
||||
// Read workflow from database
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
if (nodeVersion <= 1.1) {
|
||||
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
|
||||
} else {
|
||||
const { value } = this.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
}
|
||||
|
||||
subWorkflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
// Read workflow from parameter
|
||||
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
|
||||
try {
|
||||
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
|
||||
|
||||
// subworkflow is same as parent workflow
|
||||
subWorkflowId = workflowProxy.$workflow.id;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
itemIndex,
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawData: IDataObject = { query };
|
||||
|
||||
const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], {
|
||||
rawExpressions: true,
|
||||
}) as SetField[];
|
||||
|
||||
// Copied from Set Node v2
|
||||
for (const entry of workflowFieldsJson) {
|
||||
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
|
||||
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
const options: SetNodeOptions = {
|
||||
include: 'all',
|
||||
};
|
||||
|
||||
const newItem = await manual.execute.call(
|
||||
this,
|
||||
{ json: { query } },
|
||||
itemIndex,
|
||||
options,
|
||||
rawData,
|
||||
this.getNode(),
|
||||
);
|
||||
|
||||
const items = [newItem] as INodeExecutionData[];
|
||||
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
});
|
||||
subExecutionId = receivedData.executionId;
|
||||
} catch (error) {
|
||||
// Make sure a valid error gets returned that can by json-serialized else it will
|
||||
// not show up in the frontend
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
|
||||
const response: string | undefined = get(receivedData, 'data[0][0].json') as
|
||||
| string
|
||||
| undefined;
|
||||
if (response === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'There was an error: "The workflow did not return a response"',
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVersion: 2,
|
||||
};
|
||||
|
||||
const toolHandler = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<string> => {
|
||||
const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]);
|
||||
|
||||
let response: string = '';
|
||||
let executionError: ExecutionError | undefined;
|
||||
try {
|
||||
response = await runFunction(query, runManager);
|
||||
} catch (error) {
|
||||
// TODO: Do some more testing. Issues here should actually fail the workflow
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
executionError = error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
response = `There was an error: "${error.message}"`;
|
||||
}
|
||||
|
||||
if (typeof response === 'number') {
|
||||
response = (response as number).toString();
|
||||
}
|
||||
|
||||
if (isObject(response)) {
|
||||
response = JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
// TODO: Do some more testing. Issues here should actually fail the workflow
|
||||
executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', {
|
||||
description: `The response property should be a string, but it is an ${typeof response}`,
|
||||
});
|
||||
response = `There was an error: "${executionError.message}"`;
|
||||
}
|
||||
|
||||
let metadata: ITaskMetadata | undefined;
|
||||
if (subExecutionId && subWorkflowId) {
|
||||
metadata = {
|
||||
subExecution: {
|
||||
executionId: subExecutionId,
|
||||
workflowId: subWorkflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (executionError) {
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
|
||||
} else {
|
||||
// Output always needs to be an object
|
||||
// so we try to parse the response as JSON and if it fails we just return the string wrapped in an object
|
||||
const json = jsonParse<IDataObject>(response, { fallbackValue: { response } });
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const functionBase = {
|
||||
name,
|
||||
description,
|
||||
func: toolHandler,
|
||||
};
|
||||
|
||||
if (useSchema) {
|
||||
try {
|
||||
// We initialize these even though one of them will always be empty
|
||||
// it makes it easier to navigate the ternary operator
|
||||
const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
|
||||
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
|
||||
|
||||
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
||||
const jsonSchema =
|
||||
schemaType === 'fromJson'
|
||||
? generateSchema(jsonExample)
|
||||
: jsonParse<JSONSchema7>(inputSchema);
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
||||
|
||||
tool = new DynamicStructuredTool({
|
||||
schema: zodSchema,
|
||||
...functionBase,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Error during parsing of JSON Schema. \n ' + error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tool = new DynamicTool(functionBase);
|
||||
}
|
||||
|
||||
return {
|
||||
response: tool,
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
1: new ToolWorkflowV1(baseDescription),
|
||||
1.1: new ToolWorkflowV1(baseDescription),
|
||||
1.2: new ToolWorkflowV1(baseDescription),
|
||||
1.3: new ToolWorkflowV1(baseDescription),
|
||||
2: new ToolWorkflowV2(baseDescription),
|
||||
};
|
||||
super(nodeVersions, baseDescription);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import get from 'lodash/get';
|
||||
import isObject from 'lodash/isObject';
|
||||
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
|
||||
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
|
||||
import type {
|
||||
IExecuteWorkflowInfo,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IWorkflowBase,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
ExecutionError,
|
||||
ExecuteWorkflowData,
|
||||
IDataObject,
|
||||
INodeParameterResourceLocator,
|
||||
ITaskMetadata,
|
||||
INodeTypeBaseDescription,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { versionDescription } from './versionDescription';
|
||||
import type { DynamicZodObject } from '../../../../types/zod.types';
|
||||
import { convertJsonSchemaToZod, generateSchema } from '../../../../utils/schemaParsing';
|
||||
|
||||
export class ToolWorkflowV1 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const workflowProxy = this.getWorkflowDataProxy(0);
|
||||
|
||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||
const description = this.getNodeParameter('description', itemIndex) as string;
|
||||
|
||||
let subExecutionId: string | undefined;
|
||||
let subWorkflowId: string | undefined;
|
||||
|
||||
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
|
||||
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
|
||||
|
||||
const runFunction = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<string> => {
|
||||
const source = this.getNodeParameter('source', itemIndex) as string;
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
if (source === 'database') {
|
||||
// Read workflow from database
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
if (nodeVersion <= 1.1) {
|
||||
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
|
||||
} else {
|
||||
const { value } = this.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
}
|
||||
|
||||
subWorkflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
// Read workflow from parameter
|
||||
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
|
||||
try {
|
||||
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
|
||||
|
||||
// subworkflow is same as parent workflow
|
||||
subWorkflowId = workflowProxy.$workflow.id;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
|
||||
{
|
||||
itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawData: IDataObject = { query };
|
||||
|
||||
const workflowFieldsJson = this.getNodeParameter('fields.values', itemIndex, [], {
|
||||
rawExpressions: true,
|
||||
}) as SetField[];
|
||||
|
||||
// Copied from Set Node v2
|
||||
for (const entry of workflowFieldsJson) {
|
||||
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
|
||||
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
const options: SetNodeOptions = {
|
||||
include: 'all',
|
||||
};
|
||||
|
||||
const newItem = await manual.execute.call(
|
||||
this,
|
||||
{ json: { query } },
|
||||
itemIndex,
|
||||
options,
|
||||
rawData,
|
||||
this.getNode(),
|
||||
);
|
||||
|
||||
const items = [newItem] as INodeExecutionData[];
|
||||
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedData = await this.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
});
|
||||
subExecutionId = receivedData.executionId;
|
||||
} catch (error) {
|
||||
// Make sure a valid error gets returned that can by json-serialized else it will
|
||||
// not show up in the frontend
|
||||
throw new NodeOperationError(this.getNode(), error as Error);
|
||||
}
|
||||
|
||||
const response: string | undefined = get(receivedData, 'data[0][0].json') as
|
||||
| string
|
||||
| undefined;
|
||||
if (response === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'There was an error: "The workflow did not return a response"',
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const toolHandler = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<string> => {
|
||||
const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]);
|
||||
|
||||
let response: string = '';
|
||||
let executionError: ExecutionError | undefined;
|
||||
try {
|
||||
response = await runFunction(query, runManager);
|
||||
} catch (error) {
|
||||
// TODO: Do some more testing. Issues here should actually fail the workflow
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
executionError = error;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
response = `There was an error: "${error.message}"`;
|
||||
}
|
||||
|
||||
if (typeof response === 'number') {
|
||||
response = (response as number).toString();
|
||||
}
|
||||
|
||||
if (isObject(response)) {
|
||||
response = JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
// TODO: Do some more testing. Issues here should actually fail the workflow
|
||||
executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', {
|
||||
description: `The response property should be a string, but it is an ${typeof response}`,
|
||||
});
|
||||
response = `There was an error: "${executionError.message}"`;
|
||||
}
|
||||
|
||||
let metadata: ITaskMetadata | undefined;
|
||||
if (subExecutionId && subWorkflowId) {
|
||||
metadata = {
|
||||
subExecution: {
|
||||
executionId: subExecutionId,
|
||||
workflowId: subWorkflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (executionError) {
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, executionError, metadata);
|
||||
} else {
|
||||
// Output always needs to be an object
|
||||
// so we try to parse the response as JSON and if it fails we just return the string wrapped in an object
|
||||
const json = jsonParse<IDataObject>(response, { fallbackValue: { response } });
|
||||
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const functionBase = {
|
||||
name,
|
||||
description,
|
||||
func: toolHandler,
|
||||
};
|
||||
|
||||
if (useSchema) {
|
||||
try {
|
||||
// We initialize these even though one of them will always be empty
|
||||
// it makes it easier to navigate the ternary operator
|
||||
const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
|
||||
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
|
||||
|
||||
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
||||
const jsonSchema =
|
||||
schemaType === 'fromJson'
|
||||
? generateSchema(jsonExample)
|
||||
: jsonParse<JSONSchema7>(inputSchema);
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
||||
|
||||
tool = new DynamicStructuredTool({
|
||||
schema: zodSchema,
|
||||
...functionBase,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Error during parsing of JSON Schema. \n ' + error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tool = new DynamicTool(functionBase);
|
||||
}
|
||||
|
||||
return {
|
||||
response: tool,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionType } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
inputSchemaField,
|
||||
jsonSchemaExampleField,
|
||||
schemaTypeField,
|
||||
} from '../../../../utils/descriptions';
|
||||
import { getConnectionHintNoticeField } from '../../../../utils/sharedFields';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Call n8n Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
icon: 'fa:network-wired',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
||||
defaults: {
|
||||
name: 'Call n8n Workflow Tool',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Recommended Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node
|
||||
inputs: [],
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong
|
||||
outputs: [NodeConnectionType.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'See an example of a workflow to suggest meeting slots using AI <a href="/templates/1953" target="_blank">here</a>.',
|
||||
name: 'noticeTemplateExample',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'My_Color_Tool',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. My_Color_Tool',
|
||||
validateType: 'string-alphanumeric',
|
||||
description:
|
||||
'The name of the function to be called, could contain letters, numbers, and underscores only',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder:
|
||||
'Call this tool to get a random color. The input should be a string with comma separted names of colors to exclude.',
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
displayName:
|
||||
'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger',
|
||||
name: 'executeNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Source',
|
||||
name: 'source',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Database',
|
||||
value: 'database',
|
||||
description: 'Load the workflow from the database by ID',
|
||||
},
|
||||
{
|
||||
name: 'Define Below',
|
||||
value: 'parameter',
|
||||
description: 'Pass the JSON code of a workflow',
|
||||
},
|
||||
],
|
||||
default: 'database',
|
||||
description: 'Where to get the workflow to execute from',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:database
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Workflow ID',
|
||||
name: 'workflowId',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { lte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'The workflow to execute',
|
||||
hint: 'Can be found in the URL of the workflow',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Workflow',
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:parameter
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Workflow JSON',
|
||||
name: 'workflowJson',
|
||||
type: 'json',
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['parameter'],
|
||||
},
|
||||
},
|
||||
default: '\n\n\n\n\n\n\n\n\n',
|
||||
required: true,
|
||||
description: 'The workflow JSON code to execute',
|
||||
},
|
||||
// ----------------------------------
|
||||
// For all
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Field to Return',
|
||||
name: 'responsePropertyName',
|
||||
type: 'string',
|
||||
default: 'response',
|
||||
required: true,
|
||||
hint: 'The field in the last-executed node of the workflow that contains the response',
|
||||
description:
|
||||
'Where to find the data that this tool should return. n8n will look in the output of the last-executed node of the workflow for a field with this name, and return its value.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lt: 1.3 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Extra Workflow Inputs',
|
||||
name: 'fields',
|
||||
placeholder: 'Add Value',
|
||||
type: 'fixedCollection',
|
||||
description:
|
||||
"These will be output by the 'execute workflow' trigger of the workflow being called",
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
sortable: true,
|
||||
},
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. fieldName',
|
||||
description:
|
||||
'Name of the field to set the value of. Supports dot-notation. Example: data.person[0].name.',
|
||||
requiresDataPath: 'single',
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
description: 'The field value type',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'String',
|
||||
value: 'stringValue',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'numberValue',
|
||||
},
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'booleanValue',
|
||||
},
|
||||
{
|
||||
name: 'Array',
|
||||
value: 'arrayValue',
|
||||
},
|
||||
{
|
||||
name: 'Object',
|
||||
value: 'objectValue',
|
||||
},
|
||||
],
|
||||
default: 'stringValue',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'stringValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['stringValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'string',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'numberValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['numberValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'number',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'booleanValue',
|
||||
type: 'options',
|
||||
default: 'true',
|
||||
options: [
|
||||
{
|
||||
name: 'True',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
name: 'False',
|
||||
value: 'false',
|
||||
},
|
||||
],
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['booleanValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'boolean',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'arrayValue',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. [ arrayItem1, arrayItem2, arrayItem3 ]',
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['arrayValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'array',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'objectValue',
|
||||
type: 'json',
|
||||
default: '={}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
type: ['objectValue'],
|
||||
},
|
||||
},
|
||||
validateType: 'object',
|
||||
ignoreValidationDuringExecution: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// ----------------------------------
|
||||
// Output Parsing
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Specify Input Schema',
|
||||
name: 'specifyInputSchema',
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.',
|
||||
noDataExpression: true,
|
||||
default: false,
|
||||
},
|
||||
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
|
||||
jsonSchemaExampleField,
|
||||
inputSchemaField,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
|
||||
import type {
|
||||
INodeTypeBaseDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WorkflowToolService } from './utils/WorkflowToolService';
|
||||
import { versionDescription } from './versionDescription';
|
||||
|
||||
export class ToolWorkflowV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
localResourceMapping: {
|
||||
loadWorkflowInputMappings,
|
||||
},
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const workflowToolService = new WorkflowToolService(this);
|
||||
const name = this.getNodeParameter('name', itemIndex) as string;
|
||||
const description = this.getNodeParameter('description', itemIndex) as string;
|
||||
|
||||
const tool = await workflowToolService.createTool({
|
||||
name,
|
||||
description,
|
||||
itemIndex,
|
||||
});
|
||||
|
||||
return { response: tool };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/* eslint-disable @typescript-eslint/dot-notation */ // Disabled to allow access to private methods
|
||||
import { DynamicTool } from '@langchain/core/tools';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ISupplyDataFunctions,
|
||||
INodeExecutionData,
|
||||
IWorkflowDataProxyData,
|
||||
ExecuteWorkflowData,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WorkflowToolService } from './utils/WorkflowToolService';
|
||||
|
||||
// Mock ISupplyDataFunctions interface
|
||||
function createMockContext(overrides?: Partial<ISupplyDataFunctions>): ISupplyDataFunctions {
|
||||
return {
|
||||
getNodeParameter: jest.fn(),
|
||||
getWorkflowDataProxy: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
executeWorkflow: jest.fn(),
|
||||
addInputData: jest.fn(),
|
||||
addOutputData: jest.fn(),
|
||||
getCredentials: jest.fn(),
|
||||
getCredentialsProperties: jest.fn(),
|
||||
getInputData: jest.fn(),
|
||||
getMode: jest.fn(),
|
||||
getRestApiUrl: jest.fn(),
|
||||
getTimezone: jest.fn(),
|
||||
getWorkflow: jest.fn(),
|
||||
getWorkflowStaticData: jest.fn(),
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as ISupplyDataFunctions;
|
||||
}
|
||||
|
||||
describe('WorkflowTool::WorkflowToolService', () => {
|
||||
let context: ISupplyDataFunctions;
|
||||
let service: WorkflowToolService;
|
||||
|
||||
beforeEach(() => {
|
||||
// Prepare essential mocks
|
||||
context = createMockContext();
|
||||
jest.spyOn(context, 'getNode').mockReturnValue({
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
} as unknown as INode);
|
||||
service = new WorkflowToolService(context);
|
||||
});
|
||||
|
||||
describe('createTool', () => {
|
||||
it('should create a basic dynamic tool when schema is not used', async () => {
|
||||
const toolParams = {
|
||||
name: 'TestTool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
};
|
||||
|
||||
const result = await service.createTool(toolParams);
|
||||
|
||||
expect(result).toBeInstanceOf(DynamicTool);
|
||||
expect(result).toHaveProperty('name', 'TestTool');
|
||||
expect(result).toHaveProperty('description', 'Test Description');
|
||||
});
|
||||
|
||||
it('should create a tool that can handle successful execution', async () => {
|
||||
const toolParams = {
|
||||
name: 'TestTool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
};
|
||||
|
||||
const TEST_RESPONSE = { msg: 'test response' };
|
||||
|
||||
const mockExecuteWorkflowResponse: ExecuteWorkflowData = {
|
||||
data: [[{ json: TEST_RESPONSE }]],
|
||||
executionId: 'test-execution',
|
||||
};
|
||||
|
||||
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse);
|
||||
jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
|
||||
jest.spyOn(context, 'getNodeParameter').mockReturnValue('database');
|
||||
jest.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
} as unknown as IWorkflowDataProxyData);
|
||||
|
||||
const tool = await service.createTool(toolParams);
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2));
|
||||
expect(context.addOutputData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors during tool execution', async () => {
|
||||
const toolParams = {
|
||||
name: 'TestTool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(context, 'executeWorkflow')
|
||||
.mockRejectedValueOnce(new Error('Workflow execution failed'));
|
||||
jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
|
||||
jest.spyOn(context, 'getNodeParameter').mockReturnValue('database');
|
||||
|
||||
const tool = await service.createTool(toolParams);
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toContain('There was an error');
|
||||
expect(context.addOutputData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleToolResponse', () => {
|
||||
it('should handle number response', () => {
|
||||
const result = service['handleToolResponse'](42);
|
||||
|
||||
expect(result).toBe('42');
|
||||
});
|
||||
|
||||
it('should handle object response', () => {
|
||||
const obj = { test: 'value' };
|
||||
|
||||
const result = service['handleToolResponse'](obj);
|
||||
|
||||
expect(result).toBe(JSON.stringify(obj, null, 2));
|
||||
});
|
||||
|
||||
it('should handle string response', () => {
|
||||
const result = service['handleToolResponse']('test response');
|
||||
|
||||
expect(result).toBe('test response');
|
||||
});
|
||||
|
||||
it('should throw error for invalid response type', () => {
|
||||
expect(() => service['handleToolResponse'](undefined)).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeSubWorkflow', () => {
|
||||
it('should successfully execute workflow and return response', async () => {
|
||||
const workflowInfo = { id: 'test-workflow' };
|
||||
const items: INodeExecutionData[] = [];
|
||||
const workflowProxyMock = {
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
} as unknown as IWorkflowDataProxyData;
|
||||
|
||||
const TEST_RESPONSE = { msg: 'test response' };
|
||||
|
||||
const mockResponse: ExecuteWorkflowData = {
|
||||
data: [[{ json: TEST_RESPONSE }]],
|
||||
executionId: 'test-execution',
|
||||
};
|
||||
|
||||
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await service['executeSubWorkflow'](workflowInfo, items, workflowProxyMock);
|
||||
|
||||
expect(result.response).toBe(TEST_RESPONSE);
|
||||
expect(result.subExecutionId).toBe('test-execution');
|
||||
});
|
||||
|
||||
it('should throw error when workflow execution fails', async () => {
|
||||
jest.spyOn(context, 'executeWorkflow').mockRejectedValueOnce(new Error('Execution failed'));
|
||||
|
||||
await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when workflow returns no response', async () => {
|
||||
const mockResponse: ExecuteWorkflowData = {
|
||||
data: [],
|
||||
executionId: 'test-execution',
|
||||
};
|
||||
|
||||
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await expect(service['executeSubWorkflow']({}, [], {} as never)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubWorkflowInfo', () => {
|
||||
it('should handle database source correctly', async () => {
|
||||
const source = 'database';
|
||||
const itemIndex = 0;
|
||||
const workflowProxyMock = {
|
||||
$workflow: { id: 'proxy-id' },
|
||||
} as unknown as IWorkflowDataProxyData;
|
||||
|
||||
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce({ value: 'workflow-id' });
|
||||
|
||||
const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock);
|
||||
|
||||
expect(result.workflowInfo).toHaveProperty('id', 'workflow-id');
|
||||
expect(result.subWorkflowId).toBe('workflow-id');
|
||||
});
|
||||
|
||||
it('should handle parameter source correctly', async () => {
|
||||
const source = 'parameter';
|
||||
const itemIndex = 0;
|
||||
const workflowProxyMock = {
|
||||
$workflow: { id: 'proxy-id' },
|
||||
} as unknown as IWorkflowDataProxyData;
|
||||
const mockWorkflow = { id: 'test-workflow' };
|
||||
|
||||
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce(JSON.stringify(mockWorkflow));
|
||||
|
||||
const result = await service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock);
|
||||
|
||||
expect(result.workflowInfo.code).toEqual(mockWorkflow);
|
||||
expect(result.subWorkflowId).toBe('proxy-id');
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON in parameter source', async () => {
|
||||
const source = 'parameter';
|
||||
const itemIndex = 0;
|
||||
const workflowProxyMock = {
|
||||
$workflow: { id: 'proxy-id' },
|
||||
} as unknown as IWorkflowDataProxyData;
|
||||
|
||||
jest.spyOn(context, 'getNodeParameter').mockReturnValueOnce('invalid json');
|
||||
|
||||
await expect(
|
||||
service['getSubWorkflowInfo'](source, itemIndex, workflowProxyMock),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { jsonParse, NodeOperationError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
type AllowedTypes = 'string' | 'number' | 'boolean' | 'json';
|
||||
export interface FromAIArgument {
|
||||
key: string;
|
||||
description?: string;
|
||||
type?: AllowedTypes;
|
||||
defaultValue?: string | number | boolean | Record<string, unknown>;
|
||||
}
|
||||
|
||||
// TODO: We copied this class from the core package, once the new nodes context work is merged, this should be available in root node context and this file can be removed.
|
||||
// Please apply any changes to both files
|
||||
|
||||
/**
|
||||
* AIParametersParser
|
||||
*
|
||||
* This class encapsulates the logic for parsing node parameters, extracting $fromAI calls,
|
||||
* generating Zod schemas, and creating LangChain tools.
|
||||
*/
|
||||
export class AIParametersParser {
|
||||
private ctx: ISupplyDataFunctions;
|
||||
|
||||
/**
|
||||
* Constructs an instance of AIParametersParser.
|
||||
* @param ctx The execution context.
|
||||
*/
|
||||
constructor(ctx: ISupplyDataFunctions) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a Zod schema based on the provided FromAIArgument placeholder.
|
||||
* @param placeholder The FromAIArgument object containing key, type, description, and defaultValue.
|
||||
* @returns A Zod schema corresponding to the placeholder's type and constraints.
|
||||
*/
|
||||
generateZodSchema(placeholder: FromAIArgument): z.ZodTypeAny {
|
||||
let schema: z.ZodTypeAny;
|
||||
|
||||
switch (placeholder.type?.toLowerCase()) {
|
||||
case 'string':
|
||||
schema = z.string();
|
||||
break;
|
||||
case 'number':
|
||||
schema = z.number();
|
||||
break;
|
||||
case 'boolean':
|
||||
schema = z.boolean();
|
||||
break;
|
||||
case 'json':
|
||||
schema = z.record(z.any());
|
||||
break;
|
||||
default:
|
||||
schema = z.string();
|
||||
}
|
||||
|
||||
if (placeholder.description) {
|
||||
schema = schema.describe(`${schema.description ?? ''} ${placeholder.description}`.trim());
|
||||
}
|
||||
|
||||
if (placeholder.defaultValue !== undefined) {
|
||||
schema = schema.default(placeholder.defaultValue);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively traverses the nodeParameters object to find all $fromAI calls.
|
||||
* @param payload The current object or value being traversed.
|
||||
* @param collectedArgs The array collecting FromAIArgument objects.
|
||||
*/
|
||||
traverseNodeParameters(payload: unknown, collectedArgs: FromAIArgument[]) {
|
||||
if (typeof payload === 'string') {
|
||||
const fromAICalls = this.extractFromAICalls(payload);
|
||||
fromAICalls.forEach((call) => collectedArgs.push(call));
|
||||
} else if (Array.isArray(payload)) {
|
||||
payload.forEach((item: unknown) => this.traverseNodeParameters(item, collectedArgs));
|
||||
} else if (typeof payload === 'object' && payload !== null) {
|
||||
Object.values(payload).forEach((value) => this.traverseNodeParameters(value, collectedArgs));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all $fromAI calls from a given string
|
||||
* @param str The string to search for $fromAI calls.
|
||||
* @returns An array of FromAIArgument objects.
|
||||
*
|
||||
* This method uses a regular expression to find the start of each $fromAI function call
|
||||
* in the input string. It then employs a character-by-character parsing approach to
|
||||
* accurately extract the arguments of each call, handling nested parentheses and quoted strings.
|
||||
*
|
||||
* The parsing process:
|
||||
* 1. Finds the starting position of a $fromAI call using regex.
|
||||
* 2. Iterates through characters, keeping track of parentheses depth and quote status.
|
||||
* 3. Handles escaped characters within quotes to avoid premature quote closing.
|
||||
* 4. Builds the argument string until the matching closing parenthesis is found.
|
||||
* 5. Parses the extracted argument string into a FromAIArgument object.
|
||||
* 6. Repeats the process for all $fromAI calls in the input string.
|
||||
*
|
||||
*/
|
||||
extractFromAICalls(str: string): FromAIArgument[] {
|
||||
const args: FromAIArgument[] = [];
|
||||
// Regular expression to match the start of a $fromAI function call
|
||||
const pattern = /\$fromAI\s*\(\s*/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(str)) !== null) {
|
||||
const startIndex = match.index + match[0].length;
|
||||
let current = startIndex;
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let parenthesesCount = 1;
|
||||
let argsString = '';
|
||||
|
||||
// Parse the arguments string, handling nested parentheses and quotes
|
||||
while (current < str.length && parenthesesCount > 0) {
|
||||
const char = str[current];
|
||||
|
||||
if (inQuotes) {
|
||||
// Handle characters inside quotes, including escaped characters
|
||||
if (char === '\\' && current + 1 < str.length) {
|
||||
argsString += char + str[current + 1];
|
||||
current += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === quoteChar) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
}
|
||||
argsString += char;
|
||||
} else {
|
||||
// Handle characters outside quotes
|
||||
if (['"', "'", '`'].includes(char)) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
} else if (char === '(') {
|
||||
parenthesesCount++;
|
||||
} else if (char === ')') {
|
||||
parenthesesCount--;
|
||||
}
|
||||
|
||||
// Only add characters if we're still inside the main parentheses
|
||||
if (parenthesesCount > 0 || char !== ')') {
|
||||
argsString += char;
|
||||
}
|
||||
}
|
||||
|
||||
current++;
|
||||
}
|
||||
|
||||
// If parentheses are balanced, parse the arguments
|
||||
if (parenthesesCount === 0) {
|
||||
try {
|
||||
const parsedArgs = this.parseArguments(argsString);
|
||||
args.push(parsedArgs);
|
||||
} catch (error) {
|
||||
// If parsing fails, throw an ApplicationError with details
|
||||
throw new NodeOperationError(
|
||||
this.ctx.getNode(),
|
||||
`Failed to parse $fromAI arguments: ${argsString}: ${error}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Log an error if parentheses are unbalanced
|
||||
throw new NodeOperationError(
|
||||
this.ctx.getNode(),
|
||||
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the arguments of a single $fromAI function call.
|
||||
* @param argsString The string containing the function arguments.
|
||||
* @returns A FromAIArgument object.
|
||||
*/
|
||||
parseArguments(argsString: string): FromAIArgument {
|
||||
// Split arguments by commas not inside quotes
|
||||
const args: string[] = [];
|
||||
let currentArg = '';
|
||||
let inQuotes = false;
|
||||
let quoteChar = '';
|
||||
let escapeNext = false;
|
||||
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const char = argsString[i];
|
||||
|
||||
if (escapeNext) {
|
||||
currentArg += char;
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['"', "'", '`'].includes(char)) {
|
||||
if (!inQuotes) {
|
||||
inQuotes = true;
|
||||
quoteChar = char;
|
||||
currentArg += char;
|
||||
} else if (char === quoteChar) {
|
||||
inQuotes = false;
|
||||
quoteChar = '';
|
||||
currentArg += char;
|
||||
} else {
|
||||
currentArg += char;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ',' && !inQuotes) {
|
||||
args.push(currentArg.trim());
|
||||
currentArg = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
currentArg += char;
|
||||
}
|
||||
|
||||
if (currentArg) {
|
||||
args.push(currentArg.trim());
|
||||
}
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
const cleanArgs = args.map((arg) => {
|
||||
const trimmed = arg.trim();
|
||||
if (
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('`') && trimmed.endsWith('`')) ||
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
||||
) {
|
||||
return trimmed
|
||||
.slice(1, -1)
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\`/g, '`')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
const type = cleanArgs?.[2] || 'string';
|
||||
|
||||
if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) {
|
||||
throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`);
|
||||
}
|
||||
|
||||
return {
|
||||
key: cleanArgs[0] || '',
|
||||
description: cleanArgs[1],
|
||||
type: (cleanArgs?.[2] ?? 'string') as AllowedTypes,
|
||||
defaultValue: this.parseDefaultValue(cleanArgs[3]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the default value, preserving its original type.
|
||||
* @param value The default value as a string.
|
||||
* @returns The parsed default value in its appropriate type.
|
||||
*/
|
||||
parseDefaultValue(
|
||||
value: string | undefined,
|
||||
): string | number | boolean | Record<string, unknown> | undefined {
|
||||
if (value === undefined || value === '') return undefined;
|
||||
const lowerValue = value.toLowerCase();
|
||||
if (lowerValue === 'true') return true;
|
||||
if (lowerValue === 'false') return false;
|
||||
if (!isNaN(Number(value))) return Number(value);
|
||||
try {
|
||||
return jsonParse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import get from 'lodash/get';
|
||||
import isObject from 'lodash/isObject';
|
||||
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
|
||||
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
|
||||
import { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
|
||||
import type {
|
||||
ExecuteWorkflowData,
|
||||
ExecutionError,
|
||||
IDataObject,
|
||||
IExecuteWorkflowInfo,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
ISupplyDataFunctions,
|
||||
ITaskMetadata,
|
||||
IWorkflowBase,
|
||||
IWorkflowDataProxyData,
|
||||
ResourceMapperValue,
|
||||
} from 'n8n-workflow';
|
||||
import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { FromAIArgument } from './FromAIParser';
|
||||
import { AIParametersParser } from './FromAIParser';
|
||||
|
||||
/**
|
||||
Main class for creating the Workflow tool
|
||||
Processes the node parameters and creates AI Agent tool capable of executing n8n workflows
|
||||
*/
|
||||
export class WorkflowToolService {
|
||||
// Determines if we should use input schema when creating the tool
|
||||
private useSchema: boolean;
|
||||
|
||||
// Sub-workflow id, pulled from referenced sub-workflow
|
||||
private subWorkflowId: string | undefined;
|
||||
|
||||
// Sub-workflow execution id, will be set after the sub-workflow is executed
|
||||
private subExecutionId: string | undefined;
|
||||
|
||||
constructor(private context: ISupplyDataFunctions) {
|
||||
const subWorkflowInputs = this.context.getNode().parameters
|
||||
.workflowInputs as ResourceMapperValue;
|
||||
this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0;
|
||||
}
|
||||
|
||||
// Creates the tool based on the provided parameters
|
||||
async createTool({
|
||||
name,
|
||||
description,
|
||||
itemIndex,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
itemIndex: number;
|
||||
}): Promise<DynamicTool | DynamicStructuredTool> {
|
||||
// Handler for the tool execution, will be called when the tool is executed
|
||||
// This function will execute the sub-workflow and return the response
|
||||
const toolHandler = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<string> => {
|
||||
const { index } = this.context.addInputData(NodeConnectionType.AiTool, [
|
||||
[{ json: { query } }],
|
||||
]);
|
||||
|
||||
try {
|
||||
const response = await this.runFunction(query, itemIndex, runManager);
|
||||
const processedResponse = this.handleToolResponse(response);
|
||||
|
||||
// Once the sub-workflow is executed, add the output data to the context
|
||||
// This will be used to link the sub-workflow execution in the parent workflow
|
||||
let metadata: ITaskMetadata | undefined;
|
||||
if (this.subExecutionId && this.subWorkflowId) {
|
||||
metadata = {
|
||||
subExecution: {
|
||||
executionId: this.subExecutionId,
|
||||
workflowId: this.subWorkflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
const json = jsonParse<IDataObject>(processedResponse, {
|
||||
fallbackValue: { response: processedResponse },
|
||||
});
|
||||
void this.context.addOutputData(NodeConnectionType.AiTool, index, [[{ json }]], metadata);
|
||||
|
||||
return processedResponse;
|
||||
} catch (error) {
|
||||
const executionError = error as ExecutionError;
|
||||
const errorResponse = `There was an error: "${executionError.message}"`;
|
||||
void this.context.addOutputData(NodeConnectionType.AiTool, index, executionError);
|
||||
return errorResponse;
|
||||
}
|
||||
};
|
||||
|
||||
// Create structured tool if input schema is provided
|
||||
return this.useSchema
|
||||
? await this.createStructuredTool(name, description, toolHandler)
|
||||
: new DynamicTool({ name, description, func: toolHandler });
|
||||
}
|
||||
|
||||
private handleToolResponse(response: unknown): string {
|
||||
if (typeof response === 'number') {
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
if (isObject(response)) {
|
||||
return JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
throw new NodeOperationError(this.context.getNode(), 'Wrong output type returned', {
|
||||
description: `The response property should be a string, but it is an ${typeof response}`,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes specified sub-workflow with provided inputs
|
||||
*/
|
||||
private async executeSubWorkflow(
|
||||
workflowInfo: IExecuteWorkflowInfo,
|
||||
items: INodeExecutionData[],
|
||||
workflowProxy: IWorkflowDataProxyData,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<{ response: string; subExecutionId: string }> {
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedData = await this.context.executeWorkflow(
|
||||
workflowInfo,
|
||||
items,
|
||||
runManager?.getChild(),
|
||||
{
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
},
|
||||
);
|
||||
// Set sub-workflow execution id so it can be used in other places
|
||||
this.subExecutionId = receivedData.executionId;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(this.context.getNode(), error as Error);
|
||||
}
|
||||
|
||||
const response: string | undefined = get(receivedData, 'data[0][0].json') as string | undefined;
|
||||
if (response === undefined) {
|
||||
throw new NodeOperationError(
|
||||
this.context.getNode(),
|
||||
'There was an error: "The workflow did not return a response"',
|
||||
);
|
||||
}
|
||||
|
||||
return { response, subExecutionId: receivedData.executionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sub-workflow info based on the source and executes it.
|
||||
* This function will be called as part of the tool execution (from the toolHandler)
|
||||
*/
|
||||
private async runFunction(
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<string> {
|
||||
const source = this.context.getNodeParameter('source', itemIndex) as string;
|
||||
const workflowProxy = this.context.getWorkflowDataProxy(0);
|
||||
|
||||
const { workflowInfo } = await this.getSubWorkflowInfo(source, itemIndex, workflowProxy);
|
||||
const rawData = this.prepareRawData(query, itemIndex);
|
||||
const items = await this.prepareWorkflowItems(query, itemIndex, rawData);
|
||||
|
||||
this.subWorkflowId = workflowInfo.id;
|
||||
|
||||
const { response } = await this.executeSubWorkflow(
|
||||
workflowInfo,
|
||||
items,
|
||||
workflowProxy,
|
||||
runManager,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sub-workflow info based on the source (database or parameter)
|
||||
*/
|
||||
private async getSubWorkflowInfo(
|
||||
source: string,
|
||||
itemIndex: number,
|
||||
workflowProxy: IWorkflowDataProxyData,
|
||||
): Promise<{
|
||||
workflowInfo: IExecuteWorkflowInfo;
|
||||
subWorkflowId: string;
|
||||
}> {
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
let subWorkflowId: string;
|
||||
|
||||
if (source === 'database') {
|
||||
const { value } = this.context.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
subWorkflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
const workflowJson = this.context.getNodeParameter('workflowJson', itemIndex) as string;
|
||||
try {
|
||||
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
|
||||
// subworkflow is same as parent workflow
|
||||
subWorkflowId = workflowProxy.$workflow.id;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.context.getNode(),
|
||||
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { workflowInfo, subWorkflowId: subWorkflowId! };
|
||||
}
|
||||
|
||||
private prepareRawData(query: string | IDataObject, itemIndex: number): IDataObject {
|
||||
const rawData: IDataObject = { query };
|
||||
const workflowFieldsJson = this.context.getNodeParameter('fields.values', itemIndex, [], {
|
||||
rawExpressions: true,
|
||||
}) as SetField[];
|
||||
|
||||
// Copied from Set Node v2
|
||||
for (const entry of workflowFieldsJson) {
|
||||
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
|
||||
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
return rawData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the sub-workflow items for execution
|
||||
*/
|
||||
private async prepareWorkflowItems(
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
rawData: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const options: SetNodeOptions = { include: 'all' };
|
||||
let jsonData = typeof query === 'object' ? query : { query };
|
||||
|
||||
if (this.useSchema) {
|
||||
const currentWorkflowInputs = getCurrentWorkflowInputData.call(this.context);
|
||||
jsonData = currentWorkflowInputs[itemIndex].json;
|
||||
}
|
||||
|
||||
const newItem = await manual.execute.call(
|
||||
this.context,
|
||||
{ json: jsonData },
|
||||
itemIndex,
|
||||
options,
|
||||
rawData,
|
||||
this.context.getNode(),
|
||||
);
|
||||
|
||||
return [newItem] as INodeExecutionData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create structured tool by parsing the sub-workflow input schema
|
||||
*/
|
||||
private async createStructuredTool(
|
||||
name: string,
|
||||
description: string,
|
||||
func: (query: string | IDataObject, runManager?: CallbackManagerForToolRun) => Promise<string>,
|
||||
): Promise<DynamicStructuredTool | DynamicTool> {
|
||||
const fromAIParser = new AIParametersParser(this.context);
|
||||
const collectedArguments = await this.extractFromAIParameters(fromAIParser);
|
||||
|
||||
// If there are no `fromAI` arguments, fallback to creating a simple tool
|
||||
if (collectedArguments.length === 0) {
|
||||
return new DynamicTool({ name, description, func });
|
||||
}
|
||||
|
||||
// Otherwise, prepare Zod schema and create a structured tool
|
||||
const schema = this.createZodSchema(collectedArguments, fromAIParser);
|
||||
return new DynamicStructuredTool({ schema, name, description, func });
|
||||
}
|
||||
|
||||
private async extractFromAIParameters(
|
||||
fromAIParser: AIParametersParser,
|
||||
): Promise<FromAIArgument[]> {
|
||||
const collectedArguments: FromAIArgument[] = [];
|
||||
fromAIParser.traverseNodeParameters(this.context.getNode().parameters, collectedArguments);
|
||||
|
||||
const uniqueArgsMap = new Map<string, FromAIArgument>();
|
||||
for (const arg of collectedArguments) {
|
||||
uniqueArgsMap.set(arg.key, arg);
|
||||
}
|
||||
|
||||
return Array.from(uniqueArgsMap.values());
|
||||
}
|
||||
|
||||
private createZodSchema(args: FromAIArgument[], parser: AIParametersParser): z.ZodObject<any> {
|
||||
const schemaObj = args.reduce((acc: Record<string, z.ZodTypeAny>, placeholder) => {
|
||||
acc[placeholder.key] = parser.generateZodSchema(placeholder);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return z.object(schemaObj).required();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import { NodeConnectionType, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { getConnectionHintNoticeField } from '../../../../utils/sharedFields';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Call n8n Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
icon: 'fa:network-wired',
|
||||
group: ['transform'],
|
||||
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
||||
defaults: {
|
||||
name: 'Call n8n Workflow Tool',
|
||||
},
|
||||
version: [2],
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionType.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionType.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'See an example of a workflow to suggest meeting slots using AI <a href="/templates/1953" target="_blank">here</a>.',
|
||||
name: 'noticeTemplateExample',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. My_Color_Tool',
|
||||
validateType: 'string-alphanumeric',
|
||||
description:
|
||||
'The name of the function to be called, could contain letters, numbers, and underscores only',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder:
|
||||
'Call this tool to get a random color. The input should be a string with comma separated names of colors to exclude.',
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
displayName:
|
||||
'This tool will call the workflow you define below, and look in the last node for the response. The workflow needs to start with an Execute Workflow trigger',
|
||||
name: 'executeNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Source',
|
||||
name: 'source',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Database',
|
||||
value: 'database',
|
||||
description: 'Load the workflow from the database by ID',
|
||||
},
|
||||
{
|
||||
name: 'Define Below',
|
||||
value: 'parameter',
|
||||
description: 'Pass the JSON code of a workflow',
|
||||
},
|
||||
],
|
||||
default: 'database',
|
||||
description: 'Where to get the workflow to execute from',
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:database
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Workflow',
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
// -----------------------------------------------
|
||||
// Resource mapper for workflow inputs
|
||||
// -----------------------------------------------
|
||||
{
|
||||
displayName: 'Workflow Inputs',
|
||||
name: 'workflowInputs',
|
||||
type: 'resourceMapper',
|
||||
noDataExpression: true,
|
||||
default: {
|
||||
mappingMode: 'defineBelow',
|
||||
value: null,
|
||||
},
|
||||
required: true,
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['workflowId.value'],
|
||||
resourceMapper: {
|
||||
localResourceMapperMethod: 'loadWorkflowInputMappings',
|
||||
valuesLabel: 'Workflow Inputs',
|
||||
mode: 'map',
|
||||
fieldWords: {
|
||||
singular: 'workflow input',
|
||||
plural: 'workflow inputs',
|
||||
},
|
||||
addAllFields: true,
|
||||
multiKeyMatch: false,
|
||||
supportAutoMap: false,
|
||||
},
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
},
|
||||
hide: {
|
||||
workflowId: [''],
|
||||
},
|
||||
},
|
||||
},
|
||||
// ----------------------------------
|
||||
// source:parameter
|
||||
// ----------------------------------
|
||||
{
|
||||
displayName: 'Workflow JSON',
|
||||
name: 'workflowJson',
|
||||
type: 'json',
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['parameter'],
|
||||
},
|
||||
},
|
||||
default: '\n\n\n\n\n\n\n\n\n',
|
||||
required: true,
|
||||
description: 'The workflow JSON code to execute',
|
||||
},
|
||||
],
|
||||
};
|
||||
Reference in New Issue
Block a user