mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-02 12:22:22 +00:00
Opening the agent editor fetched the full `versions` array (each a complete config snapshot) alongside the agent, so agents with large histories were slow to open. Version history is now loaded only when the user opens it. - Add `getAgentWithVersionCount` (aggregation: version count, no versions array) and `getAgentVersions` data-schemas methods. - `getAgentHandler` returns the version count without the heavy array; add `GET /agents/:id/versions` (EDIT-gated) for lazy retrieval. - Add `useGetAgentVersionsQuery`; VersionPanel reads current config from the cached expanded query and fetches versions on open. Revert keeps the expanded cache and versions query in sync.
1367 lines
45 KiB
JavaScript
1367 lines
45 KiB
JavaScript
const { z } = require('zod');
|
|
const fs = require('fs').promises;
|
|
const { nanoid } = require('nanoid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const {
|
|
refreshS3Url,
|
|
agentCreateSchema,
|
|
agentUpdateSchema,
|
|
refreshListAvatars,
|
|
collectEdgeAgentIds,
|
|
mergeAgentOcrConversion,
|
|
MAX_AVATAR_REFRESH_AGENTS,
|
|
collectToolResourceFileIds,
|
|
convertOcrToContextInPlace,
|
|
stripFileIdsFromToolResources,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Time,
|
|
Tools,
|
|
CacheKeys,
|
|
Constants,
|
|
FileSources,
|
|
ResourceType,
|
|
AccessRoleIds,
|
|
PrincipalType,
|
|
EToolResources,
|
|
isActionTool,
|
|
PermissionBits,
|
|
actionDelimiter,
|
|
AgentCapabilities,
|
|
EModelEndpoint,
|
|
removeNullishValues,
|
|
} = require('librechat-data-provider');
|
|
const {
|
|
findPubliclyAccessibleResources,
|
|
getResourcePermissionsMap,
|
|
findAccessibleResources,
|
|
hasPublicPermission,
|
|
grantPermission,
|
|
} = require('~/server/services/PermissionService');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
|
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
|
|
const { filterFile } = require('~/server/services/Files/process');
|
|
const { getCachedTools } = require('~/server/services/Config');
|
|
const {
|
|
createMCPPermissionContext,
|
|
resolveConfigServers,
|
|
userCanUseMCPServers,
|
|
} = require('~/server/services/MCP');
|
|
const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
|
|
const { getMCPServersRegistry } = require('~/config');
|
|
const { getLogStores } = require('~/cache');
|
|
const db = require('~/models');
|
|
|
|
const systemTools = {
|
|
[Tools.execute_code]: true,
|
|
[Tools.file_search]: true,
|
|
[Tools.web_search]: true,
|
|
[Tools.memory]: true,
|
|
};
|
|
|
|
const MAX_SEARCH_LEN = 100;
|
|
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const getSafeModelParameters = (modelParameters) => {
|
|
const { useResponsesApi } = modelParameters ?? {};
|
|
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
|
|
};
|
|
const hasEditBit = (permission) => (permission & PermissionBits.EDIT) === PermissionBits.EDIT;
|
|
|
|
const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
|
|
const skillScopeEnabled = agent.skills_enabled === true;
|
|
delete agent.skills_enabled;
|
|
|
|
if (!skillScopeEnabled) {
|
|
delete agent.skills;
|
|
return agent;
|
|
}
|
|
|
|
const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
|
|
if (configuredSkills.length === 0) {
|
|
// Empty allowlist means the viewer's full accessible catalog.
|
|
delete agent.skills;
|
|
agent.skills_enabled = true;
|
|
return agent;
|
|
}
|
|
|
|
const visibleSkills = configuredSkills
|
|
.map((skillId) => String(skillId))
|
|
.filter((skillId) => accessibleSkillSet.has(skillId));
|
|
|
|
if (visibleSkills.length === 0) {
|
|
delete agent.skills;
|
|
return agent;
|
|
}
|
|
|
|
agent.skills = visibleSkills;
|
|
agent.skills_enabled = true;
|
|
return agent;
|
|
};
|
|
|
|
/**
|
|
* Looks up each referenced agent id in Mongo, splits them into three
|
|
* buckets the caller needs for validation: ids that don't exist at all,
|
|
* ids the user lacks VIEW permission on, and ids that are fully
|
|
* accessible. Missing ids are intentionally NOT treated as unauthorized
|
|
* — for `edges`, a self-referential `from` can legitimately name the
|
|
* agent being created (no DB record yet); callers that should reject
|
|
* missing ids (like the subagent path) read the `missing` bucket
|
|
* instead.
|
|
* @param {Iterable<string>} agentIds
|
|
* @param {string} userId
|
|
* @param {string} userRole
|
|
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
|
|
*/
|
|
const classifyAgentReferences = async (agentIds, userId, userRole) => {
|
|
const ids = [...new Set(agentIds)];
|
|
if (ids.length === 0) return { missing: [], unauthorized: [] };
|
|
|
|
const agents = await db.getAgents({ id: { $in: ids } });
|
|
const foundIds = new Set(agents.map((a) => a.id));
|
|
const missing = ids.filter((id) => !foundIds.has(id));
|
|
|
|
if (agents.length === 0) return { missing, unauthorized: [] };
|
|
|
|
const permissionsMap = await getResourcePermissionsMap({
|
|
userId,
|
|
role: userRole,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceIds: agents.map((a) => a._id),
|
|
});
|
|
|
|
const unauthorized = agents
|
|
.filter((a) => {
|
|
const bits = permissionsMap.get(a._id.toString()) ?? 0;
|
|
return (bits & PermissionBits.VIEW) === 0;
|
|
})
|
|
.map((a) => a.id);
|
|
|
|
return { missing, unauthorized };
|
|
};
|
|
|
|
/**
|
|
* Validates VIEW access for every agent referenced in `edges`.
|
|
* Missing ids are NOT errors here — at create time a self-referential
|
|
* `from` often names the agent being built, which has no DB record
|
|
* yet. Only unauthorized (existing but unviewable) ids are returned.
|
|
*/
|
|
const validateEdgeAgentAccess = async (edges, userId, userRole) => {
|
|
const { unauthorized } = await classifyAgentReferences(
|
|
collectEdgeAgentIds(edges),
|
|
userId,
|
|
userRole,
|
|
);
|
|
return unauthorized;
|
|
};
|
|
|
|
/**
|
|
* Validates `subagents.agent_ids` more strictly than edges: both
|
|
* missing AND unauthorized ids are errors. `subagents.agent_ids`
|
|
* can't self-reference (subagents spawn *other* agents), so a
|
|
* missing id is always a typo or a reference to a deleted agent —
|
|
* `initializeClient` would silently drop it at runtime, leaving the
|
|
* persisted config out of sync with actual spawn targets (Codex P2).
|
|
* Returning the split lets the caller report each bucket with the
|
|
* appropriate status.
|
|
*/
|
|
const validateSubagentReferences = (subagents, userId, userRole) =>
|
|
classifyAgentReferences(subagents?.agent_ids ?? [], userId, userRole);
|
|
|
|
/**
|
|
* Returns true when the agents-endpoint `subagents` capability is
|
|
* enabled in this request's resolved app config. When disabled,
|
|
* `initializeClient` already strips the `subagents` block at runtime
|
|
* so persisted `agent_ids` are inert — gating the ACL check on this
|
|
* keeps stale references in legacy records from blocking unrelated
|
|
* edits after a capability-off rollback (Codex P2).
|
|
* @param {Express.Request} req
|
|
*/
|
|
const isSubagentsCapabilityEnabled = (req) => {
|
|
const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
|
|
if (!Array.isArray(capabilities)) return false;
|
|
return capabilities.includes(AgentCapabilities.subagents);
|
|
};
|
|
|
|
/**
|
|
* Filters tools to only include those the user is authorized to use.
|
|
* MCP tools must match the exact format `{toolName}_mcp_{serverName}` (exactly 2 segments).
|
|
* Multi-delimiter keys are rejected to prevent authorization/execution mismatch.
|
|
* Non-MCP tools must appear in availableTools (global tool cache) or systemTools.
|
|
*
|
|
* When `existingTools` is provided and the MCP registry is unavailable (e.g. server restart),
|
|
* tools already present on the agent are preserved rather than stripped — they were validated
|
|
* when originally added, and we cannot re-verify them without the registry.
|
|
* @param {object} params
|
|
* @param {string[]} params.tools - Raw tool strings from the request
|
|
* @param {string} params.userId - Requesting user ID for MCP server access check
|
|
* @param {string} [params.role] - Requesting user's role for ACL principal resolution
|
|
* @param {object} [params.user] - Requesting user for MCP server use permission checks
|
|
* @param {{ canUseServers: (user?: object) => Promise<boolean> }} [params.mcpPermissionContext] - Request-scoped MCP permission context
|
|
* @param {Record<string, unknown>} params.availableTools - Global non-MCP tool cache
|
|
* @param {string[]} [params.existingTools] - Tools already persisted on the agent document
|
|
* @param {Record<string, unknown>} [params.configServers] - Config-source MCP servers resolved from appConfig overrides
|
|
* @returns {Promise<string[]>} Only the authorized subset of tools
|
|
*/
|
|
const filterAuthorizedTools = async ({
|
|
tools,
|
|
userId,
|
|
role,
|
|
user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools,
|
|
configServers,
|
|
}) => {
|
|
const filteredTools = [];
|
|
let mcpServerConfigs;
|
|
let registryUnavailable = false;
|
|
const existingToolSet = existingTools?.length ? new Set(existingTools) : null;
|
|
const hasMCPTools = tools.some((tool) => tool?.includes(Constants.mcp_delimiter));
|
|
const canUseMCP = hasMCPTools
|
|
? await (mcpPermissionContext
|
|
? mcpPermissionContext.canUseServers(user)
|
|
: userCanUseMCPServers(user))
|
|
: true;
|
|
let loggedMCPDenied = false;
|
|
|
|
for (const tool of tools) {
|
|
const isActionToolName = typeof tool === 'string' && isActionTool(tool);
|
|
const isMCPTool = tool?.includes(Constants.mcp_delimiter) && !isActionToolName;
|
|
|
|
if (!isMCPTool) {
|
|
if (availableTools[tool] || systemTools[tool] || isActionToolName) {
|
|
filteredTools.push(tool);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!canUseMCP) {
|
|
if (!loggedMCPDenied) {
|
|
logger.warn(`[filterAuthorizedTools] User ${userId} lacks MCP server use permission`);
|
|
loggedMCPDenied = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (mcpServerConfigs === undefined) {
|
|
try {
|
|
mcpServerConfigs =
|
|
(role
|
|
? await getMCPServersRegistry().getAllServerConfigs(userId, configServers, role)
|
|
: await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {};
|
|
} catch (e) {
|
|
logger.warn(
|
|
'[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools',
|
|
e.message,
|
|
);
|
|
mcpServerConfigs = {};
|
|
registryUnavailable = true;
|
|
}
|
|
}
|
|
|
|
const parts = tool.split(Constants.mcp_delimiter);
|
|
if (parts.length !== 2) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected malformed MCP tool key "${tool}" for user ${userId}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (registryUnavailable && existingToolSet?.has(tool)) {
|
|
filteredTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
const [, serverName] = parts;
|
|
if (!serverName || !Object.hasOwn(mcpServerConfigs, serverName)) {
|
|
logger.warn(
|
|
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" not accessible to user ${userId}`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
filteredTools.push(tool);
|
|
}
|
|
|
|
return filteredTools;
|
|
};
|
|
|
|
/**
|
|
* Removes file IDs from tool resources unless the referenced file is owned by
|
|
* the agent owner.
|
|
* @param {object} params
|
|
* @param {object} params.tool_resources
|
|
* @param {string | object} params.ownerId
|
|
* @param {string} params.logPrefix
|
|
* @returns {Promise<number>} Count of removed file references.
|
|
*/
|
|
const pruneToolResourceFileIdsForOwner = async ({ tool_resources, ownerId, logPrefix }) => {
|
|
const referencedFileIds = collectToolResourceFileIds(tool_resources);
|
|
if (referencedFileIds.length === 0) {
|
|
return 0;
|
|
}
|
|
if (!ownerId) {
|
|
return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
|
|
}
|
|
const ownerIdStr = ownerId.toString();
|
|
|
|
try {
|
|
const ownerFiles = await db.getFiles(
|
|
{ file_id: { $in: referencedFileIds }, user: ownerIdStr },
|
|
null,
|
|
{
|
|
file_id: 1,
|
|
user: 1,
|
|
},
|
|
);
|
|
const allowedIds = new Set(
|
|
(ownerFiles ?? [])
|
|
.filter((file) => file.user && file.user.toString() === ownerIdStr)
|
|
.map((file) => file.file_id),
|
|
);
|
|
const disallowedIds = referencedFileIds.filter((id) => !allowedIds.has(id));
|
|
if (disallowedIds.length > 0) {
|
|
logger.warn(`${logPrefix} Pruning ${disallowedIds.length} invalid file reference(s)`);
|
|
return stripFileIdsFromToolResources(tool_resources, disallowedIds).removedCount;
|
|
}
|
|
return 0;
|
|
} catch (fileCheckError) {
|
|
logger.warn(`${logPrefix} File ownership check failed, pruning incoming file references`, {
|
|
error: fileCheckError?.message,
|
|
});
|
|
return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Creates an Agent.
|
|
* @route POST /Agents
|
|
* @param {ServerRequest} req - The request object.
|
|
* @param {AgentCreateParams} req.body - The request body.
|
|
* @param {ServerResponse} res - The response object.
|
|
* @returns {Promise<Agent>} 201 - success response - application/json
|
|
*/
|
|
const createAgentHandler = async (req, res) => {
|
|
try {
|
|
const validatedData = agentCreateSchema.parse(req.body);
|
|
const { tools = [], ...agentData } = removeNullishValues(validatedData);
|
|
|
|
if (agentData.model_parameters && typeof agentData.model_parameters === 'object') {
|
|
agentData.model_parameters = removeNullishValues(agentData.model_parameters, true);
|
|
}
|
|
|
|
const { id: userId, role: userRole } = req.user;
|
|
|
|
if (agentData.tool_resources) {
|
|
await pruneToolResourceFileIdsForOwner({
|
|
tool_resources: agentData.tool_resources,
|
|
ownerId: userId,
|
|
logPrefix: '[/Agents]',
|
|
});
|
|
}
|
|
|
|
if (agentData.edges?.length) {
|
|
const unauthorized = await validateEdgeAgentAccess(agentData.edges, userId, userRole);
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Only validate subagent ACL when the feature is actually enabled
|
|
* on BOTH the endpoint (capability flag in appConfig) AND the
|
|
* agent payload. Runtime (`initializeClient` + `run.ts`) checks
|
|
* `subagents?.enabled` as a truthy predicate — so `undefined` /
|
|
* `null` / missing `enabled` all disable the feature. The ACL
|
|
* check must match exactly: only enforce when `enabled === true`.
|
|
* Otherwise a payload that omits `enabled` (e.g. API clients, or
|
|
* legacy records that never set the field) could 403 here while
|
|
* runtime would happily no-op on the subagent tool. Disable-path
|
|
* is also untouched: toggling `enabled: false` always passes the
|
|
* gate, so a user who lost VIEW on a child can still save the
|
|
* disable edit.
|
|
*/
|
|
if (
|
|
isSubagentsCapabilityEnabled(req) &&
|
|
agentData.subagents?.enabled === true &&
|
|
agentData.subagents?.agent_ids?.length
|
|
) {
|
|
const { missing, unauthorized } = await validateSubagentReferences(
|
|
agentData.subagents,
|
|
userId,
|
|
userRole,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in subagents do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in subagents',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
agentData.id = `agent_${nanoid()}`;
|
|
agentData.author = userId;
|
|
agentData.tools = [];
|
|
|
|
const hasMCPTools = tools.some((t) => t?.includes(Constants.mcp_delimiter));
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined),
|
|
]);
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
agentData.tools = await filterAuthorizedTools({
|
|
tools,
|
|
userId,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
configServers,
|
|
});
|
|
|
|
const agent = await db.createAgent(agentData);
|
|
|
|
try {
|
|
await Promise.all([
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
resourceId: agent._id,
|
|
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
]);
|
|
logger.debug(
|
|
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
|
|
);
|
|
} catch (permissionError) {
|
|
logger.error(
|
|
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
|
|
permissionError,
|
|
);
|
|
}
|
|
|
|
res.status(201).json(agent);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.error('[/Agents] Validation error', error.errors);
|
|
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
|
|
}
|
|
logger.error('[/Agents] Error creating agent', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieves an Agent by ID.
|
|
* @route GET /Agents/:id
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @param {object} req.user - Authenticated user information
|
|
* @param {string} req.user.id - User ID
|
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
|
* @returns {Error} 404 - Agent not found
|
|
*/
|
|
const getAgentHandler = async (req, res, expandProperties = false) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const author = req.user.id;
|
|
|
|
// Permissions are validated by middleware before calling this function.
|
|
// Load the agent with a `version` count but without the heavy `versions`
|
|
// array; version history is fetched lazily via GET /agents/:id/versions.
|
|
const agent = await db.getAgentWithVersionCount({ id });
|
|
|
|
if (!agent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
|
try {
|
|
agent.avatar = {
|
|
...agent.avatar,
|
|
filepath: await refreshS3Url(agent.avatar),
|
|
};
|
|
} catch (e) {
|
|
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
|
|
}
|
|
}
|
|
|
|
agent.author = agent.author.toString();
|
|
|
|
// Check if agent is public
|
|
const isPublic = await hasPublicPermission({
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
agent.isPublic = isPublic;
|
|
|
|
await attachOwnerContacts([agent]);
|
|
|
|
if (agent.author !== author) {
|
|
delete agent.author;
|
|
}
|
|
|
|
if (!expandProperties) {
|
|
// VIEW permission: Basic agent info only
|
|
const responseAgent = {
|
|
_id: agent._id,
|
|
id: agent.id,
|
|
name: agent.name,
|
|
description: agent.description,
|
|
avatar: agent.avatar,
|
|
author: agent.author,
|
|
provider: agent.provider,
|
|
model: agent.model,
|
|
model_parameters: getSafeModelParameters(agent.model_parameters),
|
|
isPublic: agent.isPublic,
|
|
version: agent.version,
|
|
// Safe metadata
|
|
createdAt: agent.createdAt,
|
|
updatedAt: agent.updatedAt,
|
|
};
|
|
|
|
if (agent.support_contact !== undefined) {
|
|
responseAgent.support_contact = agent.support_contact;
|
|
}
|
|
if (agent.owner_contact !== undefined) {
|
|
responseAgent.owner_contact = agent.owner_contact;
|
|
}
|
|
|
|
return res.status(200).json(responseAgent);
|
|
}
|
|
|
|
// EDIT permission: Full agent details including sensitive configuration
|
|
return res.status(200).json(agent);
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id] Error retrieving agent', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Retrieves an agent's version history.
|
|
* Loaded lazily so the editor doesn't transfer large histories up front.
|
|
* @route GET /agents/:id/versions
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @returns {Promise<Agent[]>} 200 - The agent's version history - application/json
|
|
* @returns {Error} 404 - Agent not found
|
|
*/
|
|
const getAgentVersionsHandler = async (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const versions = await db.getAgentVersions({ id });
|
|
|
|
if (versions == null) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
return res.status(200).json(versions);
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id/versions] Error retrieving agent versions', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Updates an Agent.
|
|
* @route PATCH /Agents/:id
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @param {AgentUpdateParams} req.body - The Agent update parameters.
|
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
|
*/
|
|
const updateAgentHandler = async (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const validatedData = agentUpdateSchema.parse(req.body);
|
|
// Preserve explicit null for avatar to allow resetting the avatar
|
|
const { avatar: avatarField, _id, ...rest } = validatedData;
|
|
const updateData = removeNullishValues(rest);
|
|
|
|
if (updateData.model_parameters && typeof updateData.model_parameters === 'object') {
|
|
updateData.model_parameters = removeNullishValues(updateData.model_parameters, true);
|
|
}
|
|
|
|
if (avatarField === null) {
|
|
updateData.avatar = avatarField;
|
|
}
|
|
|
|
if (updateData.edges?.length) {
|
|
const { id: userId, role: userRole } = req.user;
|
|
const unauthorized = await validateEdgeAgentAccess(updateData.edges, userId, userRole);
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in edges',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Same guard as the create path: capability on the endpoint,
|
|
* AND `subagents.enabled === true` on the payload (runtime's
|
|
* truthy check treats `undefined` / `null` / `false` as
|
|
* disabled, so the ACL check must too). Missing or explicitly-
|
|
* disabled payloads always pass the gate — that preserves the
|
|
* "can always save a disable edit" behavior a user might need
|
|
* after losing VIEW on a referenced child. */
|
|
if (
|
|
isSubagentsCapabilityEnabled(req) &&
|
|
updateData.subagents?.enabled === true &&
|
|
updateData.subagents?.agent_ids?.length
|
|
) {
|
|
const { id: userId, role: userRole } = req.user;
|
|
const { missing, unauthorized } = await validateSubagentReferences(
|
|
updateData.subagents,
|
|
userId,
|
|
userRole,
|
|
);
|
|
if (missing.length > 0) {
|
|
return res.status(400).json({
|
|
error: 'One or more agents referenced in subagents do not exist',
|
|
agent_ids: missing,
|
|
});
|
|
}
|
|
if (unauthorized.length > 0) {
|
|
return res.status(403).json({
|
|
error: 'You do not have access to one or more agents referenced in subagents',
|
|
agent_ids: unauthorized,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert OCR to context in incoming updateData
|
|
convertOcrToContextInPlace(updateData);
|
|
|
|
const existingAgent = await db.getAgent({ id });
|
|
|
|
if (!existingAgent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
// Convert legacy OCR tool resource to context format in existing agent
|
|
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
|
|
if (ocrConversion.tool_resources) {
|
|
updateData.tool_resources = ocrConversion.tool_resources;
|
|
}
|
|
if (ocrConversion.tools) {
|
|
updateData.tools = ocrConversion.tools;
|
|
}
|
|
|
|
if (updateData.tool_resources) {
|
|
await pruneToolResourceFileIdsForOwner({
|
|
tool_resources: updateData.tool_resources,
|
|
ownerId: existingAgent.author,
|
|
logPrefix: `[/Agents/:id] Agent ${id}`,
|
|
});
|
|
}
|
|
|
|
const isMCPTool = (t) =>
|
|
typeof t === 'string' && t.includes(Constants.mcp_delimiter) && !isActionTool(t);
|
|
const hasToolUpdate = updateData.tools !== undefined;
|
|
const editingOwnAgent = existingAgent.author?.toString() === req.user.id;
|
|
const existingTools = existingAgent.tools ?? [];
|
|
const effectiveTools = (hasToolUpdate ? updateData.tools : existingAgent.tools) ?? [];
|
|
const requestedMCPTools = effectiveTools.filter(isMCPTool);
|
|
const existingMCPTools = existingTools.filter(isMCPTool);
|
|
|
|
if (requestedMCPTools.length > 0 || (hasToolUpdate && existingMCPTools.length > 0)) {
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
if (!(await mcpPermissionContext.canUseServers(req.user))) {
|
|
if (editingOwnAgent) {
|
|
updateData.tools = effectiveTools.filter((t) => !isMCPTool(t));
|
|
} else if (hasToolUpdate) {
|
|
const existingMCPToolSet = new Set(existingMCPTools);
|
|
const nextTools = updateData.tools.filter(
|
|
(t) => !isMCPTool(t) || existingMCPToolSet.has(t),
|
|
);
|
|
const nextToolSet = new Set(nextTools);
|
|
for (const existingMCPTool of existingMCPTools) {
|
|
if (!nextToolSet.has(existingMCPTool)) {
|
|
nextTools.push(existingMCPTool);
|
|
}
|
|
}
|
|
updateData.tools = nextTools;
|
|
}
|
|
} else if (hasToolUpdate) {
|
|
const existingToolSet = new Set(existingTools);
|
|
const newMCPTools = requestedMCPTools.filter((t) => !existingToolSet.has(t));
|
|
|
|
if (newMCPTools.length > 0) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const approvedNew = await filterAuthorizedTools({
|
|
tools: newMCPTools,
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
configServers,
|
|
});
|
|
const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t)));
|
|
if (rejectedSet.size > 0) {
|
|
updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let updatedAgent =
|
|
Object.keys(updateData).length > 0
|
|
? await db.updateAgent({ id }, updateData, {
|
|
updatingUserId: req.user.id,
|
|
})
|
|
: existingAgent;
|
|
|
|
// Add version count to the response
|
|
updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
|
|
|
|
if (updatedAgent.author) {
|
|
updatedAgent.author = updatedAgent.author.toString();
|
|
}
|
|
|
|
await attachOwnerContacts([updatedAgent]);
|
|
|
|
if (updatedAgent.author !== req.user.id) {
|
|
delete updatedAgent.author;
|
|
}
|
|
|
|
return res.json(updatedAgent);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
logger.error('[/Agents/:id] Validation error', error.errors);
|
|
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
|
|
}
|
|
|
|
logger.error('[/Agents/:id] Error updating Agent', error);
|
|
|
|
if (error.statusCode === 409) {
|
|
return res.status(409).json({
|
|
error: error.message,
|
|
details: error.details,
|
|
});
|
|
}
|
|
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Duplicates an Agent based on the provided ID.
|
|
* @route POST /Agents/:id/duplicate
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @returns {Promise<Agent>} 201 - success response - application/json
|
|
*/
|
|
const duplicateAgentHandler = async (req, res) => {
|
|
const { id } = req.params;
|
|
const { id: userId } = req.user;
|
|
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
|
|
|
|
try {
|
|
const agent = await db.getAgent({ id });
|
|
if (!agent) {
|
|
return res.status(404).json({
|
|
error: 'Agent not found',
|
|
status: 'error',
|
|
});
|
|
}
|
|
|
|
const {
|
|
id: _id,
|
|
_id: __id,
|
|
author: _author,
|
|
createdAt: _createdAt,
|
|
updatedAt: _updatedAt,
|
|
tool_resources: _tool_resources = {},
|
|
versions: _versions,
|
|
__v: _v,
|
|
...cloneData
|
|
} = agent;
|
|
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {
|
|
dateStyle: 'short',
|
|
timeStyle: 'short',
|
|
hour12: false,
|
|
})})`;
|
|
|
|
if (_tool_resources?.[EToolResources.context]) {
|
|
cloneData.tool_resources = {
|
|
[EToolResources.context]: _tool_resources[EToolResources.context],
|
|
};
|
|
}
|
|
|
|
if (_tool_resources?.[EToolResources.ocr]) {
|
|
cloneData.tool_resources = {
|
|
/** Legacy conversion from `ocr` to `context` */
|
|
[EToolResources.context]: {
|
|
...(_tool_resources[EToolResources.context] ?? {}),
|
|
..._tool_resources[EToolResources.ocr],
|
|
},
|
|
};
|
|
}
|
|
|
|
const newAgentId = `agent_${nanoid()}`;
|
|
const newAgentData = Object.assign(cloneData, {
|
|
id: newAgentId,
|
|
author: userId,
|
|
});
|
|
|
|
const newActionsList = [];
|
|
const originalActions = (await db.getActions({ agent_id: id }, true)) ?? [];
|
|
const promises = [];
|
|
|
|
/**
|
|
* Duplicates an action and returns the new action ID.
|
|
* @param {Action} action
|
|
* @returns {Promise<string>}
|
|
*/
|
|
const duplicateAction = async (action) => {
|
|
const newActionId = nanoid();
|
|
const { domain } = action.metadata;
|
|
const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
|
|
|
|
// Sanitize sensitive metadata before persisting
|
|
const filteredMetadata = { ...(action.metadata || {}) };
|
|
for (const field of sensitiveFields) {
|
|
delete filteredMetadata[field];
|
|
}
|
|
|
|
const newAction = await db.updateAction(
|
|
{ action_id: newActionId, agent_id: newAgentId },
|
|
{
|
|
metadata: filteredMetadata,
|
|
agent_id: newAgentId,
|
|
user: userId,
|
|
},
|
|
);
|
|
|
|
newActionsList.push(newAction);
|
|
return fullActionId;
|
|
};
|
|
|
|
for (const action of originalActions) {
|
|
promises.push(
|
|
duplicateAction(action).catch((error) => {
|
|
logger.error('[/agents/:id/duplicate] Error duplicating Action:', error);
|
|
}),
|
|
);
|
|
}
|
|
|
|
const agentActions = await Promise.all(promises);
|
|
newAgentData.actions = agentActions;
|
|
|
|
if (newAgentData.tools?.length) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
newAgentData.tools = await filterAuthorizedTools({
|
|
tools: newAgentData.tools,
|
|
userId,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools: newAgentData.tools,
|
|
configServers,
|
|
});
|
|
}
|
|
|
|
if (newAgentData.tool_resources) {
|
|
await pruneToolResourceFileIdsForOwner({
|
|
tool_resources: newAgentData.tool_resources,
|
|
ownerId: userId,
|
|
logPrefix: '[/Agents/:id/duplicate]',
|
|
});
|
|
}
|
|
|
|
const newAgent = await db.createAgent(newAgentData);
|
|
|
|
try {
|
|
await Promise.all([
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: newAgent._id,
|
|
accessRoleId: AccessRoleIds.AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
grantPermission({
|
|
principalType: PrincipalType.USER,
|
|
principalId: userId,
|
|
resourceType: ResourceType.REMOTE_AGENT,
|
|
resourceId: newAgent._id,
|
|
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
|
|
grantedBy: userId,
|
|
}),
|
|
]);
|
|
logger.debug(
|
|
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
|
|
);
|
|
} catch (permissionError) {
|
|
logger.error(
|
|
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
|
|
permissionError,
|
|
);
|
|
}
|
|
|
|
return res.status(201).json({
|
|
agent: newAgent,
|
|
actions: newActionsList,
|
|
});
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id/duplicate] Error duplicating Agent:', error);
|
|
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Deletes an Agent based on the provided ID.
|
|
* @route DELETE /Agents/:id
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.id - Agent identifier.
|
|
* @returns {Promise<Agent>} 200 - success response - application/json
|
|
*/
|
|
const deleteAgentHandler = async (req, res) => {
|
|
try {
|
|
const id = req.params.id;
|
|
const agent = await db.getAgent({ id });
|
|
if (!agent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
await db.deleteAgent({ id });
|
|
return res.json({ message: 'Agent deleted' });
|
|
} catch (error) {
|
|
logger.error('[/Agents/:id] Error deleting Agent', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Lists agents using ACL-aware permissions (ownership + explicit shares).
|
|
* @route GET /Agents
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.query - Request query
|
|
* @param {string} [req.query.user] - The user ID of the agent's author.
|
|
* @returns {Promise<AgentListResponse>} 200 - success response - application/json
|
|
*/
|
|
const getListAgentsHandler = async (req, res) => {
|
|
try {
|
|
const userId = req.user.id;
|
|
const { category, search, limit = 100, cursor, promoted } = req.query;
|
|
let requiredPermission = req.query.requiredPermission;
|
|
if (typeof requiredPermission === 'string') {
|
|
requiredPermission = parseInt(requiredPermission, 10);
|
|
if (isNaN(requiredPermission)) {
|
|
requiredPermission = PermissionBits.VIEW;
|
|
}
|
|
} else if (typeof requiredPermission !== 'number') {
|
|
requiredPermission = PermissionBits.VIEW;
|
|
}
|
|
const canReturnSkillConfig = hasEditBit(requiredPermission);
|
|
// Base filter
|
|
const filter = {};
|
|
|
|
// Handle category filter - only apply if category is defined
|
|
if (category !== undefined && category.trim() !== '') {
|
|
filter.category = category;
|
|
}
|
|
|
|
// Handle promoted filter - only from query param
|
|
if (promoted === '1') {
|
|
filter.is_promoted = true;
|
|
} else if (promoted === '0') {
|
|
filter.is_promoted = { $ne: true };
|
|
}
|
|
|
|
// Handle search filter (escape regex and cap length)
|
|
if (search && search.trim() !== '') {
|
|
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
|
|
const regex = new RegExp(safeSearch, 'i');
|
|
filter.$or = [{ name: regex }, { description: regex }];
|
|
}
|
|
|
|
// Get agent IDs the user has VIEW access to via ACL
|
|
const accessibleIds = await findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: requiredPermission,
|
|
});
|
|
|
|
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
|
resourceType: ResourceType.AGENT,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
|
|
/**
|
|
* Refresh all S3 avatars for this user's accessible agent set (not only the current page)
|
|
* This addresses page-size limits preventing refresh of agents beyond the first page
|
|
*/
|
|
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
const refreshKey = `${userId}:agents_avatar_refresh`;
|
|
let cachedRefresh = await cache.get(refreshKey);
|
|
const isValidCachedRefresh =
|
|
cachedRefresh != null && typeof cachedRefresh === 'object' && cachedRefresh.urlCache != null;
|
|
if (!isValidCachedRefresh) {
|
|
try {
|
|
const fullList = await db.getListAgentsByAccess({
|
|
accessibleIds,
|
|
otherParams: {},
|
|
limit: MAX_AVATAR_REFRESH_AGENTS,
|
|
after: null,
|
|
});
|
|
const { urlCache } = await refreshListAvatars({
|
|
agents: fullList?.data ?? [],
|
|
userId,
|
|
refreshS3Url,
|
|
updateAgent: db.updateAgent,
|
|
});
|
|
cachedRefresh = { urlCache };
|
|
await cache.set(refreshKey, cachedRefresh, Time.THIRTY_MINUTES);
|
|
} catch (err) {
|
|
logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
|
|
}
|
|
} else {
|
|
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
|
|
}
|
|
|
|
// Use the new ACL-aware function
|
|
const data = await db.getListAgentsByAccess({
|
|
accessibleIds,
|
|
otherParams: filter,
|
|
limit,
|
|
after: cursor,
|
|
includeSkillConfig: true,
|
|
});
|
|
|
|
const agents = data?.data ?? [];
|
|
if (!agents.length) {
|
|
return res.json(data);
|
|
}
|
|
|
|
let accessibleSkillSet = null;
|
|
if (!canReturnSkillConfig) {
|
|
const accessibleSkillIds = await findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.SKILL,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
accessibleSkillSet = new Set(accessibleSkillIds.map((oid) => oid.toString()));
|
|
}
|
|
|
|
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
|
|
const agentsWithContacts = await attachOwnerContacts(agents);
|
|
|
|
const urlCache = cachedRefresh?.urlCache;
|
|
data.data = agentsWithContacts.map((agent) => {
|
|
if (accessibleSkillSet) {
|
|
sanitizeViewerSkillScope(agent, accessibleSkillSet);
|
|
}
|
|
try {
|
|
if (agent?._id && publicSet.has(agent._id.toString())) {
|
|
agent.isPublic = true;
|
|
}
|
|
if (
|
|
urlCache &&
|
|
agent?.id &&
|
|
agent?.avatar?.source === FileSources.s3 &&
|
|
urlCache[agent.id]
|
|
) {
|
|
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
|
|
}
|
|
} catch (e) {
|
|
// Silently ignore mapping errors
|
|
void e;
|
|
}
|
|
return agent;
|
|
});
|
|
|
|
return res.json(data);
|
|
} catch (error) {
|
|
logger.error('[/Agents] Error listing Agents: %o', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Uploads and updates an avatar for a specific agent.
|
|
* @route POST /:agent_id/avatar
|
|
* @param {object} req - Express Request
|
|
* @param {object} req.params - Request params
|
|
* @param {string} req.params.agent_id - The ID of the agent.
|
|
* @param {Express.Multer.File} req.file - The avatar image file.
|
|
* @param {object} req.body - Request body
|
|
* @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
|
|
* @returns {Promise<void>} 200 - success response - application/json
|
|
*/
|
|
const uploadAgentAvatarHandler = async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
if (!req.file) {
|
|
return res.status(400).json({ message: 'No file uploaded' });
|
|
}
|
|
filterFile({ req, file: req.file, image: true, isAvatar: true });
|
|
const { agent_id } = req.params;
|
|
if (!agent_id) {
|
|
return res.status(400).json({ message: 'Agent ID is required' });
|
|
}
|
|
|
|
const existingAgent = await db.getAgent({ id: agent_id });
|
|
|
|
if (!existingAgent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
const buffer = await fs.readFile(req.file.path);
|
|
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
|
|
const resizedBuffer = await resizeAvatar({
|
|
userId: req.user.id,
|
|
input: buffer,
|
|
});
|
|
|
|
const { processAvatar } = getStrategyFunctions(fileStrategy);
|
|
const avatarUrl = await processAvatar({
|
|
buffer: resizedBuffer,
|
|
userId: req.user.id,
|
|
manual: 'false',
|
|
agentId: agent_id,
|
|
tenantId: req.user.tenantId,
|
|
});
|
|
|
|
const image = {
|
|
filepath: avatarUrl,
|
|
source: fileStrategy,
|
|
};
|
|
|
|
let _avatar = existingAgent.avatar;
|
|
|
|
if (_avatar && _avatar.source) {
|
|
const { deleteFile } = getStrategyFunctions(_avatar.source);
|
|
try {
|
|
await deleteFile(req, {
|
|
filepath: _avatar.filepath,
|
|
user: req.user.id,
|
|
tenantId: req.user.tenantId,
|
|
});
|
|
await db.deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
|
|
} catch (error) {
|
|
logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
avatar: {
|
|
filepath: image.filepath,
|
|
source: image.source,
|
|
},
|
|
};
|
|
|
|
const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
|
|
updatingUserId: req.user.id,
|
|
});
|
|
await attachOwnerContacts([updatedAgent]);
|
|
|
|
try {
|
|
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`);
|
|
} catch (cacheErr) {
|
|
logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
|
|
}
|
|
|
|
res.status(201).json(updatedAgent);
|
|
} catch (error) {
|
|
const message = 'An error occurred while updating the Agent Avatar';
|
|
logger.error(
|
|
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
|
|
error,
|
|
);
|
|
res.status(500).json({ message });
|
|
} finally {
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
|
|
} catch {
|
|
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Reverts an agent to a previous version from its version history.
|
|
* @route PATCH /agents/:id/revert
|
|
* @param {object} req - Express Request object
|
|
* @param {object} req.params - Request parameters
|
|
* @param {string} req.params.id - The ID of the agent to revert
|
|
* @param {object} req.body - Request body
|
|
* @param {number} req.body.version_index - The index of the version to revert to
|
|
* @param {object} req.user - Authenticated user information
|
|
* @param {string} req.user.id - User ID
|
|
* @param {string} req.user.role - User role
|
|
* @param {ServerResponse} res - Express Response object
|
|
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
|
|
* @throws {Error} 400 - If version_index is missing
|
|
* @throws {Error} 403 - If user doesn't have permission to modify the agent
|
|
* @throws {Error} 404 - If agent not found
|
|
* @throws {Error} 500 - If there's an internal server error during the reversion process
|
|
*/
|
|
const revertAgentVersionHandler = async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const { version_index } = req.body;
|
|
|
|
if (version_index === undefined) {
|
|
return res.status(400).json({ error: 'version_index is required' });
|
|
}
|
|
|
|
const existingAgent = await db.getAgent({ id });
|
|
|
|
if (!existingAgent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
// Permissions are enforced via route middleware (ACL EDIT)
|
|
|
|
let updatedAgent = await db.revertAgentVersion({ id }, version_index);
|
|
const revertUpdates = {};
|
|
|
|
if (updatedAgent.tools?.length) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const mcpPermissionContext = createMCPPermissionContext(req);
|
|
const filteredTools = await filterAuthorizedTools({
|
|
tools: updatedAgent.tools,
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
user: req.user,
|
|
mcpPermissionContext,
|
|
availableTools,
|
|
existingTools: updatedAgent.tools,
|
|
configServers,
|
|
});
|
|
if (filteredTools.length !== updatedAgent.tools.length) {
|
|
revertUpdates.tools = filteredTools;
|
|
}
|
|
}
|
|
|
|
if (updatedAgent.tool_resources) {
|
|
const removedCount = await pruneToolResourceFileIdsForOwner({
|
|
tool_resources: updatedAgent.tool_resources,
|
|
ownerId: existingAgent.author,
|
|
logPrefix: '[/Agents/:id/revert]',
|
|
});
|
|
if (removedCount > 0) {
|
|
revertUpdates.tool_resources = updatedAgent.tool_resources;
|
|
}
|
|
}
|
|
|
|
if (Object.keys(revertUpdates).length > 0) {
|
|
updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id });
|
|
}
|
|
|
|
if (updatedAgent.author) {
|
|
updatedAgent.author = updatedAgent.author.toString();
|
|
}
|
|
|
|
await attachOwnerContacts([updatedAgent]);
|
|
|
|
if (updatedAgent.author !== req.user.id) {
|
|
delete updatedAgent.author;
|
|
}
|
|
|
|
return res.json(updatedAgent);
|
|
} catch (error) {
|
|
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
};
|
|
/**
|
|
* Get all agent categories with counts
|
|
*
|
|
* @param {Object} _req - Express request object (unused)
|
|
* @param {Object} res - Express response object
|
|
*/
|
|
const getAgentCategories = async (_req, res) => {
|
|
try {
|
|
const categories = await db.getCategoriesWithCounts();
|
|
const promotedCount = await db.countPromotedAgents();
|
|
const formattedCategories = categories.map((category) => ({
|
|
value: category.value,
|
|
label: category.label,
|
|
count: category.agentCount,
|
|
description: category.description,
|
|
}));
|
|
|
|
if (promotedCount > 0) {
|
|
formattedCategories.unshift({
|
|
value: 'promoted',
|
|
label: 'Promoted',
|
|
count: promotedCount,
|
|
description: 'Our recommended agents',
|
|
});
|
|
}
|
|
|
|
formattedCategories.push({
|
|
value: 'all',
|
|
label: 'All',
|
|
description: 'All available agents',
|
|
});
|
|
|
|
res.status(200).json(formattedCategories);
|
|
} catch (error) {
|
|
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to fetch agent categories',
|
|
userMessage: 'Unable to load categories. Please refresh the page.',
|
|
suggestion: 'Try refreshing the page or check your network connection',
|
|
});
|
|
}
|
|
};
|
|
module.exports = {
|
|
createAgent: createAgentHandler,
|
|
getAgent: getAgentHandler,
|
|
getAgentVersions: getAgentVersionsHandler,
|
|
updateAgent: updateAgentHandler,
|
|
duplicateAgent: duplicateAgentHandler,
|
|
deleteAgent: deleteAgentHandler,
|
|
getListAgents: getListAgentsHandler,
|
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
|
revertAgentVersion: revertAgentVersionHandler,
|
|
getAgentCategories,
|
|
filterAuthorizedTools,
|
|
};
|