diff --git a/packages/workflow/src/WorkflowDataProxy.ts b/packages/workflow/src/WorkflowDataProxy.ts index 3fa89764b1..b791670063 100644 --- a/packages/workflow/src/WorkflowDataProxy.ts +++ b/packages/workflow/src/WorkflowDataProxy.ts @@ -6,29 +6,30 @@ import * as jmespath from 'jmespath'; import { DateTime, Duration, Interval, Settings } from 'luxon'; import { augmentArray, augmentObject } from './AugmentObject'; -import { SCRIPTING_NODE_TYPES } from './Constants'; +import { AGENT_LANGCHAIN_NODE_TYPE, SCRIPTING_NODE_TYPES } from './Constants'; import { ApplicationError } from './errors/application.error'; import { ExpressionError, type ExpressionErrorOptions } from './errors/expression.error'; import { getGlobalState } from './GlobalState'; -import { - type IDataObject, - type IExecuteData, - type INodeExecutionData, - type INodeParameters, - type IPairedItemData, - type IRunExecutionData, - type ISourceData, - type ITaskData, - type IWorkflowDataProxyAdditionalKeys, - type IWorkflowDataProxyData, - type INodeParameterResourceLocator, - type NodeParameterValueType, - type WorkflowExecuteMode, - type ProxyInput, - NodeConnectionTypes, +import { NodeConnectionTypes } from './Interfaces'; +import type { + IDataObject, + IExecuteData, + INodeExecutionData, + INodeParameters, + IPairedItemData, + IRunExecutionData, + ISourceData, + ITaskData, + IWorkflowDataProxyAdditionalKeys, + IWorkflowDataProxyData, + INodeParameterResourceLocator, + NodeParameterValueType, + WorkflowExecuteMode, + ProxyInput, + INode, } from './Interfaces'; import * as NodeHelpers from './NodeHelpers'; -import { deepCopy } from './utils'; +import { deepCopy, isObjectEmpty } from './utils'; import type { Workflow } from './Workflow'; import type { EnvProviderState } from './WorkflowDataProxyEnvProvider'; import { createEnvProvider, createEnvProviderState } from './WorkflowDataProxyEnvProvider'; @@ -159,6 +160,89 @@ export class WorkflowDataProxy { ); } + private buildAgentToolInfo(node: INode) { + const nodeType = this.workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + const type = nodeType.description.displayName; + const params = NodeHelpers.getNodeParameters( + nodeType.description.properties, + node.parameters, + true, + false, + node, + nodeType.description, + ); + const resourceKey = params?.resource; + const operationKey = params?.operation; + + const resource = + nodeType.description.properties + .find((nodeProperties) => nodeProperties.name === 'resource') + ?.options?.find((option) => 'value' in option && option.value === resourceKey)?.name ?? + null; + + const operation = + nodeType.description.properties + .find( + (nodeProperty) => + nodeProperty.name === 'operation' && + nodeProperty.displayOptions?.show?.resource?.some((y) => y === resourceKey), + ) + ?.options?.find((y) => 'value' in y && y.value === operationKey)?.name ?? null; + + const hasCredentials = !isObjectEmpty(node.credentials ?? {}); + + const hasValidCalendar = nodeType.description.name.includes('googleCalendar') + ? isResourceLocatorValue(node.parameters.calendar) && node.parameters.calendar.value !== '' + : undefined; + + const aiDefinedFields = Object.entries(node.parameters) + .map(([key, value]) => [key, isResourceLocatorValue(value) ? value.value : value] as const) + .filter(([_, value]) => value?.toString().toLowerCase().includes('$fromai')) + .map( + ([key]) => + nodeType.description.properties.find((property) => property.name === key)?.displayName, + ); + + return { + name: node.name, + type, + resource, + operation, + hasCredentials, + hasValidCalendar, + aiDefinedFields, + }; + } + + private agentInfo() { + const agentNode = this.workflow.getNode(this.activeNodeName); + if (!agentNode || agentNode.type !== AGENT_LANGCHAIN_NODE_TYPE) return undefined; + const connectedTools = this.workflow + .getParentNodes(this.activeNodeName, NodeConnectionTypes.AiTool) + .map((nodeName) => this.workflow.getNode(nodeName)) + .filter((node) => node) as INode[]; + const memoryConnectedToAgent = + this.workflow.getParentNodes(this.activeNodeName, NodeConnectionTypes.AiMemory).length > 0; + const allTools = this.workflow.queryNodes((nodeType) => { + return nodeType.description.name.toLowerCase().includes('tool'); + }); + + const unconnectedTools = allTools + .filter( + (node) => + this.workflow.getChildNodes(node.name, NodeConnectionTypes.AiTool, 1).length === 0, + ) + .filter((node) => !connectedTools.includes(node)); + + return { + memoryConnectedToAgent, + tools: [ + ...connectedTools.map((node) => ({ connected: true, ...this.buildAgentToolInfo(node) })), + ...unconnectedTools.map((node) => ({ connected: false, ...this.buildAgentToolInfo(node) })), + ], + }; + } + /** * Returns a proxy which allows to query parameter data of a given node * @@ -1394,6 +1478,7 @@ export class WorkflowDataProxy { $thisRunIndex: this.runIndex, $nodeVersion: that.workflow.getNode(that.activeNodeName)?.typeVersion, $nodeId: that.workflow.getNode(that.activeNodeName)?.id, + $agentInfo: this.agentInfo(), $webhookId: that.workflow.getNode(that.activeNodeName)?.webhookId, }; const throwOnMissingExecutionData = opts?.throwOnMissingExecutionData ?? true; diff --git a/packages/workflow/test/NodeTypes.ts b/packages/workflow/test/NodeTypes.ts index 4dcec35b0c..b2c7339338 100644 --- a/packages/workflow/test/NodeTypes.ts +++ b/packages/workflow/test/NodeTypes.ts @@ -828,6 +828,179 @@ const executeWorkflowNode: LoadedClass = { sourcePath: '', }; +const aiAgentNode: LoadedClass = { + sourcePath: '', + type: { + description: { + displayName: 'AI Agent', + name: '@n8n/n8n-nodes-langchain.agent', + icon: 'fa:robot', + iconColor: 'black', + group: ['transform'], + version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8], + description: 'Generates an action plan and executes it. Can use external tools.', + defaults: { + name: 'AI Agent', + color: '#404040', + }, + inputs: [ + NodeConnectionTypes.Main, + NodeConnectionTypes.AiLanguageModel, + NodeConnectionTypes.AiTool, + NodeConnectionTypes.AiMemory, + ], + outputs: [NodeConnectionTypes.Main], + properties: [], + }, + }, +}; + +const wikipediaTool: LoadedClass = { + sourcePath: '', + type: { + description: { + displayName: 'Wikipedia', + name: '@n8n/n8n-nodes-langchain.toolWikipedia', + icon: 'file:wikipedia.svg', + group: ['transform'], + version: 1, + description: 'Search in Wikipedia', + defaults: { + name: 'Wikipedia', + }, + inputs: [], + outputs: [NodeConnectionTypes.AiTool], + properties: [], + }, + }, +}; + +const calculatorTool: LoadedClass = { + sourcePath: '', + type: { + description: { + displayName: 'Calculator', + name: '@n8n/n8n-nodes-langchain.toolCalculator', + icon: 'fa:calculator', + iconColor: 'black', + group: ['transform'], + version: 1, + description: 'Make it easier for AI agents to perform arithmetic', + defaults: { + name: 'Calculator', + }, + inputs: [], + outputs: [NodeConnectionTypes.AiTool], + properties: [], + }, + }, +}; + +const googleCalendarTool: LoadedClass = { + sourcePath: '', + type: { + description: { + displayName: 'Google Calendar', + name: 'n8n-nodes-base.googleCalendarTool', + icon: 'file:googleCalendar.svg', + group: ['input'], + version: [1, 1.1, 1.2, 1.3], + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Consume Google Calendar API', + defaults: { + name: 'Google Calendar', + }, + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], + usableAsTool: true, + properties: [ + { name: 'start', type: 'dateTime', displayName: 'Start', default: '' }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['calendar'], + }, + }, + options: [ + { + name: 'Availability', + value: 'availability', + description: 'If a time-slot is available in a calendar', + action: 'Get availability in a calendar', + }, + ], + default: 'availability', + }, + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { + show: { + resource: ['event'], + }, + }, + options: [ + { + name: 'Create', + value: 'create', + description: 'Add a event to calendar', + action: 'Create an event', + }, + { + name: 'Delete', + value: 'delete', + description: 'Delete an event', + action: 'Delete an event', + }, + { + name: 'Get', + value: 'get', + description: 'Retrieve an event', + action: 'Get an event', + }, + { + name: 'Get Many', + value: 'getAll', + description: 'Retrieve many events from a calendar', + action: 'Get many events', + }, + { + name: 'Update', + value: 'update', + description: 'Update an event', + action: 'Update an event', + }, + ], + default: 'create', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Calendar', + value: 'calendar', + }, + { + name: 'Event', + value: 'event', + }, + ], + default: 'event', + }, + ], + }, + }, +}; + export class NodeTypes implements INodeTypes { nodeTypes: INodeTypeData = { 'n8n-nodes-base.stickyNote': stickyNode, @@ -887,6 +1060,10 @@ export class NodeTypes implements INodeTypes { }, }, 'n8n-nodes-base.manualTrigger': manualTriggerNode, + '@n8n/n8n-nodes-langchain.agent': aiAgentNode, + 'n8n-nodes-base.googleCalendarTool': googleCalendarTool, + '@n8n/n8n-nodes-langchain.toolCalculator': calculatorTool, + '@n8n/n8n-nodes-langchain.toolWikipedia': wikipediaTool, }; getByName(nodeType: string): INodeType | IVersionedNodeType { @@ -900,6 +1077,7 @@ export class NodeTypes implements INodeTypes { return mock({ description: { properties: [], + name: nodeType, }, }); } diff --git a/packages/workflow/test/WorkflowDataProxy.test.ts b/packages/workflow/test/WorkflowDataProxy.test.ts index 9e592ef008..6e40fe609b 100644 --- a/packages/workflow/test/WorkflowDataProxy.test.ts +++ b/packages/workflow/test/WorkflowDataProxy.test.ts @@ -787,4 +787,52 @@ describe('WorkflowDataProxy', () => { }); }); }); + + describe('$agentInfo', () => { + const fixture = loadFixture('agentInfo'); + const proxy = getProxyFromFixture(fixture.workflow, fixture.run, 'AI Agent'); + + test('$agentInfo should return undefined for non-agent nodes', () => { + const nonAgentProxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Calculator'); + expect(nonAgentProxy.$agentInfo).toBeUndefined(); + }); + + test('$agentInfo should return memoryConnectedToAgent as true if memory is connected', () => { + expect(proxy.$agentInfo.memoryConnectedToAgent).toBe(true); + }); + + test('$agentInfo should return memoryConnectedToAgent as false if no memory is connected', () => { + const noMemoryProxy = getProxyFromFixture(fixture.workflow, fixture.run, 'Another Agent'); + expect(noMemoryProxy.$agentInfo.memoryConnectedToAgent).toBe(false); + }); + + test('$agentInfo.tools should include connected tools with correct details', () => { + const tools = proxy.$agentInfo.tools; + // don't show tool connected to other agent + expect(tools.length).toEqual(2); + expect(tools[0]).toMatchObject({ + connected: true, + name: 'Google Calendar', + type: 'Google Calendar', + resource: 'Event', + operation: 'Create', + hasCredentials: false, + }); + expect(tools[1]).toMatchObject({ + connected: false, + name: 'Calculator', + type: 'Calculator', + resource: null, + operation: null, + hasCredentials: false, + }); + }); + + test('$agentInfo.tools should correctly identify AI-defined fields', () => { + const tools = proxy.$agentInfo.tools; + expect(tools[0].name).toBe('Google Calendar'); + expect(tools[0].aiDefinedFields.length).toBe(1); + expect(tools[0].aiDefinedFields).toEqual(['Start']); + }); + }); }); diff --git a/packages/workflow/test/fixtures/WorkflowDataProxy/agentInfo_run.json b/packages/workflow/test/fixtures/WorkflowDataProxy/agentInfo_run.json new file mode 100644 index 0000000000..12383639a7 --- /dev/null +++ b/packages/workflow/test/fixtures/WorkflowDataProxy/agentInfo_run.json @@ -0,0 +1,7 @@ +{ + "data": { + "resultData": { + "runData": {} + } + } +} diff --git a/packages/workflow/test/fixtures/WorkflowDataProxy/agentInfo_workflow.json b/packages/workflow/test/fixtures/WorkflowDataProxy/agentInfo_workflow.json new file mode 100644 index 0000000000..e265b5bf4f --- /dev/null +++ b/packages/workflow/test/fixtures/WorkflowDataProxy/agentInfo_workflow.json @@ -0,0 +1,130 @@ +{ + "nodes": [ + { + "parameters": { + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1.8, + "position": [280, -160], + "id": "48d38b5e-d75f-4245-9f6b-c9ab623b1a7a", + "name": "AI Agent" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [0, 0], + "id": "914a83be-5bd5-46f9-8b39-1456d12f9429", + "name": "When clicking ‘Test workflow’" + }, + { + "parameters": {}, + "type": "@n8n/n8n-nodes-langchain.toolCalculator", + "typeVersion": 1, + "position": [780, 60], + "id": "d939bfce-6fbd-4f13-8ba2-91d605bdb81b", + "name": "Calculator" + }, + { + "parameters": {}, + "type": "@n8n/n8n-nodes-langchain.toolWikipedia", + "typeVersion": 1, + "position": [680, 500], + "id": "efdb00f3-cf60-4c3a-9b18-2523d2fc3177", + "name": "Wikipedia" + }, + { + "parameters": { + "calendar": { + "__rl": true, + "mode": "list", + "value": "" + }, + "start": "={{ /*n8n-auto-generated-fromAI-override*/ $fromAI('Start', ``, 'string') }}", + "additionalFields": {} + }, + "type": "n8n-nodes-base.googleCalendarTool", + "typeVersion": 1.3, + "position": [440, 60], + "id": "a76e6696-1e19-4aa4-b5d4-c43b332c8bc8", + "name": "Google Calendar" + }, + { + "parameters": { + "options": {} + }, + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 1.8, + "position": [280, 280], + "id": "18a77a68-10dc-486d-b179-6d787371878c", + "name": "Another Agent" + }, + { + "parameters": {}, + "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow", + "typeVersion": 1.3, + "position": [300, 80], + "id": "a0f31ee2-14b1-4ce7-97eb-a070346db0d3", + "name": "Simple Memory" + } + ], + "connections": { + "When clicking ‘Test workflow’": { + "main": [ + [ + { + "node": "AI Agent", + "type": "main", + "index": 0 + }, + { + "node": "Another Agent", + "type": "main", + "index": 0 + } + ] + ] + }, + "Calculator": { + "ai_tool": [[]] + }, + "Wikipedia": { + "ai_tool": [ + [ + { + "node": "Another Agent", + "type": "ai_tool", + "index": 0 + } + ] + ] + }, + "Google Calendar": { + "ai_tool": [ + [ + { + "node": "AI Agent", + "type": "ai_tool", + "index": 0 + } + ] + ] + }, + "Simple Memory": { + "ai_memory": [ + [ + { + "node": "AI Agent", + "type": "ai_memory", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "meta": { + "instanceId": "866ca65bee13401b1e2b632cdf2767d28ec3301d61bdb4ceabc832d1fe22a83e" + } +}