mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-24 04:59:13 +00:00
fix(core): Fix support for multiple invocation of AI tools (#12141)
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
committed by
GitHub
parent
f4c2523419
commit
c572c0648c
@@ -1,9 +1,11 @@
|
|||||||
import { DynamicStructuredTool } from '@langchain/core/tools';
|
import { DynamicStructuredTool } from '@langchain/core/tools';
|
||||||
import type {
|
import type {
|
||||||
IExecuteFunctions,
|
IExecuteFunctions,
|
||||||
|
INode,
|
||||||
INodeParameters,
|
INodeParameters,
|
||||||
INodeType,
|
INodeType,
|
||||||
ISupplyDataFunctions,
|
ISupplyDataFunctions,
|
||||||
|
ITaskDataConnections,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -16,6 +18,12 @@ interface FromAIArgument {
|
|||||||
defaultValue?: string | number | boolean | Record<string, unknown>;
|
defaultValue?: string | number | boolean | Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParserOptions = {
|
||||||
|
node: INode;
|
||||||
|
nodeType: INodeType;
|
||||||
|
contextFactory: (runIndex: number, inputData: ITaskDataConnections) => ISupplyDataFunctions;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AIParametersParser
|
* AIParametersParser
|
||||||
*
|
*
|
||||||
@@ -23,15 +31,12 @@ interface FromAIArgument {
|
|||||||
* generating Zod schemas, and creating LangChain tools.
|
* generating Zod schemas, and creating LangChain tools.
|
||||||
*/
|
*/
|
||||||
class AIParametersParser {
|
class AIParametersParser {
|
||||||
private ctx: ISupplyDataFunctions;
|
private runIndex = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs an instance of AIParametersParser.
|
* Constructs an instance of AIParametersParser.
|
||||||
* @param ctx The execution context.
|
|
||||||
*/
|
*/
|
||||||
constructor(ctx: ISupplyDataFunctions) {
|
constructor(private readonly options: ParserOptions) {}
|
||||||
this.ctx = ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a Zod schema based on the provided FromAIArgument placeholder.
|
* Generates a Zod schema based on the provided FromAIArgument placeholder.
|
||||||
@@ -162,14 +167,14 @@ class AIParametersParser {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If parsing fails, throw an ApplicationError with details
|
// If parsing fails, throw an ApplicationError with details
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
this.ctx.getNode(),
|
this.options.node,
|
||||||
`Failed to parse $fromAI arguments: ${argsString}: ${error}`,
|
`Failed to parse $fromAI arguments: ${argsString}: ${error}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Log an error if parentheses are unbalanced
|
// Log an error if parentheses are unbalanced
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
this.ctx.getNode(),
|
this.options.node,
|
||||||
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
|
`Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,7 +259,7 @@ class AIParametersParser {
|
|||||||
const type = cleanArgs?.[2] || 'string';
|
const type = cleanArgs?.[2] || 'string';
|
||||||
|
|
||||||
if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) {
|
if (!['string', 'number', 'boolean', 'json'].includes(type.toLowerCase())) {
|
||||||
throw new NodeOperationError(this.ctx.getNode(), `Invalid type: ${type}`);
|
throw new NodeOperationError(this.options.node, `Invalid type: ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -315,13 +320,12 @@ class AIParametersParser {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a DynamicStructuredTool from a node.
|
* Creates a DynamicStructuredTool from a node.
|
||||||
* @param node The node type.
|
|
||||||
* @param nodeParameters The parameters of the node.
|
|
||||||
* @returns A DynamicStructuredTool instance.
|
* @returns A DynamicStructuredTool instance.
|
||||||
*/
|
*/
|
||||||
public createTool(node: INodeType, nodeParameters: INodeParameters): DynamicStructuredTool {
|
public createTool(): DynamicStructuredTool {
|
||||||
|
const { node, nodeType } = this.options;
|
||||||
const collectedArguments: FromAIArgument[] = [];
|
const collectedArguments: FromAIArgument[] = [];
|
||||||
this.traverseNodeParameters(nodeParameters, collectedArguments);
|
this.traverseNodeParameters(node.parameters, collectedArguments);
|
||||||
|
|
||||||
// Validate each collected argument
|
// Validate each collected argument
|
||||||
const nameValidationRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
const nameValidationRegex = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||||
@@ -331,7 +335,7 @@ class AIParametersParser {
|
|||||||
const isEmptyError = 'You must specify a key when using $fromAI()';
|
const isEmptyError = 'You must specify a key when using $fromAI()';
|
||||||
const isInvalidError = `Parameter key \`${argument.key}\` is invalid`;
|
const isInvalidError = `Parameter key \`${argument.key}\` is invalid`;
|
||||||
const error = new Error(argument.key.length === 0 ? isEmptyError : isInvalidError);
|
const error = new Error(argument.key.length === 0 ? isEmptyError : isInvalidError);
|
||||||
throw new NodeOperationError(this.ctx.getNode(), error, {
|
throw new NodeOperationError(node, error, {
|
||||||
description:
|
description:
|
||||||
'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens',
|
'Invalid parameter key, must be between 1 and 64 characters long and only contain letters, numbers, underscores, and hyphens',
|
||||||
});
|
});
|
||||||
@@ -348,7 +352,7 @@ class AIParametersParser {
|
|||||||
) {
|
) {
|
||||||
// If not, throw an error for inconsistent duplicate keys
|
// If not, throw an error for inconsistent duplicate keys
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
this.ctx.getNode(),
|
node,
|
||||||
`Duplicate key '${argument.key}' found with different description or type`,
|
`Duplicate key '${argument.key}' found with different description or type`,
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
@@ -378,37 +382,38 @@ class AIParametersParser {
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const schema = z.object(schemaObj).required();
|
const schema = z.object(schemaObj).required();
|
||||||
const description = this.getDescription(node, nodeParameters);
|
const description = this.getDescription(nodeType, node.parameters);
|
||||||
const nodeName = this.ctx.getNode().name.replace(/ /g, '_');
|
const nodeName = node.name.replace(/ /g, '_');
|
||||||
const name = nodeName || node.description.name;
|
const name = nodeName || nodeType.description.name;
|
||||||
|
|
||||||
const tool = new DynamicStructuredTool({
|
const tool = new DynamicStructuredTool({
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
schema,
|
schema,
|
||||||
func: async (functionArgs: z.infer<typeof schema>) => {
|
func: async (toolArgs: z.infer<typeof schema>) => {
|
||||||
const { index } = this.ctx.addInputData(NodeConnectionType.AiTool, [
|
const context = this.options.contextFactory(this.runIndex, {});
|
||||||
[{ json: functionArgs }],
|
context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]);
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Execute the node with the proxied context
|
// Execute the node with the proxied context
|
||||||
const result = await node.execute?.bind(this.ctx as IExecuteFunctions)();
|
const result = await nodeType.execute?.call(context as IExecuteFunctions);
|
||||||
|
|
||||||
// Process and map the results
|
// Process and map the results
|
||||||
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
||||||
|
|
||||||
// Add output data to the context
|
// Add output data to the context
|
||||||
this.ctx.addOutputData(NodeConnectionType.AiTool, index, [
|
context.addOutputData(NodeConnectionType.AiTool, this.runIndex, [
|
||||||
[{ json: { response: mappedResults } }],
|
[{ json: { response: mappedResults } }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Return the stringified results
|
// Return the stringified results
|
||||||
return JSON.stringify(mappedResults);
|
return JSON.stringify(mappedResults);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const nodeError = new NodeOperationError(this.ctx.getNode(), error as Error);
|
const nodeError = new NodeOperationError(this.options.node, error as Error);
|
||||||
this.ctx.addOutputData(NodeConnectionType.AiTool, index, nodeError);
|
context.addOutputData(NodeConnectionType.AiTool, this.runIndex, nodeError);
|
||||||
return 'Error during node execution: ' + nodeError.description;
|
return 'Error during node execution: ' + nodeError.description;
|
||||||
|
} finally {
|
||||||
|
this.runIndex++;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -421,20 +426,8 @@ class AIParametersParser {
|
|||||||
* Converts node into LangChain tool by analyzing node parameters,
|
* Converts node into LangChain tool by analyzing node parameters,
|
||||||
* identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates
|
* identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates
|
||||||
* a DynamicStructuredTool that can be used in LangChain workflows.
|
* a DynamicStructuredTool that can be used in LangChain workflows.
|
||||||
*
|
|
||||||
* @param ctx The execution context.
|
|
||||||
* @param node The node type.
|
|
||||||
* @param nodeParameters The parameters of the node.
|
|
||||||
* @returns An object containing the DynamicStructuredTool instance.
|
|
||||||
*/
|
*/
|
||||||
export function createNodeAsTool(
|
export function createNodeAsTool(options: ParserOptions) {
|
||||||
ctx: ISupplyDataFunctions,
|
const parser = new AIParametersParser(options);
|
||||||
node: INodeType,
|
return { response: parser.createTool() };
|
||||||
nodeParameters: INodeParameters,
|
|
||||||
) {
|
|
||||||
const parser = new AIParametersParser(ctx);
|
|
||||||
|
|
||||||
return {
|
|
||||||
response: parser.createTool(node, nodeParameters),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ import type {
|
|||||||
DeduplicationScope,
|
DeduplicationScope,
|
||||||
DeduplicationItemTypes,
|
DeduplicationItemTypes,
|
||||||
ICheckProcessedContextData,
|
ICheckProcessedContextData,
|
||||||
ISupplyDataFunctions,
|
|
||||||
WebhookType,
|
WebhookType,
|
||||||
SchedulingFunctions,
|
SchedulingFunctions,
|
||||||
|
SupplyData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
NodeConnectionType,
|
NodeConnectionType,
|
||||||
@@ -2023,9 +2023,9 @@ export async function getInputConnectionData(
|
|||||||
this: IAllExecuteFunctions,
|
this: IAllExecuteFunctions,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
runExecutionData: IRunExecutionData,
|
runExecutionData: IRunExecutionData,
|
||||||
runIndex: number,
|
parentRunIndex: number,
|
||||||
connectionInputData: INodeExecutionData[],
|
connectionInputData: INodeExecutionData[],
|
||||||
inputData: ITaskDataConnections,
|
parentInputData: ITaskDataConnections,
|
||||||
additionalData: IWorkflowExecuteAdditionalData,
|
additionalData: IWorkflowExecuteAdditionalData,
|
||||||
executeData: IExecuteData,
|
executeData: IExecuteData,
|
||||||
mode: WorkflowExecuteMode,
|
mode: WorkflowExecuteMode,
|
||||||
@@ -2034,10 +2034,13 @@ export async function getInputConnectionData(
|
|||||||
itemIndex: number,
|
itemIndex: number,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const node = this.getNode();
|
const parentNode = this.getNode();
|
||||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
const parentNodeType = workflow.nodeTypes.getByNameAndVersion(
|
||||||
|
parentNode.type,
|
||||||
|
parentNode.typeVersion,
|
||||||
|
);
|
||||||
|
|
||||||
const inputs = NodeHelpers.getNodeInputs(workflow, node, nodeType.description);
|
const inputs = NodeHelpers.getNodeInputs(workflow, parentNode, parentNodeType.description);
|
||||||
|
|
||||||
let inputConfiguration = inputs.find((input) => {
|
let inputConfiguration = inputs.find((input) => {
|
||||||
if (typeof input === 'string') {
|
if (typeof input === 'string') {
|
||||||
@@ -2048,7 +2051,7 @@ export async function getInputConnectionData(
|
|||||||
|
|
||||||
if (inputConfiguration === undefined) {
|
if (inputConfiguration === undefined) {
|
||||||
throw new ApplicationError('Node does not have input of type', {
|
throw new ApplicationError('Node does not have input of type', {
|
||||||
extra: { nodeName: node.name, connectionType },
|
extra: { nodeName: parentNode.name, connectionType },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2059,14 +2062,14 @@ export async function getInputConnectionData(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const connectedNodes = workflow
|
const connectedNodes = workflow
|
||||||
.getParentNodes(node.name, connectionType, 1)
|
.getParentNodes(parentNode.name, connectionType, 1)
|
||||||
.map((nodeName) => workflow.getNode(nodeName) as INode)
|
.map((nodeName) => workflow.getNode(nodeName) as INode)
|
||||||
.filter((connectedNode) => connectedNode.disabled !== true);
|
.filter((connectedNode) => connectedNode.disabled !== true);
|
||||||
|
|
||||||
if (connectedNodes.length === 0) {
|
if (connectedNodes.length === 0) {
|
||||||
if (inputConfiguration.required) {
|
if (inputConfiguration.required) {
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
node,
|
parentNode,
|
||||||
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
`A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2078,82 +2081,86 @@ export async function getInputConnectionData(
|
|||||||
connectedNodes.length > inputConfiguration.maxConnections
|
connectedNodes.length > inputConfiguration.maxConnections
|
||||||
) {
|
) {
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
node,
|
parentNode,
|
||||||
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
`Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const constParentNodes = connectedNodes.map(async (connectedNode) => {
|
const nodes: SupplyData[] = [];
|
||||||
const nodeType = workflow.nodeTypes.getByNameAndVersion(
|
for (const connectedNode of connectedNodes) {
|
||||||
|
const connectedNodeType = workflow.nodeTypes.getByNameAndVersion(
|
||||||
connectedNode.type,
|
connectedNode.type,
|
||||||
connectedNode.typeVersion,
|
connectedNode.typeVersion,
|
||||||
);
|
);
|
||||||
const context = new SupplyDataContext(
|
const contextFactory = (runIndex: number, inputData: ITaskDataConnections) =>
|
||||||
workflow,
|
new SupplyDataContext(
|
||||||
connectedNode,
|
workflow,
|
||||||
additionalData,
|
connectedNode,
|
||||||
mode,
|
additionalData,
|
||||||
runExecutionData,
|
mode,
|
||||||
runIndex,
|
runExecutionData,
|
||||||
connectionInputData,
|
runIndex,
|
||||||
inputData,
|
connectionInputData,
|
||||||
executeData,
|
inputData,
|
||||||
closeFunctions,
|
connectionType,
|
||||||
abortSignal,
|
executeData,
|
||||||
);
|
closeFunctions,
|
||||||
|
abortSignal,
|
||||||
|
);
|
||||||
|
|
||||||
if (!nodeType.supplyData) {
|
if (!connectedNodeType.supplyData) {
|
||||||
if (nodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
|
if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) {
|
||||||
nodeType.supplyData = async function (this: ISupplyDataFunctions) {
|
const supplyData = createNodeAsTool({
|
||||||
return createNodeAsTool(this, nodeType, this.getNode().parameters);
|
node: connectedNode,
|
||||||
};
|
nodeType: connectedNodeType,
|
||||||
|
contextFactory,
|
||||||
|
});
|
||||||
|
nodes.push(supplyData);
|
||||||
} else {
|
} else {
|
||||||
throw new ApplicationError('Node does not have a `supplyData` method defined', {
|
throw new ApplicationError('Node does not have a `supplyData` method defined', {
|
||||||
extra: { nodeName: connectedNode.name },
|
extra: { nodeName: connectedNode.name },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
const context = contextFactory(parentRunIndex, parentInputData);
|
||||||
|
try {
|
||||||
|
const supplyData = await connectedNodeType.supplyData.call(context, itemIndex);
|
||||||
|
if (supplyData.closeFunction) {
|
||||||
|
closeFunctions.push(supplyData.closeFunction);
|
||||||
|
}
|
||||||
|
nodes.push(supplyData);
|
||||||
|
} catch (error) {
|
||||||
|
// Propagate errors from sub-nodes
|
||||||
|
if (error.functionality === 'configuration-node') throw error;
|
||||||
|
if (!(error instanceof ExecutionBaseError)) {
|
||||||
|
error = new NodeOperationError(connectedNode, error, {
|
||||||
|
itemIndex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
let currentNodeRunIndex = 0;
|
||||||
const response = await nodeType.supplyData.call(context, itemIndex);
|
if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) {
|
||||||
if (response.closeFunction) {
|
currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length;
|
||||||
closeFunctions.push(response.closeFunction);
|
}
|
||||||
}
|
|
||||||
return response;
|
// Display the error on the node which is causing it
|
||||||
} catch (error) {
|
await context.addExecutionDataFunctions(
|
||||||
// Propagate errors from sub-nodes
|
'input',
|
||||||
if (error.functionality === 'configuration-node') throw error;
|
error,
|
||||||
if (!(error instanceof ExecutionBaseError)) {
|
connectionType,
|
||||||
error = new NodeOperationError(connectedNode, error, {
|
parentNode.name,
|
||||||
|
currentNodeRunIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Display on the calling node which node has the error
|
||||||
|
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
|
||||||
itemIndex,
|
itemIndex,
|
||||||
|
functionality: 'configuration-node',
|
||||||
|
description: error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentNodeRunIndex = 0;
|
|
||||||
if (runExecutionData.resultData.runData.hasOwnProperty(node.name)) {
|
|
||||||
currentNodeRunIndex = runExecutionData.resultData.runData[node.name].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display the error on the node which is causing it
|
|
||||||
await context.addExecutionDataFunctions(
|
|
||||||
'input',
|
|
||||||
error,
|
|
||||||
connectionType,
|
|
||||||
node.name,
|
|
||||||
currentNodeRunIndex,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Display on the calling node which node has the error
|
|
||||||
throw new NodeOperationError(connectedNode, `Error in sub-node ${connectedNode.name}`, {
|
|
||||||
itemIndex,
|
|
||||||
functionality: 'configuration-node',
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// Validate the inputs
|
|
||||||
const nodes = await Promise.all(constParentNodes);
|
|
||||||
|
|
||||||
return inputConfiguration.maxConnections === 1
|
return inputConfiguration.maxConnections === 1
|
||||||
? (nodes || [])[0]?.response
|
? (nodes || [])[0]?.response
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe('SupplyDataContext', () => {
|
|||||||
runIndex,
|
runIndex,
|
||||||
connectionInputData,
|
connectionInputData,
|
||||||
inputData,
|
inputData,
|
||||||
|
connectionType,
|
||||||
executeData,
|
executeData,
|
||||||
[closeFn],
|
[closeFn],
|
||||||
abortSignal,
|
abortSignal,
|
||||||
|
|||||||
@@ -21,9 +21,14 @@ import type {
|
|||||||
IWorkflowDataProxyData,
|
IWorkflowDataProxyData,
|
||||||
ISourceData,
|
ISourceData,
|
||||||
AiEvent,
|
AiEvent,
|
||||||
NodeConnectionType,
|
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow';
|
import {
|
||||||
|
ApplicationError,
|
||||||
|
NodeHelpers,
|
||||||
|
NodeConnectionType,
|
||||||
|
WAIT_INDEFINITELY,
|
||||||
|
WorkflowDataProxy,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
||||||
@@ -176,7 +181,7 @@ export class BaseExecuteContext extends NodeExecutionContext {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInputSourceData(inputIndex = 0, connectionType = 'main'): ISourceData {
|
getInputSourceData(inputIndex = 0, connectionType = NodeConnectionType.Main): ISourceData {
|
||||||
if (this.executeData?.source === null) {
|
if (this.executeData?.source === null) {
|
||||||
// Should never happen as n8n sets it automatically
|
// Should never happen as n8n sets it automatically
|
||||||
throw new ApplicationError('Source data is missing');
|
throw new ApplicationError('Source data is missing');
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
runIndex: number,
|
runIndex: number,
|
||||||
connectionInputData: INodeExecutionData[],
|
connectionInputData: INodeExecutionData[],
|
||||||
inputData: ITaskDataConnections,
|
inputData: ITaskDataConnections,
|
||||||
|
private readonly connectionType: NodeConnectionType,
|
||||||
executeData: IExecuteData,
|
executeData: IExecuteData,
|
||||||
private readonly closeFunctions: CloseFunction[],
|
private readonly closeFunctions: CloseFunction[],
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
@@ -126,7 +127,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInputData(inputIndex = 0, connectionType = NodeConnectionType.Main) {
|
getInputData(inputIndex = 0, connectionType = this.connectionType) {
|
||||||
if (!this.inputData.hasOwnProperty(connectionType)) {
|
if (!this.inputData.hasOwnProperty(connectionType)) {
|
||||||
// Return empty array because else it would throw error when nothing is connected to input
|
// Return empty array because else it would throw error when nothing is connected to input
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { IExecuteFunctions, INodeParameters, INodeType } from 'n8n-workflow';
|
import { mock } from 'jest-mock-extended';
|
||||||
|
import type { INodeType, ISupplyDataFunctions, INode } from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -14,28 +15,29 @@ jest.mock('@langchain/core/tools', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('createNodeAsTool', () => {
|
describe('createNodeAsTool', () => {
|
||||||
let mockCtx: IExecuteFunctions;
|
const context = mock<ISupplyDataFunctions>({
|
||||||
let mockNode: INodeType;
|
getNodeParameter: jest.fn(),
|
||||||
let mockNodeParameters: INodeParameters;
|
addInputData: jest.fn(),
|
||||||
|
addOutputData: jest.fn(),
|
||||||
|
getNode: jest.fn(),
|
||||||
|
});
|
||||||
|
const contextFactory = () => context;
|
||||||
|
const nodeType = mock<INodeType>({
|
||||||
|
description: {
|
||||||
|
name: 'TestNode',
|
||||||
|
description: 'Test node description',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const node = mock<INode>({ name: 'Test_Node' });
|
||||||
|
const options = { node, nodeType, contextFactory };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Setup mock objects
|
jest.clearAllMocks();
|
||||||
mockCtx = {
|
(context.addInputData as jest.Mock).mockReturnValue({ index: 0 });
|
||||||
getNodeParameter: jest.fn(),
|
(context.getNode as jest.Mock).mockReturnValue(node);
|
||||||
addInputData: jest.fn().mockReturnValue({ index: 0 }),
|
(nodeType.execute as jest.Mock).mockResolvedValue([[{ json: { result: 'test' } }]]);
|
||||||
addOutputData: jest.fn(),
|
|
||||||
getNode: jest.fn().mockReturnValue({ name: 'Test_Node' }),
|
|
||||||
} as unknown as IExecuteFunctions;
|
|
||||||
|
|
||||||
mockNode = {
|
node.parameters = {
|
||||||
description: {
|
|
||||||
name: 'TestNode',
|
|
||||||
description: 'Test node description',
|
|
||||||
},
|
|
||||||
execute: jest.fn().mockResolvedValue([[{ json: { result: 'test' } }]]),
|
|
||||||
} as unknown as INodeType;
|
|
||||||
|
|
||||||
mockNodeParameters = {
|
|
||||||
param1: "={{$fromAI('param1', 'Test parameter', 'string') }}",
|
param1: "={{$fromAI('param1', 'Test parameter', 'string') }}",
|
||||||
param2: 'static value',
|
param2: 'static value',
|
||||||
nestedParam: {
|
nestedParam: {
|
||||||
@@ -45,13 +47,11 @@ describe('createNodeAsTool', () => {
|
|||||||
resource: 'testResource',
|
resource: 'testResource',
|
||||||
operation: 'testOperation',
|
operation: 'testOperation',
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Tool Creation and Basic Properties', () => {
|
describe('Tool Creation and Basic Properties', () => {
|
||||||
it('should create a DynamicStructuredTool with correct properties', () => {
|
it('should create a DynamicStructuredTool with correct properties', () => {
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool).toBeDefined();
|
expect(tool).toBeDefined();
|
||||||
expect(tool.name).toBe('Test_Node');
|
expect(tool.name).toBe('Test_Node');
|
||||||
@@ -62,10 +62,10 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should use toolDescription if provided', () => {
|
it('should use toolDescription if provided', () => {
|
||||||
mockNodeParameters.descriptionType = 'manual';
|
node.parameters.descriptionType = 'manual';
|
||||||
mockNodeParameters.toolDescription = 'Custom tool description';
|
node.parameters.toolDescription = 'Custom tool description';
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.description).toBe('Custom tool description');
|
expect(tool.description).toBe('Custom tool description');
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Schema Creation and Parameter Handling', () => {
|
describe('Schema Creation and Parameter Handling', () => {
|
||||||
it('should create a schema based on fromAI arguments in nodeParameters', () => {
|
it('should create a schema based on fromAI arguments in nodeParameters', () => {
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema).toBeDefined();
|
expect(tool.schema).toBeDefined();
|
||||||
expect(tool.schema.shape).toHaveProperty('param1');
|
expect(tool.schema.shape).toHaveProperty('param1');
|
||||||
@@ -82,14 +82,14 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle fromAI arguments correctly', () => {
|
it('should handle fromAI arguments correctly', () => {
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.subparam).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.subparam).toBeInstanceOf(z.ZodString);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle default values correctly', () => {
|
it('should handle default values correctly', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
paramWithDefault:
|
paramWithDefault:
|
||||||
"={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}",
|
"={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}",
|
||||||
numberWithDefault:
|
numberWithDefault:
|
||||||
@@ -98,7 +98,7 @@ describe('createNodeAsTool', () => {
|
|||||||
"={{ $fromAI('booleanWithDefault', 'Boolean with default', 'boolean', true) }}",
|
"={{ $fromAI('booleanWithDefault', 'Boolean with default', 'boolean', true) }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.paramWithDefault.description).toBe('Parameter with default');
|
expect(tool.schema.shape.paramWithDefault.description).toBe('Parameter with default');
|
||||||
expect(tool.schema.shape.numberWithDefault.description).toBe('Number with default');
|
expect(tool.schema.shape.numberWithDefault.description).toBe('Number with default');
|
||||||
@@ -106,7 +106,7 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle nested parameters correctly', () => {
|
it('should handle nested parameters correctly', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}",
|
topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}",
|
||||||
nested: {
|
nested: {
|
||||||
level1: "={{ $fromAI('level1', 'Nested level 1', 'string') }}",
|
level1: "={{ $fromAI('level1', 'Nested level 1', 'string') }}",
|
||||||
@@ -116,7 +116,7 @@ describe('createNodeAsTool', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.topLevel).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.topLevel).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.level1).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.level1).toBeInstanceOf(z.ZodString);
|
||||||
@@ -124,14 +124,14 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle array parameters correctly', () => {
|
it('should handle array parameters correctly', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
arrayParam: [
|
arrayParam: [
|
||||||
"={{ $fromAI('item1', 'First item', 'string') }}",
|
"={{ $fromAI('item1', 'First item', 'string') }}",
|
||||||
"={{ $fromAI('item2', 'Second item', 'number') }}",
|
"={{ $fromAI('item2', 'Second item', 'number') }}",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.item1).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.item1).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.item2).toBeInstanceOf(z.ZodNumber);
|
expect(tool.schema.shape.item2).toBeInstanceOf(z.ZodNumber);
|
||||||
@@ -140,13 +140,13 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Error Handling and Edge Cases', () => {
|
describe('Error Handling and Edge Cases', () => {
|
||||||
it('should handle error during node execution', async () => {
|
it('should handle error during node execution', async () => {
|
||||||
mockNode.execute = jest.fn().mockRejectedValue(new Error('Execution failed'));
|
nodeType.execute = jest.fn().mockRejectedValue(new Error('Execution failed'));
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
const result = await tool.func({ param1: 'test value' });
|
const result = await tool.func({ param1: 'test value' });
|
||||||
|
|
||||||
expect(result).toContain('Error during node execution:');
|
expect(result).toContain('Error during node execution:');
|
||||||
expect(mockCtx.addOutputData).toHaveBeenCalledWith(
|
expect(context.addOutputData).toHaveBeenCalledWith(
|
||||||
NodeConnectionType.AiTool,
|
NodeConnectionType.AiTool,
|
||||||
0,
|
0,
|
||||||
expect.any(NodeOperationError),
|
expect.any(NodeOperationError),
|
||||||
@@ -154,31 +154,27 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for invalid parameter names', () => {
|
it('should throw an error for invalid parameter names', () => {
|
||||||
mockNodeParameters.invalidParam = "$fromAI('invalid param', 'Invalid parameter', 'string')";
|
node.parameters.invalidParam = "$fromAI('invalid param', 'Invalid parameter', 'string')";
|
||||||
|
|
||||||
expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow(
|
expect(() => createNodeAsTool(options)).toThrow('Parameter key `invalid param` is invalid');
|
||||||
'Parameter key `invalid param` is invalid',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for $fromAI calls with unsupported types', () => {
|
it('should throw an error for $fromAI calls with unsupported types', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
invalidTypeParam:
|
invalidTypeParam:
|
||||||
"={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}",
|
"={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow(
|
expect(() => createNodeAsTool(options)).toThrow('Invalid type: unsupportedType');
|
||||||
'Invalid type: unsupportedType',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty parameters and parameters with no fromAI calls', () => {
|
it('should handle empty parameters and parameters with no fromAI calls', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
param1: 'static value 1',
|
param1: 'static value 1',
|
||||||
param2: 'static value 2',
|
param2: 'static value 2',
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape).toEqual({});
|
expect(tool.schema.shape).toEqual({});
|
||||||
});
|
});
|
||||||
@@ -186,13 +182,13 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Parameter Name and Description Handling', () => {
|
describe('Parameter Name and Description Handling', () => {
|
||||||
it('should accept parameter names with underscores and hyphens', () => {
|
it('should accept parameter names with underscores and hyphens', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
validName1:
|
validName1:
|
||||||
"={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}",
|
"={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}",
|
||||||
validName2: "={{ $fromAI('param_name_2', 'Another valid name', 'number') }}",
|
validName2: "={{ $fromAI('param_name_2', 'Another valid name', 'number') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape['param_name-1']).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape['param_name-1']).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape['param_name-1'].description).toBe(
|
expect(tool.schema.shape['param_name-1'].description).toBe(
|
||||||
@@ -204,22 +200,20 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for parameter names with invalid special characters', () => {
|
it('should throw an error for parameter names with invalid special characters', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
invalidNameParam:
|
invalidNameParam:
|
||||||
"={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}",
|
"={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow(
|
expect(() => createNodeAsTool(options)).toThrow('Parameter key `param@name!` is invalid');
|
||||||
'Parameter key `param@name!` is invalid',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for empty parameter name', () => {
|
it('should throw an error for empty parameter name', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
invalidNameParam: "={{ $fromAI('', 'Invalid name with special characters', 'string') }}",
|
invalidNameParam: "={{ $fromAI('', 'Invalid name with special characters', 'string') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow(
|
expect(() => createNodeAsTool(options)).toThrow(
|
||||||
'You must specify a key when using $fromAI()',
|
'You must specify a key when using $fromAI()',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -227,50 +221,51 @@ describe('createNodeAsTool', () => {
|
|||||||
it('should handle parameter names with exact and exceeding character limits', () => {
|
it('should handle parameter names with exact and exceeding character limits', () => {
|
||||||
const longName = 'a'.repeat(64);
|
const longName = 'a'.repeat(64);
|
||||||
const tooLongName = 'a'.repeat(65);
|
const tooLongName = 'a'.repeat(65);
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
longNameParam: `={{ $fromAI('${longName}', 'Param with 64 character name', 'string') }}`,
|
longNameParam: `={{ $fromAI('${longName}', 'Param with 64 character name', 'string') }}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape[longName]).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape[longName]).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape[longName].description).toBe('Param with 64 character name');
|
expect(tool.schema.shape[longName].description).toBe('Param with 64 character name');
|
||||||
|
|
||||||
expect(() =>
|
node.parameters = {
|
||||||
createNodeAsTool(mockCtx, mockNode, {
|
tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`,
|
||||||
tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`,
|
};
|
||||||
}),
|
expect(() => createNodeAsTool(options)).toThrow(
|
||||||
).toThrow(`Parameter key \`${tooLongName}\` is invalid`);
|
`Parameter key \`${tooLongName}\` is invalid`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle $fromAI calls with empty description', () => {
|
it('should handle $fromAI calls with empty description', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
emptyDescriptionParam: "={{ $fromAI('emptyDescription', '', 'number') }}",
|
emptyDescriptionParam: "={{ $fromAI('emptyDescription', '', 'number') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.emptyDescription).toBeInstanceOf(z.ZodNumber);
|
expect(tool.schema.shape.emptyDescription).toBeInstanceOf(z.ZodNumber);
|
||||||
expect(tool.schema.shape.emptyDescription.description).toBeUndefined();
|
expect(tool.schema.shape.emptyDescription.description).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for calls with the same parameter but different descriptions', () => {
|
it('should throw an error for calls with the same parameter but different descriptions', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
||||||
duplicateParam2: "={{ $fromAI('duplicate', 'Second duplicate', 'number') }}",
|
duplicateParam2: "={{ $fromAI('duplicate', 'Second duplicate', 'number') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow(
|
expect(() => createNodeAsTool(options)).toThrow(
|
||||||
"Duplicate key 'duplicate' found with different description or type",
|
"Duplicate key 'duplicate' found with different description or type",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('should throw an error for calls with the same parameter but different types', () => {
|
it('should throw an error for calls with the same parameter but different types', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}",
|
||||||
duplicateParam2: "={{ $fromAI('duplicate', 'First duplicate', 'number') }}",
|
duplicateParam2: "={{ $fromAI('duplicate', 'First duplicate', 'number') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow(
|
expect(() => createNodeAsTool(options)).toThrow(
|
||||||
"Duplicate key 'duplicate' found with different description or type",
|
"Duplicate key 'duplicate' found with different description or type",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -278,7 +273,7 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Complex Parsing Scenarios', () => {
|
describe('Complex Parsing Scenarios', () => {
|
||||||
it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => {
|
it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
varyingSpacing1: "={{$fromAI('param1','Description1','string')}}",
|
varyingSpacing1: "={{$fromAI('param1','Description1','string')}}",
|
||||||
varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}",
|
varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}",
|
||||||
varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}",
|
varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}",
|
||||||
@@ -288,7 +283,7 @@ describe('createNodeAsTool', () => {
|
|||||||
"={{ `Value is: ${$fromAI('templatedParam', 'Templated param description', 'string')}` }}",
|
"={{ `Value is: ${$fromAI('templatedParam', 'Templated param description', 'string')}` }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.param1.description).toBe('Description1');
|
expect(tool.schema.shape.param1.description).toBe('Description1');
|
||||||
@@ -307,12 +302,12 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly parse multiple $fromAI calls interleaved with regular text', () => {
|
it('should correctly parse multiple $fromAI calls interleaved with regular text', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
interleavedParams:
|
interleavedParams:
|
||||||
"={{ 'Start ' + $fromAI('param1', 'First param', 'string') + ' Middle ' + $fromAI('param2', 'Second param', 'number') + ' End' }}",
|
"={{ 'Start ' + $fromAI('param1', 'First param', 'string') + ' Middle ' + $fromAI('param2', 'Second param', 'number') + ' End' }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.param1).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.param1.description).toBe('First param');
|
expect(tool.schema.shape.param1.description).toBe('First param');
|
||||||
@@ -322,12 +317,12 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly parse $fromAI calls with complex JSON default values', () => {
|
it('should correctly parse $fromAI calls with complex JSON default values', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
complexJsonDefault:
|
complexJsonDefault:
|
||||||
'={{ $fromAI(\'complexJson\', \'Param with complex JSON default\', \'json\', \'{"nested": {"key": "value"}, "array": [1, 2, 3]}\') }}',
|
'={{ $fromAI(\'complexJson\', \'Param with complex JSON default\', \'json\', \'{"nested": {"key": "value"}, "array": [1, 2, 3]}\') }}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodRecord);
|
expect(tool.schema.shape.complexJson._def.innerType).toBeInstanceOf(z.ZodRecord);
|
||||||
expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default');
|
expect(tool.schema.shape.complexJson.description).toBe('Param with complex JSON default');
|
||||||
@@ -338,7 +333,7 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should ignore $fromAI calls embedded in non-string node parameters', () => {
|
it('should ignore $fromAI calls embedded in non-string node parameters', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
numberParam: 42,
|
numberParam: 42,
|
||||||
booleanParam: false,
|
booleanParam: false,
|
||||||
objectParam: {
|
objectParam: {
|
||||||
@@ -355,7 +350,7 @@ describe('createNodeAsTool', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.innerParam).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.innerParam).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.innerParam.description).toBe('Inner param');
|
expect(tool.schema.shape.innerParam.description).toBe('Inner param');
|
||||||
@@ -373,48 +368,48 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Escaping and Special Characters', () => {
|
describe('Escaping and Special Characters', () => {
|
||||||
it('should handle escaped single quotes in parameter names and descriptions', () => {
|
it('should handle escaped single quotes in parameter names and descriptions', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
escapedQuotesParam:
|
escapedQuotesParam:
|
||||||
"={{ $fromAI('paramName', 'Description with \\'escaped\\' quotes', 'string') }}",
|
"={{ $fromAI('paramName', 'Description with \\'escaped\\' quotes', 'string') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.paramName.description).toBe("Description with 'escaped' quotes");
|
expect(tool.schema.shape.paramName.description).toBe("Description with 'escaped' quotes");
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle escaped double quotes in parameter names and descriptions', () => {
|
it('should handle escaped double quotes in parameter names and descriptions', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
escapedQuotesParam:
|
escapedQuotesParam:
|
||||||
'={{ $fromAI("paramName", "Description with \\"escaped\\" quotes", "string") }}',
|
'={{ $fromAI("paramName", "Description with \\"escaped\\" quotes", "string") }}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.paramName.description).toBe('Description with "escaped" quotes');
|
expect(tool.schema.shape.paramName.description).toBe('Description with "escaped" quotes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle escaped backslashes in parameter names and descriptions', () => {
|
it('should handle escaped backslashes in parameter names and descriptions', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
escapedBackslashesParam:
|
escapedBackslashesParam:
|
||||||
"={{ $fromAI('paramName', 'Description with \\\\ backslashes', 'string') }}",
|
"={{ $fromAI('paramName', 'Description with \\\\ backslashes', 'string') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.paramName.description).toBe('Description with \\ backslashes');
|
expect(tool.schema.shape.paramName.description).toBe('Description with \\ backslashes');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed escaped characters in parameter names and descriptions', () => {
|
it('should handle mixed escaped characters in parameter names and descriptions', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
mixedEscapesParam:
|
mixedEscapesParam:
|
||||||
'={{ $fromAI(`paramName`, \'Description with \\\'mixed" characters\', "number") }}',
|
'={{ $fromAI(`paramName`, \'Description with \\\'mixed" characters\', "number") }}',
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodNumber);
|
expect(tool.schema.shape.paramName).toBeInstanceOf(z.ZodNumber);
|
||||||
expect(tool.schema.shape.paramName.description).toBe('Description with \'mixed" characters');
|
expect(tool.schema.shape.paramName.description).toBe('Description with \'mixed" characters');
|
||||||
@@ -423,12 +418,12 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Edge Cases and Limitations', () => {
|
describe('Edge Cases and Limitations', () => {
|
||||||
it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => {
|
it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
excessArgsParam:
|
excessArgsParam:
|
||||||
"={{ $fromAI('excessArgs', 'Param with excess arguments', 'string', 'default', 'extraArg1', 'extraArg2') }}",
|
"={{ $fromAI('excessArgs', 'Param with excess arguments', 'string', 'default', 'extraArg1', 'extraArg2') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.excessArgs._def.innerType).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.excessArgs._def.innerType).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.excessArgs.description).toBe('Param with excess arguments');
|
expect(tool.schema.shape.excessArgs.description).toBe('Param with excess arguments');
|
||||||
@@ -436,12 +431,12 @@ describe('createNodeAsTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly parse $fromAI calls with nested parentheses', () => {
|
it('should correctly parse $fromAI calls with nested parentheses', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
nestedParenthesesParam:
|
nestedParenthesesParam:
|
||||||
"={{ $fromAI('paramWithNested', 'Description with ((nested)) parentheses', 'string') }}",
|
"={{ $fromAI('paramWithNested', 'Description with ((nested)) parentheses', 'string') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.paramWithNested).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.paramWithNested).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.paramWithNested.description).toBe(
|
expect(tool.schema.shape.paramWithNested.description).toBe(
|
||||||
@@ -451,24 +446,24 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
it('should handle $fromAI calls with very long descriptions', () => {
|
it('should handle $fromAI calls with very long descriptions', () => {
|
||||||
const longDescription = 'A'.repeat(1000);
|
const longDescription = 'A'.repeat(1000);
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
longParam: `={{ $fromAI('longParam', '${longDescription}', 'string') }}`,
|
longParam: `={{ $fromAI('longParam', '${longDescription}', 'string') }}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.longParam).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.longParam).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.longParam.description).toBe(longDescription);
|
expect(tool.schema.shape.longParam.description).toBe(longDescription);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle $fromAI calls with only some parameters', () => {
|
it('should handle $fromAI calls with only some parameters', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
partialParam1: "={{ $fromAI('partial1') }}",
|
partialParam1: "={{ $fromAI('partial1') }}",
|
||||||
partialParam2: "={{ $fromAI('partial2', 'Description only') }}",
|
partialParam2: "={{ $fromAI('partial2', 'Description only') }}",
|
||||||
partialParam3: "={{ $fromAI('partial3', '', 'number') }}",
|
partialParam3: "={{ $fromAI('partial3', '', 'number') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.partial1).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.partial1).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.partial2).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.partial2).toBeInstanceOf(z.ZodString);
|
||||||
@@ -478,11 +473,11 @@ describe('createNodeAsTool', () => {
|
|||||||
|
|
||||||
describe('Unicode and Internationalization', () => {
|
describe('Unicode and Internationalization', () => {
|
||||||
it('should handle $fromAI calls with unicode characters', () => {
|
it('should handle $fromAI calls with unicode characters', () => {
|
||||||
mockNodeParameters = {
|
node.parameters = {
|
||||||
unicodeParam: "={{ $fromAI('unicodeParam', '🌈 Unicode parameter 你好', 'string') }}",
|
unicodeParam: "={{ $fromAI('unicodeParam', '🌈 Unicode parameter 你好', 'string') }}",
|
||||||
};
|
};
|
||||||
|
|
||||||
const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response;
|
const tool = createNodeAsTool(options).response;
|
||||||
|
|
||||||
expect(tool.schema.shape.unicodeParam).toBeInstanceOf(z.ZodString);
|
expect(tool.schema.shape.unicodeParam).toBeInstanceOf(z.ZodString);
|
||||||
expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好');
|
expect(tool.schema.shape.unicodeParam.description).toBe('🌈 Unicode parameter 你好');
|
||||||
|
|||||||
@@ -945,10 +945,11 @@ export class WorkflowDataProxy {
|
|||||||
_type: string = 'string',
|
_type: string = 'string',
|
||||||
defaultValue?: unknown,
|
defaultValue?: unknown,
|
||||||
) => {
|
) => {
|
||||||
|
const { itemIndex, runIndex } = that;
|
||||||
if (!name || name === '') {
|
if (!name || name === '') {
|
||||||
throw new ExpressionError("Add a key, e.g. $fromAI('placeholder_name')", {
|
throw new ExpressionError("Add a key, e.g. $fromAI('placeholder_name')", {
|
||||||
runIndex: that.runIndex,
|
runIndex,
|
||||||
itemIndex: that.itemIndex,
|
itemIndex,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const nameValidationRegex = /^[a-zA-Z0-9_-]{0,64}$/;
|
const nameValidationRegex = /^[a-zA-Z0-9_-]{0,64}$/;
|
||||||
@@ -956,20 +957,20 @@ export class WorkflowDataProxy {
|
|||||||
throw new ExpressionError(
|
throw new ExpressionError(
|
||||||
'Invalid parameter key, must be between 1 and 64 characters long and only contain lowercase letters, uppercase letters, numbers, underscores, and hyphens',
|
'Invalid parameter key, must be between 1 and 64 characters long and only contain lowercase letters, uppercase letters, numbers, underscores, and hyphens',
|
||||||
{
|
{
|
||||||
runIndex: that.runIndex,
|
runIndex,
|
||||||
itemIndex: that.itemIndex,
|
itemIndex,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const inputData =
|
||||||
|
that.runExecutionData?.resultData.runData[that.activeNodeName]?.[runIndex].inputOverride;
|
||||||
const placeholdersDataInputData =
|
const placeholdersDataInputData =
|
||||||
that.runExecutionData?.resultData.runData[that.activeNodeName]?.[0].inputOverride?.[
|
inputData?.[NodeConnectionType.AiTool]?.[0]?.[itemIndex].json;
|
||||||
NodeConnectionType.AiTool
|
|
||||||
]?.[0]?.[0].json;
|
|
||||||
|
|
||||||
if (Boolean(!placeholdersDataInputData)) {
|
if (Boolean(!placeholdersDataInputData)) {
|
||||||
throw new ExpressionError('No execution data available', {
|
throw new ExpressionError('No execution data available', {
|
||||||
runIndex: that.runIndex,
|
runIndex,
|
||||||
itemIndex: that.itemIndex,
|
itemIndex,
|
||||||
type: 'no_execution_data',
|
type: 'no_execution_data',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ExpressionError } from '@/errors/expression.error';
|
import { ExpressionError } from '@/errors/expression.error';
|
||||||
import type {
|
import {
|
||||||
IExecuteData,
|
NodeConnectionType,
|
||||||
INode,
|
type IExecuteData,
|
||||||
IPinData,
|
type INode,
|
||||||
IRun,
|
type IPinData,
|
||||||
IWorkflowBase,
|
type IRun,
|
||||||
WorkflowExecuteMode,
|
type IWorkflowBase,
|
||||||
|
type WorkflowExecuteMode,
|
||||||
} from '@/Interfaces';
|
} from '@/Interfaces';
|
||||||
import { Workflow } from '@/Workflow';
|
import { Workflow } from '@/Workflow';
|
||||||
import { WorkflowDataProxy } from '@/WorkflowDataProxy';
|
import { WorkflowDataProxy } from '@/WorkflowDataProxy';
|
||||||
@@ -26,10 +27,15 @@ const getProxyFromFixture = (
|
|||||||
run: IRun | null,
|
run: IRun | null,
|
||||||
activeNode: string,
|
activeNode: string,
|
||||||
mode?: WorkflowExecuteMode,
|
mode?: WorkflowExecuteMode,
|
||||||
opts?: { throwOnMissingExecutionData: boolean },
|
opts?: {
|
||||||
|
throwOnMissingExecutionData: boolean;
|
||||||
|
connectionType?: NodeConnectionType;
|
||||||
|
runIndex?: number;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
const taskData = run?.data.resultData.runData[activeNode]?.[0];
|
const taskData = run?.data.resultData.runData[activeNode]?.[opts?.runIndex ?? 0];
|
||||||
const lastNodeConnectionInputData = taskData?.data?.main[0];
|
const lastNodeConnectionInputData =
|
||||||
|
taskData?.data?.[opts?.connectionType ?? NodeConnectionType.Main]?.[0];
|
||||||
|
|
||||||
let executeData: IExecuteData | undefined;
|
let executeData: IExecuteData | undefined;
|
||||||
|
|
||||||
@@ -38,7 +44,7 @@ const getProxyFromFixture = (
|
|||||||
data: taskData.data!,
|
data: taskData.data!,
|
||||||
node: workflow.nodes.find((node) => node.name === activeNode) as INode,
|
node: workflow.nodes.find((node) => node.name === activeNode) as INode,
|
||||||
source: {
|
source: {
|
||||||
main: taskData.source,
|
[opts?.connectionType ?? NodeConnectionType.Main]: taskData.source,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,7 @@ const getProxyFromFixture = (
|
|||||||
pinData,
|
pinData,
|
||||||
}),
|
}),
|
||||||
run?.data ?? null,
|
run?.data ?? null,
|
||||||
0,
|
opts?.runIndex ?? 0,
|
||||||
0,
|
0,
|
||||||
activeNode,
|
activeNode,
|
||||||
lastNodeConnectionInputData ?? [],
|
lastNodeConnectionInputData ?? [],
|
||||||
@@ -443,4 +449,41 @@ describe('WorkflowDataProxy', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('$fromAI', () => {
|
||||||
|
const fixture = loadFixture('from_ai_multiple_items');
|
||||||
|
const getFromAIProxy = (runIndex = 0) =>
|
||||||
|
getProxyFromFixture(fixture.workflow, fixture.run, 'Google Sheets1', 'manual', {
|
||||||
|
connectionType: NodeConnectionType.AiTool,
|
||||||
|
throwOnMissingExecutionData: false,
|
||||||
|
runIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Retrieves values for first item', () => {
|
||||||
|
expect(getFromAIProxy().$fromAI('full_name')).toEqual('Mr. Input 1');
|
||||||
|
expect(getFromAIProxy().$fromAI('email')).toEqual('input1@n8n.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Retrieves values for second item', () => {
|
||||||
|
expect(getFromAIProxy(1).$fromAI('full_name')).toEqual('Mr. Input 2');
|
||||||
|
expect(getFromAIProxy(1).$fromAI('email')).toEqual('input2@n8n.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Case variants: $fromAi and $fromai', () => {
|
||||||
|
expect(getFromAIProxy().$fromAi('full_name')).toEqual('Mr. Input 1');
|
||||||
|
expect(getFromAIProxy().$fromai('email')).toEqual('input1@n8n.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Returns default value when key not found', () => {
|
||||||
|
expect(
|
||||||
|
getFromAIProxy().$fromAI('non_existent_key', 'description', 'string', 'default_value'),
|
||||||
|
).toEqual('default_value');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Throws an error when a key is invalid (e.g. empty string)', () => {
|
||||||
|
expect(() => getFromAIProxy().$fromAI('')).toThrow(ExpressionError);
|
||||||
|
expect(() => getFromAIProxy().$fromAI('invalid key')).toThrow(ExpressionError);
|
||||||
|
expect(() => getFromAIProxy().$fromAI('invalid!')).toThrow(ExpressionError);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
221
packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_run.json
vendored
Normal file
221
packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_run.json
vendored
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"startData": {},
|
||||||
|
"resultData": {
|
||||||
|
"runData": {
|
||||||
|
"When clicking ‘Test workflow’": [
|
||||||
|
{
|
||||||
|
"hints": [],
|
||||||
|
"startTime": 1733478795595,
|
||||||
|
"executionTime": 0,
|
||||||
|
"source": [],
|
||||||
|
"executionStatus": "success",
|
||||||
|
"data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Code": [
|
||||||
|
{
|
||||||
|
"hints": [
|
||||||
|
{
|
||||||
|
"message": "To make sure expressions after this node work, return the input items that produced each output item. <a target=\"_blank\" href=\"https://docs.n8n.io/data/data-mapping/data-item-linking/item-linking-code-node/\">More info</a>",
|
||||||
|
"location": "outputPane"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"startTime": 1733478795595,
|
||||||
|
"executionTime": 2,
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"previousNode": "When clicking ‘Test workflow’"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"executionStatus": "success",
|
||||||
|
"data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"full_name": "Mr. Input 1",
|
||||||
|
"email": "input1@n8n.io"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"full_name": "Mr. Input 2",
|
||||||
|
"email": "input2@n8n.io"
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Google Sheets1": [
|
||||||
|
{
|
||||||
|
"startTime": 1733478796468,
|
||||||
|
"executionTime": 1417,
|
||||||
|
"executionStatus": "success",
|
||||||
|
"source": [null],
|
||||||
|
"data": {
|
||||||
|
"ai_tool": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"full name": "Mr. Input 1",
|
||||||
|
"email": "input1@n8n.io"
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputOverride": {
|
||||||
|
"ai_tool": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"full_name": "Mr. Input 1",
|
||||||
|
"email": "input1@n8n.io"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"subRun": [
|
||||||
|
{
|
||||||
|
"node": "Google Sheets1",
|
||||||
|
"runIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Google Sheets1",
|
||||||
|
"runIndex": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"startTime": 1733478799915,
|
||||||
|
"executionTime": 1271,
|
||||||
|
"executionStatus": "success",
|
||||||
|
"source": [null],
|
||||||
|
"data": {
|
||||||
|
"ai_tool": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"full name": "Mr. Input 1",
|
||||||
|
"email": "input1@n8n.io"
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"inputOverride": {
|
||||||
|
"ai_tool": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"full_name": "Mr. Input 2",
|
||||||
|
"email": "input2@n8n.io"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Agent single list with multiple tool calls": [
|
||||||
|
{
|
||||||
|
"hints": [],
|
||||||
|
"startTime": 1733478795597,
|
||||||
|
"executionTime": 9157,
|
||||||
|
"source": [
|
||||||
|
{
|
||||||
|
"previousNode": "Code"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"executionStatus": "success",
|
||||||
|
"data": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"output": "The user \"Mr. Input 1\" with the email \"input1@n8n.io\" has been successfully added to your Users sheet."
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"json": {
|
||||||
|
"output": "The user \"Mr. Input 2\" with the email \"input2@n8n.io\" has been successfully added to your Users sheet."
|
||||||
|
},
|
||||||
|
"pairedItem": {
|
||||||
|
"item": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"pinData": {},
|
||||||
|
"lastNodeExecuted": "Agent single list with multiple tool calls"
|
||||||
|
},
|
||||||
|
"executionData": {
|
||||||
|
"contextData": {},
|
||||||
|
"nodeExecutionStack": [],
|
||||||
|
"metadata": {
|
||||||
|
"Google Sheets1": [
|
||||||
|
{
|
||||||
|
"subRun": [
|
||||||
|
{
|
||||||
|
"node": "Google Sheets1",
|
||||||
|
"runIndex": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "Google Sheets1",
|
||||||
|
"runIndex": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"waitingExecution": {},
|
||||||
|
"waitingExecutionSource": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mode": "manual",
|
||||||
|
"startedAt": "2024-02-08T15:45:18.848Z",
|
||||||
|
"stoppedAt": "2024-02-08T15:45:18.862Z",
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
112
packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_workflow.json
vendored
Normal file
112
packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_workflow.json
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
{
|
||||||
|
"id": "8d7lUG8IdEyvIUim",
|
||||||
|
"name": "Multiple items tool",
|
||||||
|
"active": false,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"mode": "runOnceForAllItems",
|
||||||
|
"language": "javaScript",
|
||||||
|
"jsCode": "return [\n { \"full_name\": \"Mr. Input 1\", \"email\": \"input1@n8n.io\" }, \n { \"full_name\": \"Mr. Input 2\", \"email\": \"input2@n8n.io\" }\n]",
|
||||||
|
"notice": ""
|
||||||
|
},
|
||||||
|
"id": "cb19a188-12ae-4d46-86df-4a2044ec3346",
|
||||||
|
"name": "Code",
|
||||||
|
"type": "n8n-nodes-base.code",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [-160, 480]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": { "notice": "", "model": "gpt-4o-mini", "options": {} },
|
||||||
|
"id": "c448b6b4-9e11-4044-96e5-f4138534ae52",
|
||||||
|
"name": "OpenAI Chat Model1",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1,
|
||||||
|
"position": [40, 700]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"descriptionType": "manual",
|
||||||
|
"toolDescription": "Add row to Users sheet",
|
||||||
|
"authentication": "oAuth2",
|
||||||
|
"resource": "sheet",
|
||||||
|
"operation": "append",
|
||||||
|
"columns": {
|
||||||
|
"mappingMode": "defineBelow",
|
||||||
|
"value": {
|
||||||
|
"full name": "={{ $fromAI('full_name') }}",
|
||||||
|
"email": "={{ $fromAI('email') }}"
|
||||||
|
},
|
||||||
|
"matchingColumns": [],
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"id": "full name",
|
||||||
|
"displayName": "full name",
|
||||||
|
"required": false,
|
||||||
|
"defaultMatch": false,
|
||||||
|
"display": true,
|
||||||
|
"type": "string",
|
||||||
|
"canBeUsedToMatch": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "email",
|
||||||
|
"displayName": "email",
|
||||||
|
"required": false,
|
||||||
|
"defaultMatch": false,
|
||||||
|
"display": true,
|
||||||
|
"type": "string",
|
||||||
|
"canBeUsedToMatch": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": { "useAppend": true }
|
||||||
|
},
|
||||||
|
"id": "d8b40267-9397-45b6-8a64-ee7e8f9eb8a8",
|
||||||
|
"name": "Google Sheets1",
|
||||||
|
"type": "n8n-nodes-base.googleSheetsTool",
|
||||||
|
"typeVersion": 4.5,
|
||||||
|
"position": [240, 700]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"notice_tip": "",
|
||||||
|
"agent": "toolsAgent",
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "=Add this user to my Users sheet:\n{{ $json.toJsonString() }}",
|
||||||
|
"hasOutputParser": false,
|
||||||
|
"options": {},
|
||||||
|
"credentials": ""
|
||||||
|
},
|
||||||
|
"id": "0d6c1bd7-cc91-4571-8fdb-c875a1af44c7",
|
||||||
|
"name": "Agent single list with multiple tool calls",
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 1.7,
|
||||||
|
"position": [40, 480]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"When clicking ‘Test workflow’": { "main": [[{ "node": "Code", "type": "main", "index": 0 }]] },
|
||||||
|
"Code": {
|
||||||
|
"main": [
|
||||||
|
[{ "node": "Agent single list with multiple tool calls", "type": "main", "index": 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"OpenAI Chat Model1": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Agent single list with multiple tool calls",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Google Sheets1": {
|
||||||
|
"ai_tool": [
|
||||||
|
[{ "node": "Agent single list with multiple tool calls", "type": "ai_tool", "index": 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pinData": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user