diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/CreateNodeAsTool.ts index c569f943ce272..8e0656ce69f18 100644 --- a/packages/core/src/CreateNodeAsTool.ts +++ b/packages/core/src/CreateNodeAsTool.ts @@ -1,9 +1,11 @@ import { DynamicStructuredTool } from '@langchain/core/tools'; import type { IExecuteFunctions, + INode, INodeParameters, INodeType, ISupplyDataFunctions, + ITaskDataConnections, } from 'n8n-workflow'; import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import { z } from 'zod'; @@ -16,6 +18,12 @@ interface FromAIArgument { defaultValue?: string | number | boolean | Record; } +type ParserOptions = { + node: INode; + nodeType: INodeType; + contextFactory: (runIndex: number, inputData: ITaskDataConnections) => ISupplyDataFunctions; +}; + /** * AIParametersParser * @@ -23,15 +31,12 @@ interface FromAIArgument { * generating Zod schemas, and creating LangChain tools. */ class AIParametersParser { - private ctx: ISupplyDataFunctions; + private runIndex = 0; /** * Constructs an instance of AIParametersParser. - * @param ctx The execution context. */ - constructor(ctx: ISupplyDataFunctions) { - this.ctx = ctx; - } + constructor(private readonly options: ParserOptions) {} /** * Generates a Zod schema based on the provided FromAIArgument placeholder. @@ -162,14 +167,14 @@ class AIParametersParser { } catch (error) { // If parsing fails, throw an ApplicationError with details throw new NodeOperationError( - this.ctx.getNode(), + this.options.node, `Failed to parse $fromAI arguments: ${argsString}: ${error}`, ); } } else { // Log an error if parentheses are unbalanced throw new NodeOperationError( - this.ctx.getNode(), + this.options.node, `Unbalanced parentheses while parsing $fromAI call: ${str.slice(startIndex)}`, ); } @@ -254,7 +259,7 @@ class AIParametersParser { const type = cleanArgs?.[2] || 'string'; 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 { @@ -315,13 +320,12 @@ class AIParametersParser { /** * Creates a DynamicStructuredTool from a node. - * @param node The node type. - * @param nodeParameters The parameters of the node. * @returns A DynamicStructuredTool instance. */ - public createTool(node: INodeType, nodeParameters: INodeParameters): DynamicStructuredTool { + public createTool(): DynamicStructuredTool { + const { node, nodeType } = this.options; const collectedArguments: FromAIArgument[] = []; - this.traverseNodeParameters(nodeParameters, collectedArguments); + this.traverseNodeParameters(node.parameters, collectedArguments); // Validate each collected argument 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 isInvalidError = `Parameter key \`${argument.key}\` is invalid`; const error = new Error(argument.key.length === 0 ? isEmptyError : isInvalidError); - throw new NodeOperationError(this.ctx.getNode(), error, { + throw new NodeOperationError(node, error, { description: '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 throw new NodeOperationError( - this.ctx.getNode(), + node, `Duplicate key '${argument.key}' found with different description or type`, { description: @@ -378,37 +382,38 @@ class AIParametersParser { }, {}); const schema = z.object(schemaObj).required(); - const description = this.getDescription(node, nodeParameters); - const nodeName = this.ctx.getNode().name.replace(/ /g, '_'); - const name = nodeName || node.description.name; + const description = this.getDescription(nodeType, node.parameters); + const nodeName = node.name.replace(/ /g, '_'); + const name = nodeName || nodeType.description.name; const tool = new DynamicStructuredTool({ name, description, schema, - func: async (functionArgs: z.infer) => { - const { index } = this.ctx.addInputData(NodeConnectionType.AiTool, [ - [{ json: functionArgs }], - ]); + func: async (toolArgs: z.infer) => { + const context = this.options.contextFactory(this.runIndex, {}); + context.addInputData(NodeConnectionType.AiTool, [[{ json: toolArgs }]]); try { // 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 const mappedResults = result?.[0]?.flatMap((item) => item.json); // Add output data to the context - this.ctx.addOutputData(NodeConnectionType.AiTool, index, [ + context.addOutputData(NodeConnectionType.AiTool, this.runIndex, [ [{ json: { response: mappedResults } }], ]); // Return the stringified results return JSON.stringify(mappedResults); } catch (error) { - const nodeError = new NodeOperationError(this.ctx.getNode(), error as Error); - this.ctx.addOutputData(NodeConnectionType.AiTool, index, nodeError); + const nodeError = new NodeOperationError(this.options.node, error as Error); + context.addOutputData(NodeConnectionType.AiTool, this.runIndex, nodeError); 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, * identifying placeholders using the $fromAI function, and generating a Zod schema. It then creates * 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( - ctx: ISupplyDataFunctions, - node: INodeType, - nodeParameters: INodeParameters, -) { - const parser = new AIParametersParser(ctx); - - return { - response: parser.createTool(node, nodeParameters), - }; +export function createNodeAsTool(options: ParserOptions) { + const parser = new AIParametersParser(options); + return { response: parser.createTool() }; } diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index a07674eecdcae..4967617c9be8a 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -77,9 +77,9 @@ import type { DeduplicationScope, DeduplicationItemTypes, ICheckProcessedContextData, - ISupplyDataFunctions, WebhookType, SchedulingFunctions, + SupplyData, } from 'n8n-workflow'; import { NodeConnectionType, @@ -2023,9 +2023,9 @@ export async function getInputConnectionData( this: IAllExecuteFunctions, workflow: Workflow, runExecutionData: IRunExecutionData, - runIndex: number, + parentRunIndex: number, connectionInputData: INodeExecutionData[], - inputData: ITaskDataConnections, + parentInputData: ITaskDataConnections, additionalData: IWorkflowExecuteAdditionalData, executeData: IExecuteData, mode: WorkflowExecuteMode, @@ -2034,10 +2034,13 @@ export async function getInputConnectionData( itemIndex: number, abortSignal?: AbortSignal, ): Promise { - const node = this.getNode(); - const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + const parentNode = this.getNode(); + 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) => { if (typeof input === 'string') { @@ -2048,7 +2051,7 @@ export async function getInputConnectionData( if (inputConfiguration === undefined) { 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 - .getParentNodes(node.name, connectionType, 1) + .getParentNodes(parentNode.name, connectionType, 1) .map((nodeName) => workflow.getNode(nodeName) as INode) .filter((connectedNode) => connectedNode.disabled !== true); if (connectedNodes.length === 0) { if (inputConfiguration.required) { throw new NodeOperationError( - node, + parentNode, `A ${inputConfiguration?.displayName ?? connectionType} sub-node must be connected and enabled`, ); } @@ -2078,82 +2081,86 @@ export async function getInputConnectionData( connectedNodes.length > inputConfiguration.maxConnections ) { throw new NodeOperationError( - node, + parentNode, `Only ${inputConfiguration.maxConnections} ${connectionType} sub-nodes are/is allowed to be connected`, ); } - const constParentNodes = connectedNodes.map(async (connectedNode) => { - const nodeType = workflow.nodeTypes.getByNameAndVersion( + const nodes: SupplyData[] = []; + for (const connectedNode of connectedNodes) { + const connectedNodeType = workflow.nodeTypes.getByNameAndVersion( connectedNode.type, connectedNode.typeVersion, ); - const context = new SupplyDataContext( - workflow, - connectedNode, - additionalData, - mode, - runExecutionData, - runIndex, - connectionInputData, - inputData, - executeData, - closeFunctions, - abortSignal, - ); + const contextFactory = (runIndex: number, inputData: ITaskDataConnections) => + new SupplyDataContext( + workflow, + connectedNode, + additionalData, + mode, + runExecutionData, + runIndex, + connectionInputData, + inputData, + connectionType, + executeData, + closeFunctions, + abortSignal, + ); - if (!nodeType.supplyData) { - if (nodeType.description.outputs.includes(NodeConnectionType.AiTool)) { - nodeType.supplyData = async function (this: ISupplyDataFunctions) { - return createNodeAsTool(this, nodeType, this.getNode().parameters); - }; + if (!connectedNodeType.supplyData) { + if (connectedNodeType.description.outputs.includes(NodeConnectionType.AiTool)) { + const supplyData = createNodeAsTool({ + node: connectedNode, + nodeType: connectedNodeType, + contextFactory, + }); + nodes.push(supplyData); } else { throw new ApplicationError('Node does not have a `supplyData` method defined', { 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 { - const response = await nodeType.supplyData.call(context, itemIndex); - if (response.closeFunction) { - closeFunctions.push(response.closeFunction); - } - return response; - } catch (error) { - // Propagate errors from sub-nodes - if (error.functionality === 'configuration-node') throw error; - if (!(error instanceof ExecutionBaseError)) { - error = new NodeOperationError(connectedNode, error, { + let currentNodeRunIndex = 0; + if (runExecutionData.resultData.runData.hasOwnProperty(parentNode.name)) { + currentNodeRunIndex = runExecutionData.resultData.runData[parentNode.name].length; + } + + // Display the error on the node which is causing it + await context.addExecutionDataFunctions( + 'input', + error, + connectionType, + parentNode.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, }); } - - 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 ? (nodes || [])[0]?.response diff --git a/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts b/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts index d3ebddc75cc96..99ee41c6fda3a 100644 --- a/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts +++ b/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts @@ -72,6 +72,7 @@ describe('SupplyDataContext', () => { runIndex, connectionInputData, inputData, + connectionType, executeData, [closeFn], abortSignal, diff --git a/packages/core/src/node-execution-context/base-execute-context.ts b/packages/core/src/node-execution-context/base-execute-context.ts index 4f186e559708c..8ecc658579400 100644 --- a/packages/core/src/node-execution-context/base-execute-context.ts +++ b/packages/core/src/node-execution-context/base-execute-context.ts @@ -21,9 +21,14 @@ import type { IWorkflowDataProxyData, ISourceData, AiEvent, +} from 'n8n-workflow'; +import { + ApplicationError, + NodeHelpers, NodeConnectionType, + WAIT_INDEFINITELY, + WorkflowDataProxy, } from 'n8n-workflow'; -import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow'; import { Container } from 'typedi'; 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) { // Should never happen as n8n sets it automatically throw new ApplicationError('Source data is missing'); diff --git a/packages/core/src/node-execution-context/supply-data-context.ts b/packages/core/src/node-execution-context/supply-data-context.ts index 0155b9d85e857..0ca059d048fa8 100644 --- a/packages/core/src/node-execution-context/supply-data-context.ts +++ b/packages/core/src/node-execution-context/supply-data-context.ts @@ -49,6 +49,7 @@ export class SupplyDataContext extends BaseExecuteContext implements ISupplyData runIndex: number, connectionInputData: INodeExecutionData[], inputData: ITaskDataConnections, + private readonly connectionType: NodeConnectionType, executeData: IExecuteData, private readonly closeFunctions: CloseFunction[], 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)) { // Return empty array because else it would throw error when nothing is connected to input return []; diff --git a/packages/core/test/CreateNodeAsTool.test.ts b/packages/core/test/CreateNodeAsTool.test.ts index 5c485b983718f..2f6dd313229e6 100644 --- a/packages/core/test/CreateNodeAsTool.test.ts +++ b/packages/core/test/CreateNodeAsTool.test.ts @@ -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 { z } from 'zod'; @@ -14,28 +15,29 @@ jest.mock('@langchain/core/tools', () => ({ })); describe('createNodeAsTool', () => { - let mockCtx: IExecuteFunctions; - let mockNode: INodeType; - let mockNodeParameters: INodeParameters; + const context = mock({ + getNodeParameter: jest.fn(), + addInputData: jest.fn(), + addOutputData: jest.fn(), + getNode: jest.fn(), + }); + const contextFactory = () => context; + const nodeType = mock({ + description: { + name: 'TestNode', + description: 'Test node description', + }, + }); + const node = mock({ name: 'Test_Node' }); + const options = { node, nodeType, contextFactory }; beforeEach(() => { - // Setup mock objects - mockCtx = { - getNodeParameter: jest.fn(), - addInputData: jest.fn().mockReturnValue({ index: 0 }), - addOutputData: jest.fn(), - getNode: jest.fn().mockReturnValue({ name: 'Test_Node' }), - } as unknown as IExecuteFunctions; - - mockNode = { - description: { - name: 'TestNode', - description: 'Test node description', - }, - execute: jest.fn().mockResolvedValue([[{ json: { result: 'test' } }]]), - } as unknown as INodeType; + jest.clearAllMocks(); + (context.addInputData as jest.Mock).mockReturnValue({ index: 0 }); + (context.getNode as jest.Mock).mockReturnValue(node); + (nodeType.execute as jest.Mock).mockResolvedValue([[{ json: { result: 'test' } }]]); - mockNodeParameters = { + node.parameters = { param1: "={{$fromAI('param1', 'Test parameter', 'string') }}", param2: 'static value', nestedParam: { @@ -45,13 +47,11 @@ describe('createNodeAsTool', () => { resource: 'testResource', operation: 'testOperation', }; - - jest.clearAllMocks(); }); describe('Tool Creation and Basic 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.name).toBe('Test_Node'); @@ -62,10 +62,10 @@ describe('createNodeAsTool', () => { }); it('should use toolDescription if provided', () => { - mockNodeParameters.descriptionType = 'manual'; - mockNodeParameters.toolDescription = 'Custom tool description'; + node.parameters.descriptionType = 'manual'; + 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'); }); @@ -73,7 +73,7 @@ describe('createNodeAsTool', () => { describe('Schema Creation and Parameter Handling', () => { 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.shape).toHaveProperty('param1'); @@ -82,14 +82,14 @@ describe('createNodeAsTool', () => { }); 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.subparam).toBeInstanceOf(z.ZodString); }); it('should handle default values correctly', () => { - mockNodeParameters = { + node.parameters = { paramWithDefault: "={{ $fromAI('paramWithDefault', 'Parameter with default', 'string', 'default value') }}", numberWithDefault: @@ -98,7 +98,7 @@ describe('createNodeAsTool', () => { "={{ $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.numberWithDefault.description).toBe('Number with default'); @@ -106,7 +106,7 @@ describe('createNodeAsTool', () => { }); it('should handle nested parameters correctly', () => { - mockNodeParameters = { + node.parameters = { topLevel: "={{ $fromAI('topLevel', 'Top level parameter', 'string') }}", nested: { 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.level1).toBeInstanceOf(z.ZodString); @@ -124,14 +124,14 @@ describe('createNodeAsTool', () => { }); it('should handle array parameters correctly', () => { - mockNodeParameters = { + node.parameters = { arrayParam: [ "={{ $fromAI('item1', 'First item', 'string') }}", "={{ $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.item2).toBeInstanceOf(z.ZodNumber); @@ -140,13 +140,13 @@ describe('createNodeAsTool', () => { describe('Error Handling and Edge Cases', () => { it('should handle error during node execution', async () => { - mockNode.execute = jest.fn().mockRejectedValue(new Error('Execution failed')); - const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + nodeType.execute = jest.fn().mockRejectedValue(new Error('Execution failed')); + const tool = createNodeAsTool(options).response; const result = await tool.func({ param1: 'test value' }); expect(result).toContain('Error during node execution:'); - expect(mockCtx.addOutputData).toHaveBeenCalledWith( + expect(context.addOutputData).toHaveBeenCalledWith( NodeConnectionType.AiTool, 0, expect.any(NodeOperationError), @@ -154,31 +154,27 @@ describe('createNodeAsTool', () => { }); 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( - 'Parameter key `invalid param` is invalid', - ); + expect(() => createNodeAsTool(options)).toThrow('Parameter key `invalid param` is invalid'); }); it('should throw an error for $fromAI calls with unsupported types', () => { - mockNodeParameters = { + node.parameters = { invalidTypeParam: "={{ $fromAI('invalidType', 'Param with unsupported type', 'unsupportedType') }}", }; - expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( - 'Invalid type: unsupportedType', - ); + expect(() => createNodeAsTool(options)).toThrow('Invalid type: unsupportedType'); }); it('should handle empty parameters and parameters with no fromAI calls', () => { - mockNodeParameters = { + node.parameters = { param1: 'static value 1', param2: 'static value 2', }; - const tool = createNodeAsTool(mockCtx, mockNode, mockNodeParameters).response; + const tool = createNodeAsTool(options).response; expect(tool.schema.shape).toEqual({}); }); @@ -186,13 +182,13 @@ describe('createNodeAsTool', () => { describe('Parameter Name and Description Handling', () => { it('should accept parameter names with underscores and hyphens', () => { - mockNodeParameters = { + node.parameters = { validName1: "={{ $fromAI('param_name-1', 'Valid name with underscore and hyphen', 'string') }}", 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'].description).toBe( @@ -204,22 +200,20 @@ describe('createNodeAsTool', () => { }); it('should throw an error for parameter names with invalid special characters', () => { - mockNodeParameters = { + node.parameters = { invalidNameParam: "={{ $fromAI('param@name!', 'Invalid name with special characters', 'string') }}", }; - expect(() => createNodeAsTool(mockCtx, mockNode, mockNodeParameters)).toThrow( - 'Parameter key `param@name!` is invalid', - ); + expect(() => createNodeAsTool(options)).toThrow('Parameter key `param@name!` is invalid'); }); it('should throw an error for empty parameter name', () => { - mockNodeParameters = { + node.parameters = { 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()', ); }); @@ -227,50 +221,51 @@ describe('createNodeAsTool', () => { it('should handle parameter names with exact and exceeding character limits', () => { const longName = 'a'.repeat(64); const tooLongName = 'a'.repeat(65); - mockNodeParameters = { + node.parameters = { 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].description).toBe('Param with 64 character name'); - expect(() => - createNodeAsTool(mockCtx, mockNode, { - tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`, - }), - ).toThrow(`Parameter key \`${tooLongName}\` is invalid`); + node.parameters = { + tooLongNameParam: `={{ $fromAI('${tooLongName}', 'Param with 65 character name', 'string') }}`, + }; + expect(() => createNodeAsTool(options)).toThrow( + `Parameter key \`${tooLongName}\` is invalid`, + ); }); it('should handle $fromAI calls with empty description', () => { - mockNodeParameters = { + node.parameters = { 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.description).toBeUndefined(); }); it('should throw an error for calls with the same parameter but different descriptions', () => { - mockNodeParameters = { + node.parameters = { duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}", 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", ); }); it('should throw an error for calls with the same parameter but different types', () => { - mockNodeParameters = { + node.parameters = { duplicateParam1: "={{ $fromAI('duplicate', 'First duplicate', 'string') }}", 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", ); }); @@ -278,7 +273,7 @@ describe('createNodeAsTool', () => { describe('Complex Parsing Scenarios', () => { it('should correctly parse $fromAI calls with varying spaces, capitalization, and within template literals', () => { - mockNodeParameters = { + node.parameters = { varyingSpacing1: "={{$fromAI('param1','Description1','string')}}", varyingSpacing2: "={{ $fromAI ( 'param2' , 'Description2' , 'number' ) }}", varyingSpacing3: "={{ $FROMai('param3', 'Description3', 'boolean') }}", @@ -288,7 +283,7 @@ describe('createNodeAsTool', () => { "={{ `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.description).toBe('Description1'); @@ -307,12 +302,12 @@ describe('createNodeAsTool', () => { }); it('should correctly parse multiple $fromAI calls interleaved with regular text', () => { - mockNodeParameters = { + node.parameters = { interleavedParams: "={{ '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.description).toBe('First param'); @@ -322,12 +317,12 @@ describe('createNodeAsTool', () => { }); it('should correctly parse $fromAI calls with complex JSON default values', () => { - mockNodeParameters = { + node.parameters = { complexJsonDefault: '={{ $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.description).toBe('Param with complex JSON default'); @@ -338,7 +333,7 @@ describe('createNodeAsTool', () => { }); it('should ignore $fromAI calls embedded in non-string node parameters', () => { - mockNodeParameters = { + node.parameters = { numberParam: 42, booleanParam: false, 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.description).toBe('Inner param'); @@ -373,48 +368,48 @@ describe('createNodeAsTool', () => { describe('Escaping and Special Characters', () => { it('should handle escaped single quotes in parameter names and descriptions', () => { - mockNodeParameters = { + node.parameters = { escapedQuotesParam: "={{ $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.description).toBe("Description with 'escaped' quotes"); }); it('should handle escaped double quotes in parameter names and descriptions', () => { - mockNodeParameters = { + node.parameters = { escapedQuotesParam: '={{ $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.description).toBe('Description with "escaped" quotes'); }); it('should handle escaped backslashes in parameter names and descriptions', () => { - mockNodeParameters = { + node.parameters = { escapedBackslashesParam: "={{ $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.description).toBe('Description with \\ backslashes'); }); it('should handle mixed escaped characters in parameter names and descriptions', () => { - mockNodeParameters = { + node.parameters = { mixedEscapesParam: '={{ $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.description).toBe('Description with \'mixed" characters'); @@ -423,12 +418,12 @@ describe('createNodeAsTool', () => { describe('Edge Cases and Limitations', () => { it('should ignore excess arguments in $fromAI calls beyond the fourth argument', () => { - mockNodeParameters = { + node.parameters = { excessArgsParam: "={{ $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.description).toBe('Param with excess arguments'); @@ -436,12 +431,12 @@ describe('createNodeAsTool', () => { }); it('should correctly parse $fromAI calls with nested parentheses', () => { - mockNodeParameters = { + node.parameters = { nestedParenthesesParam: "={{ $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.description).toBe( @@ -451,24 +446,24 @@ describe('createNodeAsTool', () => { it('should handle $fromAI calls with very long descriptions', () => { const longDescription = 'A'.repeat(1000); - mockNodeParameters = { + node.parameters = { 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.description).toBe(longDescription); }); it('should handle $fromAI calls with only some parameters', () => { - mockNodeParameters = { + node.parameters = { partialParam1: "={{ $fromAI('partial1') }}", partialParam2: "={{ $fromAI('partial2', 'Description only') }}", 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.partial2).toBeInstanceOf(z.ZodString); @@ -478,11 +473,11 @@ describe('createNodeAsTool', () => { describe('Unicode and Internationalization', () => { it('should handle $fromAI calls with unicode characters', () => { - mockNodeParameters = { + node.parameters = { 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.description).toBe('🌈 Unicode parameter 你好'); diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index ea167f50b31fb..d94d9220d0908 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -945,10 +945,11 @@ export class WorkflowDataProxy { _type: string = 'string', defaultValue?: unknown, ) => { + const { itemIndex, runIndex } = that; if (!name || name === '') { throw new ExpressionError("Add a key, e.g. $fromAI('placeholder_name')", { - runIndex: that.runIndex, - itemIndex: that.itemIndex, + runIndex, + itemIndex, }); } const nameValidationRegex = /^[a-zA-Z0-9_-]{0,64}$/; @@ -956,20 +957,20 @@ export class WorkflowDataProxy { 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', { - runIndex: that.runIndex, - itemIndex: that.itemIndex, + runIndex, + itemIndex, }, ); } + const inputData = + that.runExecutionData?.resultData.runData[that.activeNodeName]?.[runIndex].inputOverride; const placeholdersDataInputData = - that.runExecutionData?.resultData.runData[that.activeNodeName]?.[0].inputOverride?.[ - NodeConnectionType.AiTool - ]?.[0]?.[0].json; + inputData?.[NodeConnectionType.AiTool]?.[0]?.[itemIndex].json; if (Boolean(!placeholdersDataInputData)) { throw new ExpressionError('No execution data available', { - runIndex: that.runIndex, - itemIndex: that.itemIndex, + runIndex, + itemIndex, type: 'no_execution_data', }); } diff --git a/packages/workflow/test/WorkflowDataProxy.test.ts b/packages/workflow/test/WorkflowDataProxy.test.ts index 89b0751321543..8c41b0571cb47 100644 --- a/packages/workflow/test/WorkflowDataProxy.test.ts +++ b/packages/workflow/test/WorkflowDataProxy.test.ts @@ -1,11 +1,12 @@ import { ExpressionError } from '@/errors/expression.error'; -import type { - IExecuteData, - INode, - IPinData, - IRun, - IWorkflowBase, - WorkflowExecuteMode, +import { + NodeConnectionType, + type IExecuteData, + type INode, + type IPinData, + type IRun, + type IWorkflowBase, + type WorkflowExecuteMode, } from '@/Interfaces'; import { Workflow } from '@/Workflow'; import { WorkflowDataProxy } from '@/WorkflowDataProxy'; @@ -26,10 +27,15 @@ const getProxyFromFixture = ( run: IRun | null, activeNode: string, mode?: WorkflowExecuteMode, - opts?: { throwOnMissingExecutionData: boolean }, + opts?: { + throwOnMissingExecutionData: boolean; + connectionType?: NodeConnectionType; + runIndex?: number; + }, ) => { - const taskData = run?.data.resultData.runData[activeNode]?.[0]; - const lastNodeConnectionInputData = taskData?.data?.main[0]; + const taskData = run?.data.resultData.runData[activeNode]?.[opts?.runIndex ?? 0]; + const lastNodeConnectionInputData = + taskData?.data?.[opts?.connectionType ?? NodeConnectionType.Main]?.[0]; let executeData: IExecuteData | undefined; @@ -38,7 +44,7 @@ const getProxyFromFixture = ( data: taskData.data!, node: workflow.nodes.find((node) => node.name === activeNode) as INode, source: { - main: taskData.source, + [opts?.connectionType ?? NodeConnectionType.Main]: taskData.source, }, }; } @@ -64,7 +70,7 @@ const getProxyFromFixture = ( pinData, }), run?.data ?? null, - 0, + opts?.runIndex ?? 0, 0, activeNode, 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); + }); + }); }); diff --git a/packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_run.json b/packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_run.json new file mode 100644 index 0000000000000..563e52f15a38b --- /dev/null +++ b/packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_run.json @@ -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. More info", + "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" +} diff --git a/packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_workflow.json b/packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_workflow.json new file mode 100644 index 0000000000000..9f3f75970e1f7 --- /dev/null +++ b/packages/workflow/test/fixtures/WorkflowDataProxy/from_ai_multiple_items_workflow.json @@ -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": {} +}