diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 6379a9ff6ff..1f514a66641 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -2354,6 +2354,22 @@ export const KnowledgeBase: ToolCatalogEntry = { description: 'Document IDs (for batch delete_document)', items: { type: 'string' }, }, + documentTags: { + type: 'array', + description: + 'Tag values to persist on a document (optional for update_document). Use tag display names from list_tags.', + items: { + type: 'object', + properties: { + tagName: { + type: 'string', + description: 'Tag display name as returned by list_tags', + }, + value: { type: ['string', 'number', 'boolean'], description: 'Typed tag value' }, + }, + required: ['tagName', 'value'], + }, + }, enabled: { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', @@ -2409,6 +2425,42 @@ export const KnowledgeBase: ToolCatalogEntry = { 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', enum: ['text', 'number', 'date', 'boolean'], }, + tagFilters: { + type: 'array', + description: 'Tag filters applied to the query. Use tag display names from list_tags.', + items: { + type: 'object', + properties: { + operator: { + type: 'string', + description: 'Comparison operator (defaults to eq)', + enum: [ + 'eq', + 'neq', + 'contains', + 'not_contains', + 'starts_with', + 'ends_with', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + ], + }, + tagName: { + type: 'string', + description: 'Tag display name as returned by list_tags', + }, + tagValue: { type: ['string', 'number', 'boolean'], description: 'Typed tag value' }, + valueTo: { + type: ['string', 'number'], + description: 'Upper bound required by the between operator', + }, + }, + required: ['tagName', 'tagValue'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', @@ -3719,6 +3771,42 @@ export const SearchKnowledgeBase: ToolCatalogEntry = { description: 'Knowledge base ID (required for all operations)', }, query: { type: 'string', description: "Search query text (required for 'query')" }, + tagFilters: { + type: 'array', + description: 'Tag filters applied to the query. Use tag display names from list_tags.', + items: { + type: 'object', + properties: { + operator: { + type: 'string', + description: 'Comparison operator (defaults to eq)', + enum: [ + 'eq', + 'neq', + 'contains', + 'not_contains', + 'starts_with', + 'ends_with', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + ], + }, + tagName: { + type: 'string', + description: 'Tag display name as returned by list_tags', + }, + tagValue: { type: ['string', 'number', 'boolean'], description: 'Typed tag value' }, + valueTo: { + type: ['string', 'number'], + description: 'Upper bound required by the between operator', + }, + }, + required: ['tagName', 'tagValue'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 95caf84ac66..24505c4a73d 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2159,6 +2159,25 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', }, }, + documentTags: { + type: 'array', + description: + 'Tag values to persist on a document (optional for update_document). Use tag display names from list_tags.', + items: { + type: 'object', + properties: { + tagName: { + type: 'string', + description: 'Tag display name as returned by list_tags', + }, + value: { + type: ['string', 'number', 'boolean'], + description: 'Typed tag value', + }, + }, + required: ['tagName', 'value'], + }, + }, enabled: { type: 'boolean', description: 'Enable/disable a document (optional for update_document)', @@ -2221,6 +2240,46 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'Field type: text, number, date, boolean (optional for create_tag, defaults to text)', enum: ['text', 'number', 'date', 'boolean'], }, + tagFilters: { + type: 'array', + description: + 'Tag filters applied to the query. Use tag display names from list_tags.', + items: { + type: 'object', + properties: { + operator: { + type: 'string', + description: 'Comparison operator (defaults to eq)', + enum: [ + 'eq', + 'neq', + 'contains', + 'not_contains', + 'starts_with', + 'ends_with', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + ], + }, + tagName: { + type: 'string', + description: 'Tag display name as returned by list_tags', + }, + tagValue: { + type: ['string', 'number', 'boolean'], + description: 'Typed tag value', + }, + valueTo: { + type: ['string', 'number'], + description: 'Upper bound required by the between operator', + }, + }, + required: ['tagName', 'tagValue'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', @@ -3501,6 +3560,46 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'string', description: "Search query text (required for 'query')", }, + tagFilters: { + type: 'array', + description: + 'Tag filters applied to the query. Use tag display names from list_tags.', + items: { + type: 'object', + properties: { + operator: { + type: 'string', + description: 'Comparison operator (defaults to eq)', + enum: [ + 'eq', + 'neq', + 'contains', + 'not_contains', + 'starts_with', + 'ends_with', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + ], + }, + tagName: { + type: 'string', + description: 'Tag display name as returned by list_tags', + }, + tagValue: { + type: ['string', 'number', 'boolean'], + description: 'Typed tag value', + }, + valueTo: { + type: ['string', 'number'], + description: 'Upper bound required by the between operator', + }, + }, + required: ['tagName', 'tagValue'], + }, + }, topK: { type: 'number', description: 'Number of results to return (1-50, default: 5)', diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts index 7055cee52f6..086696738f4 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts @@ -95,6 +95,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ })) vi.mock('@/app/api/knowledge/search/utils', () => ({ getQueryStrategy: vi.fn(), + handleTagAndVectorSearch: vi.fn(), handleVectorOnlySearch: vi.fn(), })) vi.mock('@/app/api/knowledge/utils', () => ({ @@ -103,7 +104,31 @@ vi.mock('@/app/api/knowledge/utils', () => ({ checkKnowledgeBaseWriteAccess: mockCheckKnowledgeBaseWriteAccess, })) +import { checkAttributedUsageLimits } from '@/lib/billing/core/billing-attribution' import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' +import { updateDocument } from '@/lib/knowledge/documents/service' +import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import { getDocumentTagDefinitions, getTagUsageStats } from '@/lib/knowledge/tags/service' +import { + getQueryStrategy, + handleTagAndVectorSearch, + handleVectorOnlySearch, +} from '@/app/api/knowledge/search/utils' +import { checkDocumentWriteAccess, checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' + +const mockCheckAttributedUsageLimits = vi.mocked(checkAttributedUsageLimits) +const mockCheckDocumentWriteAccess = vi.mocked(checkDocumentWriteAccess) +const mockCheckKnowledgeBaseAccess = vi.mocked(checkKnowledgeBaseAccess) +const mockGenerateSearchEmbedding = vi.mocked(generateSearchEmbedding) +const mockGetDocumentTagDefinitions = vi.mocked(getDocumentTagDefinitions) +const mockGetKnowledgeBaseById = vi.mocked(getKnowledgeBaseById) +const mockGetQueryStrategy = vi.mocked(getQueryStrategy) +const mockGetTagUsageStats = vi.mocked(getTagUsageStats) +const mockHandleTagAndVectorSearch = vi.mocked(handleTagAndVectorSearch) +const mockHandleVectorOnlySearch = vi.mocked(handleVectorOnlySearch) +const mockRecordSearchEmbeddingUsage = vi.mocked(recordSearchEmbeddingUsage) +const mockUpdateDocument = vi.mocked(updateDocument) const BILLING_ATTRIBUTION = { actorUserId: 'external-admin', @@ -118,7 +143,7 @@ const BILLING_ATTRIBUTION = { payerSubscription: null, } -describe('knowledge base connector Copilot operations', () => { +describe('knowledge base Copilot operations', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('fetch', mockFetch) @@ -191,4 +216,272 @@ describe('knowledge base connector Copilot operations', () => { expect(mockSerializeBillingAttributionHeader).toHaveBeenCalledWith(BILLING_ATTRIBUTION) } ) + + it('persists document tags by resolving display names to storage slots', async () => { + mockCheckDocumentWriteAccess.mockResolvedValue({ hasAccess: true } as never) + mockGetDocumentTagDefinitions.mockResolvedValue([ + { + id: 'tag-definition-1', + knowledgeBaseId: 'knowledge-base-1', + tagSlot: 'tag1', + displayName: 'identity_key', + fieldType: 'text', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + }, + ]) + mockUpdateDocument.mockResolvedValue({} as never) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_document', + args: { + knowledgeBaseId: 'knowledge-base-1', + documentId: 'document-1', + documentTags: [ + { + tagName: 'identity_key', + value: 'dana@example.com', + }, + ], + }, + }, + { userId: 'user-1', workspaceId: 'workspace-paid' } + ) + + expect(result).toMatchObject({ + success: true, + data: { + documentId: 'document-1', + tags: { identity_key: 'dana@example.com' }, + }, + }) + expect(mockUpdateDocument).toHaveBeenCalledWith( + 'document-1', + { tag1: 'dana@example.com' }, + expect.any(String) + ) + }) + + it.each([ + ['an empty array', []], + ['null', null], + ['only blank entries', [{ tagName: ' ', tagValue: ' ' }]], + ])('applies other document updates when documentTags contains %s', async (_label, tags) => { + mockCheckDocumentWriteAccess.mockResolvedValue({ hasAccess: true } as never) + mockUpdateDocument.mockResolvedValue({} as never) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_document', + args: { + knowledgeBaseId: 'knowledge-base-1', + documentId: 'document-1', + filename: 'renamed.txt', + enabled: false, + documentTags: tags, + }, + }, + { userId: 'user-1', workspaceId: 'workspace-paid' } + ) + + expect(result).toMatchObject({ + success: true, + data: { + documentId: 'document-1', + filename: 'renamed.txt', + enabled: false, + }, + }) + expect(mockGetDocumentTagDefinitions).not.toHaveBeenCalled() + expect(mockUpdateDocument).toHaveBeenCalledWith( + 'document-1', + { filename: 'renamed.txt', enabled: false }, + expect.any(String) + ) + }) + + it('rejects update_document when empty documentTags is the only supplied update', async () => { + mockCheckDocumentWriteAccess.mockResolvedValue({ hasAccess: true } as never) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'update_document', + args: { + knowledgeBaseId: 'knowledge-base-1', + documentId: 'document-1', + documentTags: [], + }, + }, + { userId: 'user-1', workspaceId: 'workspace-paid' } + ) + + expect(result).toEqual({ + success: false, + message: 'At least one of filename, enabled, or documentTags is required for update_document', + }) + expect(mockUpdateDocument).not.toHaveBeenCalled() + }) + + it('applies tag filters to semantic queries', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true } as never) + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'User Memory', + workspaceId: 'workspace-paid', + embeddingModel: 'text-embedding-3-small', + } as never) + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false } as never) + mockGenerateSearchEmbedding.mockResolvedValue({ + embedding: [0.1, 0.2], + isBYOK: false, + } as never) + mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 1 } as never) + mockGetDocumentTagDefinitions.mockResolvedValue([ + { + id: 'tag-definition-1', + knowledgeBaseId: 'knowledge-base-1', + tagSlot: 'tag1', + displayName: 'identity_key', + fieldType: 'text', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + }, + ]) + mockHandleTagAndVectorSearch.mockResolvedValue([ + { + documentId: 'document-1', + content: 'Dana memory', + chunkIndex: 0, + distance: 0.1, + } as never, + ]) + mockRecordSearchEmbeddingUsage.mockResolvedValue(undefined) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'query', + args: { + knowledgeBaseId: 'knowledge-base-1', + query: 'memory', + tagFilters: [{ tagName: 'identity_key', tagValue: 'dana@example.com' }], + }, + }, + { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + } + ) + + expect(result.success).toBe(true) + expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({ + knowledgeBaseIds: ['knowledge-base-1'], + topK: 5, + structuredFilters: [ + { + tagSlot: 'tag1', + fieldType: 'text', + operator: 'eq', + value: 'dana@example.com', + valueTo: undefined, + }, + ], + queryVector: JSON.stringify([0.1, 0.2]), + distanceThreshold: 1, + }) + expect(mockHandleVectorOnlySearch).not.toHaveBeenCalled() + }) + + it.each([ + ['an empty array', []], + ['null', null], + ['only blank entries', [{ tagName: ' ', tagValue: ' ' }]], + ])('uses vector-only search when tagFilters contains %s', async (_label, filters) => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true } as never) + mockGetKnowledgeBaseById.mockResolvedValue({ + id: 'knowledge-base-1', + name: 'User Memory', + workspaceId: 'workspace-paid', + embeddingModel: 'text-embedding-3-small', + } as never) + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false } as never) + mockGenerateSearchEmbedding.mockResolvedValue({ + embedding: [0.1, 0.2], + isBYOK: false, + } as never) + mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 1 } as never) + mockHandleVectorOnlySearch.mockResolvedValue([]) + mockRecordSearchEmbeddingUsage.mockResolvedValue(undefined) + + const result = await knowledgeBaseServerTool.execute( + { + operation: 'query', + args: { + knowledgeBaseId: 'knowledge-base-1', + query: 'memory', + tagFilters: filters, + }, + }, + { + userId: 'external-admin', + workspaceId: 'workspace-paid', + billingAttribution: BILLING_ATTRIBUTION, + } + ) + + expect(result.success).toBe(true) + expect(mockGetDocumentTagDefinitions).not.toHaveBeenCalled() + expect(mockHandleTagAndVectorSearch).not.toHaveBeenCalled() + expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({ + knowledgeBaseIds: ['knowledge-base-1'], + topK: 5, + queryVector: JSON.stringify([0.1, 0.2]), + distanceThreshold: 1, + }) + }) + + it('wraps tag definitions in an object-shaped result payload', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true } as never) + mockGetDocumentTagDefinitions.mockResolvedValue([ + { + id: 'tag-definition-1', + knowledgeBaseId: 'knowledge-base-1', + tagSlot: 'tag1', + displayName: 'identity_key', + fieldType: 'text', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + updatedAt: new Date('2026-07-01T00:00:00.000Z'), + }, + ]) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'list_tags', args: { knowledgeBaseId: 'knowledge-base-1' } }, + { userId: 'user-1' } + ) + + expect(result.data).toEqual({ + tags: [ + { + id: 'tag-definition-1', + tagSlot: 'tag1', + displayName: 'identity_key', + fieldType: 'text', + createdAt: new Date('2026-07-01T00:00:00.000Z'), + }, + ], + }) + }) + + it('wraps tag usage in an object-shaped result payload', async () => { + mockCheckKnowledgeBaseAccess.mockResolvedValue({ hasAccess: true } as never) + mockGetTagUsageStats.mockResolvedValue([{ tagSlot: 'tag1', documentCount: 1 }] as never) + + const result = await knowledgeBaseServerTool.execute( + { operation: 'get_tag_usage', args: { knowledgeBaseId: 'knowledge-base-1' } }, + { userId: 'user-1' } + ) + + expect(result.data).toEqual({ usage: [{ tagSlot: 'tag1', documentCount: 1 }] }) + }) }) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index aececd01078..d4feb180120 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -33,6 +33,7 @@ import { getConfiguredEmbeddingModel, recordSearchEmbeddingUsage, } from '@/lib/knowledge/embeddings' +import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' import { createKnowledgeBase, deleteKnowledgeBase, @@ -48,14 +49,21 @@ import { getTagUsageStats, updateTagDefinition, } from '@/lib/knowledge/tags/service' +import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils' +import type { StructuredFilter, TagDefinition } from '@/lib/knowledge/types' import { StorageService } from '@/lib/uploads' import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils' +import { + getQueryStrategy, + handleTagAndVectorSearch, + handleVectorOnlySearch, +} from '@/app/api/knowledge/search/utils' import { checkDocumentWriteAccess, checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess, } from '@/app/api/knowledge/utils' +import { parseDocumentTags, parseTagFilters } from '@/tools/shared/tags' const logger = createLogger('KnowledgeBaseServerTool') @@ -244,6 +252,14 @@ export const knowledgeBaseServerTool: BaseServerTool 0 ? await getDocumentTagDefinitions(args.knowledgeBaseId) : [] + const structuredFilters = resolveStructuredTagFilters(parsedTagFilters, tagDefinitions) + if (!structuredFilters.success) { + return structuredFilters.result + } + const topK = args.topK || 5 const billingAttribution = kb.workspaceId @@ -266,12 +282,19 @@ export const knowledgeBaseServerTool: BaseServerTool 0 + ? await handleTagAndVectorSearch({ + ...searchParams, + structuredFilters: structuredFilters.filters, + }) + : await handleVectorOnlySearch(searchParams) await recordSearchEmbeddingUsage({ userId: context.userId, @@ -580,28 +603,66 @@ export const knowledgeBaseServerTool: BaseServerTool[1] = {} if (args.filename !== undefined) { updateData.filename = args.filename } if (args.enabled !== undefined) { updateData.enabled = args.enabled } - if (Object.keys(updateData).length === 0) { - return { - success: false, - message: 'At least one of filename or enabled is required for update_document', + const parsedDocumentTags = parseDocumentTags(args.documentTags) + const updatedTags: Record = {} + if (parsedDocumentTags.length > 0) { + const tagDefinitions = await getDocumentTagDefinitions(args.knowledgeBaseId) + const tagDefinitionsByName = new Map( + tagDefinitions.map((definition) => [definition.displayName, definition]) + ) + const undefinedTags: string[] = [] + const typeErrors: string[] = [] + + for (const tag of parsedDocumentTags) { + const definition = tagDefinitionsByName.get(tag.tagName) + if (!definition) { + undefinedTags.push(tag.tagName) + continue + } + const validationError = validateTagValue(tag.tagName, tag.value, definition.fieldType) + if (validationError) { + typeErrors.push(validationError) + continue + } + + ;(updateData as Record)[definition.tagSlot] = + tag.value + updatedTags[tag.tagName] = tag.value + } + + if (undefinedTags.length > 0 || typeErrors.length > 0) { + return { + success: false, + message: [ + ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), + ...typeErrors, + ].join('\n'), + } } } - const docAccess = await checkDocumentWriteAccess( - args.knowledgeBaseId, - args.documentId, - context.userId - ) - if (!docAccess.hasAccess) { + if (Object.keys(updateData).length === 0) { return { success: false, - message: `Document with ID "${args.documentId}" not found`, + message: + 'At least one of filename, enabled, or documentTags is required for update_document', } } const requestId = generateId().slice(0, 8) @@ -613,7 +674,9 @@ export const knowledgeBaseServerTool: BaseServerTool 0 && { tags: updatedTags }), }, } } @@ -645,13 +708,15 @@ export const knowledgeBaseServerTool: BaseServerTool ({ - id: td.id, - tagSlot: td.tagSlot, - displayName: td.displayName, - fieldType: td.fieldType, - createdAt: td.createdAt, - })), + data: { + tags: tagDefinitions.map((td) => ({ + id: td.id, + tagSlot: td.tagSlot, + displayName: td.displayName, + fieldType: td.fieldType, + createdAt: td.createdAt, + })), + }, } } @@ -856,7 +921,7 @@ export const knowledgeBaseServerTool: BaseServerTool [definition.displayName, definition]) + ) + const undefinedTags: string[] = [] + const validationErrors: string[] = [] + const resolvedFilters: StructuredFilter[] = [] + + for (const filter of filters) { + const definition = definitionsByName.get(filter.tagName ?? '') + if (!definition) { + undefinedTags.push(filter.tagName ?? '') + continue + } + if (!isFilterFieldType(definition.fieldType)) { + validationErrors.push( + `Tag "${definition.displayName}" has unsupported field type "${definition.fieldType}"` + ) + continue + } + + const validOperators = getOperatorsForFieldType(definition.fieldType).map( + (operator) => operator.value + ) + if (!validOperators.includes(filter.operator)) { + validationErrors.push( + `Tag "${definition.displayName}" does not support operator "${filter.operator}"` + ) + continue + } + + const valueError = validateTagValue( + definition.displayName, + String(filter.value), + definition.fieldType + ) + if (valueError) { + validationErrors.push(valueError) + continue + } + + if (filter.operator === 'between') { + if (filter.valueTo === undefined) { + validationErrors.push( + `Tag "${definition.displayName}" requires valueTo for the "between" operator` + ) + continue + } + const valueToError = validateTagValue( + definition.displayName, + String(filter.valueTo), + definition.fieldType + ) + if (valueToError) { + validationErrors.push(valueToError) + continue + } + } + + resolvedFilters.push({ + tagSlot: definition.tagSlot, + fieldType: definition.fieldType, + operator: filter.operator, + value: filter.value, + valueTo: filter.valueTo, + }) + } + + if (undefinedTags.length > 0 || validationErrors.length > 0) { + return { + success: false, + result: { + success: false, + message: [ + ...(undefinedTags.length > 0 ? [buildUndefinedTagsError(undefinedTags)] : []), + ...validationErrors, + ].join('\n'), + }, + } + } + + return { success: true, filters: resolvedFilters } +} + +function isFilterFieldType(fieldType: string): fieldType is FilterFieldType { + return ['text', 'number', 'date', 'boolean'].includes(fieldType) +} diff --git a/apps/sim/tools/knowledge/knowledge.test.ts b/apps/sim/tools/knowledge/knowledge.test.ts index 1dd0f287711..89fa65503bc 100644 --- a/apps/sim/tools/knowledge/knowledge.test.ts +++ b/apps/sim/tools/knowledge/knowledge.test.ts @@ -8,8 +8,10 @@ */ import { describe, expect, it } from 'vitest' +import { knowledgeCreateDocumentTool } from '@/tools/knowledge/create_document' import { knowledgeSearchTool } from '@/tools/knowledge/search' import { knowledgeUploadChunkTool } from '@/tools/knowledge/upload_chunk' +import { parseDocumentTags } from '@/tools/shared/tags' /** * Creates a mock Response object for testing transformResponse @@ -23,6 +25,45 @@ function createMockResponse(data: unknown): Response { } describe('Knowledge Tools', () => { + describe('parseDocumentTags', () => { + it('normalizes agent-authored tagValue entries for document creation', () => { + expect( + parseDocumentTags([ + { + id: 'tag-definition-1', + tagName: 'identity_key', + tagValue: 'dana@example.com', + }, + ]) + ).toEqual([{ tagName: 'identity_key', value: 'dana@example.com' }]) + }) + + it('serializes agent-authored tags into the create-document API payload', () => { + const body = knowledgeCreateDocumentTool.request.body?.({ + knowledgeBaseId: 'knowledge-base-1', + name: 'memory.txt', + content: 'Dana memory', + documentTags: [ + { + id: 'tag-definition-1', + tagName: 'identity_key', + tagValue: 'dana@example.com', + }, + ], + }) + + expect(body).toMatchObject({ + documents: [ + { + documentTagsData: JSON.stringify([ + { tagName: 'identity_key', value: 'dana@example.com' }, + ]), + }, + ], + }) + }) + }) + describe('knowledgeSearchTool', () => { describe('transformResponse', () => { it('should restructure cost information for logging', async () => { diff --git a/apps/sim/tools/shared/tags.ts b/apps/sim/tools/shared/tags.ts index 35faeb9995b..5b4d49a2e5a 100644 --- a/apps/sim/tools/shared/tags.ts +++ b/apps/sim/tools/shared/tags.ts @@ -70,12 +70,13 @@ function filterValidDocumentTags(tags: unknown[]): DocumentTagEntry[] { if (typeof entry !== 'object' || entry === null) return false const e = entry as Record if (!e.tagName || (typeof e.tagName === 'string' && e.tagName.trim() === '')) return false - if (e.value === undefined || e.value === null || e.value === '') return false + const tagValue = e.value ?? e.tagValue + if (tagValue === undefined || tagValue === null || tagValue === '') return false return true }) .map((entry) => ({ tagName: String(entry.tagName), - value: String(entry.value), + value: String(entry.value ?? entry.tagValue), })) }