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,17 @@
|
||||
{
|
||||
"node": "n8n-nodes-base.toolExecutor",
|
||||
"nodeVersion": "1.0",
|
||||
"codexVersion": "1.0",
|
||||
"details": "Can execute tools by simulating an agent function call with a given query.",
|
||||
"categories": ["Core Nodes"],
|
||||
"resources": {
|
||||
"primaryDocumentation": [
|
||||
{
|
||||
"url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.editimage/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"subcategories": {
|
||||
"Core Nodes": ["Helpers"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Toolkit } from '@langchain/classic/agents';
|
||||
import { StructuredTool, Tool } from '@langchain/core/tools';
|
||||
import { buildResponseMetadata, processHitlResponses } from '@utils/agent-execution';
|
||||
import {
|
||||
extractHitlMetadata,
|
||||
hasGatedToolNodeName,
|
||||
} from '@utils/agent-execution/createEngineRequests';
|
||||
import type { RequestResponseMetadata } from '@utils/agent-execution/types';
|
||||
import get from 'lodash/get';
|
||||
import type {
|
||||
EngineRequest,
|
||||
EngineResponse,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
NodeOutput,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { executeTool } from './utils/executeTool';
|
||||
import { convertValueBySchema } from './utils/convertToSchema';
|
||||
import { ZodObject } from 'zod';
|
||||
|
||||
export class ToolExecutor implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Tool Executor',
|
||||
name: 'toolExecutor',
|
||||
version: 1,
|
||||
defaults: {
|
||||
name: 'Tool Executor',
|
||||
},
|
||||
hidden: true,
|
||||
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiTool],
|
||||
outputs: [NodeConnectionTypes.Main],
|
||||
builderHint: {
|
||||
inputs: {
|
||||
ai_tool: { required: true },
|
||||
},
|
||||
},
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Query',
|
||||
name: 'query',
|
||||
type: 'json',
|
||||
default: '{}',
|
||||
description:
|
||||
'Key-value pairs, where key is the name of the tool name and value is the parameters to pass to the tool',
|
||||
},
|
||||
{
|
||||
displayName: 'Tool Name',
|
||||
name: 'toolName',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Name of the tool to execute if the connected tool is a toolkit',
|
||||
},
|
||||
{
|
||||
displayName: 'Node',
|
||||
name: 'node',
|
||||
type: 'string',
|
||||
default: '',
|
||||
description: 'Name of the node that is being executed',
|
||||
},
|
||||
],
|
||||
group: ['transform'],
|
||||
description: 'Node to execute tools without an AI Agent',
|
||||
};
|
||||
|
||||
async execute(
|
||||
this: IExecuteFunctions,
|
||||
response?: EngineResponse<RequestResponseMetadata>,
|
||||
): Promise<NodeOutput> {
|
||||
// Process HITL (Human-in-the-Loop) tool responses before running the agent
|
||||
// If there are approved HITL tools, we need to execute the gated tools first
|
||||
const hitlResult = processHitlResponses(response, 0);
|
||||
|
||||
if (hitlResult.hasApprovedHitlTools && hitlResult.pendingGatedToolRequest) {
|
||||
// Return the gated tool request immediately
|
||||
// The Agent will resume after the gated tool executes
|
||||
return hitlResult.pendingGatedToolRequest;
|
||||
}
|
||||
|
||||
const query = this.getNodeParameter('query', 0, {}) as string | object;
|
||||
const toolName = this.getNodeParameter('toolName', 0, '') as string;
|
||||
const node = this.getNodeParameter('node', 0, '') as string;
|
||||
|
||||
let parsedQuery: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
parsedQuery = typeof query === 'string' ? JSON.parse(query) : query;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Failed to parse query: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const getQueryData = (name: string) => {
|
||||
// node names in query may have underscores in place of spaces, use it for accessing the query data.
|
||||
return (get(parsedQuery, name, null) ?? get(parsedQuery, name.replaceAll(' ', '_'), null)) as
|
||||
| Record<string, unknown>
|
||||
| string
|
||||
| null;
|
||||
};
|
||||
|
||||
const resultData: INodeExecutionData[] = [];
|
||||
const toolInputs = await this.getInputConnectionData(NodeConnectionTypes.AiTool, 0);
|
||||
|
||||
if (!toolInputs || !Array.isArray(toolInputs)) {
|
||||
throw new NodeOperationError(this.getNode(), 'No tool inputs found');
|
||||
}
|
||||
|
||||
try {
|
||||
for (const tool of toolInputs) {
|
||||
// Handle toolkits
|
||||
if (tool && typeof (tool as Toolkit).getTools === 'function') {
|
||||
const toolsInToolkit = (tool as Toolkit).getTools();
|
||||
for (const toolkitTool of toolsInToolkit) {
|
||||
if (!(toolkitTool instanceof Tool || toolkitTool instanceof StructuredTool)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (toolName === toolkitTool.name) {
|
||||
if (hasGatedToolNodeName(toolkitTool.metadata) && node) {
|
||||
const toolInput: { toolParameters: unknown } = {
|
||||
toolParameters: getQueryData(toolName) ?? {},
|
||||
};
|
||||
const hitlInput = getQueryData(node);
|
||||
if (typeof hitlInput === 'string') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Invalid hitl input for tool ${toolkitTool.name}`,
|
||||
);
|
||||
}
|
||||
|
||||
// handle code tool which uses a string input, but it should be converted to an object
|
||||
const requiresObjectInput =
|
||||
toolkitTool.metadata.originalSchema &&
|
||||
toolkitTool.metadata.originalSchema instanceof ZodObject;
|
||||
if (typeof toolInput.toolParameters === 'string' && requiresObjectInput) {
|
||||
toolInput.toolParameters = convertValueBySchema(
|
||||
toolInput.toolParameters,
|
||||
toolkitTool.metadata.originalSchema,
|
||||
);
|
||||
}
|
||||
|
||||
const hitlMetadata = extractHitlMetadata(
|
||||
toolkitTool.metadata,
|
||||
toolkitTool.name,
|
||||
toolInput as IDataObject,
|
||||
);
|
||||
|
||||
// prepare request for execution engine to execute the HITL node
|
||||
const engineRequest: EngineRequest<RequestResponseMetadata>['actions'] = [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName: node,
|
||||
input: {
|
||||
tool: toolName,
|
||||
toolParameters: toolInput.toolParameters as IDataObject,
|
||||
...hitlInput,
|
||||
},
|
||||
type: 'ai_tool',
|
||||
id: crypto.randomUUID(),
|
||||
metadata: {
|
||||
itemIndex: 0,
|
||||
hitl: hitlMetadata,
|
||||
},
|
||||
},
|
||||
];
|
||||
return {
|
||||
actions: engineRequest,
|
||||
metadata: buildResponseMetadata(response, 0),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await executeTool(toolkitTool, getQueryData(toolName) ?? {});
|
||||
resultData.push(result);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle single tool
|
||||
if (!toolName || toolName === tool.name) {
|
||||
const toolInput = getQueryData(toolName || tool.name);
|
||||
const result = await executeTool(tool, toolInput ?? {});
|
||||
resultData.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Error executing tool: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
return [resultData];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
// Mock the utility functions before imports
|
||||
jest.mock('@utils/agent-execution', () => ({
|
||||
processHitlResponses: jest.fn(),
|
||||
buildResponseMetadata: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/agent-execution/createEngineRequests', () => ({
|
||||
hasGatedToolNodeName: jest.fn(),
|
||||
extractHitlMetadata: jest.fn(),
|
||||
}));
|
||||
|
||||
import { DynamicTool, DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import type { RequestResponseMetadata } from '@utils/agent-execution/types';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { EngineResponse, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ToolExecutor } from '../ToolExecutor.node';
|
||||
|
||||
const { processHitlResponses, buildResponseMetadata } = jest.requireMock('@utils/agent-execution');
|
||||
const { hasGatedToolNodeName, extractHitlMetadata } = jest.requireMock(
|
||||
'@utils/agent-execution/createEngineRequests',
|
||||
);
|
||||
|
||||
const mockProcessHitlResponses = jest.mocked(processHitlResponses);
|
||||
const mockBuildResponseMetadata = jest.mocked(buildResponseMetadata);
|
||||
const mockHasGatedToolNodeName = jest.mocked(hasGatedToolNodeName);
|
||||
const mockExtractHitlMetadata = jest.mocked(extractHitlMetadata);
|
||||
|
||||
describe('ToolExecutor Node', () => {
|
||||
let node: ToolExecutor;
|
||||
let mockExecuteFunction: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
node = new ToolExecutor();
|
||||
mockExecuteFunction = mock<IExecuteFunctions>();
|
||||
|
||||
mockExecuteFunction.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
mockExecuteFunction.getNode.mockReturnValue({
|
||||
name: 'Tool Executor',
|
||||
typeVersion: 1,
|
||||
parameters: {},
|
||||
} as INode);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock default return for processHitlResponses - no pending HITL tools
|
||||
// This must come after clearAllMocks to take effect
|
||||
mockProcessHitlResponses.mockReturnValue({
|
||||
hasApprovedHitlTools: false,
|
||||
pendingGatedToolRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('description', () => {
|
||||
it('should have the expected properties', () => {
|
||||
expect(node.description).toBeDefined();
|
||||
expect(node.description.name).toBe('toolExecutor');
|
||||
expect(node.description.displayName).toBe('Tool Executor');
|
||||
expect(node.description.version).toBe(1);
|
||||
expect(node.description.properties).toBeDefined();
|
||||
expect(node.description.inputs).toEqual([
|
||||
NodeConnectionTypes.Main,
|
||||
NodeConnectionTypes.AiTool,
|
||||
]);
|
||||
expect(node.description.outputs).toEqual([NodeConnectionTypes.Main]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolExecutor', () => {
|
||||
it('should throw error if no tool inputs found', async () => {
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue(null);
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow(
|
||||
new NodeOperationError(mockExecuteFunction.getNode(), 'No tool inputs found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('executes a basic tool with string input', async () => {
|
||||
const mockInvoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
|
||||
mockTool.invoke = mockInvoke;
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { test_tool: 'test input' };
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith('test input');
|
||||
expect(result).toEqual([[{ json: 'test result' }]]);
|
||||
});
|
||||
|
||||
it('executes a structured tool with schema validation', async () => {
|
||||
const mockTool = new DynamicStructuredTool({
|
||||
name: 'test_structured_tool',
|
||||
description: 'A test structured tool',
|
||||
schema: z.object({
|
||||
number: z.number(),
|
||||
boolean: z.boolean(),
|
||||
}),
|
||||
func: jest.fn(),
|
||||
});
|
||||
|
||||
const mockInvoke = jest.fn().mockResolvedValue('test result');
|
||||
mockTool.invoke = mockInvoke;
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { test_structured_tool: { number: '42', boolean: 'true' } };
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ number: 42, boolean: true });
|
||||
expect(result).toEqual([[{ json: 'test result' }]]);
|
||||
});
|
||||
|
||||
it('executes a specific tool from a toolkit with several tools', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'specific_tool',
|
||||
description: 'A specific tool',
|
||||
func: jest.fn().mockResolvedValue('specific result'),
|
||||
});
|
||||
|
||||
const irrelevantTool = new DynamicTool({
|
||||
name: 'other_tool',
|
||||
description: 'A specific irrelevant tool',
|
||||
func: jest.fn().mockResolvedValue('specific result'),
|
||||
});
|
||||
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('specific result');
|
||||
|
||||
const toolkit = {
|
||||
getTools: () => [mockTool, irrelevantTool],
|
||||
};
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { specific_tool: 'test input' };
|
||||
if (param === 'toolName') return 'specific_tool';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith('test input');
|
||||
expect(result).toEqual([[{ json: 'specific result' }]]);
|
||||
});
|
||||
|
||||
it('handles JSON string query inputs', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'json_tool',
|
||||
description: 'A tool that handles JSON',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('json result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return '{"json_tool": {"key": "value"}}';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ key: 'value' });
|
||||
expect(result).toEqual([[{ json: 'json result' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HITL response handling', () => {
|
||||
beforeEach(() => {
|
||||
mockProcessHitlResponses.mockReset();
|
||||
mockBuildResponseMetadata.mockReset();
|
||||
});
|
||||
|
||||
it('should return pending gated tool request when HITL tools are approved', async () => {
|
||||
const mockPendingRequest = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName: 'test_node',
|
||||
input: { test: 'data' },
|
||||
type: 'ai_tool',
|
||||
id: 'test-id',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
mockProcessHitlResponses.mockReturnValue({
|
||||
hasApprovedHitlTools: true,
|
||||
pendingGatedToolRequest: mockPendingRequest,
|
||||
});
|
||||
|
||||
const mockResponse: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {},
|
||||
};
|
||||
const result = await node.execute.call(mockExecuteFunction, mockResponse);
|
||||
|
||||
expect(processHitlResponses).toHaveBeenCalledWith(mockResponse, 0);
|
||||
expect(result).toEqual(mockPendingRequest);
|
||||
});
|
||||
|
||||
it('should continue execution when no approved HITL tools', async () => {
|
||||
mockProcessHitlResponses.mockReturnValue({
|
||||
hasApprovedHitlTools: false,
|
||||
pendingGatedToolRequest: null,
|
||||
});
|
||||
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { test_tool: 'test input' };
|
||||
return '';
|
||||
});
|
||||
|
||||
const mockResponse: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {},
|
||||
};
|
||||
const result = await node.execute.call(mockExecuteFunction, mockResponse);
|
||||
|
||||
expect(processHitlResponses).toHaveBeenCalledWith(mockResponse, 0);
|
||||
expect(result).toEqual([[{ json: 'test result' }]]);
|
||||
});
|
||||
|
||||
it('should continue execution when processHitlResponses returns undefined pendingGatedToolRequest', async () => {
|
||||
mockProcessHitlResponses.mockReturnValue({
|
||||
hasApprovedHitlTools: true,
|
||||
pendingGatedToolRequest: undefined,
|
||||
});
|
||||
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'test_tool',
|
||||
description: 'A test tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('test result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { test_tool: 'test input' };
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(result).toEqual([[{ json: 'test result' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Gated tools handling', () => {
|
||||
beforeEach(() => {
|
||||
mockProcessHitlResponses.mockReset();
|
||||
mockHasGatedToolNodeName.mockReset();
|
||||
mockExtractHitlMetadata.mockReset();
|
||||
mockBuildResponseMetadata.mockReset();
|
||||
|
||||
mockProcessHitlResponses.mockReturnValue({
|
||||
hasApprovedHitlTools: false,
|
||||
pendingGatedToolRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle gated tool in toolkit and return engine request', async () => {
|
||||
const mockHitlMetadata = {
|
||||
tool: 'gated_tool',
|
||||
toolInput: { toolParameters: { param: 'value' } },
|
||||
};
|
||||
|
||||
mockHasGatedToolNodeName.mockReturnValue(true);
|
||||
mockExtractHitlMetadata.mockReturnValue(mockHitlMetadata);
|
||||
mockBuildResponseMetadata.mockReturnValue({ test: 'metadata' });
|
||||
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'gated_tool',
|
||||
description: 'A gated tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
|
||||
mockTool.metadata = { gatedToolNodeName: 'hitl_node' };
|
||||
|
||||
const toolkit = {
|
||||
getTools: () => [mockTool],
|
||||
};
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query')
|
||||
return { gated_tool: { param: 'value' }, hitl_node: { approval: 'pending' } };
|
||||
if (param === 'toolName') return 'gated_tool';
|
||||
if (param === 'node') return 'hitl_node';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(hasGatedToolNodeName).toHaveBeenCalledWith(mockTool.metadata);
|
||||
expect(extractHitlMetadata).toHaveBeenCalledWith(mockTool.metadata, 'gated_tool', {
|
||||
toolParameters: {
|
||||
param: 'value',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the result is a NodeOutput with actions
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== 'object' ||
|
||||
Array.isArray(result) ||
|
||||
!('actions' in result)
|
||||
) {
|
||||
throw new Error('Expected result to be an object with actions');
|
||||
}
|
||||
|
||||
expect(result).toHaveProperty('actions');
|
||||
expect(result).toHaveProperty('metadata');
|
||||
expect(result.actions).toHaveLength(1);
|
||||
expect(result.actions[0].nodeName).toBe('hitl_node');
|
||||
expect(result.actions[0].actionType).toBe('ExecutionNodeAction');
|
||||
expect(result.actions[0].input).toMatchObject({
|
||||
tool: 'gated_tool',
|
||||
toolParameters: { param: 'value' },
|
||||
approval: 'pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not treat tool as gated when hasGatedToolNodeName returns false', async () => {
|
||||
mockHasGatedToolNodeName.mockReturnValue(false);
|
||||
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'normal_tool',
|
||||
description: 'A normal tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('normal result');
|
||||
mockTool.metadata = {};
|
||||
|
||||
const toolkit = {
|
||||
getTools: () => [mockTool],
|
||||
};
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { normal_tool: 'test input' };
|
||||
if (param === 'toolName') return 'normal_tool';
|
||||
if (param === 'node') return 'some_node';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(hasGatedToolNodeName).toHaveBeenCalledWith(mockTool.metadata);
|
||||
expect(extractHitlMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([[{ json: 'normal result' }]]);
|
||||
});
|
||||
|
||||
it('should not treat tool as gated when node parameter is empty', async () => {
|
||||
mockHasGatedToolNodeName.mockReturnValue(true);
|
||||
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'tool_with_metadata',
|
||||
description: 'A tool with gated metadata',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('tool result');
|
||||
mockTool.metadata = { gatedToolNodeName: 'hitl_node' };
|
||||
|
||||
const toolkit = {
|
||||
getTools: () => [mockTool],
|
||||
};
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { tool_with_metadata: 'test input' };
|
||||
if (param === 'toolName') return 'tool_with_metadata';
|
||||
if (param === 'node') return '';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(hasGatedToolNodeName).toHaveBeenCalledWith(mockTool.metadata);
|
||||
expect(extractHitlMetadata).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([[{ json: 'tool result' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query data extraction', () => {
|
||||
beforeEach(() => {
|
||||
mockProcessHitlResponses.mockReset();
|
||||
mockProcessHitlResponses.mockReturnValue({
|
||||
hasApprovedHitlTools: false,
|
||||
pendingGatedToolRequest: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract query data using node name with spaces', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'tool with spaces',
|
||||
description: 'A tool with spaces in name',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { tool_with_spaces: { param: 'value' } };
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ param: 'value' });
|
||||
expect(result).toEqual([[{ json: 'result' }]]);
|
||||
});
|
||||
|
||||
it('should extract query data using underscore-converted node name', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'my tool name',
|
||||
description: 'A tool with multiple spaces',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { my_tool_name: { data: 'test' } };
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ data: 'test' });
|
||||
expect(result).toEqual([[{ json: 'result' }]]);
|
||||
});
|
||||
|
||||
it('should prefer exact node name match over underscore-converted name', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'test tool',
|
||||
description: 'A test tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query')
|
||||
return {
|
||||
'test tool': { exact: 'match' },
|
||||
test_tool: { underscore: 'match' },
|
||||
};
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
// Should use exact match first
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ exact: 'match' });
|
||||
expect(result).toEqual([[{ json: 'result' }]]);
|
||||
});
|
||||
|
||||
it('should handle toolkit tools with query data extraction', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'toolkit tool',
|
||||
description: 'A toolkit tool',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('toolkit result');
|
||||
|
||||
const toolkit = {
|
||||
getTools: () => [mockTool],
|
||||
};
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([toolkit]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { toolkit_tool: { toolkit: 'data' } };
|
||||
if (param === 'toolName') return 'toolkit tool';
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({ toolkit: 'data' });
|
||||
expect(result).toEqual([[{ json: 'toolkit result' }]]);
|
||||
});
|
||||
|
||||
it('should use empty object when query data is not found for tool', async () => {
|
||||
const mockTool = new DynamicTool({
|
||||
name: 'missing_tool',
|
||||
description: 'A tool not in query',
|
||||
func: jest.fn(),
|
||||
});
|
||||
mockTool.invoke = jest.fn().mockResolvedValue('result');
|
||||
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([mockTool]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return { other_tool: { param: 'value' } };
|
||||
return '';
|
||||
});
|
||||
|
||||
const result = await node.execute.call(mockExecuteFunction);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith({});
|
||||
expect(result).toEqual([[{ json: 'result' }]]);
|
||||
});
|
||||
|
||||
it('should throw error when query JSON is invalid', async () => {
|
||||
mockExecuteFunction.getInputConnectionData.mockResolvedValue([]);
|
||||
mockExecuteFunction.getNodeParameter.mockImplementation((param) => {
|
||||
if (param === 'query') return '{ invalid json }';
|
||||
return '';
|
||||
});
|
||||
|
||||
await expect(node.execute.call(mockExecuteFunction)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { convertValueBySchema, convertObjectBySchema } from '../utils/convertToSchema';
|
||||
|
||||
describe('convertToSchema', () => {
|
||||
describe('convertValueBySchema', () => {
|
||||
it('should convert string to number when schema is ZodNumber', () => {
|
||||
const result = convertValueBySchema('42', z.number());
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('should convert string to boolean when schema is ZodBoolean', () => {
|
||||
expect(convertValueBySchema('true', z.boolean())).toBe(true);
|
||||
expect(convertValueBySchema('false', z.boolean())).toBe(false);
|
||||
expect(convertValueBySchema('TRUE', z.boolean())).toBe(true);
|
||||
expect(convertValueBySchema('FALSE', z.boolean())).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse JSON string when schema is ZodObject', () => {
|
||||
const result = convertValueBySchema(
|
||||
'{"key": "value", "other_key": 1, "booleanValue": false }',
|
||||
z.object({}),
|
||||
);
|
||||
expect(result).toEqual({ key: 'value', other_key: 1, booleanValue: false });
|
||||
});
|
||||
|
||||
it('should return original value if JSON parsing fails', () => {
|
||||
const result = convertValueBySchema('invalid json', z.object({}));
|
||||
expect(result).toEqual('invalid json');
|
||||
});
|
||||
|
||||
it('should return original value for non-string inputs', () => {
|
||||
const input = { key: 'value' };
|
||||
const result = convertValueBySchema(input, z.object({}));
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertObjectBySchema', () => {
|
||||
it('should convert object values according to schema', () => {
|
||||
const schema = z.object({
|
||||
numberValue: z.number(),
|
||||
booleanValue: z.boolean(),
|
||||
object: z.object({}),
|
||||
unchanged: z.string(),
|
||||
});
|
||||
|
||||
const input = {
|
||||
numberValue: '42',
|
||||
booleanValue: 'true',
|
||||
object: '{"nested": "value"}',
|
||||
unchanged: 'string value',
|
||||
};
|
||||
|
||||
const result = convertObjectBySchema(input, schema);
|
||||
|
||||
expect(result).toEqual({
|
||||
numberValue: 42,
|
||||
booleanValue: true,
|
||||
object: { nested: 'value' },
|
||||
unchanged: 'string value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return original object if schema has no shape', () => {
|
||||
const input = { key: 'value' };
|
||||
const result = convertObjectBySchema(input, {});
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it('should return original object if input is null', () => {
|
||||
const result = convertObjectBySchema(null, z.object({}));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle nested objects', () => {
|
||||
const schema = z.object({
|
||||
nested: z.object({
|
||||
numberValue: z.number(),
|
||||
booleanValue: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
const input = {
|
||||
nested: {
|
||||
numberValue: '42',
|
||||
booleanValue: 'true',
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertObjectBySchema(input, schema);
|
||||
|
||||
expect(result).toEqual({
|
||||
nested: {
|
||||
numberValue: 42,
|
||||
booleanValue: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve fields not in schema', () => {
|
||||
const schema = z.object({
|
||||
number: z.number(),
|
||||
});
|
||||
|
||||
const input = {
|
||||
number: '42',
|
||||
extra: 'value',
|
||||
};
|
||||
|
||||
const result = convertObjectBySchema(input, schema);
|
||||
|
||||
expect(result).toEqual({
|
||||
number: 42,
|
||||
extra: 'value',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const convertValueBySchema = (value: unknown, schema: any): unknown => {
|
||||
if (!schema || !value) return value;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (schema instanceof z.ZodNumber) {
|
||||
return Number(value);
|
||||
} else if (schema instanceof z.ZodBoolean) {
|
||||
return value.toLowerCase() === 'true';
|
||||
} else if (schema instanceof z.ZodObject) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return convertValueBySchema(parsed, schema);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (schema instanceof z.ZodObject && typeof value === 'object' && value !== null) {
|
||||
const result: any = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const fieldSchema = schema.shape[key];
|
||||
if (fieldSchema) {
|
||||
result[key] = convertValueBySchema(val, fieldSchema);
|
||||
} else {
|
||||
result[key] = val;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
export const convertObjectBySchema = (obj: any, schema: any): any => {
|
||||
return convertValueBySchema(obj, schema);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Tool } from '@langchain/core/tools';
|
||||
import { type IDataObject, type INodeExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { convertObjectBySchema } from './convertToSchema';
|
||||
|
||||
export async function executeTool(tool: Tool, query: string | object): Promise<INodeExecutionData> {
|
||||
let convertedQuery: string | object = query;
|
||||
if ('schema' in tool && tool.schema) {
|
||||
convertedQuery = convertObjectBySchema(query, tool.schema);
|
||||
}
|
||||
|
||||
const result = await tool.invoke(convertedQuery);
|
||||
|
||||
return {
|
||||
json: result as IDataObject,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user