feat: Add execute function to tools (no-changelog) (#19997)

This commit is contained in:
Benjamin Schroth 2025-09-29 14:47:27 +02:00 committed by GitHub
parent 0789364838
commit 1291399b88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1594 additions and 250 deletions

View File

@ -0,0 +1,105 @@
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);
});
});
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,
},
},
],
]);
});
});
});

View File

@ -1,5 +1,7 @@
import { Calculator } from '@langchain/community/tools/calculator';
import {
type IExecuteFunctions,
type INodeExecutionData,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
@ -10,6 +12,12 @@ import {
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions): Calculator {
const calculator = new Calculator();
calculator.name = ctx.getNode().name;
return calculator;
}
export class ToolCalculator implements INodeType {
description: INodeTypeDescription = {
displayName: 'Calculator',
@ -46,7 +54,27 @@ export class ToolCalculator implements INodeType {
async supplyData(this: ISupplyDataFunctions): Promise<SupplyData> {
return {
response: logWrapper(new Calculator(), this),
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];
}
}

View File

@ -1,6 +1,11 @@
import { mock } from 'jest-mock-extended';
import { DynamicTool } from 'langchain/tools';
import { type INode, type ISupplyDataFunctions } from 'n8n-workflow';
import {
type IExecuteFunctions,
type INode,
type INodeExecutionData,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import { ToolCode } from './ToolCode.node';
@ -78,4 +83,125 @@ describe('ToolCode', () => {
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);
});
});
});

View File

@ -10,18 +10,20 @@ import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox';
import type {
ExecutionError,
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
ISupplyDataFunctions,
SupplyData,
} from 'n8n-workflow';
import {
jsonParse,
NodeConnectionTypes,
nodeNameToToolName,
NodeOperationError,
} from 'n8n-workflow';
import {
buildInputSchemaField,
buildJsonSchemaExampleField,
@ -46,6 +48,154 @@ const jsonSchemaExampleNotice = buildJsonSchemaExampleNotice({
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 runnersConfig = Container.get(TaskRunnersConfig);
const isRunnerEnabled = runnersConfig.enabled;
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;
}
// @deprecated - TODO: Remove this after a new python runner is implemented
const getSandbox = (query: string | IDataObject, index = 0) => {
const context = getSandboxContext.call(ctx, index);
context.query = query;
let sandbox: Sandbox;
if (language === 'javaScript') {
sandbox = new JavaScriptSandbox(context, code, ctx.helpers);
} else {
sandbox = new PythonSandbox(context, code, ctx.helpers);
}
sandbox.on(
'output',
workflowMode === 'manual'
? ctx.sendMessageToUI.bind(ctx)
: (...args: unknown[]) =>
console.log(`[Workflow "${ctx.getWorkflow().id}"][Node "${node.name}"]`, ...args),
);
return sandbox;
};
const runFunction = async (query: string | IDataObject): Promise<unknown> => {
if (language === 'javaScript' && isRunnerEnabled) {
const sandbox = new JsTaskRunnerSandbox(
code,
'runOnceForAllItems',
workflowMode,
ctx,
undefined,
{
query,
},
);
const executionData = await sandbox.runCodeForTool();
return executionData;
} else {
// use old vm2-based sandbox for python or when without runner enabled
const sandbox = getSandbox(query, itemIndex);
return await sandbox.runCode<string>();
}
};
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',
@ -200,146 +350,26 @@ export class ToolCode implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const node = this.getNode();
const workflowMode = this.getMode();
const runnersConfig = Container.get(TaskRunnersConfig);
const isRunnerEnabled = runnersConfig.enabled;
const { typeVersion } = node;
const name =
typeVersion <= 1.1
? (this.getNodeParameter('name', itemIndex) as string)
: nodeNameToToolName(node);
const description = this.getNodeParameter('description', itemIndex) as string;
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
const language = this.getNodeParameter('language', itemIndex) as string;
let code = '';
if (language === 'javaScript') {
code = this.getNodeParameter('jsCode', itemIndex) as string;
} else {
code = this.getNodeParameter('pythonCode', itemIndex) as string;
}
// @deprecated - TODO: Remove this after a new python runner is implemented
const getSandbox = (query: string | IDataObject, index = 0) => {
const context = getSandboxContext.call(this, index);
context.query = query;
let sandbox: Sandbox;
if (language === 'javaScript') {
sandbox = new JavaScriptSandbox(context, code, this.helpers);
} else {
sandbox = new PythonSandbox(context, code, this.helpers);
}
sandbox.on(
'output',
workflowMode === 'manual'
? this.sendMessageToUI.bind(this)
: (...args: unknown[]) =>
console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args),
);
return sandbox;
};
const runFunction = async (query: string | IDataObject): Promise<unknown> => {
if (language === 'javaScript' && isRunnerEnabled) {
const sandbox = new JsTaskRunnerSandbox(
code,
'runOnceForAllItems',
workflowMode,
this,
undefined,
{
query,
},
);
const executionData = await sandbox.runCodeForTool();
return executionData;
} else {
// use old vm2-based sandbox for python or when without runner enabled
const sandbox = getSandbox(query, itemIndex);
return await sandbox.runCode<string>();
}
};
const toolHandler = async (query: string | IDataObject): Promise<string> => {
const { index } = this.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]);
let response: any = '';
let executionError: ExecutionError | undefined;
try {
response = await runFunction(query);
} catch (error: unknown) {
executionError = new NodeOperationError(this.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(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}"`;
}
if (executionError) {
void this.addOutputData(NodeConnectionTypes.AiTool, index, executionError);
} else {
void this.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 = 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, this.getNode().typeVersion >= 1.3)
: jsonParse<JSONSchema7>(inputSchema);
const zodSchema = convertJsonSchemaToZod<DynamicZodObject>(jsonSchema);
tool = new DynamicStructuredTool({
schema: zodSchema,
...commonToolOptions,
});
} catch (error) {
throw new NodeOperationError(
this.getNode(),
'Error during parsing of JSON Schema. \n ' + error,
);
}
} else {
tool = new DynamicTool(commonToolOptions);
}
return {
response: tool,
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];
}
}

View File

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

View File

@ -1,6 +1,8 @@
import { SearxngSearch } from '@langchain/community/tools/searxng_search';
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
ISupplyDataFunctions,
@ -17,6 +19,19 @@ type Options = {
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',
@ -107,19 +122,27 @@ export class ToolSearXng implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials<{ apiUrl: string }>('searXngApi');
const options = this.getNodeParameter('options', itemIndex) as Options;
const tool = new SearxngSearch({
apiBase: credentials.apiUrl,
headers: {
Accept: 'application/json',
},
params: options,
});
return {
response: logWrapper(tool, this),
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];
}
}

View File

@ -0,0 +1,148 @@
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: { query: '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]);
});
it('should handle multiple input items', async () => {
const node = new ToolSerpApi();
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 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: { query: '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);
});
});
});

View File

@ -1,14 +1,23 @@
import { SerpAPI } from '@langchain/community/tools/serpapi';
import {
type IExecuteFunctions,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
type INodeExecutionData,
} from 'n8n-workflow';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
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 = {
@ -114,12 +123,26 @@ export class ToolSerpApi implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('serpApi');
const options = this.getNodeParameter('options', itemIndex) as object;
return {
response: logWrapper(new SerpAPI(credentials.apiKey as string, options), this),
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 query = inputData[itemIndex];
const result = await tool.invoke(query);
returnData.push({
json: {
response: result,
},
pairedItem: { item: itemIndex },
});
}
return [returnData];
}
}

View File

@ -1,16 +1,37 @@
import { DynamicTool } from 'langchain/tools';
import {
type IExecuteFunctions,
NodeConnectionTypes,
nodeNameToToolName,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
type INodeExecutionData,
} from 'n8n-workflow';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
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 =
@ -63,22 +84,30 @@ export class ToolThink implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const node = this.getNode();
const { typeVersion } = node;
const name = typeVersion === 1 ? 'thinking_tool' : nodeNameToToolName(node);
const description = this.getNodeParameter('description', itemIndex) as string;
const tool = new DynamicTool({
name,
description,
func: async (subject: string) => {
return subject;
},
});
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];
}
}

View File

@ -1,6 +1,11 @@
import { mock } from 'jest-mock-extended';
import { DynamicTool } from 'langchain/tools';
import type { ISupplyDataFunctions, INode } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
ISupplyDataFunctions,
INode,
} from 'n8n-workflow';
import { ToolThink } from '../ToolThink.node';
@ -59,4 +64,135 @@ describe('ToolThink', () => {
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();
});
});
});

View File

@ -1,6 +1,12 @@
import { mock } from 'jest-mock-extended';
import { VectorStoreQATool } from 'langchain/tools';
import { NodeConnectionTypes, type INode, type ISupplyDataFunctions } from 'n8n-workflow';
import {
NodeConnectionTypes,
type IExecuteFunctions,
type INode,
type INodeExecutionData,
type ISupplyDataFunctions,
} from 'n8n-workflow';
import { ToolVectorStore } from './ToolVectorStore.node';
@ -88,4 +94,135 @@ describe('ToolVectorStore', () => {
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);
});
});
});

View File

@ -3,6 +3,8 @@ import type { VectorStore } from '@langchain/core/vectorstores';
import { VectorDBQAChain } from 'langchain/chains';
import { VectorStoreQATool } from 'langchain/tools';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
ISupplyDataFunctions,
@ -13,6 +15,40 @@ import { NodeConnectionTypes, nodeNameToToolName } from 'n8n-workflow';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
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',
@ -97,37 +133,29 @@ export class ToolVectorStore implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const node = this.getNode();
const { typeVersion } = node;
const name =
typeVersion <= 1
? (this.getNodeParameter('name', itemIndex) as string)
: nodeNameToToolName(node);
const toolDescription = this.getNodeParameter('description', itemIndex) as string;
const topK = this.getNodeParameter('topK', itemIndex, 4) as number;
const vectorStore = (await this.getInputConnectionData(
NodeConnectionTypes.AiVectorStore,
itemIndex,
)) as VectorStore;
const llm = (await this.getInputConnectionData(
NodeConnectionTypes.AiLanguageModel,
0,
)) as BaseLanguageModel;
const description = VectorStoreQATool.getDescription(name, toolDescription);
const vectorStoreTool = new VectorStoreQATool(name, description, {
llm,
vectorStore,
});
vectorStoreTool.chain = VectorDBQAChain.fromLLM(llm, vectorStore, {
k: topK,
});
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];
}
}

View File

@ -0,0 +1,154 @@
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);
});
});
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);
});
});
});

View File

@ -1,15 +1,25 @@
import { WikipediaQueryRun } from '@langchain/community/tools/wikipedia_query_run';
import {
type IExecuteFunctions,
NodeConnectionTypes,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
type SupplyData,
type INodeExecutionData,
} from 'n8n-workflow';
import { logWrapper } from '@utils/logWrapper';
import { getConnectionHintNoticeField } from '@utils/sharedFields';
function getTool(ctx: ISupplyDataFunctions | IExecuteFunctions): WikipediaQueryRun {
const WikiTool = new WikipediaQueryRun();
WikiTool.name = ctx.getNode().name;
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',
@ -44,13 +54,29 @@ export class ToolWikipedia implements INodeType {
};
async supplyData(this: ISupplyDataFunctions): Promise<SupplyData> {
const WikiTool = new WikipediaQueryRun();
WikiTool.description =
'A tool for interacting with and fetching data from the Wikipedia API. The input should always be a string query.';
return {
response: logWrapper(WikiTool, this),
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];
}
}

View File

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

View File

@ -1,6 +1,8 @@
import { WolframAlphaTool } from '@langchain/community/tools/wolframalpha';
import {
NodeConnectionTypes,
type IExecuteFunctions,
type INodeExecutionData,
type INodeType,
type INodeTypeDescription,
type ISupplyDataFunctions,
@ -56,4 +58,25 @@ export class ToolWolframAlpha implements INodeType {
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];
}
}

View File

@ -1,17 +1,43 @@
import type { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import type {
INodeTypeBaseDescription,
ISupplyDataFunctions,
SupplyData,
INodeType,
INodeTypeDescription,
IExecuteFunctions,
INodeExecutionData,
} from 'n8n-workflow';
import { nodeNameToToolName } 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;
@ -27,24 +53,24 @@ export class ToolWorkflowV2 implements INodeType {
};
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const node = this.getNode();
const { typeVersion } = node;
const returnAllItems = typeVersion > 2;
return { response: await getTool(this, true, itemIndex) };
}
const workflowToolService = new WorkflowToolService(this, { returnAllItems });
const name =
typeVersion <= 2.1
? (this.getNodeParameter('name', itemIndex) as string)
: nodeNameToToolName(node);
const description = this.getNodeParameter('description', itemIndex) as string;
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
const tool = await workflowToolService.createTool({
ctx: this,
name,
description,
itemIndex,
});
const response: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < this.getInputData().length; itemIndex++) {
const item = items[itemIndex];
const tool = await getTool(this, false, itemIndex);
return { response: tool };
if (item === undefined) {
continue;
}
const result = await tool.invoke(item.json);
response.push(result);
}
return [response];
}
}

View File

@ -10,6 +10,7 @@ import type {
ExecutionError,
FromAIArgument,
IDataObject,
IExecuteFunctions,
IExecuteWorkflowInfo,
INodeExecutionData,
INodeParameterResourceLocator,
@ -51,7 +52,7 @@ export class WorkflowToolService {
private returnAllItems: boolean = false;
constructor(
private baseContext: ISupplyDataFunctions,
private baseContext: ISupplyDataFunctions | IExecuteFunctions,
options?: { returnAllItems: boolean },
) {
const subWorkflowInputs = this.baseContext.getNode().parameters
@ -66,11 +67,13 @@ export class WorkflowToolService {
name,
description,
itemIndex,
manualLogging = true,
}: {
ctx: ISupplyDataFunctions;
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
@ -78,7 +81,7 @@ export class WorkflowToolService {
// of the same tool when the tool is used in a loop or in a parallel execution.
const node = ctx.getNode();
let runIndex: number = ctx.getNextRunIndex();
let runIndex: number = 'getNextRunIndex' in ctx ? ctx.getNextRunIndex() : 0;
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
@ -97,13 +100,17 @@ export class WorkflowToolService {
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
const context = this.baseContext.cloneWith({
runIndex: localRunIndex,
inputData: [[{ json: { query } }]],
});
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?.();
@ -153,12 +160,14 @@ export class WorkflowToolService {
};
}
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
[responseData],
metadata,
);
if (manualLogging) {
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
[responseData],
metadata,
);
}
return processedResponse;
} catch (error) {
@ -171,13 +180,15 @@ export class WorkflowToolService {
lastError = executionError;
const errorResponse = `There was an error: "${executionError.message}"`;
const metadata = parseErrorMetadata(error);
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
executionError,
metadata,
);
if (manualLogging) {
const metadata = parseErrorMetadata(error);
void context.addOutputData(
NodeConnectionTypes.AiTool,
localRunIndex,
executionError,
metadata,
);
}
if (tryIndex === maxTries - 1) {
return errorResponse;
@ -224,7 +235,7 @@ export class WorkflowToolService {
* Executes specified sub-workflow with provided inputs
*/
private async executeSubWorkflow(
context: ISupplyDataFunctions,
context: ISupplyDataFunctions | IExecuteFunctions,
workflowInfo: IExecuteWorkflowInfo,
items: INodeExecutionData[],
workflowProxy: IWorkflowDataProxyData,
@ -265,7 +276,7 @@ export class WorkflowToolService {
* This function will be called as part of the tool execution (from the toolHandler)
*/
private async runFunction(
context: ISupplyDataFunctions,
context: ISupplyDataFunctions | IExecuteFunctions,
query: string | IDataObject,
itemIndex: number,
runManager?: CallbackManagerForToolRun,
@ -298,7 +309,7 @@ export class WorkflowToolService {
* Gets the sub-workflow info based on the source (database or parameter)
*/
private async getSubWorkflowInfo(
context: ISupplyDataFunctions,
context: ISupplyDataFunctions | IExecuteFunctions,
source: string,
itemIndex: number,
workflowProxy: IWorkflowDataProxyData,
@ -336,7 +347,7 @@ export class WorkflowToolService {
}
private prepareRawData(
context: ISupplyDataFunctions,
context: ISupplyDataFunctions | IExecuteFunctions,
query: string | IDataObject,
itemIndex: number,
): IDataObject {
@ -359,7 +370,7 @@ export class WorkflowToolService {
* Prepares the sub-workflow items for execution
*/
private async prepareWorkflowItems(
context: ISupplyDataFunctions,
context: ISupplyDataFunctions | IExecuteFunctions,
query: string | IDataObject,
itemIndex: number,
rawData: IDataObject,