const mongoose = require('mongoose'); const { agentSchema } = require('@librechat/data-schemas'); const { SystemRoles, Tools } = require('librechat-data-provider'); const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } = require('librechat-data-provider').Constants; const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys; const { getProjectByName, addAgentIdsToProject, removeAgentIdsFromProject, removeAgentFromAllProjects, } = require('./Project'); const getLogStores = require('~/cache/getLogStores'); // Category values - must match the frontend values in client/src/constants/agentCategories.ts const CATEGORY_VALUES = { GENERAL: 'general', HR: 'hr', RD: 'rd', FINANCE: 'finance', IT: 'it', SALES: 'sales', AFTERSALES: 'aftersales', }; const VALID_CATEGORIES = Object.values(CATEGORY_VALUES); // Add category field to the Agent schema if it doesn't already exist if (!agentSchema.paths.category) { agentSchema.add({ category: { type: String, trim: true, enum: { values: VALID_CATEGORIES, message: '"{VALUE}" is not a supported agent category. Valid categories are: ' + VALID_CATEGORIES.join(', ') + '.', }, index: true, default: CATEGORY_VALUES.GENERAL, }, }); } // Add support_contact field to the Agent schema if it doesn't already exist if (!agentSchema.paths.support_contact) { agentSchema.add({ support_contact: { type: Object, default: {}, name: { type: String, minlength: [3, 'Support contact name must be at least 3 characters.'], trim: true, }, email: { type: String, match: [ /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, 'Please enter a valid email address.', ], trim: true, }, }, }); } const Agent = mongoose.model('agent', agentSchema); /** * Create an agent with the provided data. * @param {Object} agentData - The agent data to create. * @returns {Promise} The created agent document as a plain object. * @throws {Error} If the agent creation fails. */ const createAgent = async (agentData) => { // Ensure the agent has a category (default to 'general' if none provided) const dataWithCategory = { ...agentData, category: agentData.category || 'general', }; return (await Agent.create(dataWithCategory)).toObject(); }; /** * Get an agent document based on the provided ID. * * @param {Object} searchParameter - The search parameters to find the agent to update. * @param {string} searchParameter.id - The ID of the agent to update. * @param {string} searchParameter.author - The user ID of the agent's author. * @returns {Promise} The agent document as a plain object, or null if not found. */ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter).lean(); /** * Load an agent based on the provided ID * * @param {Object} params * @param {ServerRequest} params.req * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Agent|null} The agent document as a plain object, or null if not found. */ const loadEphemeralAgent = ({ req, agent_id, endpoint, model_parameters: _m }) => { const { model, ...model_parameters } = _m; /** @type {Record} */ const availableTools = req.app.locals.availableTools; const mcpServers = new Set(req.body.ephemeralAgent?.mcp); /** @type {string[]} */ const tools = []; if (req.body.ephemeralAgent?.execute_code === true) { tools.push(Tools.execute_code); } if (mcpServers.size > 0) { for (const toolName of Object.keys(availableTools)) { if (!toolName.includes(mcp_delimiter)) { continue; } const mcpServer = toolName.split(mcp_delimiter)?.[1]; if (mcpServer && mcpServers.has(mcpServer)) { tools.push(toolName); } } } const instructions = req.body.promptPrefix; return { id: agent_id, instructions, provider: endpoint, model_parameters, model, tools, }; }; /** * Load an agent based on the provided ID * * @param {Object} params * @param {ServerRequest} params.req * @param {string} params.agent_id * @param {string} params.endpoint * @param {import('@librechat/agents').ClientOptions} [params.model_parameters] * @returns {Promise} The agent document as a plain object, or null if not found. */ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => { if (!agent_id) { return null; } if (agent_id === EPHEMERAL_AGENT_ID) { return loadEphemeralAgent({ req, agent_id, endpoint, model_parameters }); } const agent = await getAgent({ id: agent_id, }); if (!agent) { return null; } if (agent.author.toString() === req.user.id) { return agent; } if (!agent.projectIds) { return null; } const cache = getLogStores(CONFIG_STORE); /** @type {TStartupConfig} */ const cachedStartupConfig = await cache.get(STARTUP_CONFIG); let { instanceProjectId } = cachedStartupConfig ?? {}; if (!instanceProjectId) { instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString(); } for (const projectObjectId of agent.projectIds) { const projectId = projectObjectId.toString(); if (projectId === instanceProjectId) { return agent; } } }; /** * Update an agent with new data without overwriting existing * properties, or create a new agent if it doesn't exist. * * @param {Object} searchParameter - The search parameters to find the agent to update. * @param {string} searchParameter.id - The ID of the agent to update. * @param {string} [searchParameter.author] - The user ID of the agent's author. * @param {Object} updateData - An object containing the properties to update. * @returns {Promise} The updated or newly created agent document as a plain object. */ const updateAgent = async (searchParameter, updateData) => { const options = { new: true, upsert: false }; return Agent.findOneAndUpdate(searchParameter, updateData, options).lean(); }; /** * Modifies an agent with the resource file id. * @param {object} params * @param {ServerRequest} params.req * @param {string} params.agent_id * @param {string} params.tool_resource * @param {string} params.file_id * @returns {Promise} The updated agent. */ const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => { const searchParameter = { id: agent_id }; let agent = await getAgent(searchParameter); if (!agent) { throw new Error('Agent not found for adding resource file'); } const fileIdsPath = `tool_resources.${tool_resource}.file_ids`; await Agent.updateOne( { id: agent_id, [`${fileIdsPath}`]: { $exists: false }, }, { $set: { [`${fileIdsPath}`]: [], }, }, ); const updateData = { $addToSet: { tools: tool_resource, [fileIdsPath]: file_id, }, }; const updatedAgent = await updateAgent(searchParameter, updateData); if (updatedAgent) { return updatedAgent; } else { throw new Error('Agent not found for adding resource file'); } }; /** * Removes multiple resource files from an agent using atomic operations. * @param {object} params * @param {string} params.agent_id * @param {Array<{tool_resource: string, file_id: string}>} params.files * @returns {Promise} The updated agent. * @throws {Error} If the agent is not found or update fails. */ const removeAgentResourceFiles = async ({ agent_id, files }) => { const searchParameter = { id: agent_id }; // Group files to remove by resource const filesByResource = files.reduce((acc, { tool_resource, file_id }) => { if (!acc[tool_resource]) { acc[tool_resource] = []; } acc[tool_resource].push(file_id); return acc; }, {}); // Step 1: Atomically remove file IDs using $pull const pullOps = {}; const resourcesToCheck = new Set(); for (const [resource, fileIds] of Object.entries(filesByResource)) { const fileIdsPath = `tool_resources.${resource}.file_ids`; pullOps[fileIdsPath] = { $in: fileIds }; resourcesToCheck.add(resource); } const updatePullData = { $pull: pullOps }; const agentAfterPull = await Agent.findOneAndUpdate(searchParameter, updatePullData, { new: true, }).lean(); if (!agentAfterPull) { // Agent might have been deleted concurrently, or never existed. // Check if it existed before trying to throw. const agentExists = await getAgent(searchParameter); if (!agentExists) { throw new Error('Agent not found for removing resource files'); } // If it existed but findOneAndUpdate returned null, something else went wrong. throw new Error('Failed to update agent during file removal (pull step)'); } // Return the agent state directly after the $pull operation. // Skipping the $unset step for now to simplify and test core $pull atomicity. // Empty arrays might remain, but the removal itself should be correct. return agentAfterPull; }; /** * Deletes an agent based on the provided ID. * * @param {Object} searchParameter - The search parameters to find the agent to delete. * @param {string} searchParameter.id - The ID of the agent to delete. * @param {string} [searchParameter.author] - The user ID of the agent's author. * @returns {Promise} Resolves when the agent has been successfully deleted. */ const deleteAgent = async (searchParameter) => { const agent = await Agent.findOneAndDelete(searchParameter); if (agent) { await removeAgentFromAllProjects(agent.id); } return agent; }; /** * Get all agents. * @param {Object} searchParameter - The search parameters to find matching agents. * @param {string} searchParameter.author - The user ID of the agent's author. * @returns {Promise} A promise that resolves to an object containing the agents data and pagination info. */ const getListAgents = async (searchParameter) => { const { author, ...otherParams } = searchParameter; let query = Object.assign({ author }, otherParams); const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']); if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) { const globalQuery = { id: { $in: globalProject.agentIds }, ...otherParams }; delete globalQuery.author; query = { $or: [globalQuery, query] }; } const agents = ( await Agent.find(query, { id: 1, _id: 0, name: 1, avatar: 1, author: 1, projectIds: 1, description: 1, isCollaborative: 1, category: 1, }).lean() ).map((agent) => { if (agent.author?.toString() !== author) { delete agent.author; } if (agent.author) { agent.author = agent.author.toString(); } return agent; }); const hasMore = agents.length > 0; const firstId = agents.length > 0 ? agents[0].id : null; const lastId = agents.length > 0 ? agents[agents.length - 1].id : null; return { data: agents, has_more: hasMore, first_id: firstId, last_id: lastId, }; }; /** * Updates the projects associated with an agent, adding and removing project IDs as specified. * This function also updates the corresponding projects to include or exclude the agent ID. * * @param {Object} params - Parameters for updating the agent's projects. * @param {MongoUser} params.user - Parameters for updating the agent's projects. * @param {string} params.agentId - The ID of the agent to update. * @param {string[]} [params.projectIds] - Array of project IDs to add to the agent. * @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent. * @returns {Promise} The updated agent document. * @throws {Error} If there's an error updating the agent or projects. */ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds }) => { const updateOps = {}; if (removeProjectIds && removeProjectIds.length > 0) { for (const projectId of removeProjectIds) { await removeAgentIdsFromProject(projectId, [agentId]); } updateOps.$pull = { projectIds: { $in: removeProjectIds } }; } if (projectIds && projectIds.length > 0) { for (const projectId of projectIds) { await addAgentIdsToProject(projectId, [agentId]); } updateOps.$addToSet = { projectIds: { $each: projectIds } }; } if (Object.keys(updateOps).length === 0) { return await getAgent({ id: agentId }); } const updateQuery = { id: agentId, author: user.id }; if (user.role === SystemRoles.ADMIN) { delete updateQuery.author; } const updatedAgent = await updateAgent(updateQuery, updateOps); if (updatedAgent) { return updatedAgent; } if (updateOps.$addToSet) { for (const projectId of projectIds) { await removeAgentIdsFromProject(projectId, [agentId]); } } else if (updateOps.$pull) { for (const projectId of removeProjectIds) { await addAgentIdsToProject(projectId, [agentId]); } } return await getAgent({ id: agentId }); }; module.exports = { Agent, getAgent, loadAgent, createAgent, updateAgent, deleteAgent, getListAgents, updateAgentProjects, addAgentResourceFile, removeAgentResourceFiles, };