feat: (Execute Workflow Node): Inputs for Sub-workflows (#11830) (#11837)

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:
Ivan Atanasov
2024-12-20 17:01:22 +01:00
committed by GitHub
parent 6c323e4e49
commit d4116630a6
52 changed files with 4023 additions and 688 deletions

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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,
],
};

View File

@@ -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 };
}
}

View File

@@ -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);
});
});
});

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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',
},
],
};