diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 4819e7fccc..90146ab374 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -2,7 +2,7 @@ * Getters */ -import { getVisibleSelect } from '../utils'; +import { getVisibleSelect } from '../utils/popper'; export function getCredentialSelect(eq = 0) { return cy.getByTestId('node-credentials-select').eq(eq); diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index bc27048219..29b871f560 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -1,4 +1,5 @@ import { getManualChatModal } from './modals/chat-modal'; +import { clickGetBackToCanvas, getParameterInputByName } from './ndv'; import { ROUTES } from '../constants'; /** @@ -127,7 +128,7 @@ export function navigateToNewWorkflowPage(preventNodeViewUnload = true) { }); } -export function addSupplementalNodeToParent( +function connectNodeToParent( nodeName: string, endpointType: EndpointType, parentNodeName: string, @@ -141,6 +142,15 @@ export function addSupplementalNodeToParent( } else { getNodeCreatorItems().contains(nodeName).click(); } +} + +export function addSupplementalNodeToParent( + nodeName: string, + endpointType: EndpointType, + parentNodeName: string, + exactMatch = false, +) { + connectNodeToParent(nodeName, endpointType, parentNodeName, exactMatch); getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist'); } @@ -160,6 +170,15 @@ export function addToolNodeToParent(nodeName: string, parentNodeName: string) { addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName); } +export function addVectorStoreToolToParent(nodeName: string, parentNodeName: string) { + connectNodeToParent(nodeName, 'ai_tool', parentNodeName, false); + getParameterInputByName('mode') + .find('input') + .should('have.value', 'Retrieve Documents (As Tool for AI Agent)'); + clickGetBackToCanvas(); + getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist'); +} + export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) { addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName); } diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index a2cd5968d1..e841605863 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -1,10 +1,12 @@ +import { clickGetBackToCanvas } from '../composables/ndv'; import { addNodeToCanvas, addRetrieverNodeToParent, addVectorStoreNodeToParent, + addVectorStoreToolToParent, getNodeCreatorItems, } from '../composables/workflow'; -import { IF_NODE_NAME } from '../constants'; +import { AGENT_NODE_NAME, IF_NODE_NAME, MANUAL_CHAT_TRIGGER_NODE_NAME } from '../constants'; import { NodeCreator } from '../pages/features/node-creator'; import { NDV } from '../pages/ndv'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; @@ -536,7 +538,7 @@ describe('Node Creator', () => { }); }); - it('should add node directly for sub-connection', () => { + it('should add node directly for sub-connection as vector store', () => { addNodeToCanvas('Question and Answer Chain', true); addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain'); cy.realPress('Escape'); @@ -544,4 +546,12 @@ describe('Node Creator', () => { cy.realPress('Escape'); WorkflowPage.getters.canvasNodes().should('have.length', 4); }); + + it('should add node directly for sub-connection as tool', () => { + addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true); + addNodeToCanvas(AGENT_NODE_NAME, true, true); + clickGetBackToCanvas(); + + addVectorStoreToolToParent('In-Memory Vector Store', AGENT_NODE_NAME); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts index aaa2ca37d9..97113643f3 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolVectorStore/ToolVectorStore.node.ts @@ -15,15 +15,15 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields'; export class ToolVectorStore implements INodeType { description: INodeTypeDescription = { - displayName: 'Vector Store Tool', + displayName: 'Vector Store Question Answer Tool', name: 'toolVectorStore', icon: 'fa:database', iconColor: 'black', group: ['transform'], version: [1], - description: 'Retrieve context from vector store', + description: 'Answer questions with a vector store', defaults: { - name: 'Vector Store Tool', + name: 'Answer questions with a vector store', }, codex: { categories: ['AI'], @@ -60,20 +60,23 @@ export class ToolVectorStore implements INodeType { properties: [ getConnectionHintNoticeField([NodeConnectionType.AiAgent]), { - displayName: 'Name', + displayName: 'Data Name', name: 'name', type: 'string', default: '', - placeholder: 'e.g. company_knowledge_base', + placeholder: 'e.g. users_info', validateType: 'string-alphanumeric', - description: 'Name of the vector store', + description: + 'Name of the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.', }, { - displayName: 'Description', + displayName: 'Description of Data', name: 'description', type: 'string', default: '', - placeholder: 'Retrieves data about [insert information about your data here]...', + placeholder: "[Describe your data here, e.g. a user's name, email, etc.]", + description: + 'Describe the data in vector store. This will be used to fill this tool description: Useful for when you need to answer questions about [name]. Whenever you need information about [data description], you should ALWAYS use this. Input should be a fully formed question.', typeOptions: { rows: 3, }, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts index 6d5da1615b..d9d5ee611a 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts @@ -228,7 +228,7 @@ export class VectorStorePGVector extends createVectorStoreNode({ testedBy: 'postgresConnectionTest', }, ], - operationModes: ['load', 'insert', 'retrieve'], + operationModes: ['load', 'insert', 'retrieve', 'retrieve-as-tool'], }, sharedFields, insertFields, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts index 711425df55..5a11acea24 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts @@ -65,7 +65,7 @@ export class VectorStorePinecone extends createVectorStoreNode({ required: true, }, ], - operationModes: ['load', 'insert', 'retrieve', 'update'], + operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'], }, methods: { listSearch: { pineconeIndexSearch } }, retrieveFields, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts index b1b80fea5a..a462ff8cf6 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts @@ -55,7 +55,7 @@ export class VectorStoreSupabase extends createVectorStoreNode({ required: true, }, ], - operationModes: ['load', 'insert', 'retrieve', 'update'], + operationModes: ['load', 'insert', 'retrieve', 'update', 'retrieve-as-tool'], }, methods: { listSearch: { supabaseTableNameSearch }, diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.test.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.test.ts new file mode 100644 index 0000000000..26036dce80 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.test.ts @@ -0,0 +1,161 @@ +import type { DocumentInterface } from '@langchain/core/documents'; +import type { Embeddings } from '@langchain/core/embeddings'; +import type { VectorStore } from '@langchain/core/vectorstores'; +import { mock } from 'jest-mock-extended'; +import type { DynamicTool } from 'langchain/tools'; +import type { ISupplyDataFunctions, NodeParameterValueType } from 'n8n-workflow'; + +import type { VectorStoreNodeConstructorArgs } from './createVectorStoreNode'; +import { createVectorStoreNode } from './createVectorStoreNode'; + +jest.mock('@utils/logWrapper', () => ({ + logWrapper: jest.fn().mockImplementation((val: DynamicTool) => ({ logWrapped: val })), +})); + +const DEFAULT_PARAMETERS = { + options: {}, + topK: 1, +}; + +const MOCK_DOCUMENTS: Array<[DocumentInterface, number]> = [ + [ + { + pageContent: 'first page', + metadata: { + id: 123, + }, + }, + 0, + ], + [ + { + pageContent: 'second page', + metadata: { + id: 567, + }, + }, + 0, + ], +]; + +const MOCK_SEARCH_VALUE = 'search value'; +const MOCK_EMBEDDED_SEARCH_VALUE = [1, 2, 3]; + +describe('createVectorStoreNode', () => { + const vectorStore = mock({ + similaritySearchVectorWithScore: jest.fn().mockResolvedValue(MOCK_DOCUMENTS), + }); + + const vectorStoreNodeArgs = mock({ + sharedFields: [], + insertFields: [], + loadFields: [], + retrieveFields: [], + updateFields: [], + getVectorStoreClient: jest.fn().mockReturnValue(vectorStore), + }); + + const embeddings = mock({ + embedQuery: jest.fn().mockResolvedValue(MOCK_EMBEDDED_SEARCH_VALUE), + }); + + const context = mock({ + getNodeParameter: jest.fn(), + getInputConnectionData: jest.fn().mockReturnValue(embeddings), + }); + + describe('retrieve mode', () => { + it('supplies vector store as data', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve', + }; + context.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const data = await nodeType.supplyData.call(context, 1); + const wrappedVectorStore = (data.response as { logWrapped: VectorStore }).logWrapped; + + // ASSERT + expect(wrappedVectorStore).toEqual(vectorStore); + expect(vectorStoreNodeArgs.getVectorStoreClient).toHaveBeenCalled(); + }); + }); + + describe('retrieve-as-tool mode', () => { + it('supplies DynamicTool that queries vector store and returns documents with metadata', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + description: 'tool description', + toolName: 'tool name', + includeDocumentMetadata: true, + }; + context.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const data = await nodeType.supplyData.call(context, 1); + const tool = (data.response as { logWrapped: DynamicTool }).logWrapped; + const output = await tool?.func(MOCK_SEARCH_VALUE); + + // ASSERT + expect(tool?.getName()).toEqual(parameters.toolName); + expect(tool?.description).toEqual(parameters.toolDescription); + expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE); + expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + MOCK_EMBEDDED_SEARCH_VALUE, + parameters.topK, + parameters.filter, + ); + expect(output).toEqual([ + { type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[0][0]) }, + { type: 'text', text: JSON.stringify(MOCK_DOCUMENTS[1][0]) }, + ]); + }); + + it('supplies DynamicTool that queries vector store and returns documents without metadata', async () => { + // ARRANGE + const parameters: Record = { + ...DEFAULT_PARAMETERS, + mode: 'retrieve-as-tool', + description: 'tool description', + toolName: 'tool name', + includeDocumentMetadata: false, + }; + context.getNodeParameter.mockImplementation( + (parameterName: string): NodeParameterValueType | object => parameters[parameterName], + ); + + // ACT + const VectorStoreNodeType = createVectorStoreNode(vectorStoreNodeArgs); + const nodeType = new VectorStoreNodeType(); + const data = await nodeType.supplyData.call(context, 1); + const tool = (data.response as { logWrapped: DynamicTool }).logWrapped; + const output = await tool?.func(MOCK_SEARCH_VALUE); + + // ASSERT + expect(tool?.getName()).toEqual(parameters.toolName); + expect(tool?.description).toEqual(parameters.toolDescription); + expect(embeddings.embedQuery).toHaveBeenCalledWith(MOCK_SEARCH_VALUE); + expect(vectorStore.similaritySearchVectorWithScore).toHaveBeenCalledWith( + MOCK_EMBEDDED_SEARCH_VALUE, + parameters.topK, + parameters.filter, + ); + expect(output).toEqual([ + { type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[0][0].pageContent }) }, + { type: 'text', text: JSON.stringify({ pageContent: MOCK_DOCUMENTS[1][0].pageContent }) }, + ]); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts index 84f1d550e5..b7c0de3922 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts @@ -3,6 +3,7 @@ import type { Document } from '@langchain/core/documents'; import type { Embeddings } from '@langchain/core/embeddings'; import type { VectorStore } from '@langchain/core/vectorstores'; +import { DynamicTool } from 'langchain/tools'; import { NodeConnectionType, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, @@ -28,9 +29,14 @@ import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { processDocument } from './processDocuments'; -type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update'; +type NodeOperationMode = 'insert' | 'load' | 'retrieve' | 'update' | 'retrieve-as-tool'; -const DEFAULT_OPERATION_MODES: NodeOperationMode[] = ['load', 'insert', 'retrieve']; +const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [ + 'load', + 'insert', + 'retrieve', + 'retrieve-as-tool', +]; interface NodeMeta { displayName: string; @@ -43,7 +49,7 @@ interface NodeMeta { operationModes?: NodeOperationMode[]; } -interface VectorStoreNodeConstructorArgs { +export interface VectorStoreNodeConstructorArgs { meta: NodeMeta; methods?: { listSearch?: { @@ -102,10 +108,18 @@ function getOperationModeOptions(args: VectorStoreNodeConstructorArgs): INodePro action: 'Add documents to vector store', }, { - name: 'Retrieve Documents (For Agent/Chain)', + name: 'Retrieve Documents (As Vector Store for AI Agent)', value: 'retrieve', - description: 'Retrieve documents from vector store to be used with AI nodes', - action: 'Retrieve documents for AI processing', + description: 'Retrieve documents from vector store to be used as vector store with AI nodes', + action: 'Retrieve documents for AI processing as Vector Store', + outputConnectionType: NodeConnectionType.AiVectorStore, + }, + { + name: 'Retrieve Documents (As Tool for AI Agent)', + value: 'retrieve-as-tool', + description: 'Retrieve documents from vector store to be used as tool with AI nodes', + action: 'Retrieve documents for AI processing as Tool', + outputConnectionType: NodeConnectionType.AiTool, }, { name: 'Update Documents', @@ -136,7 +150,8 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => codex: { categories: ['AI'], subcategories: { - AI: ['Vector Stores', 'Root Nodes'], + AI: ['Vector Stores', 'Tools', 'Root Nodes'], + Tools: ['Other Tools'], }, resources: { primaryDocumentation: [ @@ -153,6 +168,10 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => const mode = parameters?.mode; const inputs = [{ displayName: "Embedding", type: "${NodeConnectionType.AiEmbedding}", required: true, maxConnections: 1}] + if (mode === 'retrieve-as-tool') { + return inputs; + } + if (['insert', 'load', 'update'].includes(mode)) { inputs.push({ displayName: "", type: "${NodeConnectionType.Main}"}) } @@ -166,6 +185,11 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => outputs: `={{ ((parameters) => { const mode = parameters?.mode ?? 'retrieve'; + + if (mode === 'retrieve-as-tool') { + return [{ displayName: "Tool", type: "${NodeConnectionType.AiTool}"}] + } + if (mode === 'retrieve') { return [{ displayName: "Vector Store", type: "${NodeConnectionType.AiVectorStore}"}] } @@ -189,6 +213,37 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => }, }, }, + { + displayName: 'Name', + name: 'toolName', + type: 'string', + default: '', + required: true, + description: 'Name of the vector store', + placeholder: 'e.g. company_knowledge_base', + validateType: 'string-alphanumeric', + displayOptions: { + show: { + mode: ['retrieve-as-tool'], + }, + }, + }, + { + displayName: 'Description', + name: 'toolDescription', + type: 'string', + default: '', + required: true, + typeOptions: { rows: 2 }, + description: + 'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often', + placeholder: `e.g. ${args.meta.description}`, + displayOptions: { + show: { + mode: ['retrieve-as-tool'], + }, + }, + }, ...args.sharedFields, ...transformDescriptionForOperationMode(args.insertFields ?? [], 'insert'), // Prompt and topK are always used for the load operation @@ -214,7 +269,19 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => description: 'Number of top results to fetch from vector store', displayOptions: { show: { - mode: ['load'], + mode: ['load', 'retrieve-as-tool'], + }, + }, + }, + { + displayName: 'Include Metadata', + name: 'includeDocumentMetadata', + type: 'boolean', + default: true, + description: 'Whether or not to include document metadata', + displayOptions: { + show: { + mode: ['load', 'retrieve-as-tool'], }, }, }, @@ -271,10 +338,16 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => filter, ); + const includeDocumentMetadata = this.getNodeParameter( + 'includeDocumentMetadata', + itemIndex, + true, + ) as boolean; + const serializedDocs = docs.map(([doc, score]) => { const document = { - metadata: doc.metadata, pageContent: doc.pageContent, + ...(includeDocumentMetadata ? { metadata: doc.metadata } : {}), }; return { @@ -381,12 +454,12 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => throw new NodeOperationError( this.getNode(), - 'Only the "load" and "insert" operation modes are supported with execute', + 'Only the "load", "update" and "insert" operation modes are supported with execute', ); } async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - const mode = this.getNodeParameter('mode', 0) as 'load' | 'insert' | 'retrieve'; + const mode = this.getNodeParameter('mode', 0) as NodeOperationMode; const filter = getMetadataFiltersValues(this, itemIndex); const embeddings = (await this.getInputConnectionData( NodeConnectionType.AiEmbedding, @@ -400,9 +473,54 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) => }; } + if (mode === 'retrieve-as-tool') { + const toolDescription = this.getNodeParameter('toolDescription', itemIndex) as string; + const toolName = this.getNodeParameter('toolName', itemIndex) as string; + const topK = this.getNodeParameter('topK', itemIndex, 4) as number; + const includeDocumentMetadata = this.getNodeParameter( + 'includeDocumentMetadata', + itemIndex, + true, + ) as boolean; + + const vectorStoreTool = new DynamicTool({ + name: toolName, + description: toolDescription, + func: async (input) => { + const vectorStore = await args.getVectorStoreClient( + this, + filter, + embeddings, + itemIndex, + ); + const embeddedPrompt = await embeddings.embedQuery(input); + const documents = await vectorStore.similaritySearchVectorWithScore( + embeddedPrompt, + topK, + filter, + ); + return documents + .map((document) => { + if (includeDocumentMetadata) { + return { type: 'text', text: JSON.stringify(document[0]) }; + } + return { + type: 'text', + text: JSON.stringify({ pageContent: document[0].pageContent }), + }; + }) + .filter((document) => !!document); + }, + }); + + return { + response: logWrapper(vectorStoreTool, this), + }; + } + throw new NodeOperationError( this.getNode(), - 'Only the "retrieve" operation mode is supported to supply data', + 'Only the "retrieve" and "retrieve-as-tool" operation mode is supported to supply data', ); } }; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 2b898f7b44..6cdd045703 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -720,6 +720,7 @@ export interface ActionTypeDescription extends SimplifiedNodeType { displayOptions?: IDisplayOptions; values?: IDataObject; actionKey: string; + outputConnectionType?: NodeConnectionType; codex: { label: string; categories: string[]; diff --git a/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue b/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue index b577669169..d7ed3d87f0 100644 --- a/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue +++ b/packages/editor-ui/src/components/Node/NodeCreator/Modes/NodesMode.vue @@ -1,7 +1,7 @@