first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { DynamicTool } from '@langchain/classic/tools';
|
||||
import {
|
||||
type INode,
|
||||
type ISupplyDataFunctions,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolWorkflow } from './ToolWorkflow.node';
|
||||
import type { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node';
|
||||
import { WorkflowToolService } from './v2/utils/WorkflowToolService';
|
||||
|
||||
describe('ToolWorkflowV2', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should read name from node name on version >=2.2', async () => {
|
||||
const toolWorkflowNode = new ToolWorkflow();
|
||||
const node = toolWorkflowNode.nodeVersions[2.2] as ToolWorkflowV2;
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 2.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
case 'name':
|
||||
return 'wrong_field';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(DynamicTool);
|
||||
|
||||
const tool = supplyDataResult.response as DynamicTool;
|
||||
expect(tool.name).toBe('test_tool');
|
||||
expect(tool.description).toBe('description text');
|
||||
expect(tool.func).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should read name from name parameter on version <2.2', async () => {
|
||||
const toolWorkflowNode = new ToolWorkflow();
|
||||
const node = toolWorkflowNode.nodeVersions[2.1] as ToolWorkflowV2;
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 2.1, name: 'wrong name' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
case 'name':
|
||||
return 'test_tool';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(DynamicTool);
|
||||
|
||||
const tool = supplyDataResult.response as DynamicTool;
|
||||
expect(tool.name).toBe('test_tool');
|
||||
expect(tool.description).toBe('description text');
|
||||
expect(tool.func).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should properly spread INodeExecutionData array from tool.invoke', async () => {
|
||||
const toolWorkflowNode = new ToolWorkflow();
|
||||
const node = toolWorkflowNode.nodeVersions[2.2] as ToolWorkflowV2;
|
||||
|
||||
// Mock the tool that returns INodeExecutionData[]
|
||||
const mockToolResponse: INodeExecutionData[] = [{ json: { response: 'pikachu' } }];
|
||||
|
||||
const mockTool = {
|
||||
invoke: jest.fn().mockResolvedValue(mockToolResponse),
|
||||
} as any;
|
||||
|
||||
// Mock WorkflowToolService.createTool to return our mock tool
|
||||
jest.spyOn(WorkflowToolService.prototype, 'createTool').mockResolvedValue(mockTool);
|
||||
|
||||
const inputData: INodeExecutionData[] = [{ json: { query: 'what is a pokemon?' } }];
|
||||
|
||||
const executeResult = await node.execute.call(
|
||||
mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 2.2,
|
||||
name: 'test tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the result is properly formatted
|
||||
expect(executeResult).toHaveLength(1);
|
||||
expect(executeResult[0]).toHaveLength(1);
|
||||
expect(executeResult[0][0]).toEqual({ json: { response: 'pikachu' } });
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ query: 'what is a pokemon?' });
|
||||
});
|
||||
|
||||
it('should handle multiple items in the response', async () => {
|
||||
const toolWorkflowNode = new ToolWorkflow();
|
||||
const node = toolWorkflowNode.nodeVersions[2.2] as ToolWorkflowV2;
|
||||
|
||||
// Mock the tool that returns multiple INodeExecutionData items
|
||||
const mockToolResponse: INodeExecutionData[] = [
|
||||
{ json: { id: 1, name: 'pikachu' } },
|
||||
{ json: { id: 2, name: 'charizard' } },
|
||||
];
|
||||
|
||||
const mockTool = {
|
||||
invoke: jest.fn().mockResolvedValue(mockToolResponse),
|
||||
} as any;
|
||||
|
||||
jest.spyOn(WorkflowToolService.prototype, 'createTool').mockResolvedValue(mockTool);
|
||||
|
||||
const inputData: INodeExecutionData[] = [{ json: { query: 'list pokemon' } }];
|
||||
|
||||
const executeResult = await node.execute.call(
|
||||
mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 2.2,
|
||||
name: 'test tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify all items are properly spread into the response
|
||||
expect(executeResult).toHaveLength(1);
|
||||
expect(executeResult[0]).toHaveLength(2);
|
||||
expect(executeResult[0][0]).toEqual({ json: { id: 1, name: 'pikachu' } });
|
||||
expect(executeResult[0][1]).toEqual({ json: { id: 2, name: 'charizard' } });
|
||||
});
|
||||
|
||||
it('should handle fallback for non-array responses', async () => {
|
||||
const toolWorkflowNode = new ToolWorkflow();
|
||||
const node = toolWorkflowNode.nodeVersions[2.2] as ToolWorkflowV2;
|
||||
|
||||
// Mock the tool that returns a string (edge case)
|
||||
const mockTool = {
|
||||
invoke: jest.fn().mockResolvedValue('plain string response'),
|
||||
} as any;
|
||||
|
||||
jest.spyOn(WorkflowToolService.prototype, 'createTool').mockResolvedValue(mockTool);
|
||||
|
||||
const inputData: INodeExecutionData[] = [{ json: { query: 'test query' } }];
|
||||
|
||||
const executeResult = await node.execute.call(
|
||||
mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() =>
|
||||
mock<INode>({
|
||||
typeVersion: 2.2,
|
||||
name: 'test tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
}),
|
||||
),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the fallback wraps it properly
|
||||
expect(executeResult).toHaveLength(1);
|
||||
expect(executeResult[0]).toHaveLength(1);
|
||||
expect(executeResult[0][0]).toEqual({
|
||||
json: { response: 'plain string response' },
|
||||
pairedItem: { item: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { IVersionedNodeType, INodeTypeBaseDescription } from 'n8n-workflow';
|
||||
import { VersionedNodeType } from 'n8n-workflow';
|
||||
|
||||
import { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node';
|
||||
import { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node';
|
||||
|
||||
export class ToolWorkflow extends VersionedNodeType {
|
||||
constructor() {
|
||||
const baseDescription: INodeTypeBaseDescription = {
|
||||
displayName: 'Call n8n Sub-Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
icon: 'fa:network-wired',
|
||||
iconColor: 'black',
|
||||
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'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolworkflow/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVersion: 2.2,
|
||||
};
|
||||
|
||||
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),
|
||||
2.1: new ToolWorkflowV2(baseDescription),
|
||||
2.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 { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { versionDescription } from './versionDescription';
|
||||
import type { DynamicZodObject } from '../../../../types/zod.types';
|
||||
import { convertJsonSchemaToZod, generateSchemaFromExample } 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(NodeConnectionTypes.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(NodeConnectionTypes.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(NodeConnectionTypes.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'
|
||||
? generateSchemaFromExample(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,343 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
|
||||
import type { INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
inputSchemaField,
|
||||
jsonSchemaExampleField,
|
||||
schemaTypeField,
|
||||
} from '../../../../utils/descriptions';
|
||||
import { getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Call n8n Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
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/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.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,95 @@
|
||||
import type { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
|
||||
import type {
|
||||
INodeTypeBaseDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import { nodeNameToToolName, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { localResourceMapping } from './methods';
|
||||
import { WorkflowToolService } from './utils/WorkflowToolService';
|
||||
import { versionDescription } from './versionDescription';
|
||||
|
||||
async function getTool(
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions,
|
||||
enableLogging: boolean,
|
||||
itemIndex: number,
|
||||
): Promise<DynamicTool | DynamicStructuredTool> {
|
||||
const node = ctx.getNode();
|
||||
const { typeVersion } = node;
|
||||
const returnAllItems = typeVersion > 2;
|
||||
|
||||
const workflowToolService = new WorkflowToolService(ctx, { returnAllItems });
|
||||
const name =
|
||||
typeVersion <= 2.1 ? (ctx.getNodeParameter('name', 0) as string) : nodeNameToToolName(node);
|
||||
const description = ctx.getNodeParameter('description', 0) as string;
|
||||
|
||||
return await workflowToolService.createTool({
|
||||
ctx,
|
||||
name,
|
||||
description,
|
||||
itemIndex,
|
||||
manualLogging: enableLogging,
|
||||
});
|
||||
}
|
||||
|
||||
export class ToolWorkflowV2 implements INodeType {
|
||||
description: INodeTypeDescription;
|
||||
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
...versionDescription,
|
||||
};
|
||||
}
|
||||
|
||||
methods = {
|
||||
localResourceMapping,
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
return { response: await getTool(this, true, itemIndex) };
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
|
||||
const response: INodeExecutionData[] = [];
|
||||
for (let itemIndex = 0; itemIndex < this.getInputData().length; itemIndex++) {
|
||||
const item = items[itemIndex];
|
||||
const tool = await getTool(this, false, itemIndex);
|
||||
|
||||
if (item === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tool.invoke(item.json);
|
||||
|
||||
// When manualLogging is false, tool.invoke returns INodeExecutionData[]
|
||||
// We need to spread these into the response array
|
||||
if (Array.isArray(result)) {
|
||||
response.push(...result);
|
||||
} else {
|
||||
// Fallback for unexpected types (shouldn't happen with manualLogging=false)
|
||||
response.push({
|
||||
json: { response: result },
|
||||
pairedItem: { item: itemIndex },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Catch schema validation errors (ToolInputParsingException) and other errors
|
||||
// Re-throw as NodeOperationError with itemIndex for better error context
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
throw new NodeOperationError(this.getNode(), errorMessage, { itemIndex });
|
||||
}
|
||||
}
|
||||
|
||||
return [response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
import { DynamicTool } from '@langchain/core/tools';
|
||||
import { ApplicationError, NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ISupplyDataFunctions,
|
||||
INodeExecutionData,
|
||||
IWorkflowDataProxyData,
|
||||
ExecuteWorkflowData,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WorkflowToolService } from './utils/WorkflowToolService';
|
||||
|
||||
// Mock the sleep functions
|
||||
jest.mock('n8n-workflow', () => ({
|
||||
...jest.requireActual('n8n-workflow'),
|
||||
sleep: jest.fn().mockResolvedValue(undefined),
|
||||
sleepWithAbort: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
function createMockClonedContext(
|
||||
baseContext: ISupplyDataFunctions,
|
||||
executeWorkflowMock?: jest.MockedFunction<any>,
|
||||
): ISupplyDataFunctions {
|
||||
return {
|
||||
...baseContext,
|
||||
addOutputData: jest.fn(),
|
||||
getNodeParameter: baseContext.getNodeParameter,
|
||||
getWorkflowDataProxy: baseContext.getWorkflowDataProxy,
|
||||
executeWorkflow: executeWorkflowMock || baseContext.executeWorkflow,
|
||||
getNode: baseContext.getNode,
|
||||
} as ISupplyDataFunctions;
|
||||
}
|
||||
|
||||
function createMockContext(overrides?: Partial<ISupplyDataFunctions>): ISupplyDataFunctions {
|
||||
let runIndex = 0;
|
||||
const getNextRunIndex = jest.fn(() => {
|
||||
return runIndex++;
|
||||
});
|
||||
const context = {
|
||||
runIndex: 0,
|
||||
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(),
|
||||
getNextRunIndex,
|
||||
getTimezone: jest.fn(),
|
||||
getWorkflow: jest.fn(),
|
||||
getWorkflowStaticData: jest.fn(),
|
||||
getWorkflowSettings: jest.fn(() => ({})),
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as ISupplyDataFunctions;
|
||||
context.cloneWith = jest.fn().mockImplementation((_) => createMockClonedContext(context));
|
||||
return context;
|
||||
}
|
||||
|
||||
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 = {
|
||||
ctx: context,
|
||||
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 = {
|
||||
ctx: context,
|
||||
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);
|
||||
jest.spyOn(context, 'cloneWith').mockReturnValue(context);
|
||||
|
||||
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();
|
||||
|
||||
// Here we validate that the runIndex is correctly updated
|
||||
expect(context.cloneWith).toHaveBeenCalledWith({
|
||||
runIndex: 0,
|
||||
inputData: [[{ json: { query: 'test query' } }]],
|
||||
});
|
||||
|
||||
await tool.func('another query');
|
||||
expect(context.cloneWith).toHaveBeenCalledWith({
|
||||
runIndex: 1,
|
||||
inputData: [[{ json: { query: 'another query' } }]],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns un-stringified data if manualLogging is false (meaning it was called from the engine)', async () => {
|
||||
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, '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({
|
||||
ctx: context,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
manualLogging: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toEqual([{ json: TEST_RESPONSE }]);
|
||||
});
|
||||
|
||||
it('should handle errors during tool execution', async () => {
|
||||
const toolParams = {
|
||||
ctx: context,
|
||||
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');
|
||||
jest.spyOn(context, 'cloneWith').mockReturnValue(context);
|
||||
|
||||
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'](
|
||||
context,
|
||||
workflowInfo,
|
||||
items,
|
||||
workflowProxyMock,
|
||||
);
|
||||
|
||||
expect(result.response).toBe(TEST_RESPONSE);
|
||||
expect(result.subExecutionId).toBe('test-execution');
|
||||
});
|
||||
|
||||
it('should successfully execute workflow and return first item of many', 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_1 = { msg: 'test response 1' };
|
||||
const TEST_RESPONSE_2 = { msg: 'test response 2' };
|
||||
|
||||
const mockResponse: ExecuteWorkflowData = {
|
||||
data: [[{ json: TEST_RESPONSE_1 }, { json: TEST_RESPONSE_2 }]],
|
||||
executionId: 'test-execution',
|
||||
};
|
||||
|
||||
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await service['executeSubWorkflow'](
|
||||
context,
|
||||
workflowInfo,
|
||||
items,
|
||||
workflowProxyMock,
|
||||
);
|
||||
|
||||
expect(result.response).toBe(TEST_RESPONSE_1);
|
||||
expect(result.subExecutionId).toBe('test-execution');
|
||||
});
|
||||
|
||||
it('should successfully execute workflow and return all items', async () => {
|
||||
const serviceWithReturnAllItems = new WorkflowToolService(context, { returnAllItems: true });
|
||||
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_1 = { msg: 'test response 1' };
|
||||
const TEST_RESPONSE_2 = { msg: 'test response 2' };
|
||||
|
||||
const mockResponse: ExecuteWorkflowData = {
|
||||
data: [[{ json: TEST_RESPONSE_1 }, { json: TEST_RESPONSE_2 }]],
|
||||
executionId: 'test-execution',
|
||||
};
|
||||
|
||||
jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await serviceWithReturnAllItems['executeSubWorkflow'](
|
||||
context,
|
||||
workflowInfo,
|
||||
items,
|
||||
workflowProxyMock,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result.response).toEqual([{ json: TEST_RESPONSE_1 }, { json: TEST_RESPONSE_2 }]);
|
||||
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'](context, {}, [], {} 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'](context, {}, [], {} 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'](
|
||||
context,
|
||||
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'](
|
||||
context,
|
||||
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'](context, source, itemIndex, workflowProxyMock),
|
||||
).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error data format for addOutputData', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should pass error data in INodeExecutionData format to addOutputData', async () => {
|
||||
// This test ensures that when tool execution fails, the error is wrapped
|
||||
// in the correct format for addOutputData, not passed as raw ExecutionError
|
||||
const executeWorkflowMock = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Workflow execution failed'));
|
||||
const addOutputDataMock = jest.fn();
|
||||
|
||||
const contextWithError = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: false,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: addOutputDataMock,
|
||||
});
|
||||
contextWithError.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithError, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithError.getNodeParameter,
|
||||
addOutputData: addOutputDataMock,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithError);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithError,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
await tool.func('test query');
|
||||
|
||||
expect(addOutputDataMock).toHaveBeenCalled();
|
||||
const [connectionType, _runIndex, outputData] = addOutputDataMock.mock.calls[0];
|
||||
|
||||
// The output data should be in INodeExecutionData[][] format, not raw Error
|
||||
// This is critical for the agent to receive proper execution data
|
||||
expect(connectionType).toBe('ai_tool');
|
||||
expect(Array.isArray(outputData)).toBe(true);
|
||||
// Structure is [[{json: {error: ...}}]] - outer array for runs, inner for items
|
||||
expect(Array.isArray(outputData[0])).toBe(true);
|
||||
expect(outputData[0][0]).toHaveProperty('json');
|
||||
expect(outputData[0][0].json).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('should include error message in the wrapped output data', async () => {
|
||||
const errorMessage = 'Sub-workflow failed with validation error';
|
||||
const executeWorkflowMock = jest.fn().mockRejectedValue(new Error(errorMessage));
|
||||
const addOutputDataMock = jest.fn();
|
||||
|
||||
const contextWithError = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: false,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: addOutputDataMock,
|
||||
});
|
||||
contextWithError.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithError, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithError.getNodeParameter,
|
||||
addOutputData: addOutputDataMock,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithError);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithError,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
await tool.func('test query');
|
||||
|
||||
expect(addOutputDataMock).toHaveBeenCalled();
|
||||
const [, , outputData] = addOutputDataMock.mock.calls[0];
|
||||
|
||||
// The error message should be preserved in the output data
|
||||
// Structure is [[{json: {error: ...}}]]
|
||||
expect(Array.isArray(outputData)).toBe(true);
|
||||
expect(Array.isArray(outputData[0])).toBe(true);
|
||||
const errorJson = outputData[0][0]?.json?.error;
|
||||
expect(errorJson).toContain(errorMessage);
|
||||
});
|
||||
|
||||
it('should call addOutputData with correct arguments on error', async () => {
|
||||
// Test that addOutputData is called with all expected arguments
|
||||
const executeWorkflowMock = jest.fn().mockRejectedValue(new Error('Execution failed'));
|
||||
const addOutputDataMock = jest.fn();
|
||||
|
||||
const contextWithError = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: false,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: addOutputDataMock,
|
||||
});
|
||||
contextWithError.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithError, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithError.getNodeParameter,
|
||||
addOutputData: addOutputDataMock,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithError);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithError,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
await tool.func('test query');
|
||||
|
||||
expect(addOutputDataMock).toHaveBeenCalled();
|
||||
// Should be called with 4 arguments: connectionType, runIndex, data, metadata
|
||||
expect(addOutputDataMock.mock.calls[0]).toHaveLength(4);
|
||||
const [connectionType, runIndex, outputData, _metadata] = addOutputDataMock.mock.calls[0];
|
||||
expect(connectionType).toBe('ai_tool');
|
||||
expect(typeof runIndex).toBe('number');
|
||||
expect(Array.isArray(outputData)).toBe(true);
|
||||
// metadata may be undefined if parseErrorMetadata returns nothing, that's ok
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry functionality', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should not retry when retryOnFail is false', async () => {
|
||||
const executeWorkflowMock = jest.fn().mockRejectedValue(new Error('Test error'));
|
||||
const contextWithNonRetryNode = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: false,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: jest.fn(),
|
||||
});
|
||||
contextWithNonRetryNode.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithNonRetryNode, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithNonRetryNode.getNodeParameter,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithNonRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithNonRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
|
||||
expect(result).toContain('There was an error');
|
||||
});
|
||||
|
||||
it('should retry up to maxTries when retryOnFail is true', async () => {
|
||||
const executeWorkflowMock = jest.fn().mockRejectedValue(new Error('Test error'));
|
||||
const contextWithRetryNode = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: true,
|
||||
maxTries: 3,
|
||||
waitBetweenTries: 0,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: jest.fn(),
|
||||
});
|
||||
contextWithRetryNode.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithRetryNode, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithRetryNode.getNodeParameter,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(3);
|
||||
expect(result).toContain('There was an error');
|
||||
});
|
||||
|
||||
it('should succeed on retry after initial failure', async () => {
|
||||
const mockSuccessResponse = {
|
||||
data: [[{ json: { result: 'success' } }]],
|
||||
executionId: 'success-exec-id',
|
||||
};
|
||||
|
||||
const executeWorkflowMock = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('First attempt fails'))
|
||||
.mockResolvedValueOnce(mockSuccessResponse);
|
||||
|
||||
const contextWithRetryNode = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: true,
|
||||
maxTries: 3,
|
||||
waitBetweenTries: 0,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: jest.fn(),
|
||||
});
|
||||
contextWithRetryNode.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithRetryNode, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithRetryNode.getNodeParameter,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe(JSON.stringify({ result: 'success' }, null, 2));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ maxTries: 1, expected: 2 }, // Should be clamped to minimum 2
|
||||
{ maxTries: 3, expected: 3 },
|
||||
{ maxTries: 6, expected: 5 }, // Should be clamped to maximum 5
|
||||
])('should respect maxTries limits (2-5)', async ({ maxTries, expected }) => {
|
||||
const executeWorkflowMock = jest.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const contextWithRetryNode = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: true,
|
||||
maxTries,
|
||||
waitBetweenTries: 0,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
});
|
||||
|
||||
contextWithRetryNode.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithRetryNode, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithRetryNode.getNodeParameter,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
await tool.func('test query');
|
||||
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(expected);
|
||||
});
|
||||
|
||||
it('should respect waitBetweenTries with sleepWithAbort', async () => {
|
||||
const { sleepWithAbort } = jest.requireMock('n8n-workflow');
|
||||
sleepWithAbort.mockClear();
|
||||
const executeWorkflowMock = jest.fn().mockRejectedValue(new Error('Test error'));
|
||||
|
||||
const contextWithRetryNode = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: true,
|
||||
maxTries: 2,
|
||||
waitBetweenTries: 1500,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: jest.fn(),
|
||||
});
|
||||
contextWithRetryNode.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithRetryNode, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithRetryNode.getNodeParameter,
|
||||
...cloneOverrides,
|
||||
}));
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
await tool.func('test query');
|
||||
|
||||
expect(sleepWithAbort).toHaveBeenCalledWith(1500, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('abort signal functionality', () => {
|
||||
let abortController: AbortController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
abortController = new AbortController();
|
||||
});
|
||||
|
||||
const createAbortSignalContext = (
|
||||
executeWorkflowMock: jest.MockedFunction<any>,
|
||||
abortSignal?: AbortSignal,
|
||||
) => {
|
||||
const contextWithRetryNode = createMockContext({
|
||||
getNode: jest.fn().mockReturnValue({
|
||||
name: 'Test Tool',
|
||||
parameters: { workflowInputs: { schema: [] } },
|
||||
retryOnFail: true,
|
||||
maxTries: 3,
|
||||
waitBetweenTries: 100,
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockImplementation((name) => {
|
||||
if (name === 'source') return 'database';
|
||||
if (name === 'workflowId') return { value: 'test-workflow-id' };
|
||||
if (name === 'fields.values') return [];
|
||||
return {};
|
||||
}),
|
||||
executeWorkflow: executeWorkflowMock,
|
||||
addOutputData: jest.fn(),
|
||||
});
|
||||
contextWithRetryNode.cloneWith = jest.fn().mockImplementation((cloneOverrides) => ({
|
||||
...createMockClonedContext(contextWithRetryNode, executeWorkflowMock),
|
||||
getWorkflowDataProxy: jest.fn().mockReturnValue({
|
||||
$execution: { id: 'exec-id' },
|
||||
$workflow: { id: 'workflow-id' },
|
||||
}),
|
||||
getNodeParameter: contextWithRetryNode.getNodeParameter,
|
||||
getExecutionCancelSignal: jest.fn(() => abortSignal),
|
||||
...cloneOverrides,
|
||||
}));
|
||||
return contextWithRetryNode;
|
||||
};
|
||||
|
||||
it('should return cancellation message if signal is already aborted', async () => {
|
||||
const executeWorkflowMock = jest.fn().mockResolvedValue({
|
||||
data: [[{ json: { result: 'success' } }]],
|
||||
executionId: 'success-exec-id',
|
||||
});
|
||||
|
||||
// Abort before starting
|
||||
abortController.abort();
|
||||
|
||||
const contextWithRetryNode = createAbortSignalContext(
|
||||
executeWorkflowMock,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toBe('There was an error: "Execution was cancelled"');
|
||||
expect(executeWorkflowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle abort signal during retry wait', async () => {
|
||||
const { sleepWithAbort } = jest.requireMock('n8n-workflow');
|
||||
sleepWithAbort.mockRejectedValue(new Error('Execution was cancelled'));
|
||||
|
||||
const executeWorkflowMock = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('First attempt fails'))
|
||||
.mockResolvedValueOnce({
|
||||
data: [[{ json: { result: 'success' } }]],
|
||||
executionId: 'success-exec-id',
|
||||
});
|
||||
|
||||
const contextWithRetryNode = createAbortSignalContext(
|
||||
executeWorkflowMock,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toBe('There was an error: "Execution was cancelled"');
|
||||
expect(sleepWithAbort).toHaveBeenCalledWith(100, abortController.signal);
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(1); // Only first attempt
|
||||
});
|
||||
|
||||
it('should handle abort signal during execution', async () => {
|
||||
const executeWorkflowMock = jest.fn().mockImplementation(() => {
|
||||
// Simulate abort during execution
|
||||
abortController.abort();
|
||||
throw new ApplicationError('Workflow execution failed');
|
||||
});
|
||||
|
||||
const contextWithRetryNode = createAbortSignalContext(
|
||||
executeWorkflowMock,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toBe('There was an error: "Execution was cancelled"');
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should complete successfully if not aborted', async () => {
|
||||
const { sleepWithAbort } = jest.requireMock('n8n-workflow');
|
||||
sleepWithAbort.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
const executeWorkflowMock = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('First attempt fails'))
|
||||
.mockResolvedValueOnce({
|
||||
data: [[{ json: { result: 'success' } }]],
|
||||
executionId: 'success-exec-id',
|
||||
});
|
||||
|
||||
const contextWithRetryNode = createAbortSignalContext(
|
||||
executeWorkflowMock,
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toBe(JSON.stringify({ result: 'success' }, null, 2));
|
||||
expect(executeWorkflowMock).toHaveBeenCalledTimes(2);
|
||||
expect(sleepWithAbort).toHaveBeenCalledWith(100, abortController.signal);
|
||||
});
|
||||
|
||||
it('should work when getExecutionCancelSignal is not available', async () => {
|
||||
const { sleepWithAbort } = jest.requireMock('n8n-workflow');
|
||||
sleepWithAbort.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
const executeWorkflowMock = jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('First attempt fails'))
|
||||
.mockResolvedValueOnce({
|
||||
data: [[{ json: { result: 'success' } }]],
|
||||
executionId: 'success-exec-id',
|
||||
});
|
||||
|
||||
// Create context without getExecutionCancelSignal
|
||||
const contextWithRetryNode = createAbortSignalContext(executeWorkflowMock, undefined);
|
||||
|
||||
service = new WorkflowToolService(contextWithRetryNode);
|
||||
const tool = await service.createTool({
|
||||
ctx: contextWithRetryNode,
|
||||
name: 'Test Tool',
|
||||
description: 'Test Description',
|
||||
itemIndex: 0,
|
||||
});
|
||||
|
||||
const result = await tool.func('test query');
|
||||
|
||||
expect(result).toBe(JSON.stringify({ result: 'success' }, null, 2));
|
||||
expect(sleepWithAbort).toHaveBeenCalledWith(100, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * as localResourceMapping from './localResourceMapping';
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { loadWorkflowInputMappings } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
|
||||
import type { ILocalLoadOptionsFunctions, ResourceMapperFields } from 'n8n-workflow';
|
||||
|
||||
export async function loadSubWorkflowInputs(
|
||||
this: ILocalLoadOptionsFunctions,
|
||||
): Promise<ResourceMapperFields> {
|
||||
const { fields, subworkflowInfo, dataMode } = await loadWorkflowInputMappings.bind(this)();
|
||||
let emptyFieldsNotice: string | undefined;
|
||||
if (fields.length === 0) {
|
||||
const { triggerId, workflowId } = subworkflowInfo ?? {};
|
||||
const path = (workflowId ?? '') + (triggerId ? `/${triggerId.slice(0, 6)}` : '');
|
||||
const subworkflowLink = workflowId
|
||||
? `<a href="/workflow/${path}" target="_blank">sub-workflow’s trigger</a>`
|
||||
: 'sub-workflow’s trigger';
|
||||
|
||||
switch (dataMode) {
|
||||
case 'passthrough':
|
||||
emptyFieldsNotice = `This sub-workflow is set up to receive all input data, without specific inputs the Agent will not be able to pass data to this tool. You can define specific inputs in the ${subworkflowLink}.`;
|
||||
break;
|
||||
default:
|
||||
emptyFieldsNotice = `This sub-workflow will not receive any input when called by your AI node. Define your expected input in the ${subworkflowLink}.`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { fields, emptyFieldsNotice };
|
||||
}
|
||||
+432
@@ -0,0 +1,432 @@
|
||||
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import isArray from 'lodash/isArray';
|
||||
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,
|
||||
IExecuteFunctions,
|
||||
IExecuteWorkflowInfo,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
ISupplyDataFunctions,
|
||||
ITaskMetadata,
|
||||
IWorkflowBase,
|
||||
IWorkflowDataProxyData,
|
||||
ResourceMapperValue,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
jsonParse,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
parseErrorMetadata,
|
||||
sleepWithAbort,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { createZodSchemaFromArgs, extractFromAIParameters } from '@n8n/ai-utilities';
|
||||
|
||||
function isNodeExecutionData(data: unknown): data is INodeExecutionData[] {
|
||||
return isArray(data) && Boolean(data.length) && isObject(data[0]) && 'json' in data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
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;
|
||||
|
||||
private returnAllItems: boolean = false;
|
||||
|
||||
constructor(
|
||||
private baseContext: ISupplyDataFunctions | IExecuteFunctions,
|
||||
options?: { returnAllItems: boolean },
|
||||
) {
|
||||
const subWorkflowInputs = this.baseContext.getNode().parameters
|
||||
.workflowInputs as ResourceMapperValue;
|
||||
this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0;
|
||||
this.returnAllItems = options?.returnAllItems ?? false;
|
||||
}
|
||||
|
||||
// Creates the tool based on the provided parameters
|
||||
async createTool({
|
||||
ctx,
|
||||
name,
|
||||
description,
|
||||
itemIndex,
|
||||
manualLogging = true,
|
||||
}: {
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions;
|
||||
name: string;
|
||||
description: string;
|
||||
itemIndex: number;
|
||||
manualLogging?: boolean;
|
||||
}): 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
|
||||
// We get the runIndex from the context to handle multiple executions
|
||||
// of the same tool when the tool is used in a loop or in a parallel execution.
|
||||
const node = ctx.getNode();
|
||||
|
||||
let runIndex: number = 'getNextRunIndex' in ctx ? ctx.getNextRunIndex() : 0;
|
||||
const toolHandler = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<IDataObject | IDataObject[] | string> => {
|
||||
let maxTries = 1;
|
||||
if (node.retryOnFail === true) {
|
||||
maxTries = Math.min(5, Math.max(2, node.maxTries ?? 3));
|
||||
}
|
||||
|
||||
let waitBetweenTries = 0;
|
||||
if (node.retryOnFail === true) {
|
||||
waitBetweenTries = Math.min(5000, Math.max(0, node.waitBetweenTries ?? 1000));
|
||||
}
|
||||
|
||||
let lastError: ExecutionError | undefined;
|
||||
|
||||
for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) {
|
||||
const localRunIndex = runIndex++;
|
||||
|
||||
let context = this.baseContext;
|
||||
// We need to clone the context here to handle runIndex correctly
|
||||
// Otherwise the runIndex will be shared between different executions
|
||||
// Causing incorrect data to be passed to the sub-workflow and via $fromAI
|
||||
if ('cloneWith' in this.baseContext) {
|
||||
context = this.baseContext.cloneWith({
|
||||
runIndex: localRunIndex,
|
||||
inputData: [[{ json: { query } }]],
|
||||
});
|
||||
}
|
||||
|
||||
// Get abort signal from context for cancellation support
|
||||
const abortSignal = context.getExecutionCancelSignal?.();
|
||||
|
||||
// Check if execution was cancelled before retry
|
||||
if (abortSignal?.aborted) {
|
||||
return 'There was an error: "Execution was cancelled"';
|
||||
}
|
||||
|
||||
if (tryIndex !== 0) {
|
||||
// Reset error from previous attempt
|
||||
lastError = undefined;
|
||||
if (waitBetweenTries !== 0) {
|
||||
try {
|
||||
await sleepWithAbort(waitBetweenTries, abortSignal);
|
||||
} catch (abortError) {
|
||||
return 'There was an error: "Execution was cancelled"';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.runFunction(context, query, itemIndex, runManager);
|
||||
|
||||
const processedResponse = this.handleToolResponse(response);
|
||||
|
||||
let responseData: INodeExecutionData[];
|
||||
if (isNodeExecutionData(response)) {
|
||||
responseData = response;
|
||||
} else {
|
||||
const reParsedData = jsonParse<IDataObject>(processedResponse, {
|
||||
fallbackValue: { response: processedResponse },
|
||||
});
|
||||
|
||||
responseData = [{ json: reParsedData }];
|
||||
}
|
||||
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// If manualLogging is enabled we've been called by the AgentExecutor
|
||||
// and have to return a stringified response.
|
||||
if (manualLogging) {
|
||||
void context.addOutputData(
|
||||
NodeConnectionTypes.AiTool,
|
||||
localRunIndex,
|
||||
[responseData],
|
||||
metadata,
|
||||
);
|
||||
|
||||
return processedResponse;
|
||||
}
|
||||
// If manualLogging is false we've been called by the engine and need
|
||||
// the structured response.
|
||||
|
||||
if (metadata && 'setMetadata' in context) {
|
||||
void context.setMetadata(metadata);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
// Check if error is due to cancellation
|
||||
if (abortSignal?.aborted) {
|
||||
return 'There was an error: "Execution was cancelled"';
|
||||
}
|
||||
|
||||
const executionError = error as ExecutionError;
|
||||
lastError = executionError;
|
||||
const errorResponse = `There was an error: "${executionError.message}"`;
|
||||
|
||||
if (manualLogging) {
|
||||
const metadata = parseErrorMetadata(error);
|
||||
// Wrap error in INodeExecutionData format so it can be properly processed
|
||||
// by buildSteps and displayed in the UI execution data
|
||||
const errorData: INodeExecutionData[] = [{ json: { error: errorResponse } }];
|
||||
void context.addOutputData(
|
||||
NodeConnectionTypes.AiTool,
|
||||
localRunIndex,
|
||||
[errorData],
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (tryIndex === maxTries - 1) {
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `There was an error: ${lastError?.message ?? 'Unknown error'}`;
|
||||
};
|
||||
|
||||
// Create structured tool if input schema is provided
|
||||
return this.useSchema
|
||||
? this.createStructuredTool(name, description, toolHandler)
|
||||
: new DynamicTool({ name, description, func: toolHandler });
|
||||
}
|
||||
|
||||
private handleToolResponse(response: unknown): string {
|
||||
if (typeof response === 'number') {
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
if (isNodeExecutionData(response)) {
|
||||
return JSON.stringify(
|
||||
response.map((item) => item.json),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(response)) {
|
||||
return JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
throw new NodeOperationError(this.baseContext.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(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
workflowInfo: IExecuteWorkflowInfo,
|
||||
items: INodeExecutionData[],
|
||||
workflowProxy: IWorkflowDataProxyData,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<{ response: IDataObject | INodeExecutionData[]; subExecutionId: string }> {
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedData = await 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(context.getNode(), error as Error);
|
||||
}
|
||||
|
||||
let response: IDataObject | INodeExecutionData[] | undefined;
|
||||
if (this.returnAllItems) {
|
||||
response = receivedData?.data?.[0]?.length ? receivedData.data[0] : undefined;
|
||||
} else {
|
||||
response = receivedData?.data?.[0]?.[0]?.json;
|
||||
}
|
||||
if (response === undefined) {
|
||||
throw new NodeOperationError(
|
||||
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(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<IDataObject | INodeExecutionData[]> {
|
||||
const source = context.getNodeParameter('source', itemIndex) as string;
|
||||
const workflowProxy = context.getWorkflowDataProxy(0);
|
||||
|
||||
const { workflowInfo } = await this.getSubWorkflowInfo(
|
||||
context,
|
||||
source,
|
||||
itemIndex,
|
||||
workflowProxy,
|
||||
);
|
||||
const rawData = this.prepareRawData(context, query, itemIndex);
|
||||
const items = await this.prepareWorkflowItems(context, query, itemIndex, rawData);
|
||||
|
||||
this.subWorkflowId = workflowInfo.id;
|
||||
|
||||
const { response } = await this.executeSubWorkflow(
|
||||
context,
|
||||
workflowInfo,
|
||||
items,
|
||||
workflowProxy,
|
||||
runManager,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sub-workflow info based on the source (database or parameter)
|
||||
*/
|
||||
private async getSubWorkflowInfo(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
source: string,
|
||||
itemIndex: number,
|
||||
workflowProxy: IWorkflowDataProxyData,
|
||||
): Promise<{
|
||||
workflowInfo: IExecuteWorkflowInfo;
|
||||
subWorkflowId: string;
|
||||
}> {
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
let subWorkflowId: string;
|
||||
|
||||
if (source === 'database') {
|
||||
const { value } = context.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
subWorkflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
const workflowJson = 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(
|
||||
context.getNode(),
|
||||
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { workflowInfo, subWorkflowId: subWorkflowId! };
|
||||
}
|
||||
|
||||
private prepareRawData(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
): IDataObject {
|
||||
const rawData: IDataObject = { query };
|
||||
const workflowFieldsJson = 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(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
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(context);
|
||||
jsonData = currentWorkflowInputs[itemIndex].json;
|
||||
}
|
||||
|
||||
const newItem = await manual.execute.call(
|
||||
context,
|
||||
{ json: jsonData },
|
||||
itemIndex,
|
||||
options,
|
||||
rawData,
|
||||
context.getNode(),
|
||||
);
|
||||
|
||||
return [newItem] as INodeExecutionData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create structured tool by parsing the sub-workflow input schema
|
||||
*/
|
||||
private createStructuredTool(
|
||||
name: string,
|
||||
description: string,
|
||||
func: (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
) => Promise<string | IDataObject | IDataObject[]>,
|
||||
): DynamicStructuredTool | DynamicTool {
|
||||
const collectedArguments = extractFromAIParameters(this.baseContext.getNode().parameters);
|
||||
|
||||
// If there are no `fromAI` arguments, fallback to creating a simple tool
|
||||
if (collectedArguments.length === 0) {
|
||||
return new DynamicTool({ name, description, func });
|
||||
}
|
||||
|
||||
// Prepare Zod schema for the structured tool
|
||||
const schema = createZodSchemaFromArgs(collectedArguments);
|
||||
|
||||
return new DynamicStructuredTool({ schema, name, description, func });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* eslint-disable n8n-nodes-base/node-filename-against-convention */
|
||||
|
||||
import { NodeConnectionTypes, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import { getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
export const versionDescription: INodeTypeDescription = {
|
||||
displayName: 'Call n8n Workflow Tool',
|
||||
name: 'toolWorkflow',
|
||||
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, 2.1, 2.2],
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.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',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { lte: 2.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 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: 'loadSubWorkflowInputs',
|
||||
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