From f8d7d7f8912d48ad30df2d5d06bf5c53fe288d55 Mon Sep 17 00:00:00 2001 From: Max Sanna Date: Fri, 29 May 2026 17:51:37 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=93=20fix:=20Skip=20Retention=20for=20Per?= =?UTF-8?q?sistent=20Agent=20Resource=20Files=20(#13394)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/services/Files/process.js | 23 +++- api/server/services/Files/process.spec.js | 121 ++++++++++++++++++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 6f2ee2b619..7ee1e78dcf 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -67,6 +67,17 @@ const createSanitizedUploadWrapper = (uploadFunction) => { }; }; +const isPersistentAgentResourceUpload = ({ messageAttachment, tool_resource }) => + !messageAttachment && !!tool_resource; + +const getAgentFileRetentionExpiry = async ({ req, messageAttachment, tool_resource }) => { + if (isPersistentAgentResourceUpload({ messageAttachment, tool_resource })) { + return {}; + } + + return await getRetentionExpiry(req); +}; + const isMissingStorageError = (err) => { const code = err?.code ?? err?.status ?? err?.statusCode ?? err?.response?.status; if ([404, '404', 'ENOENT', 'NoSuchKey', 'NotFound', 'ResourceNotFound'].includes(code)) { @@ -727,7 +738,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { `Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`, ); } - const retentionExpiry = await getRetentionExpiry(req); + const retentionExpiry = await getAgentFileRetentionExpiry({ + req, + messageAttachment, + tool_resource, + }); const fileInfo = { ...removeNullishValues({ text, @@ -925,7 +940,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { }); } - const retentionExpiry = await getRetentionExpiry(req); + const retentionExpiry = await getAgentFileRetentionExpiry({ + req, + messageAttachment, + tool_resource, + }); const fileInfo = { ...removeNullishValues({ user: req.user.id, diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 01cfb2f1e5..79ae2f7eef 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -21,6 +21,14 @@ jest.mock('librechat-data-provider', () => { return { ...actual, Providers: actual.Providers, + RetentionMode: actual.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' }, + documentParserMimeTypes: actual.documentParserMimeTypes ?? [ + /^application\/pdf$/, + /^application\/vnd\.openxmlformats-officedocument\./, + /^application\/vnd\.ms-excel$/, + /^application\/vnd\.oasis\.opendocument\./, + /^application\/(?:x-)?msexcel$/, + ], mergeFileConfig: jest.fn(), }; }); @@ -84,6 +92,16 @@ jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(), })); +jest.mock('./VectorDB/crud', () => ({ + uploadVectors: jest.fn().mockResolvedValue({ + bytes: 42, + filename: 'upload.bin', + filepath: 'vectordb', + embedded: true, + }), + deleteVectors: jest.fn(), +})); + jest.mock('~/server/utils', () => ({ determineFileType: jest.fn(), })); @@ -107,6 +125,7 @@ const { const { mergeFileConfig } = require('librechat-data-provider'); const { checkCapability } = require('~/server/services/Config'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { uploadVectors } = require('./VectorDB/crud'); const db = require('~/models'); const { processAgentFileUpload, @@ -159,6 +178,17 @@ const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({ text: { supportedMimeTypes: [] }, }); +const setupStoredFileUpload = (result = {}) => { + const handleFileUpload = jest.fn().mockResolvedValue({ + bytes: 42, + filename: 'upload.bin', + filepath: '/uploads/upload.bin', + ...result, + }); + getStrategyFunctions.mockReturnValue({ handleFileUpload }); + return handleFileUpload; +}; + describe('processAgentFileUpload', () => { beforeEach(() => { jest.clearAllMocks(); @@ -382,6 +412,71 @@ describe('processAgentFileUpload', () => { }); }); + describe('retention for agent resource uploads', () => { + test('does not apply retention metadata to persistent agent context files', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.context, + }), + ); + }); + + test('applies retention metadata to context files uploaded as message attachments', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { ...makeMetadata(), message_file: true }, + }); + + expect(getRetentionExpiry).toHaveBeenCalledTimes(1); + expect(getRetentionExpiry.mock.calls[0][0]).toBe(req); + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt, + context: FileContext.message_attachment, + }), + true, + ); + expect(db.addAgentResourceFile).not.toHaveBeenCalled(); + }); + + test('does not apply retention metadata to persistent agent file-search files', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + setupStoredFileUpload(); + const req = makeReq({ mimetype: 'text/plain', ocrConfig: null }); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { ...makeMetadata(), tool_resource: EToolResources.file_search }, + }); + + expect(uploadVectors).toHaveBeenCalled(); + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.file_search, + }), + ); + }); + }); + /* Phase C / option α regression: the upload must persist its sandbox * pointer under `metadata.codeEnvRef` (the post-cutover schema). The * legacy `metadata.fileIdentifier` key is silently stripped by mongoose @@ -485,6 +580,32 @@ describe('processAgentFileUpload', () => { ); }); + it('does not apply retention metadata to persistent agent execute_code files', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + setupCodeEnvUpload({ storage_session_id: 'sess-4', file_id: 'fid-4' }); + const req = makeReq(); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { + agent_id: 'agent-abc', + tool_resource: EToolResources.execute_code, + file_id: 'file-uuid', + }, + }); + + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.execute_code, + }), + ); + }); + it('does not persist legacy fileIdentifier key (mongoose strict drops it)', async () => { setupCodeEnvUpload({ storage_session_id: 'sess-3', file_id: 'fid-3' }); const req = makeReq();