mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 06:17:21 +02:00
* ♻️ refactor: Remove redundant scopedCacheKey caching, support user-provided key model fetching Remove redundant cache layers that used `scopedCacheKey()` (tenant-only scoping) on top of `getAppConfig()` which already caches per-principal (role+user+tenant). This caused config overrides for different principals within the same tenant to be invisible due to stale cached data. Changes: - Add `requireJwtAuth` to `/api/endpoints` route for proper user context - Remove ENDPOINT_CONFIG, STARTUP_CONFIG, PLUGINS, TOOLS, and MODELS_CONFIG cache layers — all derive from `getAppConfig()` with cheap computation - Enhance MODEL_QUERIES cache: hash(baseURL+apiKey) keys, 2-minute TTL, caching centralized in `fetchModels()` base function - Support fetching models with user-provided API keys in `loadConfigModels` via `getUserKeyValues` lookup (no caching for user keys) - Update all affected tests Closes #1028 * ♻️ refactor: Migrate config services to TypeScript in packages/api Move core config logic from CJS /api wrappers to typed TypeScript in packages/api using dependency injection factories: - `createEndpointsConfigService` — endpoint config merging + checkCapability - `createLoadConfigModels` — custom endpoint model loading with user key support - `createMCPToolCacheService` — MCP tool cache operations (update, merge, cache) /api files become thin wrappers that wire dependencies (getAppConfig, loadDefaultEndpointsConfig, getUserKeyValues, getCachedTools, etc.) into the typed factories. Also moves existing `endpoints/config.ts` → `endpoints/config/providers.ts` to accommodate the new `config/` directory structure. * 🔄 fix: Invalidate models query when user API key is set or revoked Without this, users had to refresh the page after entering their API key to see the updated model list fetched with their credentials. - Invalidate QueryKeys.models in useUpdateUserKeysMutation onSuccess - Invalidate QueryKeys.models in useRevokeUserKeyMutation onSuccess - Invalidate QueryKeys.models in useRevokeAllUserKeysMutation onSuccess * 🗺️ fix: Remap YAML-level override keys to AppConfig equivalents in mergeConfigOverrides Config overrides stored in the DB use YAML-level keys (TCustomConfig), but they're merged into the already-processed AppConfig where some fields have been renamed by AppService. This caused mcpServers overrides to land on a nonexistent key instead of mcpConfig, so config-override MCP servers never appeared in the UI. - Add OVERRIDE_KEY_MAP to remap mcpServers→mcpConfig, interface→interfaceConfig - Apply remapping before deep merge in mergeConfigOverrides - Add test for YAML-level key remapping behavior - Update existing tests to use AppConfig field names in assertions * 🧪 test: Update service.spec to use AppConfig field names after override key remapping * 🛡️ fix: Address code review findings — reliability, types, tests, and performance - Pass tenant context (getTenantId) in importers.js getEndpointsConfig call - Add 5 tests for user-provided API key model fetching (key found, no key, DB error, missing userId, apiKey-only with fixed baseURL) - Distinguish NO_USER_KEY (debug) from infrastructure errors (warn) in catch - Switch fetchPromisesMap from Promise.all to Promise.allSettled so one failing provider doesn't kill the entire model config - Parallelize getUserKeyValues DB lookups via batched Promise.allSettled instead of sequential awaits in the loop - Hoist standardCache instance in fetchModels to avoid double instantiation - Replace Record<string, unknown> types with Partial<TConfig>-based types; remove as unknown as T double-cast in endpoints config - Narrow Bedrock availableRegions to typed destructure - Narrow version field from string|number|undefined to string|undefined - Fix import ordering in mcp/tools.ts and config/models.ts per AGENTS.md - Add JSDoc to getModelsConfig alias clarifying caching semantics * fix: Guard against null getCachedTools in mergeAppTools * 🔍 fix: Address follow-up review — deduplicate extractEnvVariable, fix error discrimination, add log-level tests - Deduplicate extractEnvVariable calls: resolve apiKey/baseURL once, reuse for both the entry and isUserProvided checks (Finding A) - Move ResolvedEndpoint interface from function closure to module scope (Finding B) - Replace fragile msg.includes('NO_USER_KEY') with ErrorTypes.NO_USER_KEY enum check against actual error message format (Finding C). Also handle ErrorTypes.INVALID_USER_KEY as an expected "no key" case. - Add test asserting logger.warn is called for infra errors (not debug) - Add test asserting logger.debug is called for NO_USER_KEY errors (not warn) * fix: Preserve numeric assistants version via String() coercion * 🐛 fix: Address secondary review — Ollama cache bypass, cache tests, type safety - Fix Ollama success path bypassing cache write in fetchModels (CRITICAL): store result before returning so Ollama models benefit from 2-minute TTL - Add 4 fetchModels cache behavior tests: cache write with TTL, cache hit short-circuits HTTP, skipCache bypasses read+write, empty results not cached - Type-safe OVERRIDE_KEY_MAP: Partial<Record<keyof TCustomConfig, keyof AppConfig>> so compiler catches future field rename mismatches - Fix import ordering in config/models.ts (package types longest→shortest) - Rename ToolCacheDeps → MCPToolCacheDeps for naming consistency - Expand getModelsConfig JSDoc to explain caching granularity * fix: Narrow OVERRIDE_KEY_MAP index to satisfy strict tsconfig * 🧩 fix: Add allowedProviders to TConfig, remove Record<string, unknown> from PartialEndpointEntry The agents endpoint config includes allowedProviders (used by the frontend AgentPanel to filter available providers), but it was missing from TConfig. This forced PartialEndpointEntry to use & Record<string, unknown> as an escape hatch, violating AGENTS.md type policy. - Add allowedProviders?: (string | EModelEndpoint)[] to TConfig - Remove Record<string, unknown> from PartialEndpointEntry — now just Partial<TConfig> * 🛡️ fix: Isolate Ollama cache write from fetch try-catch, add Ollama cache tests - Separate Ollama fetch and cache write into distinct scopes so a cache failure (e.g., Redis down) doesn't misattribute the error as an Ollama API failure and fall through to the OpenAI-compatible path (Issue A) - Add 2 Ollama-specific cache tests: models written with TTL on fetch, cached models returned without hitting server (Issue B) - Replace hardcoded 120000 with Time.TWO_MINUTES constant in cache TTL test assertion (Issue C) - Fix OVERRIDE_KEY_MAP JSDoc to accurately describe runtime vs compile-time type enforcement (Issue D) - Add global beforeEach for cache mock reset to prevent cross-test leakage * 🧪 fix: Address third review — DI consistency, cache key width, MCP tests - Inject loadCustomEndpointsConfig via EndpointsConfigDeps with default fallback, matching loadDefaultEndpointsConfig DI pattern (Finding 3) - Widen modelsCacheKey from 64-bit (.slice(0,16)) to 128-bit (.slice(0,32)) for collision-sensitive cross-credential cache key (Finding 4) - Add fetchModels.mockReset() in loadConfigModels.spec beforeEach to prevent mock implementation leaks across tests (Finding 5) - Add 11 unit tests for createMCPToolCacheService covering all three functions: null/empty input, successful ops, error propagation, cold-cache merge (Finding 2) - Simplify getModelsConfig JSDoc to @see reference (Finding 10) * ♻️ refactor: Address remaining follow-ups from reviews OVERRIDE_KEY_MAP completeness: - Add missing turnstile→turnstileConfig mapping - Add exhaustiveness test verifying all three renamed keys are remapped and original YAML keys don't leak through Import role context: - Pass userRole through importConversations job → importLibreChatConvo so role-based endpoint overrides are honored during conversation import - Update convos.js route to include req.user.role in the job payload createEndpointsConfigService unit tests: - Add 8 tests covering: default+custom merge, Azure/AzureAssistants/ Anthropic Vertex/Bedrock config enrichment, assistants version coercion, agents allowedProviders, req.config bypass Plugins/tools efficiency: - Use Set for includedTools/filteredTools lookups (O(1) vs O(n) per plugin) - Combine auth check + filter into single pass (eliminates intermediate array) - Pre-compute toolDefKeys Set for O(1) tool definition lookups * fix: Scope model query cache by user when userIdQuery is enabled * fix: Skip model cache for userIdQuery endpoints, fix endpoints test types - When userIdQuery is true, skip caching entirely (like user_provided keys) to avoid cross-user model list leakage without duplicating cache data - Fix AgentCapabilities type error in endpoints.spec.ts — use enum values and appConfig() helper for partial mock typing * 🐛 fix: Restore filteredTools+includedTools composition, add checkCapability tests - Fix filteredTools regression: whitelist and blacklist are now applied independently (two flat guards), matching original behavior where includedTools=['a','b'] + filteredTools=['b'] produces ['a'] (Finding A) - Fix Set spread in toolkit loop: pre-compute toolDefKeysList array once alongside the Set, reuse for .some() without per-plugin allocation (Finding B) - Add 2 filteredTools tests: blacklist-only path and combined whitelist+blacklist composition (Finding C) - Add 3 checkCapability tests: capability present, capability absent, fallback to defaultAgentCapabilities for non-agents endpoints (Finding D) * 🔑 fix: Include config-override MCP servers in filterAuthorizedTools Config-override MCP servers (defined via admin config overrides for roles/groups) were rejected by filterAuthorizedTools because it called getAllServerConfigs(userId) without the configServers parameter. Only YAML and DB-backed user servers were included in the access check. - Add configServers parameter to filterAuthorizedTools - Resolve config servers via resolveConfigServers(req) at all 4 callsites (create, update, duplicate, revert) using parallel Promise.all - Pass configServers through to getAllServerConfigs(userId, configServers) so the registry merges config-source servers into the access check - Update filterAuthorizedTools.spec.js mock for resolveConfigServers * fix: Skip model cache for userIdQuery endpoints, fix endpoints test types For user-provided key endpoints (userProvide: true), skip the full model list re-fetch during message validation — the user already selected from a list we served them, and re-fetching with skipCache:true on every message send is both slow and fragile (5s provider timeout = rejected model). Instead, validate the model string format only: - Must be a string, max 256 chars - Must match [a-zA-Z0-9][a-zA-Z0-9_.:\-/@+ ]* (covers all known provider model ID formats while rejecting injection attempts) System-configured endpoints still get full model list validation as before. * 🧪 test: Add regression tests for filterAuthorizedTools configServers and validateModel filterAuthorizedTools: - Add test verifying configServers is passed to getAllServerConfigs and config-override server tools are allowed through - Guard resolveConfigServers in createAgentHandler to only run when MCP tools are present (skip for tool-free agent creates) validateModel (12 new tests): - Format validation: missing model, non-string, length overflow, leading special char, script injection, standard model ID acceptance - userProvide early-return: next() called immediately, getModelsConfig not invoked (regression guard for the exact bug this fixes) - System endpoint list validation: reject unknown model, accept known model, handle null/missing models config Also fix unnecessary backslash escape in MODEL_PATTERN regex. * 🧹 fix: Remove space from MODEL_PATTERN, trim input, clean up nits - Remove space character from MODEL_PATTERN regex — no real model ID uses spaces; prevents spurious violation logs from whitespace artifacts - Add model.trim() before validation to handle accidental whitespace - Remove redundant filterUniquePlugins call on already-deduplicated output - Add comment documenting intentional whitelist+blacklist composition - Add getUserKeyValues.mockReset() in loadConfigModels.spec beforeEach - Remove narrating JSDoc from getModelsConfig one-liner - Add 2 tests: trim whitespace handling, reject spaces in model ID * fix: Match startup tool loader semantics — includedTools takes precedence over filteredTools The startup tool loader (loadAndFormatTools) explicitly ignores filteredTools when includedTools is set, with a warning log. The PluginController was applying both independently, creating inconsistent behavior where the same config produced different results at startup vs plugin listing time. Restored mutually exclusive semantics: when includedTools is non-empty, filteredTools is not evaluated. * 🧹 chore: Simplify validateModel flow, note auth requirement on endpoints route - Separate missing-model from invalid-model checks cleanly: type+presence guard first, then trim+format guard (reviewer NIT) - Add route comment noting auth is required for role/tenant scoping * fix: Write trimmed model back to req.body.model for downstream consumers
989 lines
31 KiB
JavaScript
989 lines
31 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,
|
|
convertOcrToContextInPlace,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Time,
|
|
Tools,
|
|
CacheKeys,
|
|
Constants,
|
|
FileSources,
|
|
ResourceType,
|
|
AccessRoleIds,
|
|
PrincipalType,
|
|
EToolResources,
|
|
PermissionBits,
|
|
actionDelimiter,
|
|
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 { resolveConfigServers } = require('~/server/services/MCP');
|
|
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,
|
|
};
|
|
|
|
const MAX_SEARCH_LEN = 100;
|
|
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
/**
|
|
* Validates that the requesting user has VIEW access to every agent referenced in edges.
|
|
* Agents that do not exist in the database are skipped — at create time, the `from` field
|
|
* often references the agent being built, which has no DB record yet.
|
|
* @param {import('librechat-data-provider').GraphEdge[]} edges
|
|
* @param {string} userId
|
|
* @param {string} userRole - Used for group/role principal resolution
|
|
* @returns {Promise<string[]>} Agent IDs the user cannot VIEW (empty if all accessible)
|
|
*/
|
|
const validateEdgeAgentAccess = async (edges, userId, userRole) => {
|
|
const edgeAgentIds = collectEdgeAgentIds(edges);
|
|
if (edgeAgentIds.size === 0) {
|
|
return [];
|
|
}
|
|
|
|
const agents = await db.getAgents({ id: { $in: [...edgeAgentIds] } });
|
|
|
|
if (agents.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const permissionsMap = await getResourcePermissionsMap({
|
|
userId,
|
|
role: userRole,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceIds: agents.map((a) => a._id),
|
|
});
|
|
|
|
return agents
|
|
.filter((a) => {
|
|
const bits = permissionsMap.get(a._id.toString()) ?? 0;
|
|
return (bits & PermissionBits.VIEW) === 0;
|
|
})
|
|
.map((a) => a.id);
|
|
};
|
|
|
|
/**
|
|
* 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 {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,
|
|
availableTools,
|
|
existingTools,
|
|
configServers,
|
|
}) => {
|
|
const filteredTools = [];
|
|
let mcpServerConfigs;
|
|
let registryUnavailable = false;
|
|
const existingToolSet = existingTools?.length ? new Set(existingTools) : null;
|
|
|
|
for (const tool of tools) {
|
|
if (availableTools[tool] || systemTools[tool]) {
|
|
filteredTools.push(tool);
|
|
continue;
|
|
}
|
|
|
|
if (!tool?.includes(Constants.mcp_delimiter)) {
|
|
continue;
|
|
}
|
|
|
|
if (mcpServerConfigs === undefined) {
|
|
try {
|
|
mcpServerConfigs =
|
|
(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;
|
|
};
|
|
|
|
/**
|
|
* 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.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,
|
|
});
|
|
}
|
|
}
|
|
|
|
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),
|
|
]);
|
|
agentData.tools = await filterAuthorizedTools({
|
|
tools,
|
|
userId,
|
|
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
|
|
// Simply load the agent by ID
|
|
const agent = await db.getAgent({ id });
|
|
|
|
if (!agent) {
|
|
return res.status(404).json({ error: 'Agent not found' });
|
|
}
|
|
|
|
agent.version = agent.versions ? agent.versions.length : 0;
|
|
|
|
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;
|
|
|
|
if (agent.author !== author) {
|
|
delete agent.author;
|
|
}
|
|
|
|
if (!expandProperties) {
|
|
// VIEW permission: Basic agent info only
|
|
return res.status(200).json({
|
|
_id: agent._id,
|
|
id: agent.id,
|
|
name: agent.name,
|
|
description: agent.description,
|
|
avatar: agent.avatar,
|
|
author: agent.author,
|
|
provider: agent.provider,
|
|
model: agent.model,
|
|
isPublic: agent.isPublic,
|
|
version: agent.version,
|
|
// Safe metadata
|
|
createdAt: agent.createdAt,
|
|
updatedAt: agent.updatedAt,
|
|
});
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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.tools) {
|
|
const existingToolSet = new Set(existingAgent.tools ?? []);
|
|
const newMCPTools = updateData.tools.filter(
|
|
(t) => !existingToolSet.has(t) && t?.includes(Constants.mcp_delimiter),
|
|
);
|
|
|
|
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,
|
|
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();
|
|
}
|
|
|
|
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),
|
|
]);
|
|
newAgentData.tools = await filterAuthorizedTools({
|
|
tools: newAgentData.tools,
|
|
userId,
|
|
availableTools,
|
|
existingTools: newAgentData.tools,
|
|
configServers,
|
|
});
|
|
}
|
|
|
|
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, 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;
|
|
}
|
|
// 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,
|
|
});
|
|
|
|
const agents = data?.data ?? [];
|
|
if (!agents.length) {
|
|
return res.json(data);
|
|
}
|
|
|
|
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
|
|
|
|
const urlCache = cachedRefresh?.urlCache;
|
|
data.data = agents.map((agent) => {
|
|
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,
|
|
});
|
|
|
|
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 });
|
|
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,
|
|
});
|
|
|
|
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);
|
|
|
|
if (updatedAgent.tools?.length) {
|
|
const [availableTools, configServers] = await Promise.all([
|
|
getCachedTools().then((t) => t ?? {}),
|
|
resolveConfigServers(req),
|
|
]);
|
|
const filteredTools = await filterAuthorizedTools({
|
|
tools: updatedAgent.tools,
|
|
userId: req.user.id,
|
|
availableTools,
|
|
existingTools: updatedAgent.tools,
|
|
configServers,
|
|
});
|
|
if (filteredTools.length !== updatedAgent.tools.length) {
|
|
updatedAgent = await db.updateAgent(
|
|
{ id },
|
|
{ tools: filteredTools },
|
|
{ updatingUserId: req.user.id },
|
|
);
|
|
}
|
|
}
|
|
|
|
if (updatedAgent.author) {
|
|
updatedAgent.author = updatedAgent.author.toString();
|
|
}
|
|
|
|
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,
|
|
updateAgent: updateAgentHandler,
|
|
duplicateAgent: duplicateAgentHandler,
|
|
deleteAgent: deleteAgentHandler,
|
|
getListAgents: getListAgentsHandler,
|
|
uploadAgentAvatar: uploadAgentAvatarHandler,
|
|
revertAgentVersion: revertAgentVersionHandler,
|
|
getAgentCategories,
|
|
filterAuthorizedTools,
|
|
};
|