From 181d705579b2a34d6af80501113e7a538deb67e2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 22 Apr 2026 11:35:48 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B9=20fix:=20Clean=20Up=20Orphaned=20A?= =?UTF-8?q?gent=20File=20Stubs=20After=20Deletion=20(#12781)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿงน fix: Prune Orphaned File References on File Deletion Deleting a file via the Manage Files tab left its file_id in every agent's tool_resources.*.file_ids. Stubs accumulate until the frontend dedupe keys them as duplicates and blocks all new uploads (issue #12776). - Add removeAgentResourceFilesFromAllAgents in packages/data-schemas: a single updateMany/$pullAll across every EToolResources category. - Invoke it from processDeleteRequest after db.deleteFiles so every referencing agent is cleaned up, not just the one passed in req.body. - Wrap the cleanup in try/catch so a stale agent update cannot mask a successful file deletion. * ๐Ÿงผ fix: Prune Orphaned File References on Agent Update Already-affected agents would stay broken even after the delete-time fix: the stubs sit on the agent document until something strips them. Heal them on the next save (issue #12776). - Add collectToolResourceFileIds + stripFileIdsFromToolResources helpers in @librechat/api โ€” centralizing the tool_resources traversal used by the controller and the follow-up migration script. - In updateAgentHandler, check the effective tool_resources against the files collection. When orphans are found, either strip them from the incoming tool_resources (if the update sets them) or run the bulk cleanup (if the update leaves tool_resources untouched). * ๐Ÿงฐ chore: Add Migration to Clean Up Orphaned Agent File References Complements the delete-time and save-time fixes by healing agents that already accumulated orphan stubs before the upgrade (issue #12776). The script is idempotent โ€” re-running it on a clean database is a no-op. - Add config/migrate-orphaned-agent-files.js following the existing migrate-*.js convention: --dry-run by default omitted (writes by default) and --batch-size= tuning knob. Streams agents via cursor. - Register migrate:orphaned-agent-files and :dry-run npm scripts. - Reuse collectToolResourceFileIds from @librechat/api so migration and runtime share the same traversal logic. * ๐Ÿฉน fix: Address Codex/Copilot Review on Orphaned Agent File Cleanup Refines the #12776 fix series based on automated review feedback. - Scope save-time pruning to the current agent only. When a PATCH carries tool_resources, strip orphans from the incoming payload and pay the DB round-trip only then. Removes the collection-wide updateMany previously triggered when tool_resources was absent (Codex P2 / Copilot). - Wrap the orphan check in try/catch so a transient db.getFiles failure can't turn a good save into a 500 (comprehensive review #1). - Replace Object.values(EToolResources) casts with an explicit list of agent-side categories in both orphans.ts and agent.ts. code_interpreter belongs to the Assistants API and isn't a key of AgentToolResources โ€” including it was a type lie and generated dead MongoDB clauses (comprehensive review #3, #8). - Export TOOL_RESOURCE_KEYS from @librechat/api and consume it in the migration script, dropping one duplicated definition (#4). - Cap migration results.details at 50 sample entries so the memory footprint stays bounded on deployments with thousands of corrupted agents (Codex P3). - Add migrate:orphaned-agent-files:batch npm script to match the convention set by migrate-agent-permissions / migrate-prompt-permissions (#7). - Add controller-level tests covering the three orphan-pruning paths: strip from incoming tool_resources, leave alone when tool_resources is absent, swallow db.getFiles errors and still save (#6). - Back pre-existing "should validate tool_resources in updates" test's file_ids with real File docs โ€” the new pruning would otherwise strip them, and that test is about OCR conversion / schema filtering, not file existence. Register the File model in beforeAll so the fixture works. * ๐Ÿฉน fix: Tighten TOOL_RESOURCE_KEYS Type and Align Migration Sample Output Two follow-ups from the second review pass. - Type data-schemas' TOOL_RESOURCE_KEYS as ReadonlyArray instead of readonly string[]. Data-schemas depends on data-provider, so the import is clean. Catches typos and aligns with the matching export in @librechat/api โ€” doesn't guarantee exhaustiveness, but that's a TypeScript limitation, not a workspace one. - Align the migration's console output with DETAIL_SAMPLE_LIMIT: print every collected detail (up to 50) and, when more agents were affected than the sample size allowed, show a truncation notice. The old hard cap of 25 meant affected agents in the 26-50 range were collected but never shown. * โœ… test: Add Integration Coverage for Orphan Cleanup Paths (#12776) Exercise the delete-time and migration paths end-to-end against a real in-memory Mongo. Catches integration bugs the isolated unit tests on each layer couldn't. - api/server/services/Files/process.integration.spec.js โ€” the primary repro: seed an Agent + File, call processDeleteRequest, assert the file_id disappears from every referencing agent's tool_resources while unrelated agents stay untouched. Also covers the no-op case and confirms a failure in the new cleanup step cannot roll back the file deletion itself. - api/test/migrate-orphaned-agent-files.spec.js โ€” drives the migration module: --dry-run reports without writing, apply mode prunes across every tool_resource category, re-running is idempotent, and DETAIL_SAMPLE_LIMIT caps the in-memory sample on wide corruption. Mocks only the connect helper (the spec owns the mongoose instance) so the real migration code path โ€” cursor, $pullAll, reduce โ€” runs. * ๐Ÿ”’ fix: Run Orphan Cleanup Migration in System Tenant Context Codex P2 catch: under TENANT_ISOLATION_STRICT=true, the migration throws on the very first Agent.countDocuments() because the tenant isolation plugin fail-closes on queries without tenant context โ€” which makes migrate:orphaned-agent-files unusable on the exact deployments most likely to have accumulated corruption. - Wrap the scan/prune body in runAsSystem so queries bypass the tenant filter (SYSTEM_TENANT_ID sentinel). The migration legitimately needs cross-tenant visibility โ€” this is the same pattern seedDatabase and the S3 refresh job already use. - Add a regression test that spies on Agent.countDocuments() and asserts the active tenantStorage context is SYSTEM_TENANT_ID during the call. Pins the wrap against future regressions without the brittleness of toggling the strict-mode env var (which caches on first read). Note: the delete-time and save-time paths already run inside an authenticated HTTP request where tenantStorage.run is set by auth middleware, so the cleanup naturally scopes to the current tenant โ€” which is the correct behavior there since file ownership is tenant-scoped. * ๐Ÿงน chore: Drop Unused path Import From Process Integration Spec Leftover from an earlier iteration that resolved the migration path via path.resolve before I switched to a relative require. The import does nothing now โ€” removing it. --- api/server/controllers/agents/v1.js | 34 +++ api/server/controllers/agents/v1.spec.js | 109 +++++++++- .../Files/process.integration.spec.js | 203 ++++++++++++++++++ api/server/services/Files/process.js | 8 + api/test/migrate-orphaned-agent-files.spec.js | 184 ++++++++++++++++ config/migrate-orphaned-agent-files.js | 160 ++++++++++++++ package.json | 5 +- packages/api/src/agents/index.ts | 1 + packages/api/src/agents/orphans.spec.ts | 53 +++++ packages/api/src/agents/orphans.ts | 70 ++++++ .../data-schemas/src/methods/agent.spec.ts | 105 +++++++++ packages/data-schemas/src/methods/agent.ts | 49 ++++- 12 files changed, 978 insertions(+), 3 deletions(-) create mode 100644 api/server/services/Files/process.integration.spec.js create mode 100644 api/test/migrate-orphaned-agent-files.spec.js create mode 100644 config/migrate-orphaned-agent-files.js create mode 100644 packages/api/src/agents/orphans.spec.ts create mode 100644 packages/api/src/agents/orphans.ts diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index e365b232e4..5bddb9aac3 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -10,7 +10,9 @@ const { collectEdgeAgentIds, mergeAgentOcrConversion, MAX_AVATAR_REFRESH_AGENTS, + collectToolResourceFileIds, convertOcrToContextInPlace, + stripFileIdsFromToolResources, } = require('@librechat/api'); const { Time, @@ -387,6 +389,38 @@ const updateAgentHandler = async (req, res) => { updateData.tools = ocrConversion.tools; } + /* + * Strip orphaned file_id stubs from the incoming payload (see issue #12776). + * Scoped to updates that actually touch tool_resources: if the save does not + * modify that field, the delete-time cleanup in processDeleteRequest and the + * one-off migration already cover pre-existing corruption, so there's no + * reason to pay an extra DB round-trip here. Wrapped in try/catch so a + * transient failure in this integrity check never turns a good save into 500. + */ + if (updateData.tool_resources) { + try { + const referencedFileIds = collectToolResourceFileIds(updateData.tool_resources); + if (referencedFileIds.length > 0) { + const existingFiles = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, { + file_id: 1, + }); + const existingIds = new Set((existingFiles ?? []).map((f) => f.file_id)); + const orphans = referencedFileIds.filter((id) => !existingIds.has(id)); + if (orphans.length > 0) { + logger.warn( + `[/Agents/:id] Pruning ${orphans.length} orphaned file reference(s) from agent ${id}`, + ); + stripFileIdsFromToolResources(updateData.tool_resources, orphans); + } + } + } catch (orphanCheckError) { + logger.warn( + '[/Agents/:id] Orphan file check failed, skipping cleanup for this request', + orphanCheckError, + ); + } + } + if (updateData.tools) { const existingToolSet = new Set(existingAgent.tools ?? []); const newMCPTools = updateData.tools.filter( diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 455cea2e7c..d191822add 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1,7 +1,7 @@ const mongoose = require('mongoose'); const { nanoid } = require('nanoid'); const { v4: uuidv4 } = require('uuid'); -const { agentSchema } = require('@librechat/data-schemas'); +const { agentSchema, fileSchema } = require('@librechat/data-schemas'); const { FileSources, PermissionBits } = require('librechat-data-provider'); const { MongoMemoryServer } = require('mongodb-memory-server'); @@ -99,6 +99,9 @@ describe('Agent Controllers - Mass Assignment Protection', () => { const mongoUri = mongoServer.getUri(); await mongoose.connect(mongoUri); Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + // Register File so orphan-pruning tests (and the tool_resources validation + // test, which now needs real File docs for its ids) have a working model. + mongoose.models.File || mongoose.model('File', fileSchema); }, 20000); afterAll(async () => { @@ -542,6 +545,23 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); test('should validate tool_resources in updates', async () => { + // Back these ids with real File docs so the orphan-pruning added for + // issue #12776 does not strip them โ€” this test is about OCR conversion + // and schema filtering, not file existence. + const File = mongoose.models.File; + for (const id of ['ocr1', 'ocr2', 'img1']) { + await File.create({ + file_id: id, + user: existingAgentAuthorId, + filename: `${id}.txt`, + filepath: `/tmp/${id}`, + object: 'file', + type: 'text/plain', + bytes: 1, + source: FileSources.local, + }); + } + mockReq.user.id = existingAgentAuthorId.toString(); mockReq.params.id = existingAgentId; mockReq.body = { @@ -729,6 +749,93 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }), ); }); + + describe('orphan file_id pruning (issue #12776)', () => { + const File = () => mongoose.models.File; + + const createFileDoc = async (file_id, userId) => + File().create({ + file_id, + user: userId, + filename: `${file_id}.txt`, + filepath: `/tmp/${file_id}`, + object: 'file', + type: 'text/plain', + bytes: 1, + source: FileSources.local, + }); + + beforeEach(async () => { + await File().deleteMany({}); + }); + + test('strips orphan file_ids from incoming tool_resources before persisting', async () => { + const keeper = `file_${uuidv4()}`; + const orphan = `file_${uuidv4()}`; + await createFileDoc(keeper, existingAgentAuthorId); + + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + tool_resources: { + file_search: { file_ids: [keeper, orphan] }, + }, + }; + + await updateAgentHandler(mockReq, mockRes); + + const agentInDb = await Agent.findOne({ id: existingAgentId }).lean(); + expect(agentInDb.tool_resources.file_search.file_ids).toEqual([keeper]); + }); + + test('leaves tool_resources alone when the update omits it', async () => { + const orphan = `file_${uuidv4()}`; + await Agent.updateOne( + { id: existingAgentId }, + { $set: { tool_resources: { file_search: { file_ids: [orphan] } } } }, + ); + + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { name: 'Unrelated Rename' }; + + await updateAgentHandler(mockReq, mockRes); + + const agentInDb = await Agent.findOne({ id: existingAgentId }).lean(); + expect(agentInDb.name).toBe('Unrelated Rename'); + // Save-time pruning is intentionally scoped to tool_resources updates. + // The delete-time fix and migration script cover the untouched case. + expect(agentInDb.tool_resources.file_search.file_ids).toEqual([orphan]); + }); + + test('swallows errors from the file-existence check and still completes the save', async () => { + const db = require('~/models'); + const originalGetFiles = db.getFiles; + db.getFiles = jest.fn().mockRejectedValue(new Error('transient DB error')); + + const orphan = `file_${uuidv4()}`; + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + name: 'Save Succeeds', + tool_resources: { file_search: { file_ids: [orphan] } }, + }; + + try { + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalled(); + const agentInDb = await Agent.findOne({ id: existingAgentId }).lean(); + expect(agentInDb.name).toBe('Save Succeeds'); + // Cleanup skipped on error, so the id remains โ€” the delete-time path + // or the next successful save will reconcile it. + expect(agentInDb.tool_resources.file_search.file_ids).toEqual([orphan]); + } finally { + db.getFiles = originalGetFiles; + } + }); + }); }); describe('Mass Assignment Attack Scenarios', () => { diff --git a/api/server/services/Files/process.integration.spec.js b/api/server/services/Files/process.integration.spec.js new file mode 100644 index 0000000000..ee47e0d109 --- /dev/null +++ b/api/server/services/Files/process.integration.spec.js @@ -0,0 +1,203 @@ +/** + * Integration test for the delete-time path of issue #12776. + * + * Covers the full flow through `processDeleteRequest`: + * 1. Real Agent + File docs in an in-memory Mongo. + * 2. Invoke the delete service. + * 3. Assert both the File record is gone and every agent's + * tool_resources.*.file_ids no longer references the deleted id. + * + * Uses FileSources.text so the strategy layer (disk / S3 / OpenAI) is a + * no-op โ€” we don't need real filesystem access to exercise the agent + * reference cleanup, which is what issue #12776 is about. + */ + +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { agentSchema, fileSchema, createMethods } = require('@librechat/data-schemas'); +const { FileSources } = require('librechat-data-provider'); + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: jest.fn() }, + }; +}); + +jest.mock('@librechat/agents', () => ({ + EnvVar: { CODE_API_KEY: 'CODE_API_KEY' }, +})); + +jest.mock('@librechat/api', () => ({ + sanitizeFilename: jest.fn((n) => n), + parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }), + processAudioFile: jest.fn(), +})); + +jest.mock('~/server/controllers/assistants/v2', () => ({ + addResourceFileId: jest.fn(), + deleteResourceFileId: jest.fn(), +})); + +jest.mock('~/server/controllers/assistants/helpers', () => ({ + getOpenAIClient: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({ deleteFile: jest.fn().mockResolvedValue(undefined) })), +})); + +jest.mock('~/server/services/Files/Audio/STTService', () => ({ + STTService: { getInstance: jest.fn() }, +})); + +jest.mock('~/server/services/Config', () => ({ + checkCapability: jest.fn().mockResolvedValue(true), +})); + +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({ get: jest.fn(), set: jest.fn(), delete: jest.fn() })), +})); + +// Replace the mocked `~/models` from the sibling process.spec.js with real, +// mongoose-backed methods. All our in-memory models share this module. +jest.mock('~/models', () => { + const mongoose = require('mongoose'); + const { createMethods } = require('@librechat/data-schemas'); + return createMethods(mongoose, { + removeAllPermissions: jest.fn().mockResolvedValue(undefined), + }); +}); + +require('module-alias/register'); +const { processDeleteRequest } = require('./process'); + +describe('processDeleteRequest โ€” agent reference cleanup (issue #12776)', () => { + let mongoServer; + let Agent; + let File; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // createMethods (via ~/models) registers the File model as a side-effect, + // but we also need the Agent model registered before any queries run. + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + File = mongoose.models.File || mongoose.model('File', fileSchema); + // Touch createMethods once so the migration/setup side-effects run. + createMethods(mongoose, { removeAllPermissions: jest.fn() }); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await Agent.deleteMany({}); + await File.deleteMany({}); + }); + + const seedFile = async (file_id, userId) => + File.create({ + file_id, + user: userId, + filename: `${file_id}.txt`, + filepath: `/tmp/${file_id}`, + object: 'file', + type: 'text/plain', + bytes: 1, + source: FileSources.text, + }); + + const seedAgent = async (authorId, tool_resources) => + Agent.create({ + id: `agent_${Math.random().toString(36).slice(2, 10)}`, + name: 'Integration Test Agent', + provider: 'test', + model: 'test-model', + author: authorId, + tool_resources, + }); + + const buildReq = (fileDocs, extraBody = {}) => ({ + user: { id: fileDocs[0].user.toString() }, + body: { files: fileDocs, ...extraBody }, + config: { fileStrategy: 'local', fileConfig: {}, endpoints: {} }, + }); + + test('strips deleted file_ids from every agent that referenced them', async () => { + const userId = new mongoose.Types.ObjectId(); + const keeperId = `file_keeper_${Math.random().toString(36).slice(2, 10)}`; + const deletedId = `file_deleted_${Math.random().toString(36).slice(2, 10)}`; + + const deletedFile = await seedFile(deletedId, userId); + await seedFile(keeperId, userId); + + // Two agents both reference the file that's about to be deleted, plus the + // keeper. A third, unrelated agent has a different file_id and must not be + // touched by the cleanup. + const agentA = await seedAgent(userId, { + file_search: { file_ids: [deletedId, keeperId] }, + }); + const agentB = await seedAgent(userId, { + execute_code: { file_ids: [deletedId] }, + }); + const untouchedAgent = await seedAgent(userId, { + context: { file_ids: [keeperId] }, + }); + + await processDeleteRequest({ req: buildReq([deletedFile.toObject()]), files: [deletedFile] }); + + expect(await File.findOne({ file_id: deletedId })).toBeNull(); + expect(await File.findOne({ file_id: keeperId })).not.toBeNull(); + + const updatedA = await Agent.findOne({ id: agentA.id }).lean(); + const updatedB = await Agent.findOne({ id: agentB.id }).lean(); + const updatedUntouched = await Agent.findOne({ id: untouchedAgent.id }).lean(); + + expect(updatedA.tool_resources.file_search.file_ids).toEqual([keeperId]); + expect(updatedB.tool_resources.execute_code.file_ids).toEqual([]); + expect(updatedUntouched.tool_resources.context.file_ids).toEqual([keeperId]); + }); + + test('is a no-op when no agent references the deleted file', async () => { + const userId = new mongoose.Types.ObjectId(); + const loneId = `file_lone_${Math.random().toString(36).slice(2, 10)}`; + const loneFile = await seedFile(loneId, userId); + const unrelatedAgent = await seedAgent(userId, { + file_search: { file_ids: ['other_id'] }, + }); + + await processDeleteRequest({ req: buildReq([loneFile.toObject()]), files: [loneFile] }); + + expect(await File.findOne({ file_id: loneId })).toBeNull(); + const after = await Agent.findOne({ id: unrelatedAgent.id }).lean(); + expect(after.tool_resources.file_search.file_ids).toEqual(['other_id']); + }); + + test('still deletes the file when the agent cleanup step throws', async () => { + const userId = new mongoose.Types.ObjectId(); + const targetId = `file_target_${Math.random().toString(36).slice(2, 10)}`; + const targetFile = await seedFile(targetId, userId); + + const db = require('~/models'); + const original = db.removeAgentResourceFilesFromAllAgents; + db.removeAgentResourceFilesFromAllAgents = jest + .fn() + .mockRejectedValue(new Error('simulated cleanup failure')); + + try { + await processDeleteRequest({ req: buildReq([targetFile.toObject()]), files: [targetFile] }); + expect(await File.findOne({ file_id: targetId })).toBeNull(); + } finally { + db.removeAgentResourceFilesFromAllAgents = original; + } + }); +}); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index f7d7731975..f9891483d4 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -219,6 +219,14 @@ const processDeleteRequest = async ({ req, files }) => { await Promise.allSettled(promises); await db.deleteFiles(resolvedFileIds); + + if (resolvedFileIds.length > 0) { + try { + await db.removeAgentResourceFilesFromAllAgents({ file_ids: resolvedFileIds }); + } catch (error) { + logger.error('Error cleaning up orphaned agent file references', error); + } + } }; /** diff --git a/api/test/migrate-orphaned-agent-files.spec.js b/api/test/migrate-orphaned-agent-files.spec.js new file mode 100644 index 0000000000..f8333ecbf4 --- /dev/null +++ b/api/test/migrate-orphaned-agent-files.spec.js @@ -0,0 +1,184 @@ +/** + * Integration test for the orphan-cleanup migration script used to heal + * agents corrupted before the delete-time and save-time fixes for issue + * #12776 shipped. Exercises the full module end-to-end: + * - dry-run reports orphans without writing + * - apply mode removes them + * - re-running on a cleaned database is a no-op (idempotent) + * - the DETAIL_SAMPLE_LIMIT truncation kicks in on wide corruption + */ + +// Replace the migration's `./connect` helper โ€” it opens its own connection +// via the mongo URI env var, but the test already owns the mongoose instance. +jest.mock('../../config/connect', () => jest.fn(async () => undefined)); + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: jest.fn() }, + }; +}); + +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { agentSchema, fileSchema } = require('@librechat/data-schemas'); +const { FileSources } = require('librechat-data-provider'); + +const { migrateOrphanedAgentFiles } = require('../../config/migrate-orphaned-agent-files'); + +describe('migrate-orphaned-agent-files (issue #12776)', () => { + let mongoServer; + let Agent; + let File; + const userId = () => new mongoose.Types.ObjectId(); + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + File = mongoose.models.File || mongoose.model('File', fileSchema); + }, 30000); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await Agent.deleteMany({}); + await File.deleteMany({}); + }); + + const seedFile = (file_id) => + File.create({ + file_id, + user: userId(), + filename: `${file_id}.txt`, + filepath: `/tmp/${file_id}`, + object: 'file', + type: 'text/plain', + bytes: 1, + source: FileSources.text, + }); + + const seedAgent = (tool_resources) => + Agent.create({ + id: `agent_${Math.random().toString(36).slice(2, 10)}`, + name: `Test Agent ${Math.random().toString(36).slice(2, 6)}`, + provider: 'test', + model: 'test-model', + author: userId(), + tool_resources, + }); + + test('dry-run reports orphans without mutating any agent', async () => { + const keeperId = 'keeper'; + await seedFile(keeperId); + const agent = await seedAgent({ + file_search: { file_ids: [keeperId, 'orphan_1', 'orphan_2'] }, + }); + + const result = await migrateOrphanedAgentFiles({ dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.scannedAgents).toBe(1); + expect(result.agentsWithOrphans).toBe(1); + expect(result.totalOrphansRemoved).toBe(2); + // Dry-run reports what would change without writing โ€” no updates counted. + expect(result.agentsUpdated).toBe(0); + + const after = await Agent.findOne({ id: agent.id }).lean(); + expect(after.tool_resources.file_search.file_ids).toEqual([keeperId, 'orphan_1', 'orphan_2']); + }); + + test('apply mode removes orphans across every tool_resource category', async () => { + const keeperA = 'k_a'; + const keeperB = 'k_b'; + await seedFile(keeperA); + await seedFile(keeperB); + + const agent = await seedAgent({ + file_search: { file_ids: [keeperA, 'o1'] }, + execute_code: { file_ids: ['o2', keeperB] }, + context: { file_ids: ['o3'] }, + }); + + const result = await migrateOrphanedAgentFiles({ dryRun: false }); + + expect(result.dryRun).toBe(false); + expect(result.agentsWithOrphans).toBe(1); + expect(result.agentsUpdated).toBe(1); + expect(result.totalOrphansRemoved).toBe(3); + + const after = await Agent.findOne({ id: agent.id }).lean(); + expect(after.tool_resources.file_search.file_ids).toEqual([keeperA]); + expect(after.tool_resources.execute_code.file_ids).toEqual([keeperB]); + expect(after.tool_resources.context.file_ids).toEqual([]); + }); + + test('is idempotent โ€” re-running on a clean database is a no-op', async () => { + await seedFile('keeper'); + await seedAgent({ file_search: { file_ids: ['keeper', 'orphan'] } }); + + await migrateOrphanedAgentFiles({ dryRun: false }); + const second = await migrateOrphanedAgentFiles({ dryRun: false }); + + expect(second.agentsWithOrphans).toBe(0); + expect(second.agentsUpdated).toBe(0); + expect(second.totalOrphansRemoved).toBe(0); + }); + + test('leaves agents without orphans completely alone', async () => { + await seedFile('only'); + const agent = await seedAgent({ file_search: { file_ids: ['only'] } }); + + const result = await migrateOrphanedAgentFiles({ dryRun: false }); + + expect(result.scannedAgents).toBe(1); + expect(result.agentsWithOrphans).toBe(0); + const after = await Agent.findOne({ id: agent.id }).lean(); + expect(after.tool_resources.file_search.file_ids).toEqual(['only']); + }); + + test('sample array is bounded on wide corruption (DETAIL_SAMPLE_LIMIT)', async () => { + // Seed more than the cap (50) so the truncation branch is exercised. + const agents = []; + for (let i = 0; i < 55; i++) { + agents.push( + await seedAgent({ + file_search: { file_ids: [`orphan_${i}`] }, + }), + ); + } + + const result = await migrateOrphanedAgentFiles({ dryRun: true }); + + expect(result.agentsWithOrphans).toBe(55); + expect(result.details.length).toBeLessThanOrEqual(50); + expect(result.details.length).toBeGreaterThan(0); + }); + + test('runs the body inside a system tenant context (strict-mode safe)', async () => { + // Pins the runAsSystem wrap: without it the migration throws under + // TENANT_ISOLATION_STRICT=true on the very first Agent.countDocuments(), + // blocking the intended remediation path for corrupted agents. + const { SYSTEM_TENANT_ID, tenantStorage } = require('@librechat/data-schemas'); + await seedFile('keeper'); + await seedAgent({ file_search: { file_ids: ['keeper', 'orphan'] } }); + + const contextsObserved = []; + const originalCountDocuments = Agent.countDocuments.bind(Agent); + Agent.countDocuments = jest.fn((...args) => { + contextsObserved.push(tenantStorage.getStore()?.tenantId); + return originalCountDocuments(...args); + }); + + try { + await migrateOrphanedAgentFiles({ dryRun: false }); + expect(contextsObserved).toContain(SYSTEM_TENANT_ID); + } finally { + Agent.countDocuments = originalCountDocuments; + } + }); +}); diff --git a/config/migrate-orphaned-agent-files.js b/config/migrate-orphaned-agent-files.js new file mode 100644 index 0000000000..e074ff8154 --- /dev/null +++ b/config/migrate-orphaned-agent-files.js @@ -0,0 +1,160 @@ +const path = require('path'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); +const { TOOL_RESOURCE_KEYS, collectToolResourceFileIds } = require('@librechat/api'); + +require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); +const connect = require('./connect'); + +const { Agent, File } = require('~/db/models'); + +/** + * Cap on the number of per-agent entries we retain in `results.details`. Larger + * runs still update every affected agent and still report accurate aggregate + * counts โ€” we just stop accumulating sample data past this threshold to keep + * memory bounded on deployments with thousands of corrupted agents. + */ +const DETAIL_SAMPLE_LIMIT = 50; + +/** + * Cleans up orphaned file_id references from agent `tool_resources` โ€” that is, + * file_ids that remain on an agent after the underlying File document has + * already been deleted (see issue #12776). These stubs otherwise accumulate and + * eventually block new uploads with "Duplicate file detected." + * + * Safe to re-run โ€” if there are no orphans, nothing is written. + * + * @param {{ dryRun?: boolean, batchSize?: number }} [options] + */ +async function migrateOrphanedAgentFiles({ dryRun = true, batchSize = 100 } = {}) { + await connect(); + + logger.info('Starting Orphaned Agent Files Migration', { dryRun, batchSize }); + + /* + * Scan and heal across every tenant. Without this wrapper the tenant + * isolation plugin either scopes queries to a (non-existent) tenant or + * throws under TENANT_ISOLATION_STRICT=true, making the script unusable + * as the intended remediation path for corrupted agents. + */ + return runAsSystem(async () => { + const totalAgents = await Agent.countDocuments(); + logger.info(`Scanning ${totalAgents} agent(s) for orphaned file references`); + + const results = { + dryRun, + scannedAgents: 0, + agentsWithOrphans: 0, + agentsUpdated: 0, + totalOrphansRemoved: 0, + errors: 0, + details: [], + }; + + const cursor = Agent.find({}, { id: 1, name: 1, tool_resources: 1 }) + .lean() + .cursor({ batchSize }); + + for await (const agent of cursor) { + results.scannedAgents++; + + try { + const referencedFileIds = collectToolResourceFileIds(agent.tool_resources); + if (referencedFileIds.length === 0) { + continue; + } + + const existing = await File.find( + { file_id: { $in: referencedFileIds } }, + { file_id: 1, _id: 0 }, + ).lean(); + const existingIds = new Set(existing.map((f) => f.file_id)); + const orphans = referencedFileIds.filter((id) => !existingIds.has(id)); + if (orphans.length === 0) { + continue; + } + + results.agentsWithOrphans++; + results.totalOrphansRemoved += orphans.length; + if (results.details.length < DETAIL_SAMPLE_LIMIT) { + results.details.push({ + agentId: agent.id, + name: agent.name, + orphanCount: orphans.length, + orphans, + }); + } + + if (dryRun) { + logger.debug(`[dry-run] Would prune ${orphans.length} orphan(s) from agent ${agent.id}`); + continue; + } + + const pullAllOps = {}; + for (const key of TOOL_RESOURCE_KEYS) { + pullAllOps[`tool_resources.${key}.file_ids`] = orphans; + } + const updateResult = await Agent.updateOne({ _id: agent._id }, { $pullAll: pullAllOps }); + if (updateResult.modifiedCount > 0) { + results.agentsUpdated++; + logger.info( + `Pruned ${orphans.length} orphan(s) from agent "${agent.name}" (${agent.id})`, + ); + } + } catch (error) { + results.errors++; + logger.error(`Failed to process agent ${agent.id}`, { error: error.message }); + } + } + + logger.info('Orphaned Agent Files Migration completed', { + dryRun, + scannedAgents: results.scannedAgents, + agentsWithOrphans: results.agentsWithOrphans, + agentsUpdated: results.agentsUpdated, + totalOrphansRemoved: results.totalOrphansRemoved, + errors: results.errors, + }); + + return results; + }); +} + +if (require.main === module) { + const dryRun = process.argv.includes('--dry-run'); + const batchSize = + parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100; + + migrateOrphanedAgentFiles({ dryRun, batchSize }) + .then((result) => { + console.log(`\n=== ${dryRun ? 'DRY RUN ' : ''}RESULTS ===`); + console.log(`Agents scanned: ${result.scannedAgents}`); + console.log(`Agents with orphans: ${result.agentsWithOrphans}`); + console.log( + `Orphan references ${dryRun ? 'to remove' : 'removed'}: ${result.totalOrphansRemoved}`, + ); + if (!dryRun) { + console.log(`Agents updated: ${result.agentsUpdated}`); + } + if (result.errors > 0) { + console.log(`Errors: ${result.errors}`); + } + if (result.details.length > 0) { + console.log('\nAffected agents:'); + result.details.forEach((d, i) => { + console.log(` ${i + 1}. "${d.name}" (${d.agentId}) โ€” ${d.orphanCount} orphan(s)`); + }); + if (result.agentsWithOrphans > result.details.length) { + console.log( + ` ... and ${result.agentsWithOrphans - result.details.length} more (sample capped at ${DETAIL_SAMPLE_LIMIT})`, + ); + } + } + process.exit(0); + }) + .catch((error) => { + console.error('Orphaned agent files migration failed:', error); + process.exit(1); + }); +} + +module.exports = { migrateOrphanedAgentFiles }; diff --git a/package.json b/package.json index 05421a23df..fb624b9689 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,10 @@ "migrate:agent-permissions:batch": "node config/migrate-agent-permissions.js --batch-size=50", "migrate:prompt-permissions:dry-run": "node config/migrate-prompt-permissions.js --dry-run", "migrate:prompt-permissions": "node config/migrate-prompt-permissions.js", - "migrate:prompt-permissions:batch": "node config/migrate-prompt-permissions.js --batch-size=50" + "migrate:prompt-permissions:batch": "node config/migrate-prompt-permissions.js --batch-size=50", + "migrate:orphaned-agent-files:dry-run": "node config/migrate-orphaned-agent-files.js --dry-run", + "migrate:orphaned-agent-files": "node config/migrate-orphaned-agent-files.js", + "migrate:orphaned-agent-files:batch": "node config/migrate-orphaned-agent-files.js --batch-size=50" }, "repository": { "type": "git", diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 336eaea479..2dad6b33d4 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -9,6 +9,7 @@ export * from './handlers'; export * from './initialize'; export * from './legacy'; export * from './memory'; +export * from './orphans'; export * from './migration'; export * from './openai'; export * from './transactions'; diff --git a/packages/api/src/agents/orphans.spec.ts b/packages/api/src/agents/orphans.spec.ts new file mode 100644 index 0000000000..3e614b93d4 --- /dev/null +++ b/packages/api/src/agents/orphans.spec.ts @@ -0,0 +1,53 @@ +import { EToolResources } from 'librechat-data-provider'; +import type { AgentToolResources } from 'librechat-data-provider'; +import { collectToolResourceFileIds, stripFileIdsFromToolResources } from './orphans'; + +const makeResources = (): AgentToolResources => ({ + [EToolResources.file_search]: { file_ids: ['a', 'b', 'c'] }, + [EToolResources.execute_code]: { file_ids: ['b', 'd'] }, + [EToolResources.context]: { file_ids: ['e'] }, +}); + +describe('collectToolResourceFileIds', () => { + it('returns empty array for nullish input', () => { + expect(collectToolResourceFileIds(undefined)).toEqual([]); + expect(collectToolResourceFileIds(null)).toEqual([]); + }); + + it('gathers and de-duplicates file_ids across every category', () => { + const ids = collectToolResourceFileIds(makeResources()); + expect(new Set(ids)).toEqual(new Set(['a', 'b', 'c', 'd', 'e'])); + }); + + it('skips categories without a file_ids array', () => { + const resources: AgentToolResources = { + [EToolResources.file_search]: { file_ids: ['a'] }, + [EToolResources.context]: {}, + }; + expect(collectToolResourceFileIds(resources)).toEqual(['a']); + }); +}); + +describe('stripFileIdsFromToolResources', () => { + it('removes matching ids from every category and reports the count', () => { + const resources = makeResources(); + const { removedCount } = stripFileIdsFromToolResources(resources, ['b', 'e']); + + expect(removedCount).toBe(3); + expect(resources[EToolResources.file_search]?.file_ids).toEqual(['a', 'c']); + expect(resources[EToolResources.execute_code]?.file_ids).toEqual(['d']); + expect(resources[EToolResources.context]?.file_ids).toEqual([]); + }); + + it('is a no-op when no ids are provided', () => { + const resources = makeResources(); + const { removedCount } = stripFileIdsFromToolResources(resources, []); + expect(removedCount).toBe(0); + expect(resources[EToolResources.file_search]?.file_ids).toEqual(['a', 'b', 'c']); + }); + + it('handles nullish tool_resources safely', () => { + const { removedCount } = stripFileIdsFromToolResources(undefined, ['a']); + expect(removedCount).toBe(0); + }); +}); diff --git a/packages/api/src/agents/orphans.ts b/packages/api/src/agents/orphans.ts new file mode 100644 index 0000000000..b350b6f027 --- /dev/null +++ b/packages/api/src/agents/orphans.ts @@ -0,0 +1,70 @@ +import { EToolResources } from 'librechat-data-provider'; +import type { AgentToolResources } from 'librechat-data-provider'; + +/** + * Every `EToolResources` member that can carry `file_ids` on an agent document. + * `code_interpreter` is intentionally omitted โ€” it's part of `EToolResources` + * for the Assistants API but not a key of the agent-side `AgentToolResources` + * shape, so including it would be a type lie and generate dead MongoDB clauses. + */ +export const TOOL_RESOURCE_KEYS: ReadonlyArray = [ + EToolResources.execute_code, + EToolResources.file_search, + EToolResources.image_edit, + EToolResources.context, + EToolResources.ocr, +]; + +/** + * Collects every file_id referenced across all tool_resource categories. + * Duplicates are de-duplicated across categories. + */ +export function collectToolResourceFileIds( + tool_resources: AgentToolResources | undefined | null, +): string[] { + if (!tool_resources) { + return []; + } + const seen = new Set(); + for (const key of TOOL_RESOURCE_KEYS) { + const ids = tool_resources[key]?.file_ids; + if (!Array.isArray(ids)) { + continue; + } + for (const id of ids) { + if (typeof id === 'string') { + seen.add(id); + } + } + } + return Array.from(seen); +} + +/** + * Removes the given file_ids from every tool_resource category on the provided + * tool_resources object. Mutates in place and also returns the same reference + * for convenience. Returns the count of removed references. + */ +export function stripFileIdsFromToolResources( + tool_resources: AgentToolResources | undefined | null, + idsToRemove: Iterable, +): { tool_resources: AgentToolResources | undefined | null; removedCount: number } { + if (!tool_resources) { + return { tool_resources, removedCount: 0 }; + } + const removeSet = idsToRemove instanceof Set ? idsToRemove : new Set(idsToRemove); + if (removeSet.size === 0) { + return { tool_resources, removedCount: 0 }; + } + let removedCount = 0; + for (const key of TOOL_RESOURCE_KEYS) { + const resource = tool_resources[key]; + if (!resource || !Array.isArray(resource.file_ids)) { + continue; + } + const before = resource.file_ids.length; + resource.file_ids = resource.file_ids.filter((id) => !removeSet.has(id)); + removedCount += before - resource.file_ids.length; + } + return { tool_resources, removedCount }; +} diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index 3184f51fa1..0c7042cde1 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -51,6 +51,7 @@ let deleteUserAgents: AgentMethods['deleteUserAgents']; let revertAgentVersion: AgentMethods['revertAgentVersion']; let addAgentResourceFile: AgentMethods['addAgentResourceFile']; let removeAgentResourceFiles: AgentMethods['removeAgentResourceFiles']; +let removeAgentResourceFilesFromAllAgents: AgentMethods['removeAgentResourceFilesFromAllAgents']; let getListAgentsByAccess: AgentMethods['getListAgentsByAccess']; let generateActionMetadataHash: AgentMethods['generateActionMetadataHash']; @@ -93,6 +94,7 @@ beforeAll(async () => { revertAgentVersion = methods.revertAgentVersion; addAgentResourceFile = methods.addAgentResourceFile; removeAgentResourceFiles = methods.removeAgentResourceFiles; + removeAgentResourceFilesFromAllAgents = methods.removeAgentResourceFilesFromAllAgents; getListAgentsByAccess = methods.getListAgentsByAccess; generateActionMetadataHash = methods.generateActionMetadataHash; @@ -2645,6 +2647,109 @@ describe('Agent Methods', () => { ).rejects.toThrow('Agent not found for removing resource files'); }); + describe('removeAgentResourceFilesFromAllAgents', () => { + beforeEach(async () => { + await Agent.deleteMany({}); + }); + + test('should strip deleted file_ids from every agent that references them', async () => { + const sharedFileId = `file_${uuidv4()}`; + const keeperFileId = `file_${uuidv4()}`; + + const agentA = await createBasicAgent(); + const agentB = await createBasicAgent(); + const untouchedAgent = await createBasicAgent(); + + await addAgentResourceFile({ + agent_id: agentA.id, + tool_resource: EToolResources.file_search, + file_id: sharedFileId, + }); + await addAgentResourceFile({ + agent_id: agentA.id, + tool_resource: EToolResources.file_search, + file_id: keeperFileId, + }); + await addAgentResourceFile({ + agent_id: agentB.id, + tool_resource: EToolResources.execute_code, + file_id: sharedFileId, + }); + await addAgentResourceFile({ + agent_id: untouchedAgent.id, + tool_resource: EToolResources.context, + file_id: keeperFileId, + }); + + const result = await removeAgentResourceFilesFromAllAgents({ + file_ids: [sharedFileId], + }); + + expect(result.matchedCount).toBe(2); + expect(result.modifiedCount).toBe(2); + + const updatedA = await getAgent({ id: agentA.id }); + const updatedB = await getAgent({ id: agentB.id }); + const updatedUntouched = await getAgent({ id: untouchedAgent.id }); + + const aFileIds = (updatedA!.tool_resources as Record) + .file_search.file_ids; + const bFileIds = (updatedB!.tool_resources as Record) + .execute_code.file_ids; + const untouchedFileIds = ( + updatedUntouched!.tool_resources as Record + ).context.file_ids; + + expect(aFileIds).not.toContain(sharedFileId); + expect(aFileIds).toContain(keeperFileId); + expect(bFileIds).not.toContain(sharedFileId); + expect(untouchedFileIds).toEqual([keeperFileId]); + }); + + test('should remove file_ids across multiple tool_resource types on the same agent', async () => { + const fileId = `file_${uuidv4()}`; + const agent = await createBasicAgent(); + + await addAgentResourceFile({ + agent_id: agent.id, + tool_resource: EToolResources.file_search, + file_id: fileId, + }); + await addAgentResourceFile({ + agent_id: agent.id, + tool_resource: EToolResources.ocr, + file_id: fileId, + }); + + await removeAgentResourceFilesFromAllAgents({ file_ids: [fileId] }); + + const updated = await getAgent({ id: agent.id }); + const resources = updated!.tool_resources as Record; + expect(resources.file_search.file_ids).not.toContain(fileId); + expect(resources.ocr.file_ids).not.toContain(fileId); + }); + + test('should no-op and not throw when file_ids is empty', async () => { + const result = await removeAgentResourceFilesFromAllAgents({ file_ids: [] }); + expect(result).toEqual({ matchedCount: 0, modifiedCount: 0 }); + }); + + test('should no-op when no agent references the given file_ids', async () => { + const fileId = `file_${uuidv4()}`; + const agent = await createBasicAgent(); + + await addAgentResourceFile({ + agent_id: agent.id, + tool_resource: EToolResources.file_search, + file_id: `different_${uuidv4()}`, + }); + + const result = await removeAgentResourceFilesFromAllAgents({ file_ids: [fileId] }); + expect(result.matchedCount).toBe(0); + expect(result.modifiedCount).toBe(0); + }); + }); + test('should handle updateAgent with complex nested updates', async () => { const agentId = `agent_${uuidv4()}`; const authorId = new mongoose.Types.ObjectId(); diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 36d2d819cb..7cdfd68f52 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -1,11 +1,26 @@ import crypto from 'node:crypto'; -import { Constants, ResourceType, actionDelimiter } from 'librechat-data-provider'; +import { Constants, EToolResources, ResourceType, actionDelimiter } from 'librechat-data-provider'; +import type { AgentToolResources } from 'librechat-data-provider'; import type { FilterQuery, Model, Types } from 'mongoose'; import type { IAgent, IAclEntry } from '~/types'; import logger from '~/config/winston'; const { mcp_delimiter } = Constants; +/** + * Mirrors `TOOL_RESOURCE_KEYS` in `@librechat/api` โ€” the subset of + * `EToolResources` that actually carries `file_ids` on an agent document. + * `code_interpreter` is excluded (it belongs to the Assistants API, not + * `AgentToolResources`) to avoid emitting dead MongoDB clauses. + */ +const TOOL_RESOURCE_KEYS: ReadonlyArray = [ + EToolResources.execute_code, + EToolResources.file_search, + EToolResources.image_edit, + EToolResources.context, + EToolResources.ocr, +]; + export interface AgentDeps { /** Removes all ACL permissions for a resource. Injected from PermissionService. */ removeAllPermissions: (params: { resourceType: string; resourceId: unknown }) => Promise; @@ -477,6 +492,37 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag return agentAfterPull; } + /** + * Removes the given file_ids from every agent's `tool_resources.*.file_ids` + * so file deletion cannot leave orphaned stubs behind (see issue #12776). + */ + async function removeAgentResourceFilesFromAllAgents({ + file_ids, + }: { + file_ids: string[]; + }): Promise<{ matchedCount: number; modifiedCount: number }> { + if (!file_ids || file_ids.length === 0) { + return { matchedCount: 0, modifiedCount: 0 }; + } + + const Agent = mongoose.models.Agent as Model; + + const orQuery = TOOL_RESOURCE_KEYS.map((key) => ({ + [`tool_resources.${key}.file_ids`]: { $in: file_ids }, + })); + + const pullAllOps = TOOL_RESOURCE_KEYS.reduce>((acc, key) => { + acc[`tool_resources.${key}.file_ids`] = file_ids; + return acc; + }, {}); + + const result = await Agent.updateMany({ $or: orQuery }, { $pullAll: pullAllOps }); + return { + matchedCount: result.matchedCount ?? 0, + modifiedCount: result.modifiedCount ?? 0, + }; + } + /** * Deletes an agent based on the provided search parameter. */ @@ -774,6 +820,7 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag removeAgentResourceFiles, generateActionMetadataHash, removeAgentFromUserFavorites, + removeAgentResourceFilesFromAllAgents, }; }