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,118 @@
|
||||
import { Calculator } from '@langchain/community/tools/calculator';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolCalculator } from './ToolCalculator.node';
|
||||
|
||||
describe('ToolCalculator', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return Calculator tool instance', async () => {
|
||||
const node = new ToolCalculator();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test calculator' })),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(Calculator);
|
||||
});
|
||||
|
||||
it('should sanitize tool name to be LLM API compatible', async () => {
|
||||
const node = new ToolCalculator();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'Calculator (1)' })),
|
||||
}),
|
||||
);
|
||||
|
||||
const tool = supplyDataResult.response as Calculator;
|
||||
expect(tool.name).toBe('Calculator_1_');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute calculator and return result', async () => {
|
||||
const node = new ToolCalculator();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: '2 + 2' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test calculator' })),
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: '4',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolCalculator();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: '2 + 2' },
|
||||
},
|
||||
{
|
||||
json: { input: '5 * 3' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test calculator' })),
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: '4',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: '15',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Calculator } from '@langchain/community/tools/calculator';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
nodeNameToToolName,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions): Calculator {
|
||||
const calculator = new Calculator();
|
||||
calculator.name = nodeNameToToolName(ctx.getNode());
|
||||
return calculator;
|
||||
}
|
||||
|
||||
export class ToolCalculator implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Calculator',
|
||||
name: 'toolCalculator',
|
||||
icon: 'fa:calculator',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Make it easier for AI agents to perform arithmetic',
|
||||
defaults: {
|
||||
name: 'Calculator',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolcalculator/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiAgent])],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions): Promise<SupplyData> {
|
||||
return {
|
||||
response: logWrapper(getTool(this), this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const calculator = getTool(this);
|
||||
const input = this.getInputData();
|
||||
const response: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const inputItem = input[i];
|
||||
const result = await calculator.invoke(inputItem.json);
|
||||
response.push({
|
||||
json: {
|
||||
response: result,
|
||||
},
|
||||
pairedItem: {
|
||||
item: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { DynamicTool } from '@langchain/classic/tools';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
type INode,
|
||||
type INodeExecutionData,
|
||||
type ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolCode } from './ToolCode.node';
|
||||
|
||||
describe('ToolCode', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should read name from node name on version >=1.2', async () => {
|
||||
const node = new ToolCode();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
case 'name':
|
||||
return 'wrong_field';
|
||||
case 'specifyInputSchema':
|
||||
return false;
|
||||
case 'language':
|
||||
return 'javaScript';
|
||||
case 'jsCode':
|
||||
return 'return 1;';
|
||||
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 <1.2', async () => {
|
||||
const node = new ToolCode();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.1, name: 'wrong name' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
case 'name':
|
||||
return 'test_tool';
|
||||
case 'specifyInputSchema':
|
||||
return false;
|
||||
case 'language':
|
||||
return 'javaScript';
|
||||
case 'jsCode':
|
||||
return 'return 1;';
|
||||
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 execute code tool and return result', async () => {
|
||||
const node = new ToolCode();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'test query' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
case 'name':
|
||||
return 'wrong_field';
|
||||
case 'specifyInputSchema':
|
||||
return false;
|
||||
case 'language':
|
||||
return 'javaScript';
|
||||
case 'jsCode':
|
||||
return 'return "test result";';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
getMode: jest.fn(() => 'manual'),
|
||||
});
|
||||
|
||||
// Mock the DynamicTool.invoke method
|
||||
const mockResult = 'test result';
|
||||
DynamicTool.prototype.invoke = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: mockResult,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(DynamicTool.prototype.invoke).toHaveBeenCalledWith({ query: 'test query' });
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolCode();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'first query' },
|
||||
},
|
||||
{
|
||||
json: { query: 'second query' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'description text';
|
||||
case 'name':
|
||||
return 'wrong_field';
|
||||
case 'specifyInputSchema':
|
||||
return false;
|
||||
case 'language':
|
||||
return 'javaScript';
|
||||
case 'jsCode':
|
||||
return 'return "result for " + query;';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
getMode: jest.fn(() => 'manual'),
|
||||
});
|
||||
|
||||
// Mock the DynamicTool.invoke method
|
||||
DynamicTool.prototype.invoke = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('result for first query')
|
||||
.mockResolvedValueOnce('result for second query');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'result for first query',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: 'result for second query',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(DynamicTool.prototype.invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { JsTaskRunnerSandbox } from 'n8n-nodes-base/dist/nodes/Code/JsTaskRunnerSandbox';
|
||||
import { PythonTaskRunnerSandbox } from 'n8n-nodes-base/dist/nodes/Code/PythonTaskRunnerSandbox';
|
||||
import type {
|
||||
ExecutionError,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
jsonParse,
|
||||
NodeConnectionTypes,
|
||||
nodeNameToToolName,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
buildInputSchemaField,
|
||||
buildJsonSchemaExampleField,
|
||||
buildJsonSchemaExampleNotice,
|
||||
schemaTypeField,
|
||||
} from '@utils/descriptions';
|
||||
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
|
||||
import { getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import type { DynamicZodObject } from '../../../types/zod.types';
|
||||
|
||||
const jsonSchemaExampleField = buildJsonSchemaExampleField({
|
||||
showExtraProps: { specifyInputSchema: [true] },
|
||||
});
|
||||
|
||||
const jsonSchemaExampleNotice = buildJsonSchemaExampleNotice({
|
||||
showExtraProps: {
|
||||
specifyInputSchema: [true],
|
||||
'@version': [{ _cnd: { gte: 1.3 } }],
|
||||
},
|
||||
});
|
||||
|
||||
const jsonSchemaField = buildInputSchemaField({ showExtraProps: { specifyInputSchema: [true] } });
|
||||
|
||||
function getTool(
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
log: boolean = true,
|
||||
) {
|
||||
const node = ctx.getNode();
|
||||
const workflowMode = ctx.getMode();
|
||||
|
||||
const { typeVersion } = node;
|
||||
const name =
|
||||
typeVersion <= 1.1
|
||||
? (ctx.getNodeParameter('name', itemIndex) as string)
|
||||
: nodeNameToToolName(node);
|
||||
|
||||
const description = ctx.getNodeParameter('description', itemIndex) as string;
|
||||
|
||||
const useSchema = ctx.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
|
||||
|
||||
const language = ctx.getNodeParameter('language', itemIndex) as string;
|
||||
let code = '';
|
||||
if (language === 'javaScript') {
|
||||
code = ctx.getNodeParameter('jsCode', itemIndex) as string;
|
||||
} else {
|
||||
code = ctx.getNodeParameter('pythonCode', itemIndex) as string;
|
||||
}
|
||||
|
||||
const runFunction = async (query: string | IDataObject): Promise<unknown> => {
|
||||
if (language === 'javaScript') {
|
||||
const sandbox = new JsTaskRunnerSandbox(workflowMode, ctx, /*chunkSize=*/ undefined, {
|
||||
query,
|
||||
});
|
||||
return await sandbox.runCodeForTool(code);
|
||||
} else {
|
||||
const sandbox = new PythonTaskRunnerSandbox(
|
||||
code,
|
||||
'runOnceForAllItems',
|
||||
workflowMode,
|
||||
ctx as IExecuteFunctions,
|
||||
{
|
||||
query,
|
||||
},
|
||||
);
|
||||
return await sandbox.runCodeForTool();
|
||||
}
|
||||
};
|
||||
|
||||
const toolHandler = async (query: string | IDataObject): Promise<string> => {
|
||||
const { index } = log
|
||||
? ctx.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]])
|
||||
: { index: 0 };
|
||||
|
||||
let response: any = '';
|
||||
let executionError: ExecutionError | undefined;
|
||||
try {
|
||||
response = await runFunction(query);
|
||||
} catch (error: unknown) {
|
||||
executionError = new NodeOperationError(ctx.getNode(), error as ExecutionError);
|
||||
response = `There was an error: "${executionError.message}"`;
|
||||
}
|
||||
|
||||
if (typeof response === 'number') {
|
||||
response = (response as number).toString();
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
// TODO: Do some more testing. Issues here should actually fail the workflow
|
||||
executionError = new NodeOperationError(ctx.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}"`;
|
||||
}
|
||||
|
||||
if (executionError && log) {
|
||||
void ctx.addOutputData(NodeConnectionTypes.AiTool, index, executionError);
|
||||
} else if (log) {
|
||||
void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const commonToolOptions = {
|
||||
name,
|
||||
description,
|
||||
func: toolHandler,
|
||||
};
|
||||
|
||||
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
|
||||
|
||||
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 = ctx.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
|
||||
const inputSchema = ctx.getNodeParameter('inputSchema', itemIndex, '') as string;
|
||||
|
||||
const schemaType = ctx.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
|
||||
|
||||
const jsonSchema =
|
||||
schemaType === 'fromJson'
|
||||
? generateSchemaFromExample(jsonExample, ctx.getNode().typeVersion >= 1.3)
|
||||
: jsonParse<JSONSchema7>(inputSchema);
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
|
||||
|
||||
tool = new DynamicStructuredTool({
|
||||
schema: zodSchema,
|
||||
...commonToolOptions,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'Error during parsing of JSON Schema. \n ' + error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tool = new DynamicTool(commonToolOptions);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
export class ToolCode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Code Tool',
|
||||
name: 'toolCode',
|
||||
icon: 'fa:code',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1, 1.2, 1.3],
|
||||
description: 'Write a tool in JS or Python',
|
||||
defaults: {
|
||||
name: 'Code 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.toolcode/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName:
|
||||
'See an example of a conversational agent with custom tool written in JavaScript <a href="/templates/1963" target="_blank">here</a>.',
|
||||
name: 'noticeTemplateExample',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'My_Tool',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. My_Tool',
|
||||
validateType: 'string-alphanumeric',
|
||||
description:
|
||||
'The name of the function to be called, could contain letters, numbers, and underscores only',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [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: 'Language',
|
||||
name: 'language',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'JavaScript',
|
||||
value: 'javaScript',
|
||||
},
|
||||
{
|
||||
name: 'Python (Beta)',
|
||||
value: 'python',
|
||||
},
|
||||
],
|
||||
default: 'javaScript',
|
||||
},
|
||||
{
|
||||
displayName: 'JavaScript',
|
||||
name: 'jsCode',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
language: ['javaScript'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
editor: 'jsEditor',
|
||||
},
|
||||
default:
|
||||
'// Example: convert the incoming query to uppercase and return it\nreturn query.toUpperCase()',
|
||||
// TODO: Add proper text here later
|
||||
hint: 'You can access the input the tool receives via the input property "query". The returned value should be a single string.',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-missing-final-period
|
||||
description: 'E.g. Converts any text to uppercase',
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Python',
|
||||
name: 'pythonCode',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
language: ['python'],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
editor: 'codeNodeEditor', // TODO: create a separate `pythonEditor` component
|
||||
editorLanguage: 'python',
|
||||
},
|
||||
default:
|
||||
'# Example: convert the incoming query to uppercase and return it\nreturn _query.upper()',
|
||||
// TODO: Add proper text here later
|
||||
hint: 'You can access the input the tool receives via the input property "_query". The returned value should be a single string.',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-description-missing-final-period
|
||||
description: 'E.g. Converts any text to uppercase',
|
||||
noDataExpression: true,
|
||||
},
|
||||
{
|
||||
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,
|
||||
jsonSchemaExampleNotice,
|
||||
jsonSchemaField,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
return {
|
||||
response: getTool(this, itemIndex),
|
||||
};
|
||||
}
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const result: INodeExecutionData[] = [];
|
||||
const input = this.getInputData();
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const item = input[i];
|
||||
const tool = getTool(this, i, false);
|
||||
result.push({
|
||||
json: {
|
||||
response: await tool.invoke(item.json),
|
||||
},
|
||||
pairedItem: {
|
||||
item: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [result];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
import { DynamicTool } from '@langchain/core/tools';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
IHttpRequestMethods,
|
||||
IHttpRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
nodeNameToToolName,
|
||||
tryToParseAlphanumericString,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { N8nTool } from '@utils/N8nTool';
|
||||
import { getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import {
|
||||
authenticationProperties,
|
||||
jsonInput,
|
||||
optimizeResponseProperties,
|
||||
parametersCollection,
|
||||
placeholderDefinitionsCollection,
|
||||
specifyBySelector,
|
||||
} from './descriptions';
|
||||
import type { PlaceholderDefinition, ToolParameter } from './interfaces';
|
||||
import {
|
||||
configureHttpRequestFunction,
|
||||
configureResponseOptimizer,
|
||||
extractParametersFromText,
|
||||
prepareToolDescription,
|
||||
configureToolFunction,
|
||||
updateParametersAndOptions,
|
||||
makeToolInputSchema,
|
||||
} from './utils';
|
||||
|
||||
export class ToolHttpRequest implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'HTTP Request Tool',
|
||||
name: 'toolHttpRequest',
|
||||
icon: { light: 'file:httprequest.svg', dark: 'file:httprequest.dark.svg' },
|
||||
group: ['output'],
|
||||
version: [1, 1.1],
|
||||
description: 'Makes an HTTP request and returns the response data',
|
||||
subtitle: '={{ $parameter.toolDescription }}',
|
||||
defaults: {
|
||||
name: 'HTTP Request',
|
||||
},
|
||||
credentials: [],
|
||||
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.toolhttprequest/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Replaced by a `usableAsTool` version of the standalone HttpRequest node
|
||||
hidden: true,
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
description:
|
||||
'Explain to LLM what this tool does, better description would allow LLM to produce expected result',
|
||||
placeholder: 'e.g. Get the current weather in the requested city',
|
||||
default: '',
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Method',
|
||||
name: 'method',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'DELETE',
|
||||
value: 'DELETE',
|
||||
},
|
||||
{
|
||||
name: 'GET',
|
||||
value: 'GET',
|
||||
},
|
||||
{
|
||||
name: 'PATCH',
|
||||
value: 'PATCH',
|
||||
},
|
||||
{
|
||||
name: 'POST',
|
||||
value: 'POST',
|
||||
},
|
||||
{
|
||||
name: 'PUT',
|
||||
value: 'PUT',
|
||||
},
|
||||
],
|
||||
default: 'GET',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Tip: You can use a {placeholder} for any part of the request to be filled by the model. Provide more context about them in the placeholders section',
|
||||
name: 'placeholderNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
default: '',
|
||||
required: true,
|
||||
placeholder: 'e.g. http://www.example.com/{path}',
|
||||
},
|
||||
...authenticationProperties,
|
||||
//----------------------------------------------------------------
|
||||
{
|
||||
displayName: 'Send Query Parameters',
|
||||
name: 'sendQuery',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
description: 'Whether the request has query params or not',
|
||||
},
|
||||
{
|
||||
...specifyBySelector,
|
||||
displayName: 'Specify Query Parameters',
|
||||
name: 'specifyQuery',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendQuery: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...parametersCollection,
|
||||
displayName: 'Query Parameters',
|
||||
name: 'parametersQuery',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendQuery: [true],
|
||||
specifyQuery: ['keypair'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...jsonInput,
|
||||
name: 'jsonQuery',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendQuery: [true],
|
||||
specifyQuery: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
//----------------------------------------------------------------
|
||||
{
|
||||
displayName: 'Send Headers',
|
||||
name: 'sendHeaders',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
description: 'Whether the request has headers or not',
|
||||
},
|
||||
{
|
||||
...specifyBySelector,
|
||||
displayName: 'Specify Headers',
|
||||
name: 'specifyHeaders',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendHeaders: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...parametersCollection,
|
||||
displayName: 'Header Parameters',
|
||||
name: 'parametersHeaders',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendHeaders: [true],
|
||||
specifyHeaders: ['keypair'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...jsonInput,
|
||||
name: 'jsonHeaders',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendHeaders: [true],
|
||||
specifyHeaders: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
//----------------------------------------------------------------
|
||||
{
|
||||
displayName: 'Send Body',
|
||||
name: 'sendBody',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
description: 'Whether the request has body or not',
|
||||
},
|
||||
{
|
||||
...specifyBySelector,
|
||||
displayName: 'Specify Body',
|
||||
name: 'specifyBody',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendBody: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...parametersCollection,
|
||||
displayName: 'Body Parameters',
|
||||
name: 'parametersBody',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendBody: [true],
|
||||
specifyBody: ['keypair'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
...jsonInput,
|
||||
name: 'jsonBody',
|
||||
displayOptions: {
|
||||
show: {
|
||||
sendBody: [true],
|
||||
specifyBody: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
//----------------------------------------------------------------
|
||||
placeholderDefinitionsCollection,
|
||||
...optimizeResponseProperties,
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const name = nodeNameToToolName(this.getNode());
|
||||
try {
|
||||
tryToParseAlphanumericString(name);
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'The name of this tool is not a valid alphanumeric string',
|
||||
{
|
||||
itemIndex,
|
||||
description:
|
||||
"Only alphanumeric characters and underscores are allowed in the tool's name, and the name cannot start with a number",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const toolDescription = this.getNodeParameter('toolDescription', itemIndex) as string;
|
||||
const sendQuery = this.getNodeParameter('sendQuery', itemIndex, false) as boolean;
|
||||
const sendHeaders = this.getNodeParameter('sendHeaders', itemIndex, false) as boolean;
|
||||
const sendBody = this.getNodeParameter('sendBody', itemIndex, false) as boolean;
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
method: this.getNodeParameter('method', itemIndex, 'GET') as IHttpRequestMethods,
|
||||
url: this.getNodeParameter('url', itemIndex) as string,
|
||||
qs: {},
|
||||
headers: {
|
||||
// FIXME: This is a workaround to prevent the node from sending a default User-Agent (`n8n`) when the header is not set.
|
||||
// Needs to be replaced with a proper fix after NODE-1777 is resolved
|
||||
'User-Agent': undefined,
|
||||
},
|
||||
body: {},
|
||||
// We will need a full response object later to extract the headers and check the response's content type.
|
||||
returnFullResponse: true,
|
||||
};
|
||||
|
||||
const authentication = this.getNodeParameter('authentication', itemIndex, 'none') as
|
||||
| 'predefinedCredentialType'
|
||||
| 'genericCredentialType'
|
||||
| 'none';
|
||||
|
||||
if (authentication !== 'none') {
|
||||
const domain = new URL(requestOptions.url).hostname;
|
||||
if (domain.includes('{') && domain.includes('}')) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
"Can't use a placeholder for the domain when using authentication",
|
||||
{
|
||||
itemIndex,
|
||||
description:
|
||||
'This is for security reasons, to prevent the model accidentally sending your credentials to an unauthorized domain',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const httpRequest = await configureHttpRequestFunction(this, authentication, itemIndex);
|
||||
const optimizeResponse = configureResponseOptimizer(this, itemIndex);
|
||||
|
||||
const rawRequestOptions: { [key: string]: string } = {
|
||||
qs: '',
|
||||
headers: '',
|
||||
body: '',
|
||||
};
|
||||
|
||||
const placeholdersDefinitions = (
|
||||
this.getNodeParameter(
|
||||
'placeholderDefinitions.values',
|
||||
itemIndex,
|
||||
[],
|
||||
) as PlaceholderDefinition[]
|
||||
).map((p) => {
|
||||
if (p.name.startsWith('{') && p.name.endsWith('}')) {
|
||||
p.name = p.name.slice(1, -1);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const toolParameters: ToolParameter[] = [];
|
||||
|
||||
toolParameters.push(
|
||||
...extractParametersFromText(placeholdersDefinitions, requestOptions.url, 'path'),
|
||||
);
|
||||
|
||||
if (sendQuery) {
|
||||
updateParametersAndOptions({
|
||||
ctx: this,
|
||||
itemIndex,
|
||||
toolParameters,
|
||||
placeholdersDefinitions,
|
||||
requestOptions,
|
||||
rawRequestOptions,
|
||||
requestOptionsProperty: 'qs',
|
||||
inputTypePropertyName: 'specifyQuery',
|
||||
jsonPropertyName: 'jsonQuery',
|
||||
parametersPropertyName: 'parametersQuery.values',
|
||||
});
|
||||
}
|
||||
|
||||
if (sendHeaders) {
|
||||
updateParametersAndOptions({
|
||||
ctx: this,
|
||||
itemIndex,
|
||||
toolParameters,
|
||||
placeholdersDefinitions,
|
||||
requestOptions,
|
||||
rawRequestOptions,
|
||||
requestOptionsProperty: 'headers',
|
||||
inputTypePropertyName: 'specifyHeaders',
|
||||
jsonPropertyName: 'jsonHeaders',
|
||||
parametersPropertyName: 'parametersHeaders.values',
|
||||
});
|
||||
}
|
||||
|
||||
if (sendBody) {
|
||||
updateParametersAndOptions({
|
||||
ctx: this,
|
||||
itemIndex,
|
||||
toolParameters,
|
||||
placeholdersDefinitions,
|
||||
requestOptions,
|
||||
rawRequestOptions,
|
||||
requestOptionsProperty: 'body',
|
||||
inputTypePropertyName: 'specifyBody',
|
||||
jsonPropertyName: 'jsonBody',
|
||||
parametersPropertyName: 'parametersBody.values',
|
||||
});
|
||||
}
|
||||
|
||||
for (const placeholder of placeholdersDefinitions) {
|
||||
if (!toolParameters.find((parameter) => parameter.name === placeholder.name)) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Misconfigured placeholder '${placeholder.name}'`,
|
||||
{
|
||||
itemIndex,
|
||||
description:
|
||||
"This placeholder is defined in the 'Placeholder Definitions' but isn't used anywhere. Either remove the definition, or add the placeholder to a part of the request.",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const func = configureToolFunction(
|
||||
this,
|
||||
itemIndex,
|
||||
toolParameters,
|
||||
requestOptions,
|
||||
rawRequestOptions,
|
||||
httpRequest,
|
||||
optimizeResponse,
|
||||
);
|
||||
|
||||
let tool: DynamicTool | N8nTool;
|
||||
|
||||
// If the node version is 1.1 or higher, we use the N8nTool wrapper:
|
||||
// it allows to use tool as a DynamicStructuredTool and have a fallback to DynamicTool
|
||||
if (this.getNode().typeVersion >= 1.1) {
|
||||
const schema = makeToolInputSchema(toolParameters);
|
||||
|
||||
tool = new N8nTool(this, {
|
||||
name,
|
||||
description: toolDescription,
|
||||
func,
|
||||
schema,
|
||||
});
|
||||
} else {
|
||||
// Keep the old behavior for nodes with version 1.0
|
||||
const description = prepareToolDescription(toolDescription, toolParameters);
|
||||
tool = new DynamicTool({ name, description, func });
|
||||
}
|
||||
|
||||
return {
|
||||
response: tool,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export const specifyBySelector: INodeProperties = {
|
||||
displayName: 'Specify By',
|
||||
name: 'specifyBy',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Using Fields Below',
|
||||
value: 'keypair',
|
||||
},
|
||||
{
|
||||
name: 'Using JSON Below',
|
||||
value: 'json',
|
||||
},
|
||||
{
|
||||
name: 'Let Model Specify Entire Body',
|
||||
value: 'model',
|
||||
},
|
||||
],
|
||||
default: 'keypair',
|
||||
};
|
||||
|
||||
export const parametersCollection: INodeProperties = {
|
||||
displayName: 'Parameters',
|
||||
name: 'parameters',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Parameter',
|
||||
default: {
|
||||
values: [
|
||||
{
|
||||
name: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Value Provided',
|
||||
name: 'valueProvider',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'By Model (and is required)',
|
||||
value: 'modelRequired',
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-display-name-miscased
|
||||
name: 'By Model (but is optional)',
|
||||
value: 'modelOptional',
|
||||
},
|
||||
{
|
||||
name: 'Using Field Below',
|
||||
value: 'fieldValue',
|
||||
},
|
||||
],
|
||||
default: 'modelRequired',
|
||||
},
|
||||
{
|
||||
displayName: 'Value',
|
||||
name: 'value',
|
||||
type: 'string',
|
||||
default: '',
|
||||
hint: 'Use a {placeholder} for any data to be filled in by the model',
|
||||
displayOptions: {
|
||||
show: {
|
||||
valueProvider: ['fieldValue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
export const placeholderDefinitionsCollection: INodeProperties = {
|
||||
displayName: 'Placeholder Definitions',
|
||||
name: 'placeholderDefinitions',
|
||||
type: 'fixedCollection',
|
||||
typeOptions: {
|
||||
multipleValues: true,
|
||||
},
|
||||
placeholder: 'Add Definition',
|
||||
default: [],
|
||||
options: [
|
||||
{
|
||||
name: 'values',
|
||||
displayName: 'Values',
|
||||
values: [
|
||||
{
|
||||
displayName: 'Placeholder Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Type',
|
||||
name: 'type',
|
||||
type: 'options',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
|
||||
options: [
|
||||
{
|
||||
name: 'Not Specified (Default)',
|
||||
value: 'not specified',
|
||||
},
|
||||
{
|
||||
name: 'String',
|
||||
value: 'string',
|
||||
},
|
||||
{
|
||||
name: 'Number',
|
||||
value: 'number',
|
||||
},
|
||||
{
|
||||
name: 'Boolean',
|
||||
value: 'boolean',
|
||||
},
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json',
|
||||
},
|
||||
],
|
||||
default: 'not specified',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const jsonInput: INodeProperties = {
|
||||
displayName: 'JSON',
|
||||
name: 'json',
|
||||
type: 'string',
|
||||
typeOptions: {
|
||||
rows: 5,
|
||||
},
|
||||
hint: 'Use a {placeholder} for any data to be filled in by the model',
|
||||
default: '',
|
||||
};
|
||||
|
||||
export const authenticationProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Authentication',
|
||||
name: 'authentication',
|
||||
description:
|
||||
'Select the type of authentication to use if needed, authentication would be done by n8n and your credentials will not be shared with the LLM',
|
||||
noDataExpression: true,
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'None',
|
||||
value: 'none',
|
||||
},
|
||||
{
|
||||
name: 'Predefined Credential Type',
|
||||
value: 'predefinedCredentialType',
|
||||
description:
|
||||
"We've already implemented auth for many services so that you don't have to set it up manually",
|
||||
},
|
||||
{
|
||||
name: 'Generic Credential Type',
|
||||
value: 'genericCredentialType',
|
||||
description: 'Fully customizable. Choose between basic, header, OAuth2, etc.',
|
||||
},
|
||||
],
|
||||
default: 'none',
|
||||
},
|
||||
{
|
||||
displayName: 'Credential Type',
|
||||
name: 'nodeCredentialType',
|
||||
type: 'credentialsSelect',
|
||||
noDataExpression: true,
|
||||
required: true,
|
||||
default: '',
|
||||
credentialTypes: ['extends:oAuth2Api', 'extends:oAuth1Api', 'has:authenticate'],
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['predefinedCredentialType'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Make sure you have specified the scope(s) for the Service Account in the credential',
|
||||
name: 'googleApiWarning',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
nodeCredentialType: ['googleApi'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Generic Auth Type',
|
||||
name: 'genericAuthType',
|
||||
type: 'credentialsSelect',
|
||||
required: true,
|
||||
default: '',
|
||||
credentialTypes: ['has:genericAuth'],
|
||||
displayOptions: {
|
||||
show: {
|
||||
authentication: ['genericCredentialType'],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const optimizeResponseProperties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Optimize Response',
|
||||
name: 'optimizeResponse',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
noDataExpression: true,
|
||||
description:
|
||||
'Whether the optimize the tool response to reduce amount of data passed to the LLM that could lead to better result and reduce cost',
|
||||
},
|
||||
{
|
||||
displayName: 'Expected Response Type',
|
||||
name: 'responseType',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'JSON',
|
||||
value: 'json',
|
||||
},
|
||||
{
|
||||
name: 'HTML',
|
||||
value: 'html',
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: 'text',
|
||||
},
|
||||
],
|
||||
default: 'json',
|
||||
},
|
||||
{
|
||||
displayName: 'Field Containing Data',
|
||||
name: 'dataField',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. records',
|
||||
description: 'Specify the name of the field in the response containing the data',
|
||||
hint: 'leave blank to use whole response',
|
||||
requiresDataPath: 'single',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Include Fields',
|
||||
name: 'fieldsToInclude',
|
||||
type: 'options',
|
||||
description: 'What fields response object should include',
|
||||
default: 'all',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['json'],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'All',
|
||||
value: 'all',
|
||||
description: 'Include all fields',
|
||||
},
|
||||
{
|
||||
name: 'Selected',
|
||||
value: 'selected',
|
||||
description: 'Include only fields specified below',
|
||||
},
|
||||
{
|
||||
name: 'Except',
|
||||
value: 'except',
|
||||
description: 'Exclude fields specified below',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Fields',
|
||||
name: 'fields',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. field1,field2',
|
||||
description:
|
||||
'Comma-separated list of the field names. Supports dot notation. You can drag the selected fields from the input panel.',
|
||||
requiresDataPath: 'multiple',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['json'],
|
||||
},
|
||||
hide: {
|
||||
fieldsToInclude: ['all'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Selector (CSS)',
|
||||
name: 'cssSelector',
|
||||
type: 'string',
|
||||
description:
|
||||
'Select specific element(e.g. body) or multiple elements(e.g. div) of chosen type in the response HTML.',
|
||||
placeholder: 'e.g. body',
|
||||
default: 'body',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['html'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Return Only Content',
|
||||
name: 'onlyContent',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to return only content of html elements, stripping html tags and attributes',
|
||||
hint: 'Uses less tokens and may be easier for model to understand',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['html'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Elements To Omit',
|
||||
name: 'elementsToOmit',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['html'],
|
||||
onlyContent: [true],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
placeholder: 'e.g. img, .className, #ItemId',
|
||||
description: 'Comma-separated list of selectors that would be excluded when extracting content',
|
||||
},
|
||||
{
|
||||
displayName: 'Truncate Response',
|
||||
name: 'truncateResponse',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
hint: 'Helps save tokens',
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['text', 'html'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Max Response Characters',
|
||||
name: 'maxLength',
|
||||
type: 'number',
|
||||
default: 1000,
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
optimizeResponse: [true],
|
||||
responseType: ['text', 'html'],
|
||||
truncateResponse: [true],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M40 20C40 8.95314 31.0469 0 20 0C8.95314 0 0 8.95314 0 20C0 31.0469 8.95314 40 20 40C31.0469 40 40 31.0469 40 20ZM20 36.9458C18.8852 36.9458 17.1378 35.967 15.4998 32.6985C14.7964 31.2918 14.1961 29.5431 13.7526 27.6847H26.1898C25.8045 29.5403 25.2044 31.2901 24.5002 32.6985C22.8622 35.967 21.1148 36.9458 20 36.9458ZM12.9064 20C12.9064 21.6097 13.0087 23.164 13.2003 24.6305H26.7997C26.9913 23.164 27.0936 21.6097 27.0936 20C27.0936 18.3903 26.9913 16.836 26.7997 15.3695H13.2003C13.0087 16.836 12.9064 18.3903 12.9064 20ZM20 3.05419C21.1149 3.05419 22.8622 4.03078 24.5001 7.30039C25.2066 8.71408 25.8072 10.4067 26.192 12.3153H13.7501C14.1933 10.4047 14.7942 8.71254 15.4998 7.30064C17.1377 4.03083 18.8851 3.05419 20 3.05419ZM30.1478 20C30.1478 18.4099 30.0543 16.8617 29.8227 15.3695H36.3042C36.7252 16.842 36.9458 18.3964 36.9458 20C36.9458 21.6036 36.7252 23.158 36.3042 24.6305H29.8227C30.0543 23.1383 30.1478 21.5901 30.1478 20ZM26.2767 4.25512C27.6365 6.36019 28.711 9.132 29.3774 12.3153H35.1046C33.2511 8.668 30.107 5.78346 26.2767 4.25512ZM10.6226 12.3153H4.89293C6.75147 8.66784 9.89351 5.78341 13.7232 4.25513C12.3635 6.36021 11.289 9.13201 10.6226 12.3153ZM3.05419 20C3.05419 21.603 3.27743 23.1575 3.69484 24.6305H10.1217C9.94619 23.142 9.85222 21.5943 9.85222 20C9.85222 18.4057 9.94619 16.858 10.1217 15.3695H3.69484C3.27743 16.8425 3.05419 18.397 3.05419 20ZM26.2766 35.7427C27.6365 33.6393 28.711 30.868 29.3774 27.6847H35.1046C33.251 31.3322 30.1068 34.2179 26.2766 35.7427ZM13.7234 35.7427C9.89369 34.2179 6.75155 31.3324 4.89293 27.6847H10.6226C11.289 30.868 12.3635 33.6393 13.7234 35.7427Z" fill="#8F87F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><path fill="#0004F5" fill-rule="evenodd" d="M40 20C40 8.953 31.047 0 20 0S0 8.953 0 20s8.953 20 20 20 20-8.953 20-20M20 36.946c-1.115 0-2.862-.979-4.5-4.247-.704-1.407-1.304-3.156-1.747-5.014H26.19c-.386 1.855-.986 3.605-1.69 5.014-1.638 3.268-3.385 4.247-4.5 4.247M12.906 20c0 1.61.103 3.164.294 4.63h13.6a36 36 0 0 0 .294-4.63c0-1.61-.103-3.164-.294-4.63H13.2a36 36 0 0 0-.294 4.63M20 3.054c1.115 0 2.862.977 4.5 4.246.707 1.414 1.307 3.107 1.692 5.015H13.75c.443-1.91 1.044-3.602 1.75-5.014 1.638-3.27 3.385-4.247 4.5-4.247M30.148 20c0-1.59-.094-3.138-.325-4.63h6.481c.421 1.472.642 3.026.642 4.63s-.22 3.158-.642 4.63h-6.481c.231-1.492.325-3.04.325-4.63M26.277 4.255c1.36 2.105 2.434 4.877 3.1 8.06h5.728a16.98 16.98 0 0 0-8.828-8.06m-15.654 8.06h-5.73c1.858-3.647 5-6.532 8.83-8.06-1.36 2.105-2.434 4.877-3.1 8.06M3.054 20c0 1.603.223 3.157.64 4.63h6.428a40 40 0 0 1-.27-4.63c0-1.594.094-3.142.27-4.63H3.695a17 17 0 0 0-.64 4.63m23.223 15.743c1.36-2.104 2.434-4.875 3.1-8.058h5.728a16.96 16.96 0 0 1-8.828 8.058m-12.554 0a17 17 0 0 1-8.83-8.058h5.73c.666 3.183 1.74 5.954 3.1 8.058" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,23 @@
|
||||
export type ToolParameter = {
|
||||
name: string;
|
||||
required: boolean;
|
||||
type?: string;
|
||||
description?: string;
|
||||
sendIn: SendIn;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type PlaceholderDefinition = {
|
||||
name: string;
|
||||
type?: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type ParametersValues = Array<{
|
||||
name: string;
|
||||
valueProvider: 'modelRequired' | 'modelOptional' | 'fieldValue';
|
||||
value?: string;
|
||||
}>;
|
||||
|
||||
export type ParameterInputType = 'keypair' | 'json' | 'model';
|
||||
export type SendIn = 'body' | 'qs' | 'path' | 'headers';
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import type { N8nTool } from '@utils/N8nTool';
|
||||
|
||||
import { ToolHttpRequest } from '../ToolHttpRequest.node';
|
||||
|
||||
describe('ToolHttpRequest', () => {
|
||||
const httpTool = new ToolHttpRequest();
|
||||
const helpers = mock<ISupplyDataFunctions['helpers']>();
|
||||
const executeFunctions = mock<ISupplyDataFunctions>({ helpers });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
executeFunctions.getNode.mockReturnValue(
|
||||
mock<INode>({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
name: 'HTTP Request',
|
||||
typeVersion: 1.1,
|
||||
}),
|
||||
);
|
||||
executeFunctions.addInputData.mockReturnValue({ index: 0 });
|
||||
});
|
||||
|
||||
describe('Binary response', () => {
|
||||
it('should return the error when receiving a binary response', async () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: Buffer.from(''),
|
||||
headers: {
|
||||
'content-type': 'image/jpeg',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/image/jpeg';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
expect(res).toContain('error');
|
||||
expect(res).toContain('Binary data is not supported');
|
||||
});
|
||||
|
||||
it('should return the response text when receiving a text response', async () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: 'Hello World',
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/text/plain';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
expect(res).toEqual('Hello World');
|
||||
});
|
||||
|
||||
it('should return the response text when receiving a text response with a charset', async () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: 'こんにちは世界',
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=iso-2022-jp',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/text/plain';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
expect(res).toEqual('こんにちは世界');
|
||||
});
|
||||
|
||||
it('should return the response object when receiving a JSON response', async () => {
|
||||
const mockJson = { hello: 'world' };
|
||||
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: JSON.stringify(mockJson),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/json';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
expect(jsonParse(res)).toEqual(mockJson);
|
||||
});
|
||||
|
||||
it('should handle authentication with predefined credentials', async () => {
|
||||
helpers.httpRequestWithAuthentication.mockResolvedValue({
|
||||
body: 'Hello World',
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/text/plain';
|
||||
case 'authentication':
|
||||
return 'predefinedCredentialType';
|
||||
case 'nodeCredentialType':
|
||||
return 'linearApi';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
|
||||
expect(res).toEqual('Hello World');
|
||||
|
||||
expect(helpers.httpRequestWithAuthentication).toHaveBeenCalledWith(
|
||||
'linearApi',
|
||||
expect.objectContaining({
|
||||
returnFullResponse: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication with generic credentials', async () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: 'Hello World',
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/text/plain';
|
||||
case 'authentication':
|
||||
return 'genericCredentialType';
|
||||
case 'genericAuthType':
|
||||
return 'httpBasicAuth';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
executeFunctions.getCredentials.mockResolvedValue({
|
||||
user: 'username',
|
||||
password: 'password',
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
|
||||
expect(res).toEqual('Hello World');
|
||||
|
||||
expect(helpers.httpRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
returnFullResponse: true,
|
||||
auth: expect.objectContaining({
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the error when receiving text that contains a null character', async () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: 'Hello\0World',
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/text/plain';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
// Check that the returned string is formatted as an error message.
|
||||
expect(res).toContain('error');
|
||||
expect(res).toContain('Binary data is not supported');
|
||||
});
|
||||
|
||||
it('should return the error when receiving a JSON response containing a null character', async () => {
|
||||
// Provide a raw JSON string with a literal null character.
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: '{"message":"hello\0world"}',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return 'https://httpbin.org/json';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
const res = await (response as N8nTool).invoke({});
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
// Check that the tool returns an error string rather than resolving to valid JSON.
|
||||
expect(res).toContain('error');
|
||||
expect(res).toContain('Binary data is not supported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Optimize response', () => {
|
||||
it('should extract body from the response HTML', async () => {
|
||||
helpers.httpRequest.mockResolvedValue({
|
||||
body: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test</h1>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
Test content
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
headers: {
|
||||
'content-type': 'text/html',
|
||||
},
|
||||
});
|
||||
|
||||
executeFunctions.getNodeParameter.mockImplementation(
|
||||
(paramName: string, _: any, fallback: any) => {
|
||||
switch (paramName) {
|
||||
case 'method':
|
||||
return 'GET';
|
||||
case 'url':
|
||||
return '{url}';
|
||||
case 'options':
|
||||
return {};
|
||||
case 'placeholderDefinitions.values':
|
||||
return [];
|
||||
case 'optimizeResponse':
|
||||
return true;
|
||||
case 'responseType':
|
||||
return 'html';
|
||||
case 'cssSelector':
|
||||
return 'body';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
|
||||
|
||||
const res = await (response as N8nTool).invoke({
|
||||
url: 'https://httpbin.org/html',
|
||||
});
|
||||
|
||||
expect(helpers.httpRequest).toHaveBeenCalled();
|
||||
expect(res).toEqual(
|
||||
JSON.stringify(['<h1>Test</h1> <div> <p> Test content </p> </div>'], null, 2),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,826 @@
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { convert } from 'html-to-text';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import unset from 'lodash/unset';
|
||||
import { getOAuth2AdditionalParameters } from 'n8n-nodes-base/dist/nodes/HttpRequest/GenericFunctions';
|
||||
import type {
|
||||
IDataObject,
|
||||
IHttpRequestOptions,
|
||||
IRequestOptionsSimplified,
|
||||
ExecutionError,
|
||||
NodeApiError,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
ParameterInputType,
|
||||
ParametersValues,
|
||||
PlaceholderDefinition,
|
||||
ParametersValues as RawParametersValues,
|
||||
SendIn,
|
||||
ToolParameter,
|
||||
} from './interfaces';
|
||||
import type { DynamicZodObject } from '../../../types/zod.types';
|
||||
|
||||
const genericCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||
const genericType = ctx.getNodeParameter('genericAuthType', itemIndex) as string;
|
||||
|
||||
if (genericType === 'httpBasicAuth' || genericType === 'httpDigestAuth') {
|
||||
const basicAuth = await ctx.getCredentials('httpBasicAuth', itemIndex);
|
||||
const sendImmediately = genericType === 'httpDigestAuth' ? false : undefined;
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
options.auth = {
|
||||
username: basicAuth.user as string,
|
||||
password: basicAuth.password as string,
|
||||
sendImmediately,
|
||||
};
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
};
|
||||
}
|
||||
|
||||
if (genericType === 'httpHeaderAuth') {
|
||||
const headerAuth = await ctx.getCredentials('httpHeaderAuth', itemIndex);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
if (!options.headers) options.headers = {};
|
||||
options.headers[headerAuth.name as string] = headerAuth.value;
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
};
|
||||
}
|
||||
|
||||
if (genericType === 'httpQueryAuth') {
|
||||
const queryAuth = await ctx.getCredentials('httpQueryAuth', itemIndex);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
if (!options.qs) options.qs = {};
|
||||
options.qs[queryAuth.name as string] = queryAuth.value;
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
};
|
||||
}
|
||||
|
||||
if (genericType === 'httpCustomAuth') {
|
||||
const customAuth = await ctx.getCredentials('httpCustomAuth', itemIndex);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
const auth = jsonParse<IRequestOptionsSimplified>((customAuth.json as string) || '{}', {
|
||||
errorMessage: 'Invalid Custom Auth JSON',
|
||||
});
|
||||
if (auth.headers) {
|
||||
options.headers = { ...options.headers, ...auth.headers };
|
||||
}
|
||||
if (auth.body) {
|
||||
options.body = { ...(options.body as IDataObject), ...auth.body };
|
||||
}
|
||||
if (auth.qs) {
|
||||
options.qs = { ...options.qs, ...auth.qs };
|
||||
}
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
};
|
||||
}
|
||||
|
||||
if (genericType === 'oAuth1Api') {
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
return await ctx.helpers.requestOAuth1.call(ctx, 'oAuth1Api', options);
|
||||
};
|
||||
}
|
||||
|
||||
if (genericType === 'oAuth2Api') {
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
return await ctx.helpers.requestOAuth2.call(ctx, 'oAuth2Api', options, {
|
||||
tokenType: 'Bearer',
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
throw new NodeOperationError(ctx.getNode(), `The type ${genericType} is not supported`, {
|
||||
itemIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const predefinedCredentialRequest = async (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||
const predefinedType = ctx.getNodeParameter('nodeCredentialType', itemIndex) as string;
|
||||
const additionalOptions = getOAuth2AdditionalParameters(predefinedType);
|
||||
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
return await ctx.helpers.httpRequestWithAuthentication.call(
|
||||
ctx,
|
||||
predefinedType,
|
||||
options,
|
||||
additionalOptions && { oauth2: additionalOptions },
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const configureHttpRequestFunction = async (
|
||||
ctx: ISupplyDataFunctions,
|
||||
credentialsType: 'predefinedCredentialType' | 'genericCredentialType' | 'none',
|
||||
itemIndex: number,
|
||||
) => {
|
||||
switch (credentialsType) {
|
||||
case 'genericCredentialType':
|
||||
return await genericCredentialRequest(ctx, itemIndex);
|
||||
case 'predefinedCredentialType':
|
||||
return await predefinedCredentialRequest(ctx, itemIndex);
|
||||
default:
|
||||
return async (options: IHttpRequestOptions) => {
|
||||
return await ctx.helpers.httpRequest(options);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const defaultOptimizer = <T>(response: T) => {
|
||||
if (typeof response === 'string') {
|
||||
return response;
|
||||
}
|
||||
if (typeof response === 'object') {
|
||||
return JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
return String(response);
|
||||
};
|
||||
|
||||
function isBinary(data: unknown) {
|
||||
// Check if data is a Buffer
|
||||
if (Buffer.isBuffer(data)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If data is a string, assume it's text unless it contains null characters.
|
||||
if (typeof data === 'string') {
|
||||
// If the string contains a null character, it's likely binary.
|
||||
if (data.includes('\0')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// For any other type, assume it's not binary.
|
||||
return false;
|
||||
}
|
||||
|
||||
const htmlOptimizer = (ctx: ISupplyDataFunctions, itemIndex: number, maxLength: number) => {
|
||||
const cssSelector = ctx.getNodeParameter('cssSelector', itemIndex, '') as string;
|
||||
const onlyContent = ctx.getNodeParameter('onlyContent', itemIndex, false) as boolean;
|
||||
let elementsToOmit: string[] = [];
|
||||
|
||||
if (onlyContent) {
|
||||
const elementsToOmitUi = ctx.getNodeParameter('elementsToOmit', itemIndex, '') as
|
||||
| string
|
||||
| string[];
|
||||
|
||||
if (typeof elementsToOmitUi === 'string') {
|
||||
elementsToOmit = elementsToOmitUi
|
||||
.split(',')
|
||||
.filter((s) => s)
|
||||
.map((s) => s.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return <T>(response: T) => {
|
||||
if (typeof response !== 'string') {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`The response type must be a string. Received: ${typeof response}`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
const returnData: string[] = [];
|
||||
|
||||
const html = cheerio.load(response);
|
||||
const htmlElements = html(cssSelector);
|
||||
|
||||
htmlElements.each((_, el) => {
|
||||
let value = html(el).html() || '';
|
||||
|
||||
if (onlyContent) {
|
||||
let htmlToTextOptions;
|
||||
|
||||
if (elementsToOmit?.length) {
|
||||
htmlToTextOptions = {
|
||||
selectors: elementsToOmit.map((selector) => ({
|
||||
selector,
|
||||
format: 'skip',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
value = convert(value, htmlToTextOptions);
|
||||
}
|
||||
|
||||
value = value
|
||||
.trim()
|
||||
.replace(/^\s+|\s+$/g, '')
|
||||
.replace(/(\r\n|\n|\r)/gm, '')
|
||||
.replace(/\s+/g, ' ');
|
||||
|
||||
returnData.push(value);
|
||||
});
|
||||
|
||||
const text = JSON.stringify(returnData, null, 2);
|
||||
|
||||
if (maxLength > 0 && text.length > maxLength) {
|
||||
return text.substring(0, maxLength);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
};
|
||||
|
||||
const textOptimizer = (ctx: ISupplyDataFunctions, itemIndex: number, maxLength: number) => {
|
||||
return (response: string | IDataObject) => {
|
||||
if (typeof response === 'object') {
|
||||
try {
|
||||
response = JSON.stringify(response, null, 2);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`The response type must be a string. Received: ${typeof response}`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
|
||||
const dom = new JSDOM(response);
|
||||
const article = new Readability(dom.window.document, {
|
||||
keepClasses: true,
|
||||
}).parse();
|
||||
|
||||
const text = article?.textContent || '';
|
||||
|
||||
if (maxLength > 0 && text.length > maxLength) {
|
||||
return text.substring(0, maxLength);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
};
|
||||
|
||||
const jsonOptimizer = (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||
return (response: string): string => {
|
||||
let responseData: IDataObject | IDataObject[] | string = response;
|
||||
|
||||
if (typeof responseData === 'string') {
|
||||
responseData = jsonParse(response);
|
||||
}
|
||||
|
||||
if (typeof responseData !== 'object' || !responseData) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'The response type must be an object or an array of objects',
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
|
||||
const dataField = ctx.getNodeParameter('dataField', itemIndex, '') as string;
|
||||
let returnData: IDataObject[] = [];
|
||||
|
||||
if (!Array.isArray(responseData)) {
|
||||
if (dataField) {
|
||||
const data = responseData[dataField] as IDataObject | IDataObject[];
|
||||
if (Array.isArray(data)) {
|
||||
responseData = data;
|
||||
} else {
|
||||
responseData = [data];
|
||||
}
|
||||
} else {
|
||||
responseData = [responseData];
|
||||
}
|
||||
} else {
|
||||
if (dataField) {
|
||||
responseData = responseData.map((data) => data[dataField]) as IDataObject[];
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsToInclude = ctx.getNodeParameter('fieldsToInclude', itemIndex, 'all') as
|
||||
| 'all'
|
||||
| 'selected'
|
||||
| 'except';
|
||||
|
||||
let fields: string | string[] = [];
|
||||
|
||||
if (fieldsToInclude !== 'all') {
|
||||
fields = ctx.getNodeParameter('fields', itemIndex, []) as string[] | string;
|
||||
|
||||
if (typeof fields === 'string') {
|
||||
fields = fields.split(',').map((field) => field.trim());
|
||||
}
|
||||
} else {
|
||||
returnData = responseData;
|
||||
}
|
||||
|
||||
if (fieldsToInclude === 'selected') {
|
||||
for (const item of responseData) {
|
||||
const newItem: IDataObject = {};
|
||||
|
||||
for (const field of fields) {
|
||||
set(newItem, field, get(item, field));
|
||||
}
|
||||
|
||||
returnData.push(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldsToInclude === 'except') {
|
||||
for (const item of responseData) {
|
||||
for (const field of fields) {
|
||||
unset(item, field);
|
||||
}
|
||||
|
||||
returnData.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(returnData, null, 2);
|
||||
};
|
||||
};
|
||||
|
||||
export const configureResponseOptimizer = (ctx: ISupplyDataFunctions, itemIndex: number) => {
|
||||
const optimizeResponse = ctx.getNodeParameter('optimizeResponse', itemIndex, false) as boolean;
|
||||
|
||||
if (optimizeResponse) {
|
||||
const responseType = ctx.getNodeParameter('responseType', itemIndex) as
|
||||
| 'json'
|
||||
| 'text'
|
||||
| 'html';
|
||||
|
||||
let maxLength = 0;
|
||||
const truncateResponse = ctx.getNodeParameter('truncateResponse', itemIndex, false) as boolean;
|
||||
|
||||
if (truncateResponse) {
|
||||
maxLength = ctx.getNodeParameter('maxLength', itemIndex, 0) as number;
|
||||
}
|
||||
|
||||
switch (responseType) {
|
||||
case 'html':
|
||||
return htmlOptimizer(ctx, itemIndex, maxLength);
|
||||
case 'text':
|
||||
return textOptimizer(ctx, itemIndex, maxLength);
|
||||
case 'json':
|
||||
return jsonOptimizer(ctx, itemIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return defaultOptimizer;
|
||||
};
|
||||
|
||||
const extractPlaceholders = (text: string): string[] => {
|
||||
const placeholder = /(\{[a-zA-Z0-9_-]+\})/g;
|
||||
const returnData: string[] = [];
|
||||
|
||||
const matches = text.matchAll(placeholder);
|
||||
|
||||
for (const match of matches) {
|
||||
returnData.push(match[0].replace(/{|}/g, ''));
|
||||
}
|
||||
|
||||
return returnData;
|
||||
};
|
||||
|
||||
export const extractParametersFromText = (
|
||||
placeholders: PlaceholderDefinition[],
|
||||
text: string,
|
||||
sendIn: SendIn,
|
||||
key?: string,
|
||||
): ToolParameter[] => {
|
||||
if (typeof text !== 'string') return [];
|
||||
|
||||
const parameters = extractPlaceholders(text);
|
||||
|
||||
if (parameters.length) {
|
||||
const inputParameters = prepareParameters(
|
||||
parameters.map((name) => ({
|
||||
name,
|
||||
valueProvider: 'modelRequired',
|
||||
})),
|
||||
placeholders,
|
||||
'keypair',
|
||||
sendIn,
|
||||
'',
|
||||
);
|
||||
|
||||
return key
|
||||
? inputParameters.parameters.map((p) => ({ ...p, key }))
|
||||
: inputParameters.parameters;
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
function prepareParameters(
|
||||
rawParameters: RawParametersValues,
|
||||
placeholders: PlaceholderDefinition[],
|
||||
parametersInputType: 'model' | 'keypair' | 'json',
|
||||
sendIn: SendIn,
|
||||
modelInputDescription: string,
|
||||
jsonWithPlaceholders?: string,
|
||||
): { parameters: ToolParameter[]; values: IDataObject } {
|
||||
const parameters: ToolParameter[] = [];
|
||||
const values: IDataObject = {};
|
||||
|
||||
if (parametersInputType === 'model') {
|
||||
return {
|
||||
parameters: [
|
||||
{
|
||||
name: sendIn,
|
||||
required: true,
|
||||
type: 'json',
|
||||
description: modelInputDescription,
|
||||
sendIn,
|
||||
},
|
||||
],
|
||||
values: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (parametersInputType === 'keypair') {
|
||||
for (const entry of rawParameters) {
|
||||
if (entry.valueProvider.includes('model')) {
|
||||
const placeholder = placeholders.find((p) => p.name === entry.name);
|
||||
|
||||
const parameter: ToolParameter = {
|
||||
name: entry.name,
|
||||
required: entry.valueProvider === 'modelRequired',
|
||||
sendIn,
|
||||
};
|
||||
|
||||
if (placeholder) {
|
||||
parameter.type = placeholder.type;
|
||||
parameter.description = placeholder.description;
|
||||
}
|
||||
|
||||
parameters.push(parameter);
|
||||
} else if (entry.value) {
|
||||
// if value has placeholders push them to parameters
|
||||
parameters.push(
|
||||
...extractParametersFromText(placeholders, entry.value, sendIn, entry.name),
|
||||
);
|
||||
values[entry.name] = entry.value; //push to user provided values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parametersInputType === 'json' && jsonWithPlaceholders) {
|
||||
parameters.push(
|
||||
...extractParametersFromText(placeholders, jsonWithPlaceholders, sendIn, `${sendIn + 'Raw'}`),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
parameters,
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
const MODEL_INPUT_DESCRIPTION = {
|
||||
qs: 'Query parameters for request as key value pairs',
|
||||
headers: 'Headers parameters for request as key value pairs',
|
||||
body: 'Body parameters for request as key value pairs',
|
||||
};
|
||||
|
||||
export const updateParametersAndOptions = (options: {
|
||||
ctx: ISupplyDataFunctions;
|
||||
itemIndex: number;
|
||||
toolParameters: ToolParameter[];
|
||||
placeholdersDefinitions: PlaceholderDefinition[];
|
||||
requestOptions: IHttpRequestOptions;
|
||||
rawRequestOptions: { [key: string]: string };
|
||||
requestOptionsProperty: 'headers' | 'qs' | 'body';
|
||||
inputTypePropertyName: string;
|
||||
jsonPropertyName: string;
|
||||
parametersPropertyName: string;
|
||||
}) => {
|
||||
const {
|
||||
ctx,
|
||||
itemIndex,
|
||||
toolParameters,
|
||||
placeholdersDefinitions,
|
||||
requestOptions,
|
||||
rawRequestOptions,
|
||||
requestOptionsProperty,
|
||||
inputTypePropertyName,
|
||||
jsonPropertyName,
|
||||
parametersPropertyName,
|
||||
} = options;
|
||||
|
||||
const inputType = ctx.getNodeParameter(
|
||||
inputTypePropertyName,
|
||||
itemIndex,
|
||||
'keypair',
|
||||
) as ParameterInputType;
|
||||
|
||||
let parametersValues: ParametersValues = [];
|
||||
|
||||
if (inputType === 'json') {
|
||||
rawRequestOptions[requestOptionsProperty] = ctx.getNodeParameter(
|
||||
jsonPropertyName,
|
||||
itemIndex,
|
||||
'',
|
||||
) as string;
|
||||
} else {
|
||||
parametersValues = ctx.getNodeParameter(
|
||||
parametersPropertyName,
|
||||
itemIndex,
|
||||
[],
|
||||
) as ParametersValues;
|
||||
}
|
||||
|
||||
const inputParameters = prepareParameters(
|
||||
parametersValues,
|
||||
placeholdersDefinitions,
|
||||
inputType,
|
||||
requestOptionsProperty,
|
||||
MODEL_INPUT_DESCRIPTION[requestOptionsProperty],
|
||||
rawRequestOptions[requestOptionsProperty],
|
||||
);
|
||||
|
||||
toolParameters.push(...inputParameters.parameters);
|
||||
|
||||
requestOptions[requestOptionsProperty] = {
|
||||
...(requestOptions[requestOptionsProperty] as IDataObject),
|
||||
...inputParameters.values,
|
||||
};
|
||||
};
|
||||
|
||||
const getParametersDescription = (parameters: ToolParameter[]) =>
|
||||
parameters
|
||||
.map(
|
||||
(p) =>
|
||||
`${p.name}: (description: ${p.description ?? ''}, type: ${p.type ?? 'string'}, required: ${!!p.required})`,
|
||||
)
|
||||
.join(',\n ');
|
||||
|
||||
export const prepareToolDescription = (
|
||||
toolDescription: string,
|
||||
toolParameters: ToolParameter[],
|
||||
) => {
|
||||
let description = `${toolDescription}`;
|
||||
|
||||
if (toolParameters.length) {
|
||||
description += `
|
||||
Tool expects valid stringified JSON object with ${toolParameters.length} properties.
|
||||
Property names with description, type and required status:
|
||||
${getParametersDescription(toolParameters)}
|
||||
ALL parameters marked as required must be provided`;
|
||||
}
|
||||
|
||||
return description;
|
||||
};
|
||||
|
||||
export const configureToolFunction = (
|
||||
ctx: ISupplyDataFunctions,
|
||||
itemIndex: number,
|
||||
toolParameters: ToolParameter[],
|
||||
requestOptions: IHttpRequestOptions,
|
||||
rawRequestOptions: { [key: string]: string },
|
||||
httpRequest: (options: IHttpRequestOptions) => Promise<any>,
|
||||
optimizeResponse: (response: string) => string,
|
||||
) => {
|
||||
return async (query: string | IDataObject): Promise<string> => {
|
||||
const { index } = ctx.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]);
|
||||
|
||||
// Clone options and rawRequestOptions to avoid mutating the original objects
|
||||
const options: IHttpRequestOptions | null = structuredClone(requestOptions);
|
||||
const clonedRawRequestOptions: { [key: string]: string } = structuredClone(rawRequestOptions);
|
||||
let fullResponse: any;
|
||||
let response: string = '';
|
||||
let executionError: Error | undefined = undefined;
|
||||
|
||||
if (!toolParameters.length) {
|
||||
query = '{}';
|
||||
}
|
||||
|
||||
try {
|
||||
if (query) {
|
||||
let dataFromModel;
|
||||
|
||||
if (typeof query === 'string') {
|
||||
try {
|
||||
dataFromModel = jsonParse<IDataObject>(query);
|
||||
} catch (error) {
|
||||
if (toolParameters.length === 1) {
|
||||
dataFromModel = { [toolParameters[0].name]: query };
|
||||
} else {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Input is not a valid JSON: ${error.message}`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dataFromModel = query;
|
||||
}
|
||||
|
||||
for (const parameter of toolParameters) {
|
||||
if (
|
||||
parameter.required &&
|
||||
(dataFromModel[parameter.name] === undefined || dataFromModel[parameter.name] === null)
|
||||
) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Model did not provide parameter '${parameter.name}' which is required and must be present in the input`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parameter of toolParameters) {
|
||||
let argument = dataFromModel[parameter.name];
|
||||
|
||||
if (
|
||||
argument &&
|
||||
parameter.type === 'json' &&
|
||||
!['qsRaw', 'headersRaw', 'bodyRaw'].includes(parameter.key ?? '') &&
|
||||
typeof argument !== 'object'
|
||||
) {
|
||||
try {
|
||||
argument = jsonParse(String(argument));
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Parameter ${parameter.name} is not a valid JSON: ${error.message}`,
|
||||
{
|
||||
itemIndex,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (parameter.sendIn === 'path') {
|
||||
argument = String(argument);
|
||||
|
||||
//remove " or ' from start or end
|
||||
argument = argument.replace(/^['"]+|['"]+$/g, '');
|
||||
|
||||
options.url = options.url.replace(`{${parameter.name}}`, argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parameter.sendIn === parameter.name) {
|
||||
set(options, [parameter.sendIn], argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (['qsRaw', 'headersRaw', 'bodyRaw'].includes(parameter.key ?? '')) {
|
||||
//enclose string in quotes as user and model could omit them
|
||||
if (parameter.type === 'string') {
|
||||
argument = String(argument);
|
||||
if (
|
||||
!argument.startsWith('"') &&
|
||||
!clonedRawRequestOptions[parameter.sendIn].includes(`"{${parameter.name}}"`)
|
||||
) {
|
||||
argument = `"${argument}"`;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof argument === 'object') {
|
||||
argument = JSON.stringify(argument);
|
||||
}
|
||||
|
||||
clonedRawRequestOptions[parameter.sendIn] = clonedRawRequestOptions[
|
||||
parameter.sendIn
|
||||
].replace(`{${parameter.name}}`, String(argument));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parameter.key) {
|
||||
let requestOptionsValue = get(options, [parameter.sendIn, parameter.key]);
|
||||
|
||||
if (typeof requestOptionsValue === 'string') {
|
||||
requestOptionsValue = requestOptionsValue.replace(
|
||||
`{${parameter.name}}`,
|
||||
String(argument),
|
||||
);
|
||||
}
|
||||
|
||||
set(options, [parameter.sendIn, parameter.key], requestOptionsValue);
|
||||
continue;
|
||||
}
|
||||
|
||||
set(options, [parameter.sendIn, parameter.name], argument);
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(clonedRawRequestOptions)) {
|
||||
if (value) {
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = jsonParse<IDataObject>(value, { repairJSON: true });
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`Could not replace placeholders in ${key}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
options[key as 'qs' | 'headers' | 'body'] = parsedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options) {
|
||||
options.url = encodeURI(options.url);
|
||||
|
||||
if (options.headers && !Object.keys(options.headers).length) {
|
||||
delete options.headers;
|
||||
}
|
||||
if (options.qs && !Object.keys(options.qs).length) {
|
||||
delete options.qs;
|
||||
}
|
||||
if (options.body && !Object.keys(options.body).length) {
|
||||
delete options.body;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = 'Input provided by model is not valid';
|
||||
|
||||
if (error instanceof NodeOperationError) {
|
||||
executionError = error;
|
||||
} else {
|
||||
executionError = new NodeOperationError(ctx.getNode(), errorMessage, {
|
||||
itemIndex,
|
||||
});
|
||||
}
|
||||
|
||||
response = errorMessage;
|
||||
}
|
||||
|
||||
if (options) {
|
||||
try {
|
||||
fullResponse = await httpRequest(options);
|
||||
} catch (error) {
|
||||
const httpCode = (error as NodeApiError).httpCode;
|
||||
response = `${httpCode ? `HTTP ${httpCode} ` : ''}There was an error: "${error.message}"`;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
try {
|
||||
// Check if the response is binary data
|
||||
if (fullResponse.body && isBinary(fullResponse.body)) {
|
||||
throw new NodeOperationError(ctx.getNode(), 'Binary data is not supported');
|
||||
}
|
||||
|
||||
response = optimizeResponse(fullResponse.body ?? fullResponse);
|
||||
} catch (error) {
|
||||
response = `There was an error: "${error.message}"`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
executionError = new NodeOperationError(ctx.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}"`;
|
||||
}
|
||||
|
||||
if (executionError) {
|
||||
void ctx.addOutputData(NodeConnectionTypes.AiTool, index, executionError as ExecutionError);
|
||||
} else {
|
||||
void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
};
|
||||
|
||||
function makeParameterZodSchema(parameter: ToolParameter) {
|
||||
let schema: z.ZodTypeAny;
|
||||
|
||||
if (parameter.type === 'string') {
|
||||
schema = z.string();
|
||||
} else if (parameter.type === 'number') {
|
||||
schema = z.number();
|
||||
} else if (parameter.type === 'boolean') {
|
||||
schema = z.boolean();
|
||||
} else if (parameter.type === 'json') {
|
||||
schema = z.record(z.any());
|
||||
} else {
|
||||
schema = z.string();
|
||||
}
|
||||
|
||||
if (!parameter.required) {
|
||||
schema = schema.optional();
|
||||
}
|
||||
|
||||
if (parameter.description) {
|
||||
schema = schema.describe(parameter.description);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function makeToolInputSchema(parameters: ToolParameter[]): DynamicZodObject {
|
||||
const schemaEntries = parameters.map((parameter) => [
|
||||
parameter.name,
|
||||
makeParameterZodSchema(parameter),
|
||||
]);
|
||||
|
||||
return z.object(Object.fromEntries(schemaEntries));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { SearxngSearch } from '@langchain/community/tools/searxng_search';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolSearXng } from './ToolSearXng.node';
|
||||
|
||||
describe('ToolSearXng', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return SearXNG tool instance', async () => {
|
||||
const node = new ToolSearXng();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test searxng' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.example.com' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(SearxngSearch);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute SearXNG search and return result', async () => {
|
||||
const node = new ToolSearXng();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'artificial intelligence' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test searxng' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.example.com' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
// Mock the SearxngSearch.invoke method
|
||||
const mockResult = 'Search results for artificial intelligence...';
|
||||
SearxngSearch.prototype.invoke = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: mockResult,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(SearxngSearch.prototype.invoke).toHaveBeenCalledWith({
|
||||
query: 'artificial intelligence',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolSearXng();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'machine learning' },
|
||||
},
|
||||
{
|
||||
json: { query: 'deep learning' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test searxng' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.example.com' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
// Mock the SearxngSearch.invoke method
|
||||
SearxngSearch.prototype.invoke = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('Machine learning search results')
|
||||
.mockResolvedValueOnce('Deep learning search results');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'Machine learning search results',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: 'Deep learning search results',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(SearxngSearch.prototype.invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle credentials and options correctly', async () => {
|
||||
const node = new ToolSearXng();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'test query' },
|
||||
},
|
||||
];
|
||||
|
||||
const testOptions = { engines: ['google'], safesearch: 1 };
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test searxng' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiUrl: 'https://searx.test.com' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue(testOptions),
|
||||
});
|
||||
|
||||
SearxngSearch.prototype.invoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
await node.execute.call(mockExecute);
|
||||
|
||||
expect(mockExecute.getCredentials).toHaveBeenCalledWith('searXngApi');
|
||||
expect(mockExecute.getNodeParameter).toHaveBeenCalledWith('options', 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { SearxngSearch } from '@langchain/community/tools/searxng_search';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
type Options = {
|
||||
numResults: number;
|
||||
pageNumber: number;
|
||||
language: string;
|
||||
safesearch: 0 | 1 | 2;
|
||||
};
|
||||
|
||||
async function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number) {
|
||||
const credentials = await ctx.getCredentials<{ apiUrl: string }>('searXngApi');
|
||||
const options = ctx.getNodeParameter('options', itemIndex) as Options;
|
||||
|
||||
return new SearxngSearch({
|
||||
apiBase: credentials.apiUrl,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
params: options,
|
||||
});
|
||||
}
|
||||
|
||||
export class ToolSearXng implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'SearXNG',
|
||||
name: 'toolSearXng',
|
||||
icon: 'file:searXng.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Search in SearXNG',
|
||||
defaults: {
|
||||
name: 'SearXNG',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolsearxng',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'searXngApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Number of Results',
|
||||
name: 'numResults',
|
||||
type: 'number',
|
||||
default: 10,
|
||||
},
|
||||
{
|
||||
displayName: 'Search Page Number',
|
||||
name: 'pageNumber',
|
||||
type: 'number',
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
displayName: 'Language',
|
||||
name: 'language',
|
||||
type: 'string',
|
||||
default: 'en',
|
||||
description:
|
||||
'Defines the language to use. It\'s a two-letter language code. (e.g., `en` for English, `es` for Spanish, or `fr` for French). Head to <a href="https://docs.searxng.org/user/search-syntax.html#select-language">SearXNG search syntax page</a> for more info.',
|
||||
},
|
||||
{
|
||||
displayName: 'Safe Search',
|
||||
name: 'safesearch',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'None',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: 'Moderate',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
name: 'Strict',
|
||||
value: 2,
|
||||
},
|
||||
],
|
||||
default: 0,
|
||||
description: 'Filter search results of engines which support safe search',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
return {
|
||||
response: logWrapper(await getTool(this, itemIndex), this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const result: INodeExecutionData[] = [];
|
||||
const input = this.getInputData();
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const item = input[i];
|
||||
const tool = await getTool(this, i);
|
||||
result.push({
|
||||
json: {
|
||||
response: await tool.invoke(item.json),
|
||||
},
|
||||
pairedItem: {
|
||||
item: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [result];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="92mm" viewBox="0 0 92 92" width="92mm" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-40.921303 -17.416526)"><g fill="none"><circle cx="75" cy="92" r="0" stroke="#000" stroke-width="12"/><circle cx="75.921" cy="53.903" r="30" stroke="#3050ff" stroke-width="10"/><path d="m67.514849 37.91524a18 18 0 0 1 21.051475 3.312407 18 18 0 0 1 3.137312 21.078282" stroke="#3050ff" stroke-width="5"/></g><path d="m3.706 122.09h18.846v39.963h-18.846z" fill="#3050ff" transform="matrix(.69170581 -.72217939 .72217939 .69170581 0 0)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 558 B |
@@ -0,0 +1,168 @@
|
||||
import { SerpAPI } from '@langchain/community/tools/serpapi';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolSerpApi } from './ToolSerpApi.node';
|
||||
|
||||
describe('ToolSerpApi', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return SerpAPI tool instance', async () => {
|
||||
const node = new ToolSerpApi();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test serpapi' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(SerpAPI);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute SerpAPI search and return result', async () => {
|
||||
const node = new ToolSerpApi();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: 'artificial intelligence news' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test serpapi' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
// Mock the SerpAPI.invoke method
|
||||
const mockResult = 'Latest news about artificial intelligence...';
|
||||
SerpAPI.prototype.invoke = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: mockResult,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(SerpAPI.prototype.invoke).toHaveBeenCalledWith(inputData[0].json);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolSerpApi();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: 'machine learning' },
|
||||
},
|
||||
{
|
||||
json: { input: 'deep learning' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test serpapi' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
// Mock the SerpAPI.invoke method
|
||||
SerpAPI.prototype.invoke = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('Machine learning search results')
|
||||
.mockResolvedValueOnce('Deep learning search results');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'Machine learning search results',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: 'Deep learning search results',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(SerpAPI.prototype.invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle credentials and options correctly', async () => {
|
||||
const node = new ToolSerpApi();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: 'test query' },
|
||||
},
|
||||
];
|
||||
|
||||
const testOptions = { engine: 'google', location: 'US' };
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test serpapi' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiKey: 'secret-api-key' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue(testOptions),
|
||||
});
|
||||
|
||||
SerpAPI.prototype.invoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
await node.execute.call(mockExecute);
|
||||
|
||||
expect(mockExecute.getCredentials).toHaveBeenCalledWith('serpApi');
|
||||
expect(mockExecute.getNodeParameter).toHaveBeenCalledWith('options', 0);
|
||||
});
|
||||
|
||||
it('should fail gracefully if input is missing', async () => {
|
||||
const node = new ToolSerpApi();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test serpapi' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ apiKey: 'test-api-key' }),
|
||||
getNodeParameter: jest.fn().mockReturnValue({}),
|
||||
});
|
||||
|
||||
await expect(node.execute.call(mockExecute)).rejects.toThrow(
|
||||
'Missing search query input at itemIndex 0',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { SerpAPI } from '@langchain/community/tools/serpapi';
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
type INodeExecutionData,
|
||||
NodeOperationError,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
async function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions, itemIndex: number) {
|
||||
const credentials = await ctx.getCredentials('serpApi');
|
||||
|
||||
const options = ctx.getNodeParameter('options', itemIndex) as object;
|
||||
|
||||
return new SerpAPI(credentials.apiKey as string, options);
|
||||
}
|
||||
|
||||
export class ToolSerpApi implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'SerpApi (Google Search)',
|
||||
name: 'toolSerpApi',
|
||||
icon: 'file:serpApi.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Search in Google using SerpAPI',
|
||||
defaults: {
|
||||
name: 'SerpAPI',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolserpapi/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'serpApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
placeholder: 'Add Option',
|
||||
default: {},
|
||||
options: [
|
||||
{
|
||||
displayName: 'Country',
|
||||
name: 'gl',
|
||||
type: 'string',
|
||||
default: 'us',
|
||||
description:
|
||||
'Defines the country to use for search. Head to <a href="https://serpapi.com/google-countries">Google countries page</a> for a full list of supported countries.',
|
||||
},
|
||||
{
|
||||
displayName: 'Device',
|
||||
name: 'device',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Desktop',
|
||||
value: 'desktop',
|
||||
},
|
||||
{
|
||||
name: 'Mobile',
|
||||
value: 'mobile',
|
||||
},
|
||||
{
|
||||
name: 'Tablet',
|
||||
value: 'tablet',
|
||||
},
|
||||
],
|
||||
default: 'desktop',
|
||||
description: 'Device to use to get the results',
|
||||
},
|
||||
{
|
||||
displayName: 'Explicit Array',
|
||||
name: 'no_cache',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to force SerpApi to fetch the Google results even if a cached version is already present. Cache expires after 1h. Cached searches are free, and are not counted towards your searches per month.',
|
||||
},
|
||||
{
|
||||
displayName: 'Google Domain',
|
||||
name: 'google_domain',
|
||||
type: 'string',
|
||||
default: 'google.com',
|
||||
description:
|
||||
'Defines the domain to use for search. Head to <a href="https://serpapi.com/google-domains">Google domains page</a> for a full list of supported domains.',
|
||||
},
|
||||
{
|
||||
displayName: 'Language',
|
||||
name: 'hl',
|
||||
type: 'string',
|
||||
default: 'en',
|
||||
description:
|
||||
'Defines the language to use. It\'s a two-letter language code. (e.g., `en` for English, `es` for Spanish, or `fr` for French). Head to <a href="https://serpapi.com/google-languages">Google languages page</a> for a full list of supported languages.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
return {
|
||||
response: logWrapper(await getTool(this, itemIndex), this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const inputData = this.getInputData();
|
||||
const returnData: INodeExecutionData[] = [];
|
||||
for (let itemIndex = 0; itemIndex < inputData.length; itemIndex++) {
|
||||
const tool = await getTool(this, itemIndex);
|
||||
const item = inputData[itemIndex].json;
|
||||
|
||||
if (typeof item.input !== 'string' || !item.input) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Missing search query input at itemIndex ${itemIndex}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await tool.invoke(item)) as string;
|
||||
|
||||
returnData.push({
|
||||
json: {
|
||||
response: result,
|
||||
},
|
||||
pairedItem: { item: itemIndex },
|
||||
});
|
||||
}
|
||||
|
||||
return [returnData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.0" viewBox="0 0 4680 1340"><path fill="#7D7D87" d="M4463 121v110h207V11h-207zM300.5 47.6c-2.7.2-12.2.8-21 1.4-68.2 4.6-123.8 18.1-161 39.2C60.6 121 31.8 174.2 24 263c-2.7 31.5-2.2 98.3 1.1 135C35 509.4 72.8 555.1 185 591.5c24 7.7 55.1 15.8 98.2 25.5 20 4.5 47.1 10.9 60.3 14.2 71.8 18 95 32.5 102.5 63.9 5.2 21.9 5.2 74.3.1 97.9-9 40.9-36.1 55.8-105.8 57.8-91.6 2.8-177-9.3-279.6-39.5-10.4-3-19.2-5.1-19.7-4.7-.9 1.1-36.3 165.4-35.7 166 1.3 1.3 34.4 14.3 49.7 19.5 59.9 20.5 123.2 33.3 197.5 40 19.2 1.7 92.1 2.3 117 1 133.6-7.2 210-36.4 253.1-96.8 34.3-48 46.8-117.2 42.5-235.3-2.3-62.8-7.5-92.1-21.6-121.5-27.5-57.2-89-90.1-235.5-126.1-92.8-22.8-104.9-26.1-123.3-33.4-25.7-10.3-37.5-22-42.7-42.5-3.7-14.5-5.2-55.9-2.9-79 4-39.9 20.1-56.3 61.9-63 33.7-5.4 114.6-3.6 184.5 4.1 27 3 74.5 9.7 110.9 15.7 16.3 2.7 26 3.9 26.2 3.2.6-1.9 21.5-179.8 21.1-180.1-.7-.8-40.5-8.5-63.6-12.3C531.7 58 489.4 53 433.5 48.9c-14.1-1-122.1-2.1-133-1.3m2699 14.6c-1 2.9-291.5 956.8-291.5 957.3 0 .3 50.5.4 112.2.3l112.3-.3 25.2-91c13.8-50.1 25.4-92 25.8-93.3l.5-2.2 160.8.2 160.7.3 25.5 92c14.1 50.6 25.8 92.6 26 93.3.4 1 23.4 1.2 112.6 1l112-.3-146.6-479-146.7-479-144.2-.3c-114.8-.2-144.3 0-144.6 1m199.3 390c29.8 109.6 54.2 199.6 54.2 200 0 .5-49.5.8-110.1.8-91.3 0-110-.2-109.6-1.3.3-.8 25.2-90.8 55.3-200 30.1-109.3 55.1-198.7 55.4-198.7s25 89.7 54.8 199.2M1062 304.6c-17.4.9-38.6 2.7-48.5 4-129.8 17.5-205.8 85.9-226.5 204.1-5.2 29.7-5.3 30.9-5.7 142.8-.5 110.1-.1 126.7 3.8 154.4 7.1 50.8 25 95.7 51.4 128.6 37.3 46.6 94.8 76.9 168.9 88.9 34.1 5.6 57.4 7 102.1 6.3 53.4-.9 98.5-5.9 153.5-17.2 39.2-8 97-24.6 97-27.7 0-1.2-28.9-150.6-29.6-152.9-.4-1.5-.9-1.5-5.2-.3-40.8 12.4-110.5 23.9-166.9 27.5-24.8 1.6-77.4.7-91.1-1.5-41-6.7-60.5-20.4-70.3-49.7-4.1-12.4-5.9-25.2-6.6-49.2l-.6-20.7H1366v-73.8c0-76.6-.9-109.4-3.6-133.5-12.2-110.7-60.5-178.8-149.6-210.8-22.5-8-53.6-14.6-82.8-17.3-15.5-1.5-55.7-2.7-68-2m39 163.4c21.7 2.7 35.6 8.6 47.1 20 16.3 16.3 21.2 33.3 21.3 74.5l.1 24h-181l-.3-15c-.8-41.2 6.3-64.7 24.6-81.7 11.3-10.4 25.3-17.1 42.3-20.2 14.6-2.7 32-3.3 45.9-1.6m1296.5-162.9c-39.3 2.7-85 14.6-130.7 34-17.8 7.6-44.4 21-58.1 29.2l-10.8 6.5-1.2-3.1c-.7-1.8-5.6-14.3-10.8-27.9l-9.4-24.8H2014v506.5c0 464.7.1 506.5 1.6 506.5.9 0 47-6.3 102.4-14 55.5-7.7 101.3-14 101.9-14 .8 0 1.1-42.4 1.1-144.8v-144.9l8.3 1.8c35.6 7.9 82 14 128.5 17 24.2 1.5 72.6.7 88.7-1.5 67.9-9.5 115.3-36.5 146.9-83.6 7.6-11.3 19.5-35.7 24.4-50 7.6-22.2 12.5-46.1 15.9-76.5 1.3-11.6 1.6-34.9 2-136 .5-123.9 0-151.4-3.2-177.5-10-82.2-41.6-139.7-94.7-172.6-28.8-17.9-62.8-27.7-105.3-30.4-16.5-1.1-19-1.1-35 .1M2364 485c19.9 1.9 32.4 6.8 43.1 16.8s16.6 22 19.4 39.5c2.2 13.7 2.2 242.2 0 255-4.8 27.3-17.9 44.5-40.4 52.7-12 4.4-21.5 5.4-46.1 4.7-22.5-.6-44.9-2.9-71-7.1-15.7-2.6-37.1-6.8-44.2-8.6l-3.8-1.1V539.2l4.3-3.7c6.6-5.7 26.2-18.6 37.8-24.8 30.2-16.2 57.7-24.4 88.4-26.6 1.1 0 6.7.4 12.5.9m1714.5-180.4c-59 3.5-134.4 28.3-193.7 63.7l-10.7 6.4-4.9-12.6c-2.7-6.9-7.5-19.5-10.7-27.9l-5.9-15.2H3690v506.5c0 468 .1 506.5 1.6 506.5.9 0 47.1-6.3 102.6-14s101.3-14 101.8-14c.6 0 1-53.3 1-144.8v-144.8l18.3 3.7c30.2 6.1 56.5 9.7 97.7 13.6 23.5 2.2 88.9 2.5 105 .5 31-3.9 59.5-12 82.9-23.9 19.2-9.6 30.9-18.1 46.1-33.3 29.2-29.2 47-65.1 57-115 7.4-37 8-51.3 8-191 0-138.9-.6-152.8-8-190-20.3-102-80.6-160.8-177-172.4-13-1.5-38.1-2.6-48.5-2M4041 485c12.6 1.2 19.8 3.2 29.7 8 15.3 7.5 25.2 20.8 30.6 41l2.2 8.5v253l-2.2 8.4c-6.2 23.1-20.2 39-40.1 45.5-13.1 4.3-19.7 4.9-44.2 4.3-22.2-.6-36.1-1.9-63.5-5.8-13.7-2-45.3-8-52.7-10l-3.8-1V539l5.8-4.5c32.7-26.1 78.8-46 114.5-49.4 11.6-1.2 13.1-1.2 23.7-.1M1879 307.6c-45.1 11.8-115.7 42.6-162.5 70.9-7.8 4.8-12.2 6.9-12.6 6.2-.3-.7-3.7-15.7-7.4-33.5l-6.8-32.2H1517v701h207V592.2l10.8-7c35.7-23.1 97.7-53.7 158.4-78.2 8.4-3.4 15.6-6.5 16-6.8.4-.4-3.1-43.7-7.8-96.2-4.6-52.5-8.4-96.3-8.4-97.3 0-2.3-2.4-2.2-14 .9m2584 361.9V1020h207V319h-207z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,112 @@
|
||||
import { DynamicTool } from '@langchain/classic/tools';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
NodeConnectionTypes,
|
||||
nodeNameToToolName,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
type INodeExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
async function getTool(
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<DynamicTool> {
|
||||
const node = ctx.getNode();
|
||||
const { typeVersion } = node;
|
||||
|
||||
const name = typeVersion === 1 ? 'thinking_tool' : nodeNameToToolName(node);
|
||||
const description = ctx.getNodeParameter('description', itemIndex) as string;
|
||||
|
||||
return new DynamicTool({
|
||||
name,
|
||||
description,
|
||||
func: async (subject: string) => {
|
||||
return subject;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// A thinking tool, see https://www.anthropic.com/engineering/claude-think-tool
|
||||
|
||||
const defaultToolDescription =
|
||||
'Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed.';
|
||||
|
||||
export class ToolThink implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Think Tool',
|
||||
name: 'toolThink',
|
||||
icon: 'fa:brain',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
description: 'Invite the AI agent to do some thinking',
|
||||
defaults: {
|
||||
name: 'Think',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolthink/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
inputs: [],
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Think Tool Description',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: defaultToolDescription,
|
||||
placeholder: '[Describe your thinking tool here, explaining how it will help the AI think]',
|
||||
description: "The thinking tool's description",
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const tool = await getTool(this, itemIndex);
|
||||
|
||||
return {
|
||||
response: logWrapper(tool, this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const input = this.getInputData();
|
||||
const response: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const inputItem = input[i];
|
||||
const tool = await getTool(this, i);
|
||||
const result = await tool.invoke(inputItem.json);
|
||||
response.push({
|
||||
json: {
|
||||
response: result,
|
||||
},
|
||||
pairedItem: {
|
||||
item: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { DynamicTool } from '@langchain/classic/tools';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
INode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolThink } from '../ToolThink.node';
|
||||
|
||||
describe('ToolThink', () => {
|
||||
const thinkTool = new ToolThink();
|
||||
const helpers = mock<ISupplyDataFunctions['helpers']>();
|
||||
|
||||
const createExecuteFunctions = (node: Partial<INode> = { typeVersion: 1 }) => {
|
||||
const executeFunctions = mock<ISupplyDataFunctions>({
|
||||
helpers,
|
||||
});
|
||||
executeFunctions.addInputData.mockReturnValue({ index: 0 });
|
||||
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'Tool description';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
executeFunctions.getNode.mockReturnValue(mock<INode>(node));
|
||||
return executeFunctions;
|
||||
};
|
||||
|
||||
describe('Tool response', () => {
|
||||
it('should return the same text as response when receiving a text input', async () => {
|
||||
const executeFunctions = createExecuteFunctions();
|
||||
|
||||
const { response } = (await thinkTool.supplyData.call(executeFunctions, 0)) as {
|
||||
response: DynamicTool;
|
||||
};
|
||||
expect(response).toBeInstanceOf(DynamicTool);
|
||||
expect(response.description).toEqual('Tool description');
|
||||
const res = (await response.invoke('foo')) as string;
|
||||
expect(res).toEqual('foo');
|
||||
});
|
||||
|
||||
it('should use hardcoded name for version 1', async () => {
|
||||
const executeFunctions = createExecuteFunctions({ typeVersion: 1 });
|
||||
|
||||
const { response } = (await thinkTool.supplyData.call(executeFunctions, 0)) as {
|
||||
response: DynamicTool;
|
||||
};
|
||||
expect(response.name).toEqual('thinking_tool');
|
||||
});
|
||||
|
||||
it('should use dynamic name from node for version 1.1', async () => {
|
||||
const executeFunctions = createExecuteFunctions({
|
||||
typeVersion: 1.1,
|
||||
name: 'My Thinking Tool',
|
||||
});
|
||||
|
||||
const { response } = (await thinkTool.supplyData.call(executeFunctions, 0)) as {
|
||||
response: DynamicTool;
|
||||
};
|
||||
expect(response.name).toEqual('My_Thinking_Tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute think tool and return input as result', async () => {
|
||||
const node = new ToolThink();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: 'thinking about this problem' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.1, name: 'test think tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'Tool for thinking';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'thinking about this problem',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolThink();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: 'first thought' },
|
||||
},
|
||||
{
|
||||
json: { input: 'second thought' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.1, name: 'test think tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'Tool for thinking';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'first thought',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: 'second thought',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use hardcoded name for version 1', async () => {
|
||||
const node = new ToolThink();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { input: 'test' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'My Thinking Tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'Tool for thinking';
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'test',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
// The tool should be created with the hardcoded name for version 1
|
||||
// This is tested indirectly through the getTool function usage
|
||||
expect(mockExecute.getNode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { VectorStoreQATool } from '@langchain/classic/tools';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type IExecuteFunctions,
|
||||
type INode,
|
||||
type INodeExecutionData,
|
||||
type ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolVectorStore } from './ToolVectorStore.node';
|
||||
|
||||
describe('ToolVectorStore', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should read name from node name on version >=1.1', async () => {
|
||||
const node = new ToolVectorStore();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'name':
|
||||
return 'wrong_field';
|
||||
case 'topK':
|
||||
return 4;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => {
|
||||
switch (inputName) {
|
||||
case NodeConnectionTypes.AiVectorStore:
|
||||
return jest.fn();
|
||||
case NodeConnectionTypes.AiLanguageModel:
|
||||
return {
|
||||
_modelType: jest.fn(),
|
||||
};
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(VectorStoreQATool);
|
||||
|
||||
const tool = supplyDataResult.response as VectorStoreQATool;
|
||||
expect(tool.name).toBe('test_tool');
|
||||
expect(tool.description).toContain('test_tool');
|
||||
});
|
||||
|
||||
it('should read name from name parameter on version <1.2', async () => {
|
||||
const node = new ToolVectorStore();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1, name: 'wrong name' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'name':
|
||||
return 'test_tool';
|
||||
case 'topK':
|
||||
return 4;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => {
|
||||
switch (inputName) {
|
||||
case NodeConnectionTypes.AiVectorStore:
|
||||
return jest.fn();
|
||||
case NodeConnectionTypes.AiLanguageModel:
|
||||
return {
|
||||
_modelType: jest.fn(),
|
||||
};
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(VectorStoreQATool);
|
||||
|
||||
const tool = supplyDataResult.response as VectorStoreQATool;
|
||||
expect(tool.name).toBe('test_tool');
|
||||
expect(tool.description).toContain('test_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute vector store tool and return result', async () => {
|
||||
const node = new ToolVectorStore();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'test question' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'test description';
|
||||
case 'topK':
|
||||
return 4;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => {
|
||||
switch (inputName) {
|
||||
case NodeConnectionTypes.AiVectorStore:
|
||||
return jest.fn();
|
||||
case NodeConnectionTypes.AiLanguageModel:
|
||||
return {
|
||||
_modelType: jest.fn(),
|
||||
};
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock the VectorStoreQATool.invoke method
|
||||
const mockResult = 'This is the answer from vector store';
|
||||
VectorStoreQATool.prototype.invoke = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: mockResult,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(VectorStoreQATool.prototype.invoke).toHaveBeenCalledWith(inputData[0].json);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolVectorStore();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'first question' },
|
||||
},
|
||||
{
|
||||
json: { query: 'second question' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
|
||||
getNodeParameter: jest.fn().mockImplementation((paramName, _itemIndex) => {
|
||||
switch (paramName) {
|
||||
case 'description':
|
||||
return 'test description';
|
||||
case 'topK':
|
||||
return 4;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
getInputConnectionData: jest.fn().mockImplementation(async (inputName, _itemIndex) => {
|
||||
switch (inputName) {
|
||||
case NodeConnectionTypes.AiVectorStore:
|
||||
return jest.fn();
|
||||
case NodeConnectionTypes.AiLanguageModel:
|
||||
return {
|
||||
_modelType: jest.fn(),
|
||||
};
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock the VectorStoreQATool.invoke method
|
||||
VectorStoreQATool.prototype.invoke = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('Answer to first question')
|
||||
.mockResolvedValueOnce('Answer to second question');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'Answer to first question',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: 'Answer to second question',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(VectorStoreQATool.prototype.invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { VectorStore } from '@langchain/core/vectorstores';
|
||||
import { VectorDBQAChain } from '@langchain/classic/chains';
|
||||
import { VectorStoreQATool } from '@langchain/classic/tools';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
ISupplyDataFunctions,
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, nodeNameToToolName } from 'n8n-workflow';
|
||||
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
async function getTool(
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions,
|
||||
itemIndex: number,
|
||||
): Promise<VectorStoreQATool> {
|
||||
const node = ctx.getNode();
|
||||
const { typeVersion } = node;
|
||||
const name =
|
||||
typeVersion <= 1
|
||||
? (ctx.getNodeParameter('name', itemIndex) as string)
|
||||
: nodeNameToToolName(node);
|
||||
const toolDescription = ctx.getNodeParameter('description', itemIndex) as string;
|
||||
const topK = ctx.getNodeParameter('topK', itemIndex, 4) as number;
|
||||
const description = VectorStoreQATool.getDescription(name, toolDescription);
|
||||
const vectorStore = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiVectorStore,
|
||||
itemIndex,
|
||||
)) as VectorStore;
|
||||
const llm = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiLanguageModel,
|
||||
itemIndex,
|
||||
)) as BaseLanguageModel;
|
||||
|
||||
const vectorStoreTool = new VectorStoreQATool(name, description, {
|
||||
llm,
|
||||
vectorStore,
|
||||
});
|
||||
|
||||
vectorStoreTool.chain = VectorDBQAChain.fromLLM(llm, vectorStore, {
|
||||
k: topK,
|
||||
});
|
||||
|
||||
return vectorStoreTool;
|
||||
}
|
||||
|
||||
export class ToolVectorStore implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Vector Store Question Answer Tool',
|
||||
name: 'toolVectorStore',
|
||||
icon: 'fa:database',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
description: 'Answer questions with a vector store',
|
||||
defaults: {
|
||||
name: 'Answer questions with a vector store',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolvectorstore/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [
|
||||
{
|
||||
displayName: 'Vector Store',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiVectorStore,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
maxConnections: 1,
|
||||
type: NodeConnectionTypes.AiLanguageModel,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_vectorStore: { required: true },
|
||||
ai_languageModel: { required: true },
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
getConnectionHintNoticeField([NodeConnectionTypes.AiAgent]),
|
||||
{
|
||||
displayName: 'Data Name',
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: 'e.g. users_info',
|
||||
validateType: 'string-alphanumeric',
|
||||
description:
|
||||
'Name of the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [1],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Description of Data',
|
||||
name: 'description',
|
||||
type: 'string',
|
||||
default: '',
|
||||
placeholder: "[Describe your data here, e.g. a user's name, email, etc.]",
|
||||
description:
|
||||
'Describe the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.',
|
||||
typeOptions: {
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'topK',
|
||||
type: 'number',
|
||||
default: 4,
|
||||
description: 'The maximum number of results to return',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const vectorStoreTool = await getTool(this, itemIndex);
|
||||
|
||||
return {
|
||||
response: logWrapper(vectorStoreTool, this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const inputData = this.getInputData();
|
||||
const result: INodeExecutionData[] = [];
|
||||
for (let itemIndex = 0; itemIndex < inputData.length; itemIndex++) {
|
||||
const tool = await getTool(this, itemIndex);
|
||||
const outputData = await tool.invoke(inputData[itemIndex].json);
|
||||
result.push({
|
||||
json: {
|
||||
response: outputData,
|
||||
},
|
||||
pairedItem: {
|
||||
item: itemIndex,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [result];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolWikipedia } from './ToolWikipedia.node';
|
||||
|
||||
describe('ToolWikipedia', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return Wikipedia tool instance', async () => {
|
||||
const node = new ToolWikipedia();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wikipedia' })),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(WikipediaQueryRun);
|
||||
});
|
||||
|
||||
it('should sanitize tool name to be LLM API compatible', async () => {
|
||||
const node = new ToolWikipedia();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'Wikipedia (1)' })),
|
||||
}),
|
||||
);
|
||||
|
||||
const tool = supplyDataResult.response as WikipediaQueryRun;
|
||||
expect(tool.name).toBe('Wikipedia_1_');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute wikipedia search and return result', async () => {
|
||||
const node = new ToolWikipedia();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'artificial intelligence' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wikipedia' })),
|
||||
});
|
||||
|
||||
// Mock the WikipediaQueryRun.invoke method
|
||||
const mockResult = 'Artificial intelligence (AI) is intelligence demonstrated by machines...';
|
||||
WikipediaQueryRun.prototype.invoke = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: mockResult,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(WikipediaQueryRun.prototype.invoke).toHaveBeenCalledWith({
|
||||
query: 'artificial intelligence',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolWikipedia();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'machine learning' },
|
||||
},
|
||||
{
|
||||
json: { query: 'deep learning' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wikipedia' })),
|
||||
});
|
||||
|
||||
// Mock the WikipediaQueryRun.invoke method
|
||||
WikipediaQueryRun.prototype.invoke = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('Machine learning (ML) is a field of artificial intelligence...')
|
||||
.mockResolvedValueOnce('Deep learning (also known as deep structured learning...');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'Machine learning (ML) is a field of artificial intelligence...',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: 'Deep learning (also known as deep structured learning...',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(WikipediaQueryRun.prototype.invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should skip undefined items', async () => {
|
||||
const node = new ToolWikipedia();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'test' },
|
||||
},
|
||||
];
|
||||
// Simulate undefined item by mocking getInputData to return array with undefined
|
||||
inputData.push(undefined as any);
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wikipedia' })),
|
||||
});
|
||||
|
||||
// Mock the WikipediaQueryRun.invoke method
|
||||
WikipediaQueryRun.prototype.invoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: 'test result',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(WikipediaQueryRun.prototype.invoke).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run';
|
||||
import {
|
||||
type IExecuteFunctions,
|
||||
NodeConnectionTypes,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
type INodeExecutionData,
|
||||
nodeNameToToolName,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions): WikipediaQueryRun {
|
||||
const WikiTool = new WikipediaQueryRun();
|
||||
WikiTool.name = nodeNameToToolName(ctx.getNode());
|
||||
WikiTool.description =
|
||||
'A tool for interacting with and fetching data from the Wikipedia API. The input should always be a string query.';
|
||||
return WikiTool;
|
||||
}
|
||||
|
||||
export class ToolWikipedia implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Wikipedia',
|
||||
name: 'toolWikipedia',
|
||||
icon: 'file:wikipedia.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Search in Wikipedia',
|
||||
defaults: {
|
||||
name: 'Wikipedia',
|
||||
},
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolwikipedia/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiAgent])],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions): Promise<SupplyData> {
|
||||
return {
|
||||
response: logWrapper(getTool(this), this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const WikiTool = getTool(this);
|
||||
|
||||
const items = this.getInputData();
|
||||
|
||||
const response: INodeExecutionData[] = [];
|
||||
for (let itemIndex = 0; itemIndex < this.getInputData().length; itemIndex++) {
|
||||
const item = items[itemIndex];
|
||||
if (item === undefined) {
|
||||
continue;
|
||||
}
|
||||
const result = await WikiTool.invoke(item.json);
|
||||
response.push({
|
||||
json: { response: result },
|
||||
pairedItem: { item: itemIndex },
|
||||
});
|
||||
}
|
||||
|
||||
return [response];
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 53 KiB |
+141
@@ -0,0 +1,141 @@
|
||||
import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ToolWolframAlpha } from './ToolWolframAlpha.node';
|
||||
|
||||
describe('ToolWolframAlpha', () => {
|
||||
describe('supplyData', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should return WolframAlpha tool instance', async () => {
|
||||
const node = new ToolWolframAlpha();
|
||||
|
||||
const supplyDataResult = await node.supplyData.call(
|
||||
mock<ISupplyDataFunctions>({
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wolfram' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ appId: 'test-app-id' }),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(supplyDataResult.response).toBeInstanceOf(WolframAlphaTool);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should execute WolframAlpha query and return result', async () => {
|
||||
const node = new ToolWolframAlpha();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'what is 2+2?' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wolfram' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ appId: 'test-app-id' }),
|
||||
});
|
||||
|
||||
// Mock the WolframAlphaTool.invoke method
|
||||
const mockResult = '4';
|
||||
WolframAlphaTool.prototype.invoke = jest.fn().mockResolvedValue(mockResult);
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: mockResult,
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(WolframAlphaTool.prototype.invoke).toHaveBeenCalledWith(inputData[0].json);
|
||||
});
|
||||
|
||||
it('should handle multiple input items', async () => {
|
||||
const node = new ToolWolframAlpha();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'what is 5*3?' },
|
||||
},
|
||||
{
|
||||
json: { query: 'what is the square root of 16?' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wolfram' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ appId: 'test-app-id' }),
|
||||
});
|
||||
|
||||
// Mock the WolframAlphaTool.invoke method
|
||||
WolframAlphaTool.prototype.invoke = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce('15')
|
||||
.mockResolvedValueOnce('4');
|
||||
|
||||
const result = await node.execute.call(mockExecute);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{
|
||||
json: {
|
||||
response: '15',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
json: {
|
||||
response: '4',
|
||||
},
|
||||
pairedItem: {
|
||||
item: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(WolframAlphaTool.prototype.invoke).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should handle credentials correctly', async () => {
|
||||
const node = new ToolWolframAlpha();
|
||||
const inputData: INodeExecutionData[] = [
|
||||
{
|
||||
json: { query: 'test query' },
|
||||
},
|
||||
];
|
||||
|
||||
const mockExecute = mock<IExecuteFunctions>({
|
||||
getInputData: jest.fn(() => inputData),
|
||||
getNode: jest.fn(() => mock<INode>({ name: 'test wolfram' })),
|
||||
getCredentials: jest.fn().mockResolvedValue({ appId: 'secret-app-id' }),
|
||||
});
|
||||
|
||||
WolframAlphaTool.prototype.invoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
await node.execute.call(mockExecute);
|
||||
|
||||
expect(mockExecute.getCredentials).toHaveBeenCalledWith('wolframAlphaApi');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha';
|
||||
import {
|
||||
NodeConnectionTypes,
|
||||
type IExecuteFunctions,
|
||||
type INodeExecutionData,
|
||||
type INodeType,
|
||||
type INodeTypeDescription,
|
||||
type ISupplyDataFunctions,
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities';
|
||||
|
||||
export class ToolWolframAlpha implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Wolfram|Alpha',
|
||||
name: 'toolWolframAlpha',
|
||||
icon: 'file:wolfram-alpha.svg',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: "Connects to WolframAlpha's computational intelligence engine.",
|
||||
defaults: {
|
||||
name: 'Wolfram Alpha',
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
name: 'wolframAlphaApi',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
codex: {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
resources: {
|
||||
primaryDocumentation: [
|
||||
{
|
||||
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.toolwolframalpha/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
inputs: [],
|
||||
|
||||
outputs: [NodeConnectionTypes.AiTool],
|
||||
outputNames: ['Tool'],
|
||||
properties: [getConnectionHintNoticeField([NodeConnectionTypes.AiAgent])],
|
||||
};
|
||||
|
||||
async supplyData(this: ISupplyDataFunctions): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('wolframAlphaApi');
|
||||
|
||||
return {
|
||||
response: logWrapper(new WolframAlphaTool({ appid: credentials.appId as string }), this),
|
||||
};
|
||||
}
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const credentials = await this.getCredentials('wolframAlphaApi');
|
||||
const input = this.getInputData();
|
||||
const result: INodeExecutionData[] = [];
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const item = input[i];
|
||||
const tool = new WolframAlphaTool({ appid: credentials.appId as string });
|
||||
result.push({
|
||||
json: {
|
||||
response: await tool.invoke(item.json),
|
||||
},
|
||||
pairedItem: {
|
||||
item: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return [result];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 39"><path d="m36.971 24.875-7.869 1.673.801 7.825-7.372-3.162L18.469 38l-4.063-6.791-7.373 3.161.802-7.823-7.868-1.677L5.326 19l-5.357-5.872 7.867-1.673-.799-7.825 7.372 3.162 4.06-6.791 4.063 6.791 7.373-3.16-.801 7.824 7.867 1.676-5.357 5.871z" style="fill:#f16850"/><path d="m18.469 11.861-4.06-5.071-7.372-3.163 4.388 5.787z" style="fill:#fd694f"/><path d="m7.836 11.452-7.868 1.674 5.357 5.872 6.306-2.285z" style="fill:#ff3413"/><path d="M11.425 9.414 7.037 3.627l.799 7.825 3.795 5.261z" style="fill:#dc1d23"/><path d="M22.532 6.79 18.469 0l-4.06 6.79 4.06 5.071z" style="fill:#ff9281"/><path d="m31.613 19.001 5.358-5.871-7.867-1.675-3.797 5.258z" style="fill:#ff8b79"/><path d="m25.307 16.713 3.797-5.258.801-7.826-4.392 5.785z" style="fill:#fd694f"/><path d="m25.513 9.414 4.392-5.785-7.373 3.161-4.063 5.071z" style="fill:#ef5240"/><path d="m29.866 22.499 7.104 2.373-5.357-5.871-6.306-2.288z" style="fill:#ff482c"/><path d="m11.631 16.713-6.306 2.285-5.358 5.87 7.105-2.369z" style="fill:#ec2101"/><path d="M18.469 30.586v7.412l4.061-6.789.165-6.645z" style="fill:#d21c22"/><path d="m29.866 22.499-7.171 2.065 6.407 1.982 7.868-1.674z" style="fill:#c90901"/><path d="m22.53 31.209 7.372 3.162-.8-7.825-6.407-1.982z" style="fill:#ec2101"/><path d="m7.072 22.499-7.105 2.369 7.868 1.676 6.408-1.98z" style="fill:#b6171e"/><path d="m14.243 24.564.163 6.644 4.063 6.79v-7.412z" style="fill:#b4151b"/><path d="m7.835 26.544-.802 7.824 7.373-3.16-.163-6.644z" style="fill:#d21c22"/><path d="m25.307 16.713.206-7.299-7.044 2.447v7.102z" style="fill:#e63320"/><path d="m18.469 11.861-7.044-2.447.206 7.299 6.838 2.25z" style="fill:#ff4527"/><path d="m14.243 24.564 4.226 6.022 4.226-6.022-4.226-5.601z" style="fill:#ff9281"/><path d="m18.469 18.963 4.226 5.601 7.171-2.065-4.559-5.786z" style="fill:#fd684d"/><path d="m11.631 16.713-4.559 5.786 7.171 2.065 4.226-5.601z" style="fill:#fd745c"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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